Moneta

A changelog built from git history.

Change entries are Markdown files under .changes. Which release each one belongs to is the first tag whose tree contains it — so a release is a tag and nothing else. No commit bumps a version, nothing is moved or deleted at release time, and the changelog follows from the history.

$ moneta new -k added -m "Support nested .changes directories" -i 42
.changes/20260923T142530Z-added-support-nested-changes-directories.md

$ moneta next
v0.2.0

$ git tag v0.2.0          # that is the entire release ceremony

The rule

files(T)    = the fragment keys under .changes in tag T's tree
baseline(T) = the union of files(A) over every ancestor tag A of T
entries(T)  = files(T) minus baseline(T)

Three choices in there are load-bearing, and each rules out a wrong answer:

Tree presence, not “the commit that added the file”

After a rebase, a squash or a cherry-pick, which commit introduced this has several defensible answers. Was it in the release has exactly one.

A union over every ancestor, not a diff against the newest

Picking one predecessor goes wrong the first time a branch is merged. Nothing in attribution depends on semver ordering, so two tags on one commit, a mistyped tag and tags on divergent lineages cannot produce a wrong answer.

Keys, not paths

A fragment’s key is its filename stem, or an explicit id:. Moving one into a subdirectory is a delete and an add under path comparison, which would silently re-credit the entry to a later release.

Editing an entry after it has shipped

A tag decides one thing: which version of the code an entry is about. Everything the changelog says about that code is a record you can maintain, so it is read live from the working tree. Fix a typo, a kind, a scope or the modules of an entry from two releases ago, and the next docs build shows the fix under the same release.

  • Deleting a fragment does not erase the entry. It renders from the last version the history has, so a correction made before the deletion stays.

  • To take an entry out, set retracted: true in its frontmatter. Deleting is not a retraction, because a revert deletes the fragment too, and a reverted feature still shipped in the release before the revert.

  • An id: is the fragment’s identity. Add, change or remove one after the release and the entry stays where it is under its new key, and a fragment with an id: keeps its entry through a rename. moneta new, create and commit write one. A fragment without one is known by its filename, so renaming it is a new entry. Git sees the same thing: one file deleted and another added.

--content as-published renders everything as it shipped.

Shallow clones

moneta refuses to run in a shallow clone, and the refusal is the feature. A grafted history does not fail an ancestry question, it answers it wrongly, so the result would be a changelog quietly missing whole releases — published to a docs site and to a release page with nothing anywhere saying so.

In GitLab CI, set GIT_DEPTH: 0 on any job that runs moneta.

--allow-shallow degrades instead of refusing. It sets "truncated": true in the JSON and puts a warning at the top of every rendered format, so the result still says what it is.