Skip to content

The release

the release: the changelog composed from the layer's release records, the decisions dated inside each release's window and the conventional commits in its range; the two statements of the version and the one command that writes them; the bump the commits imply and the evidence for it; and every surface derived from both

Rendered from docs/RELEASE.md — the same Markdown GitHub shows.

What this project has shipped, what it would ship next, and the changelog that says so are one typed value, release::Changelog, composed by one module under apps/majordomus-cli/src/release/. Every surface that states any of it renders that value: majordomus release on the command line, the HTTP route /api/v1/changelog, the MCP tool majordomus_changelog and the resource majordomus://changelog, and the generated document under docs/generated/. Nothing in it is authored. There is no CHANGELOG.md in this repository and there is not meant to be. Behaviour as implemented and tested; where this document and the executable disagree, the document is wrong and changes in the same commit.

The commands, their arguments and their executable examples are in the generated reference (generated/cli.md, under majordomus release); the capabilities, their routes and their benchmark cases in generated/capabilities.md, module release. Neither is restated here.

The version is measured, not claimed

The bump a release takes is decided by comparing the public surface of the last release with this tree, not by the words in the commit messages. feat: meaning minor and fix: meaning patch describes what the author believed; it says nothing about what a caller can still call. Between v0.3.1 and 0.4.0 the command majordomus scope classify left the command line and every gate stayed green, because no gate was looking at the surface.

The surface is the capability registry — one declaration of which MCP, HTTP, OpenAPI and the command line are projections — and docs/generated/registry.json is committed at every commit and every tag, so two releases can be compared without building either. What a caller can hold is an atom: a public capability's identity, its kind, the MCP tool and resource it answers to, the HTTP method and path it is bound to, the command-line path that dispatches it.

an atom is gone      major implied   a caller who held it is broken
an atom is new       minor implied   nothing broke, something arrived
the surface is equal patch implied   nothing is owed; most commits are here
scripts/ci/version-matches-surface             # the tree against the newest version tag
scripts/ci/version-matches-surface --explain   # every atom that arrived or left
scripts/ci/version-matches-surface --implied   # just the level
majordomus release bump --level minor          # raise both writers at once

Below 1.0.0 the floor is a minor release for any surface change, gone or new. Semantic versioning grants 0.y.z a blanket exemption — anything may change — and Elm refuses that exemption by starting every package at 1.0.0. This project has earned neither answer, so it takes the strongest signal 0.x has rather than demanding 1.0.0 for a single removal. When the major reaches 1 the shift ends and the implied bump is the required one.

A removal is named whatever the verdict is. Below 1.0.0 it does not refuse the release, and it still belongs in the release record and the changelog as a breaking change: a caller who held what is gone otherwise finds out by breaking. The rule is project.the-version-is-measured; the gate is version-surface.

The gap it closes

Every other public fact in this repository has one canonical declaration and a set of projections derived from it — a command, a capability, a schema, a page. The release did not, and it failed in two different ways.

