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.
Spec leads the release
Section titled “Spec leads the release”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):
version = "0.2.0"status = "staged" # planned → staged → in_progress → releasable → released → yankedreleased_on = "2026-07-30" # written when the release is published; absent until thenrepos = ["lemonfiber", "lemonfiber-media-stack"] # the streams this version cuts; brand excludedsatisfied_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 reproducibilitylemonfiber-media-stack = "fbdafe0" # the submodule commit that shippedStaging 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.
The lifecycle
Section titled “The lifecycle”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.
The four lanes
Section titled “The four lanes”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 tag → release.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.
Locking goals
Section titled “Locking goals”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.
Release branches — the hotfix exception
Section titled “Release branches — the hotfix exception”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.
The gate — every goal satisfied
Section titled “The gate — every goal satisfied”execute-version MUST refuse unless every locked goal is satisfied, and a
goal counts as satisfied only when both hold:
- a merged PR cites its ID in a
Spec:trailer (the automatable claim), and - 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.
What a version number means
Section titled “What a version number means”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.
Cross-repo orchestration
Section titled “Cross-repo orchestration”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.
PR and issue automation
Section titled “PR and issue automation”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 |
When a goal cannot be cited
Section titled “When a goal cannot be cited”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.
Requirements
Section titled “Requirements”| 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. |
Related
Section titled “Related”- releasing.md — the tag-triggered mechanics this orchestrates
- project-workflow.md — the trunk-based model and OPS-R10, which releases now follow rather than carve out
- notifications.md — the Discord channels OPS-R50 posts to
- ../20-architecture/contracts/versioning.md — the version streams the gate checks
- ../50-governance/change-lifecycle.md — the
Acceptedstatus a goal must hold - ../50-governance/cross-repo-ci.md — the citation gate this reuses in reverse
This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page