Skip to content

Release staging — the version train

Status: Accepted

releasing.md answers “I want to cut a version, what do I do?”. This answers the question before it: “how does a version come together — spec first, across repos — and how do we know it is ready?” It defines the staging lifecycle, the goal lock, the readiness gate, and the automations that drive them.

Satisfies: roadmap M10, extends releasing.md and project-workflow.md.


The spec is already structurally ahead of the code: a behavioural PR is closed unless its spec change merged first (cross-repo-ci.md). A release is the same rule at a larger grain. A version’s goals are a set of Accepted requirements (change-lifecycle.md), chosen and locked before the work is called done, and the release does not ship until every one of them is demonstrably implemented.

The train has one source of truth, four states, and four lanes of differing ceremony. Everything downstream reads the manifest; nothing keeps release state in a person’s head.

The version manifest — one source of truth

Section titled “The version manifest — one source of truth”

Each version is a machine-readable file in versions/, idiomatic to the org’s other generated-from-truth files (maintainers.toml, labels.yml):

70-operations/versions/0.2.0.toml
version = "0.2.0"
status = "staged" # planned → staged → in_progress → releasable → released → yanked
released_on = "2026-07-30" # written when the release is published; absent until then
repos = ["lemonfiber", "lemonfiber-media-stack"] # the streams this version cuts; brand excluded
satisfied_in = ["lemonfiber", "lemonfiber-web"] # where the gate looks for citations; defaults to `repos`
goals = ["A2-R1", "A2-R6", "C1-R13"] # locked Accepted requirement IDs
[pins] # recorded at execute, for reproducibility
lemonfiber-media-stack = "fbdafe0" # the submodule commit that shipped

Staging writes it, the tracker reads it, the gate checks it, and publishing flips its status, records its pins and stamps the date it went out. The full contract is in versions/README.md.

The date belongs on the manifest rather than being looked up from the tag, because the manifest is what everything downstream reads. A page that renders the train from these files can say when each version shipped without reaching for the forge, and a version that claims to be released without a date is a record that was written by hand.

stateDiagram-v2
[*] --> planned
planned --> staged: stage-version — locks the goals
staged --> in_progress: work has begun — the version people are building
in_progress --> releasable: tracker sees every goal satisfied
staged --> releasable: tracker sees every goal satisfied
releasable --> released: execute-version — gate ✓ → tag → draft → publish
staged --> released: ⚡ fast lane — goals already met
released --> yanked: a shipped release is withdrawn
released --> [*]

Every transition MUST be recorded in the manifest, so the file alone answers “where is this version” without reading CI history.

One version at a time. Outside hotfixes, the train is serial: at most one version is in flight — staged or releasable — at once. stage-version refuses while another version is still unreleased, so two minors never compete for the same main or the same goal pool. A hotfix is exempt: it branches from an already-released tag and never enters staging.

Ordered by ceremony. Each is a way of reaching the same tag-triggered release (releasing.md); they differ in how much is verified first.

Lane When Goal gate Staging period
Staged train a planned minor (0.2.0) full — every locked goal satisfied yes: branches split, progress tracked
Fast lane spec and sub-repos already in sync full, run once at execute no — stage, gate and execute in one operation
Hotfix an urgent patch (0.2.1) bypassed → replaced by a cited fix + maintainer no
Raw tag the primitive under all of the above none (git tagrelease.yml) no

The fast lane still runs the gate: even a one-shot release proves its claimed goals shipped. Only the staging period is skipped.

A version’s goals are seeded from the roadmap milestone it serves — expanded to the requirement IDs that milestone’s deliverables cite — then trimmed or extended by a maintainer before the lock. A goal MUST be an Accepted requirement; a Draft or Withdrawn one cannot be a goal, for the same reason it cannot be cited (change-lifecycle.md).

Once staged, the goal set is frozen: changing it requires review (a goals-change label) and is logged to the maintainer channel, so a release’s scope cannot drift silently after the promise is made.