The changelog did not exist. What changed in a version lived in GitHub's release notes, outside the repository that produced it, written by whoever cut the release and read by nobody afterwards. The facts it would have contained were already in the tree, unread:

  .ai/repo/releases/*.yaml   what shipped, when, from which commit   (kind release-record)
  .ai/repo/adrs/*.md         the decisions, dated                    (kind adr)
  git log <commit>..<commit> everything that has no object of its own

Nothing raised the version. It is stated in two files — apps/majordomus-cli/Cargo.toml and bin/majordomus (MJ_VERSION) — and scripts/release-version --check compared them and exited 10 when they disagreed. A check with no writer behind it verifies a person's memory: it can say the two disagree, and it can say so only after someone has already edited one of them and forgotten the other.

Neither gap needed a new source of truth. The changelog needed a join; the version needed a writer.

What a section is composed from

One section per release record, newest first by the date the record carries, with the unreleased work leading:

   release record  ──►  version, tag, date, commit, artifacts
   adr objects     ──►  the decisions dated inside this release's window
   git log A..B    ──►  the changes, as conventional commits
   issue·milestone ──►  the records those commits name, resolved against the layer

The window of a release is the same interval said in the two vocabularies its two sources have. An ADR carries a date and no commit; a commit carries no date the layer indexes. So the decisions of a release are those dated in (previous release's date, this release's date], and its changes are the commits in previous..this. The first release's range is everything up to its commit — a clone that does not carry that history yields nothing, and says so rather than showing an empty section.

Dates are compared as the strings they are: both sources write ISO-8601, where lexicographic order is chronological order. A record's published_at carries a time and an ADR's date does not, so the comparison is made on the day the two share.

A section's artifacts are the record's own evidence — the target, the file name and the SHA-256 that scripts/release-record read off the file that was published. The changelog copies them; it does not compute them and it does not reach the network.

The unreleased section

Everything after the newest record, as <last release's commit>..HEAD. It leads the document when it has any changes and is absent when it has none, because an empty "Unreleased" heading says nothing a reader can use. A repository that has never published has no records at all, and its whole history is one unreleased section — the right answer for a project before its first release rather than an error.

Conventional commits, read totally

The parse is small and deliberately total:

  feat(commands): one canonical command graph
  ^^^^ ^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  kind scope      subject

  feat(api)!: the route moved      `!` marks it breaking
  BREAKING CHANGE: <why>           so does this trailer, in the body

A subject that does not parse is kind Other and still appears, with its subject kept whole rather than split at a colon that was part of the sentence. This is the one design decision in the parser worth arguing about, and it goes the other way from most changelog generators: a changelog that silently drops what it cannot classify lies by omission, and the commits it drops are exactly the ones nobody was paying attention to when they landed. project.conventional-commits is advisory, so a repository following it will always have some subjects that do not parse.

The kinds and the headings they render under, in the order a reader of a changelog wants them — what is new, what is fixed, what is faster, then the rest:

  feat      Added            docs           Documentation
  fix       Fixed            test           Tests
  perf      Performance      ci             Pipeline
  refactor  Changed          chore          Housekeeping
                             anything else  Other

A heading with no entries is absent; nothing is hidden. Merge commits are not read at all (git log --no-merges), because a merge's subject describes the integration and its contents are already in the range.

The order is in the document, not in each renderer

A section does not carry a flat list of changes. It carries groups — one per kind that has any change at all, each with its kind, the heading it is shown under, the rank it sorts by, and its own changes — and the groups are already in rank order when the document is composed:

  section.groups[]  →  { kind: "feat", heading: "Added",   rank: 0, changes: [...] }
                       { kind: "fix",  heading: "Fixed",   rank: 1, changes: [...] }
                       { kind: "docs", heading: "Documentation", rank: 4, changes: [...] }

That order used to exist in exactly one place a projection could not reach: ChangeKind::rank(), a Rust function the Markdown renderer called. The website cannot call a Rust function, so it grouped what it was given alphabetically instead, and the same changelog was read in two different orders depending on which surface showed it. The order is in the value now. Every renderer reads heading and rank rather than deciding either — site/templates/changelog.html neither groups nor sorts, and the Markdown renderer walks groups in the order it was handed them. A presentation order stated once and carried is the only kind that survives a projection.

The ranks are the kinds' own and are not contiguous: a section with no refactors simply has no group of rank 3. A group's absence means nothing of that kind happened in the range, never that something was hidden.

Where a fact can be read

Every address the changelog carries is derived from about::REPOSITORY — the crate manifest's own repository, which the compiler passes in as CARGO_PKG_REPOSITORY. None of them is a literal, so a fork or a move carries every link with it and nothing has to be told where the project now lives:

  section  ──►  notes_url    the published release, from the record's own notes_url
                compare_url  the range against the previous tag
                tree_url     the tree at that tag
  change   ──►  url          the commit
  decision ──►  url          the ADR file that states the decision

forge() in changelog.rs recognises one shape — https://github.com/<owner>/<repo> — and returns nothing for anything else. When it returns nothing, no links are produced at all rather than links guessed from a pattern that might hold. A wrong link cannot be told from a right one until it is followed, so a reader with no link knows they have none and a reader with a plausible wrong one does not.

Two of the section addresses are not simply the tag's. A record that names its own notes_url keeps it, because the published release is where the record says it is; the fallback is the tag's release page. The first release has no predecessor to compare against, so its compare_url is the forge's list of commits up to the tag rather than a range. The unreleased section compares the last tag against master and points at the tree there.

Decision.route was removed under the same argument. It named /decisions/<id>/, and nothing publishes such a page: the honest destination for a decision is the ADR file in the repository, which is what url now carries. The Markdown rendering keeps the plain shape it always had — the addresses are in the value for the surfaces that can use them, and the site page renders every one of them.

The document names its own producer

produced_by is the capability that answered — its id, the command line that renders it, the HTTP route, the MCP tool and the MCP resource — read off the registry entry by one function, capability::builtin::release::produced_by, which the handler that serves the document and the generator that commits it both call. So the committed docs/generated/changelog.json and the live answer name the same surfaces, and a reader who has one of them can find the others. A page that points at the same value elsewhere resolves those names against the datasets the registry generates (executable.json, cli.json) rather than typing a route: the site's changelog page does exactly that, because site-check's registry and cli assertions refuse a template that names a capability or a command route by hand — and did, on the first draft of that page.

What a commit names, resolved

A commit's subject and body are scanned together for the ids this repository's plan uses — an I and four digits for an issue, an M and three for a milestone — and every id found is then looked up in the layer. An id that matches the shape and names no object is dropped. What is carried is a reference with the kind, the id, and the title from the record it resolved to, so an entry reads as the issue it belongs to rather than as a code only its author can expand.

The resolution half is what makes the inference safe. Matching alone would put a link on every string that looks like an id, including the ones nothing answers to, and that is the failure the links above are built to avoid. The scan reads the body as well as the subject because a commit that explains itself in its body is the one most worth linking, and a word that merely begins with the letter (Interesting, M1) is not an id.

Counts of what that yields go stale; measure them instead:

  majordomus release changelog --format json \
    | jq '[.sections[].groups[].changes[].references[]?] | length'

The version: two statements, one writer

The version stays stated in two places, and should. scripts/release-version gives the reason and it is a real one: an installed tree has no Cargo.toml, and the crate is compiled before the shell tool exists, so neither program can read the other's copy at run time. DISTRIBUTION.md is where that is argued. What was missing was not a single source — it was a single writer.

majordomus release version answers what both files state, whether they agree, what the commits since the last release imply, and the commits themselves as the evidence for that implication. It exits 10 when the two disagree, which is the same verdict and the same exit code scripts/release-version --check gives, so a person and a pipeline get one answer.

majordomus release bump is the writer. The bump defaults to what the commits imply:

  any commit marked breaking   →  major
  any feat                     →  minor
  anything else                →  patch
  no commits at all            →  none

It is a total function of the changes, which is what makes the answer arguable from the evidence rather than a judgement a reader has to trust. --level major|minor|patch|none overrides it and --exact 1.2.3 bypasses it, for the cases where a maintainer means something the commits do not say. --dry-run prints what would change and writes nothing.

Two properties of the write matter:

  • It is byte-narrow. Only the version line inside [package] of the manifest and the MJ_VERSION= line of the shell tool are rewritten. A dependency pinned at the same version, or the string in a comment, is untouched.
  • It checks its own work. After writing, the bump re-reads both files and reports a disagreement with exit 10 — so a bump that half-applied is caught by the thing built to catch it rather than by the release three commits later.

Zero-major is not special-cased: a project at 0.x that declares a breaking change gets 1.0.0. Some projects hold the convention that it should not, and others do not; applying it silently would make the derived answer unarguable, so a maintainer who means otherwise names the bump.

The version the crate declares is also the current field of the changelog, so a bump that was made and a changelog that was not regenerated disagree, and generate --check says so.

Which surface carries what

command linegenerateHTTP · MCPCockpit
the changelogmajordomus release [changelog [VERSION]]docs/generated/changelog.{json,yaml,md}GET /api/v1/changelog · majordomus_changelog · majordomus://changelogthrough the registry
the version reportmajordomus release versionGET /api/v1/release/version · majordomus_release_versionthrough the registry
raising the versionmajordomus release bumpwithheldwithheld

Nothing configures that last row. release bump writes tracked files, which command_graph/semantics.rs annotates as RepositoryMutation, and the exposure policy of ADR 0027 keeps repository mutations off every machine surface. No capability declares it, and majordomus commands explain executable.release.bump prints the reason each surface withholds it. The read half is two capabilities, and the command line renders them by executing them rather than by calling the code underneath, so the terminal and the API cannot drift apart.

Neither read capability declares a cli exposure, and that is deliberate: majordomus release changelog is a local command that renders the capability for a person at a terminal, and cli::LOCAL says so once — RendersCapability("release.changelog") — beside the reason it is local. A capability that declared the same path would be the same command accounted for twice, which tests/quality.rs refuses as OPERATION_CLASSIFICATION_CONFLICT. What reads that one declaration is produced_by below, so the document can name its command line without the crate stating it a second time.

The generated document is a generated artifact like any other — one value written as JSON for a program, YAML beside it and Markdown for a reader, each declaring its schema (majordomus/changelog/v1) and its source. That is the point of generating it at all: generate --check, run by scripts/rust-check, is what notices that the changelog has stopped describing the tree, so nobody has to remember.

What could not be read

The changelog says what it could not read instead of hiding it. Every such finding appears in diagnostics and, in the Markdown, under a final "What could not be read" heading:

findingcause
a release record names no committhe record is malformed; its section cannot be composed and is omitted
no commit was readable for a versionthe clone does not carry that history — a shallow clone, or a rewritten range

A git log that fails is not an error here: it yields no commits, and the caller reports the gap. That is what lets the changelog render at all in a shallow CI checkout, where refusing would make the document unavailable exactly where it is read from a machine.

Releasing

The release procedure itself — the tag, the pipeline, the build matrix, the record written from what was published, and recovery from a bad release — is DISTRIBUTION.md. This document owns only the version and the changelog it produces. The first step of that procedure is now majordomus release bump rather than an editor over two files, and scripts/release-version --check still runs after it: the check proves the work of one writer instead of the memory of one person.

What proves it

apps/majordomus-cli/src/release/commits.rsthe parser: the conventional shapes, both spellings of breaking, an unknown lowercase type, and a subject that is not conventional kept whole; and the resolution: an id the layer holds becomes a reference carrying that record's title, an id of the same shape that names nothing does not, a name mentioned twice is carried once, and a word that merely starts with the letter is not an id
apps/majordomus-cli/src/release/version.rsthe bump is a total function of the changes, raising zeroes what it supersedes, a version that is not three numbers is refused, and writing touches only the two lines that state the version
apps/majordomus-cli/src/release/changelog.rsa decision belongs to the release whose window contains its date, the unreleased window opens after the last release, a timestamp and a date compare on the day they share, and the groups reach the renderer in rank order whatever order the commits arrived in
test/cases/103_release_projection.shthe whole surface against the real executable, in a disposable repository with a real history: which commits fall in which range, which decision belongs to which window, a record added with nothing else edited, a subject that follows no convention carried rather than dropped, a fixture whose repository is not a forge the tool knows producing no link rather than a guessed one, a commit naming one id the layer holds and one it does not, and every exit code above
scripts/ci/release-checkthe two declarations of where the version is stated have not drifted, the two sites agree on every plan rather than only at publication, no hand-kept changelog has appeared, and the tree is not behind the newest release the layer records
scripts/rust-check (gate rust-check)generate --check: the committed changelog still describes the tree
scripts/release-version --checkthe two writers agree — the same verdict majordomus release version gives, with the same exit code

The behavioural case builds its own repository rather than reading this one, because every question it asks is a question about a history. A fixture whose history is real is the only thing those answers can be checked against.

  • ADR 0029 — the decision behind this document, and the four alternatives it rejected
  • DISTRIBUTION.md — how the tool is packaged, published and installed, and the release pipeline that writes the records this reads
  • COMMANDS.md — the effect model and the exposure policy that withhold release bump
  • CAPABILITIES.md — the registry the two read capabilities are declared in
  • DYNAMICITY.md — the ownership rule this is an instance of