Trunk-based development (OPS-R10) is the rule, and OPS-R49 keeps it: a version is released from main, the tag names a commit on the trunk, and staging cuts no branches. The one carve-out is a hotfix to an already-released version, where main has moved on and the fix must reach the shipped tag. OPS-R33 confines the branch to that case — cut from the version’s tag, carrying only the fix, deleted once the fix is merged back to main. No other release/* branch exists.

execute-version MUST refuse unless every locked goal is satisfied, and a goal counts as satisfied only when both hold:

  1. a merged PR cites its ID in a Spec: trailer (the automatable claim), and
  2. the implementation status marks it done (the human attestation).

Citation proves someone did the work and said which requirement it served; the status file proves a human agrees it is complete. Requiring both is defence in depth: a citation without a tick is work in flight, a tick without a citation is an unauditable claim. A refusal MUST name the unmet goals, never fail blankly.

Citations are read from each target repo’s whole history, and the gate MUST refuse a truncated one rather than read it. A shortened history loses its oldest commits first, so a goal proven once would come undone as unrelated work landed — and a verdict that changes with the depth of a clone is not a verdict.

Before tagging, execute MUST also verify the streams still agree — the embedded stack’s schema_version and min_cli_version against the binary (versioning.md) — and record the exact submodule pins in the manifest, so the release is reproducible from the file.

A version says how much changed, so the numbers have to describe the product rather than the order the work happened to be written in. Three rules keep them honest.

A major carries the capability that justifies it. Not a stamp on a finished backlog — 0.15.0 to 1.0.0 shipping nothing would be a strange thing to announce. 1.0.0 opens the dashboard on a bare invocation, which is what v1 builds toward. 2.0.0 runs the stack without Docker, which is a different product generation. A major that adds no capability is a number nobody can read.

A generation’s work ships inside its own major. The ecosystem features used to be scheduled as 1.1.0 through 1.7.0 — minor bumps, one of which removed the container runtime. Anyone reading the version would have been misled about how much changed. They are 2.x, so the size of the change and the size of the number agree.

A version is one theme, not a backlog. They ranged from nine goals to a hundred and eighty-five; the large ones were not releases, they were everything left over with a number attached. Each unreleased version is now something you can say in a sentence, and its manifest header says it.

What a feature needs, and what it merely relates to

Section titled “What a feature needs, and what it merely relates to”

requires: is what a feature cannot meet its own acceptance criteria without — a notification channel to notify through, a surface to appear on, an error model to word a remedy in. relates: is worth reading and not needed to build.

They used to be one field, and sixty-seven features were scheduled before something they said they depended on. Fifteen were real; the rest were cross-references. The real ones had one cause: capabilities everything else is expressed in terms of — notifications, the error model, the health summary — were scheduled last, because nothing distinguished “I need this” from “see also”.

scripts/check_order.py refuses any schedule that ships a feature before something it requires:. A released version is history rather than a plan, so inversions inside one are recorded and never enforced — nothing can be moved into or out of something already shipped.

The no-stub rule — a version ships nothing half-built

Section titled “The no-stub rule — a version ships nothing half-built”

A version proves its goals requirement by requirement, and that is not the whole of what it claims. A requirement can be met while the feature around it is half built: 1.0.0 announcing a dashboard whose panels are stubs would satisfy every goal it locked and still be the release nobody wanted. The feature is the unit a reader understands, so the feature is what this asks about.

execute-version MUST refuse while any feature the manifest locks — one whose requirements appear in its goals — is not maturity: shipped in the catalogue, and the refusal MUST name them. That is the whole of what “a major ships no stubs” means here: a rule about every version, and X.0.0 is only where it bites hardest, because a major is what people read as a finished generation.

maturity is the catalogue’s own answer to how far is this built, kept apart from the status that describes the specification — a feature can be Accepted and unbuilt, and conflating the two loses whichever question is asked less often. Like the tracker tick in OPS-R34, it is an attestation written before the tag rather than derived from it: the feature is marked shipped with the version that carries it, and the gate reads that mark.

Today every arrow points into spec: repos call its reusable checks. The train needs the opposite — spec driving the sub-repos to cut branches, open PRs and start releases. That MUST authenticate through a scoped GitHub App (contents and pull-requests write on the named repos), never a personal token, so the credential is auditable, org-owned and revocable. Installing it is one-time setup, like the release secrets in releasing.md.

The train narrates and enforces itself through automation, so release state is never a person’s memory. Each row is a specified behaviour below; the workflows implementing them are built per repo.

Automation What it does
Version labelling A PR citing a locked goal is labelled with that version and assigned its milestone
Goal-advance comment A PR advancing a goal gets a self-updating comment linking the tracker and the goal’s coverage
Compat gate A required check fails a merge that would break schema_version / min_cli_version agreement for the staged version
Out-of-scope advisory During staging, a PR citing outside the locked goals gets a non-blocking advisory routing it to the next version
Tracker issue Staging opens a self-updating issue — goal checklist and burndown — that flips to releasable at full coverage
Release-blocker linkage An issue labelled release-blocker for a version links to the tracker and blocks execute until closed
Next-version issue Releasing opens the next version’s planning issue, seeded from the next planned manifest’s goals
Drift watchdog A scheduled check flags a locked goal whose requirement was withdrawn or superseded
Submodule bump A lemonfiber-media-stack release opens a lemonfiber PR bumping the submodule pin, gated by the build.rs compat check
Pin fan-out When spec’s reusable workflows move, an automated PR bumps the pinned @SHA in every consumer repo in lockstep
Issue lifecycle Releasing closes the issues opened for that version — its tracker, and any drift the watchdog raised
Release from the trunk A version is tagged on main; a hotfix to a shipped version branches from its tag and merges back
Discord cadence Staging and progress milestones (25/50/75/100%) post to #maintainers; execute posts to #releases

A merged commit cannot gain a Spec: trailer. So a change that closed several requirements under one trailer leaves the rest uncitable for ever, and the two ways out are both bad: a later commit citing a goal it did not advance is exactly the unauditable claim the second arm exists to refuse, and inventing work to carry the citation is worse.

A done row may therefore name the commit instead — landed in 3c595bb — and the gate checks it: the sha has to resolve to a commit that is an ancestor of a searched repository’s head, or the row counts for nothing. git show is the audit, which is the whole reason it is a commit rather than a pull request number. One can be checked against the artefact, offline; the other is a question for the forge.

It is reported separately from an ordinary citation, because an exception nobody can count is one that spreads. A goal satisfied this way reads cited=landed rather than cited=yes, so how many of them there are is a number somebody can look at.

ID Requirement
OPS-R29 Every release MUST be scoped by a version manifest under 70-operations/versions/, which is the single source of truth for the version’s status, target repos, and locked goals.
OPS-R30 Staging a version MUST lock its goals as an explicit list of Accepted requirement IDs, seeded from the roadmap milestone it serves and editable before the lock; a Draft or Withdrawn requirement MUST NOT be a goal.
OPS-R31 After staging, changing a version’s locked goals MUST require review and MUST be announced to the maintainer channel.
OPS-R32 A version MUST progress through planned → staged → releasable → released — optionally through in_progress between staged and releasable, with yanked terminal — and each transition MUST be recorded in its manifest.
OPS-R33 A release/<version> branch MAY exist only to carry a hotfix to an already-released version; it MUST be cut from that version’s tag and deleted once its fixes are merged back to main.
OPS-R34 execute-version MUST refuse unless every locked goal is satisfied — a merged PR cites its ID and the implementation-status tracker marks it done — and the refusal MUST name the unmet goals. Where no merged commit cites a goal and none can, a done row MAY name the merged commit that finished it instead; the gate MUST verify that commit is in a searched repository’s history, MUST report which goals were satisfied that way, and MUST NOT accept a row naming a commit it cannot find.
OPS-R35 Before tagging, execute MUST verify cross-stream compatibility (schema_version and min_cli_version against the binary) and record the embedded submodule pins in the manifest.
OPS-R36 A fast lane MUST allow staging, gating and executing in one operation when the goals are already satisfied; the goal gate MUST still run.
OPS-R37 A hotfix lane MUST allow a patch release from a released tag that bypasses the goal gate, requiring instead a cited fix or issue and maintainer authorisation.
OPS-R38 Cross-repo release orchestration MUST authenticate through a scoped, org-owned GitHub App, never a personal access token.
OPS-R39 A PR that cites a locked goal MUST be labelled with that version and assigned its milestone.
OPS-R40 A PR that advances a locked goal MUST receive a self-updating comment linking the version tracker and the goal’s current coverage.
OPS-R41 A required check MUST fail any merge that would break schema_version / min_cli_version agreement for the staged version.
OPS-R42 During a staging period, a PR whose citations fall outside the locked goals MUST receive a non-blocking advisory routing it to the next version.
OPS-R43 Staging MUST open a self-updating tracking issue — a goal checklist with a burndown — that reflects coverage and flips the version to releasable at full coverage.
OPS-R44 An issue labelled release-blocker for a version MUST link to that version’s tracker and MUST block execute until it closes.
OPS-R45 Releasing a version MUST open the next version’s planning issue, seeded from the goals of the next planned manifest.
OPS-R46 A scheduled check MUST flag a locked goal whose requirement became Withdrawn or Superseded.
OPS-R47 A lemonfiber-media-stack release MUST open a lemonfiber PR bumping the embedded submodule pin, gated by the build-time compatibility check.
OPS-R48 When spec’s reusable workflows move, an automated PR MUST bump the pinned @SHA in every consumer repo in lockstep.
OPS-R49 A version MUST be released from main: the tag names a commit on the trunk, and no long-lived release branch is cut. A hotfix to an already-released version MUST branch from that version’s tag and MUST be merged back to main.
OPS-R50 Staging and progress milestones MUST post to the maintainer channel and execute MUST post to the public announcement channel.
OPS-R52 At most one version MAY be staged or releasable at a time; stage-version MUST refuse while another version is still in flight. Hotfix patches are exempt.
OPS-R55 Releasing a version MUST close the issues opened for it — the tracker from OPS-R43 and any drift issue from OPS-R46 — so an open issue about a version means something is still owed.
OPS-R54 A version MUST NOT be released while a feature it locks — one whose requirements its goals name — is not maturity: shipped in the feature catalogue, and a refusal MUST name those features. A major ships no stubs, and neither does any version before it.
OPS-R57 A manifest whose status is released MUST carry released_on, the UTC date its release was published, as YYYY-MM-DD. The transition to released MUST write it from the publication the transition responds to; it MUST NOT be entered by hand, and no earlier status may carry it.
OPS-R58 A manifest MUST say where the work satisfying its goals landed, and the goal gate MUST search exactly those repositories. Where a manifest does not say, the streams it cuts are what is searched. A repository named there MUST NOT be tagged for being named: what a version cuts and where its goals were satisfied are separate lists, and a goal satisfied in a repository the gate does not search MUST be reported unmet rather than passed over.

This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page