Release staging — the version train
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
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
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
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 report 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
stateDiagram-v2
[*] --> planned
planned --> staged: stage-version — locks the goals
staged --> in_progress: work has begun — the version people are building
in_progress --> releasable: the report's gate run sees every goal satisfied
staged --> releasable: the report's gate run 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
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 | full — OPS-R70; a lane that cuts a release tag runs it or refuses |
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 pre-release is none of these. It is not a way of reaching the release tag; it is a way of shipping a version’s artefacts before that tag, held to the same gate — see going out before it is released.
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. A released version’s
goals are frozen the same way. goals-change.yml holds both halves: its
goals-change check refuses a pull request that moves a frozen version’s goals
without the label, naming every goal added and taken out, and once a labelled
one merges it tells the maintainer channel what moved.
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
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 tracker marks it done (the human attestation).
The tracker is not one file. Every repository a version is satisfied in keeps
its own status.toml at its root, one short row per requirement it has worked
on, and changes it in the pull request that changes what it says (OPS-R74). A
row names the requirement, its state (done, partial or open), the evidence
that holds it — the code and the test, by path — and, where no commit cites it,
the commit it landed in:
requirement = [
{ id = "F8-R6", state = "done", evidence = ["crates/lemonfiber-plugin/src/refusing/recipes.rs", "crates/lemonfiber-plugin/src/refusing/recipes/tests.rs::a_flow_no_pair_declares_is_refused"], landed = "6409706" },
]
A repository whose rows would run past a thousand lines keeps them in a
status/ directory instead, one <feature>.toml per feature in the same shape,
each holding only its own feature’s rows. Nothing else records the same thing:
no Markdown copy of a tracker is committed, and the page a person reads is the
generated report of where every version stands.
A requirement is done when any searched repository’s tracker records it done.
The gate reads the trackers from the same checkouts it reads citations from, so
naming where a version is satisfied names its trackers too. Each tracker is
checked on its own (OPS-R75): an identifier the specification does not define,
a done row naming no evidence or evidence that is not there, and a commit the
history does not hold are each refused. A done row no version locks yet is
recorded like any other: the companion takes no release of its own, and every
accepted requirement is already held to a version or to a declared wait by
scripts/check_goal_coverage.py.
scripts/status_check.py holds the format and the checks;
ADR-0039
records why it is a file per repository and a row per requirement.
Where every version stands
The gate answers one question about one version. Picking up work asks more of
each goal: is it met, is it built and not yet recorded, is somebody already on
it, or is nobody. scripts/goals.py answers all four for every version, from the
sources the gate reads, so the two cannot disagree (OPS-R76). A released version
is on it too, so how much of what shipped is recorded done is read from the same
place as what is left: state.json holds every goal of it, and STATE.md gives it
one line. The state workflow writes it every hour and publishes each
generation as that run’s state artifact: STATE.md for a person, which is also
the run’s summary, and state.json for a script. just goals <version> writes
the same from local checkouts, reading each at its origin/main rather than
whatever branch it has checked out.
Nothing is committed. A commit an hour would run every check this repository has,
every hour, on a fixed number of concurrent jobs. Each generation stays fetchable from
its run for ninety days instead, and the newest is also published as the assets of the
board release, at a fixed address that needs no credentials. Beside state.json it
publishes board.json, the snapshot the frontpage renders its roadmap and board from:
the catalogue, the manifests, every tracker row, every open pull request and the
releases, in the shape the board snapshot documents (OPS-R80).
A repository the report cannot read is named in it with the reason, and a goal
searched there reads unknown unless the repositories it could read show it met; the
rest of the report is still written and published. The release gate is stricter and refuses a version it cannot judge
(OPS-R75), but a report that stopped at one absent tracker would hide every fact it
could read.
state.json is read by other repositories, so its shape is held still. format
names the shape; a field removed, renamed or given another meaning raises it, and a
field added does not, so a reader checks format and ignores what it does not know.
| Field | Holds |
|---|---|
format |
The shape of the file, 1 |
generated_at |
When it was written, UTC, YYYY-MM-DDTHH:MM:SSZ |
ref |
The revision each repository was read at, as given to goals.py |
sources |
The full commit read of spec and of every repository, by name |
versions[] |
Every version, released and yanked ones included, in train order |
versions[].version, .status |
The manifest’s version and its status as the manifest states it, released and yanked included |
versions[].milestone, .delivers |
The manifest’s milestone and what it delivers, or null where it names none |
versions[].goals[] |
Each goal: id, verdict (below), and the evidence behind it |
goals[].cited_in |
Each merged commit citing it, as repo@sha |
goals[].done_in, .partial_in |
The repositories whose tracker records it done, or partial |
goals[].landed_in |
Each tracker row naming where it landed, as repo@sha |
goals[].claims |
Each open pull request citing it: repo, number, url, draft |
unread[] |
Each repository that could not be read: repo, reason |
| Verdict | Means |
|---|---|
| met | cited, or landed where a row names it, and recorded done |
| unmarked | cited by a merged commit and recorded done in no tracker: check it and record it |
| uncited | recorded done and cited by nothing |
| claimed | not met, and an open pull request cites it, a draft included (OPS-R77) |
| open | none of those |
| unknown | not met in the repositories read, and searched in one listed under unread |
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.
Nor does the gate warn: anything it finds wrong is an error that refuses the
verdict, and a lane that finds a warning on the gate’s output refuses as it does
for an error, so a tag is cut only where the gate prints neither (OPS-R70).
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.
The gate is on the lane, not on one workflow
execute-version is a lane, not the gate. Anything that can put a vX.Y.Z tag
on the trunk starts the same pipeline and produces the same release, so the rule
belongs to cutting a release tag rather than to the workflow that usually
does it (OPS-R70).
This was found the expensive way. v0.15.0 was tagged by release-dispatch in
the binary repository — a one-click lane that validates the version is on main
and matches Cargo.toml, and nothing else — while execute-version had not run
for three days. release-finalize then fired on the publish and wrote
status = "released" without re-asking. The version went out with F1-R1
unmet, and the record could not say so: a manifest reads released whether its
goals were proved or never asked about, and nothing anywhere distinguished the
two. It was noticed from outside, by a renderer of these files asserting that a
released version had proved its goals.
So a “raw tag” lane is no longer a sanctioned bypass. It remains the primitive every lane is built on — the tag is still what starts the pipeline — but a lane that reaches it without the verdict is refused rather than described.
Three properties make that refusal worth something, and each answers a way the first version of this could have been hollow:
| Property | Why it is written down |
|---|---|
| The verdict is taken at the commit being tagged | A gate that passed last week says nothing about this tree. v0.15.0’s goals were last gated three days and eleven commits before the tag. |
| A lane that cannot run the gate refuses | A gate that silently does not run is worse than no gate: it reads as a pass. An unreachable repository, an absent manifest and a missing status file are refusals, not skips. |
| The verdict names the unmet goals | OPS-R34 already asks this of execute-version. A lane that refuses without saying what is unmet sends somebody to read a gate rather than fix a goal. |
The hotfix lane is still exempt, and for the reason it always was: a patch to an
already-released version delivers no goals of its own, so there is no goal set to
prove. OPS-R37’s cited fix and maintainer authorisation stand in its place.
Going out before it is released
A pre-release is a version’s artefacts, built by the same pipeline from the same commit on the trunk and carrying the same provenance, cut before the version’s own tag. It is how an image lemonfiber builds reaches the stack before its version releases (ADR-0033 §4), and how the maintainers install the exact build the release is cut from.
It is cut only when every goal is met. The goal gate and the no-stub gate run at
the commit being tagged and refuse it exactly as they refuse an execute (OPS-R66,
OPS-R70). A version with a goal open does not go out, as a pre-release or
otherwise.
A pre-release passes every check a release passes, and differs only in the bundle
pin. Cross-stream compatibility is checked exactly as OPS-R35 requires before a
tag; release blockers stop it exactly as they stop an execute; what a plugin must
satisfy to ride the train it satisfies here too. The bundle pin differs because a
first-party plugin is built and proved against the core’s pre-release, then tagged
at the version itself by the release’s first step, pinned in the bundle and
embedded before the core’s release tag. So a pre-release tags no first-party
plugin’s repository, and its embedded bundle may pin a plugin that builds an image by
revision alone (OPS-R86).
It is never mistaken for the release
Three places say so, and none of them relies on a reader noticing a convention.
| Where | What says it |
|---|---|
| The tag | v0.15.0-pre.1, not v0.15.0. A semver pre-release identifier orders below the version, so nothing that compares tags can rank it above the release it precedes — and the release’s own tag is still free to be cut later. |
| The published release | Its title is the tag, and its body opens by saying it is not the release, with the gate’s verdict that every goal was met when it was cut. |
| The manifest | A [[prerelease]] record, beside a status that has not moved. The version is still staged, because it is. |
The forge’s own pre-release flag is not the signal, and cannot be. cargo-dist
marks every version below 1.0.0 as a pre-release, so all fourteen releases this
project has shipped already carry that flag and it separates nothing. Anything
written on it — an announcement guard, a record guard, a reader deciding what to
offer — would have been wrong about every release so far, and wrong quietly: a
channel that stays silent looks exactly like a channel nobody has posted to yet.
So the distinction is carried by the tag, which is why OPS-R61 puts it there
and why the artefact has to say it in words as well.
The identifier is deliberately not rc. ARCH-R43 uses the first release
candidate as the moment schema_version stops changing in place and starts
binding, which is a decision about the product’s life rather than a tag anybody
cuts. A pre-release that called itself a release candidate would fire that by
accident, so it calls itself what it is.
Nothing outside the organisation hears about it
OPS-R23 obliges a published release to announce to the public channel. A
pre-release is not one, and the announce path has to refuse on the pre-release
rather than be configured not to run — a channel that is quiet because nobody
turned it on is one release-day mistake from being loud. It announces to the
maintainer channel instead, so a pre-release is recorded rather than silent.
The same applies to the update check. An operator running 0.14.0 is not
offered 0.15.0-pre.1, because an update offer is a recommendation and this is
not one.
What the manifest keeps
The version’s own status does not move: a pre-release is not a lifecycle
transition, and OPS-R32’s chain is untouched. What is added is a record per
pre-release — the tag, the day it was cut and the submodule pins it embedded — so
the file answers what went out before the release the same way it already answers
what the release shipped.
The pins are enumerated from the tag’s own .gitmodules, not named in the lane.
That is the expensive lesson release-finalize already learned: it spelled out
the one submodule path that existed when it was written and went on recording
only that one after a second arrived, so 0.10.0 shipped a record that did not
say which build of the web app was in it.
The record lands before the tag, not after. What writes the verdict onto the published artefact reads it from here, so a tag cut while the record is still in review would produce a build that cannot carry its verdict and fails for a reason that has nothing to do with the build. The wait is bounded and its expiry is a refusal rather than a shrug: nothing has been tagged, so nothing has to be undone.
Plugins ride the train pinned, and block it when they stop validating
A plugin is not a release stream — OPS-R59 keeps the catalogue off the train and
nothing here changes that. What a plugin is, to this train, is an input to the
gate: a thing the release has to still be compatible with, checked on the way
past, and never tagged.
Every number the gate compares is the plugin’s own. Its plugin.toml says
which manifest generation it is written in; its targets.toml says which release
it is validated and proved against, or, before a release carries what it needs,
which commit of the core’s main (F10).
plugins.toml holds where to look and nothing else — a version
pinned there as well would be a second statement of one fact, and the two disagree
the day one of them is edited.
The lemonfiber version lives in targets.toml rather than in the manifest because
ARCH-R89 forbids a manifest carrying a minimum lemonfiber version: F3-R21
refuses an unmet requirement by naming the capability rather than a number that
cannot say which one, which is the right answer for an operator installing a
plugin. What the train needs is a different fact — which release it gated
against — and each plugin states it in a file of its own.
Every run that would cut a tag — a release and a pre-release alike — re-reads both pins and proves each riding plugin itself, and a plugin that no longer validates blocks the run.
The gate proves each plugin with the build it is about to tag
A plugin’s own proofs run through its reader, which fetches the release
targets.toml names, or builds the commit it names, and asks it about the plugin.
The report that leaves, proofs.json, is the plugin’s record against that release
or commit. The version being cut is neither: its binary is published by the tag this
run is deciding whether to cut. No plugin can hold a report against it, so the gate
does not ask for one.
It makes the report itself instead:
| Step | What happens |
|---|---|
| Settle | The core’s head commit is read once. The job that tags holds its checkout of the core at exactly that commit, so what is tagged is what the plugins were proved with, however far the core has moved since. |
| Build | The candidate binary is built from that commit the way the core’s release builds it: the dist profile, the pinned toolchain, the committed lockfile, every embedded submodule, on the host the release builds its Linux archive on. |
| Prove | In the gate’s own clone of each riding plugin, the candidate goes where the reader keeps a fetched release, .lemonfiber/<version>/<build>/lemonfiber; targets.toml names the version being cut; the committed report is removed. The plugin’s own .github/ is removed and plugin-template’s reader, at the commit plugins.toml pins, goes where a reader sits. It runs unchanged and writes a fresh report naming that version. |
| Read | The gate reads that report, and nothing else, against the table below. |
No code from a registered plugin runs in the train. Each plugin carries a copy of the reader for its own CI, and the gate does not run it: what it takes from a plugin is data — the manifest, the recordings, the release it targets — and the reader that asks the candidate about that data is the one the registry pins. A change to the reader reaches the train as a reviewed change to that pin.
The proving runs in a job of its own, ahead of the job that holds the release token, and holding no secret: building runs every dependency’s build script, which does not belong beside a token that can tag every repository. The build is most of the run’s time — minutes, against seconds for the proofs, which answer from recordings.
A plugin naming a commit is proved the same way
Before a release carries what a plugin needs, the plugin names a commit of the core’s
main in place of a release, and its own CI builds that commit the way the gate builds
its candidate (F10-R19). Nothing here changes for it. The gate overwrites the
checkout’s targets.toml with the version being cut, so the pinned reader finds the
candidate and proves with it as it does for a plugin naming a release, and the report
it reads names that version. What the commit is does not enter the proof: the candidate
is what is being cut, and it holds the commit.
What the gate reads of the commit is its form. A targets.toml naming a commit not in
full, or naming a release and a commit at once, cannot say what the plugin targets, and
fails the run by name.
Where the plugin is first-party, the bundle the release embeds pins a revision of it,
and that revision may still name the commit. The train tags the core only where the
core commit it tags holds that commit (OPS-R89, in releasing.md).
A plugin the gate cannot find is not a plugin that passed
This is the half that is easy to get wrong, and the shape this codebase keeps finding: the answer is true about what was looked at and silent about the rest, and silence reads as a pass. So each of these fails the run by name:
| The registry cannot be read, or declares nothing | The run cannot be answered at all |
| A registered repository does not exist, or cannot be cloned | Named as unreachable |
Its manifest is absent, unreadable, or declares no schema_version |
Named |
It cannot say what it targets — no targets.toml, unreadable, naming neither a release nor a commit, naming both, or a commit not in full |
Named |
| Its manifest pins a generation this release does not carry | Named, with both numbers |
| Its proofs do not run or do not finish with the candidate | Named, with what the reader said |
| Its reader fetched a release of its own rather than using the candidate | Named — it proved something other than what is being cut |
| The report its run wrote is unreadable, or names another version | Named |
| Its report names no proof, or names one that is anything but passed or failing as declared | Named — including unrun, which is not a passing proof (F3-R5) |
A proof failing as declared (F10-R13)
fails on a recording its manifest says it fails on, with the reason, and nowhere
else. It is not a pass and the gate does not count it as one: it names each in
what it prints, and fails nothing on it. A declaration gone stale is already a
failed proof in the report (F10-R14), and fails the run as one.
What a plugin targets is what keeps this from being retroactive. A release below
it is not that plugin’s business and the plugin is passed over; at or above it,
nothing about the plugin may be missing. A plugin naming a commit rides every
version: the train cuts from the core’s main, whose head holds every commit main
has held, so there is no version it predates. Every registered plugin is still
fetched, because the declaration deciding whether it rides is inside it — a
registry that could answer that question itself would be holding the second copy
this design exists to avoid.
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 and stops the four
interfaces this product is used through from moving again, which is a promise no
version before it can make. A major that adds no capability is a number nobody
can read.
There is one major to ship into. The ecosystem features were once scheduled
as 1.1.0 through 1.7.0 and then as a second generation, 2.x, on the
reasoning that a minor which removed the container runtime would mislead anyone
reading the number. The second half of that is gone: there are no epochs, and
what was sequenced behind 2.0.0 is the last stretch of minors before 1.0.0 —
the container-engine abstraction among them, at 0.24.0. The first half is why
they are still minors and not one release: each is one theme, and a version that
carried a generation would be the backlog-with-a-number this rule exists to
refuse.
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
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
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 thing 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.
execute-version MUST refuse while any requirement the manifest locks is not
built, and the refusal MUST name those requirements. 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.
The subject is the requirements a version carries, not the whole of every
feature it touches. The first cut of this rule asked the feature-level question
— is every feature this manifest locks finished — and nothing could ever satisfy
it: partial locking is the norm rather than the exception, 24 of 25 manifests
lock part of at least one feature, and 0.1.0 locks two of B1’s fifteen, so a
feature spanning two versions could never be finished when the first of them
shipped. A rule nothing can satisfy is a rule nobody runs.
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.
It is derived from every repository’s tracker and every version manifest rather
than typed (OPS-R78): planned where no requirement is done, building where
some are, built where all are, and shipped where all are and every one is
locked by a released version. withdrawn is the one value a person sets. It is
read per requirement:
maturity |
What it answers for the requirements the version locks |
|---|---|
built, shipped |
all of them: the feature is finished, so any subset of it is |
planned, withdrawn |
none of them: the feature is untouched, so no subset of it is |
building |
some and not others, and the catalogue does not say which — so the implementation-status tracker is asked, requirement by requirement |
built is what makes this checkable at all. shipped means out in a released
version, so requiring it before the tag would be a gate no version could ever
pass; built is the state a finished feature waits in while the version
carrying it is staged.
The two records are held against each other rather than trusted separately: a
planned feature whose requirements the tracker ticks is refused here even
though the goal gate would pass them, because a catalogue calling a feature
untouched and a tracker calling its requirements done cannot both be right. The
specification’s integrity job refuses a catalogue that says anything other than
what the trackers make it, naming each feature, and the maturity workflow opens
the pull request that corrects it within the hour.
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
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 |
| Releasable flip | Every run of the report runs the gate on the version in flight and moves it to releasable at full coverage; the frontpage’s page for the version shows where each goal stands, and no issue tracks it |
| Release blockers | A manifest’s blockers list blocks the releasable flip and execute until it is empty |
| 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 |
| Bump ordering | An automated bump does not merge itself while a repository downstream of it is running a required check the merge would discard |
| One bump per repository | An automated bump keeps one pull request per repository on one branch, rebuilt from main for each new version and retitled to it, rather than opening another beside it |
| The bump’s own pull request | A bump edits or arms only the pull request the release app opened from the repository’s own branch, at the commit it just made, through the one shared workflow |
| Issue lifecycle | Releasing closes any drift issue the watchdog raised for that version |
| 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 bump does not reset a gate it cannot see
The train is a chain, and each link arms its own merge. A change to the core’s contract dispatches a bump in the PHP client, which opens a pull request with auto-merge armed; merging it moves that client’s default branch, which makes the companion’s own client bump stale and discards every check running on it.
Each link is right on its own. What emerges is not: the gates are not the same length. The client’s is minutes and the companion’s is an hour and a half, so the fast link upstream always wins the race and the slow one downstream never finishes. On 23 September 2026 the companion’s bump was reset four times in one session and never landed, while every other pull request in that repository sat behind it — the drift check is required there and only that bump cures it.
So a bump that is about to merge itself asks whether anything downstream is mid-gate on the change it would discard, and waits where something is. It is a question the workflow can answer: the consumer’s pull request is open, its checks are readable, and still running is a state with an end. Waiting is bounded by the gate it waits on, which is the thing that makes this a delay rather than a deadlock.
The deferral is stated rather than silent. A bump sitting green and unmerged with nothing saying why reads as a bump somebody forgot, and the cure somebody reaches for is to merge it by hand — which is the race again, run deliberately.
This is ordering, not coupling: nothing here says a bump may be blocked by a consumer’s opinion, only that it may not discard a gate that is already running. Shortening the slow gate is the better fix and it is a separate one; while the two differ by an order of magnitude, the ordering is what keeps the difference from being fatal.
One bump per repository, rebuilt in place
A bump that opened a pull request for every version left the older ones open: by
the evening of 8 October 2026 .github held thirteen pin bumps, each running
its checks against a version already superseded, on runners the organisation
shares. Only the newest could ever be merged, and every other one was a cost
with nothing to show for it.
So an automated bump keeps one branch per repository and one pull request on it
(OPS-R85). Each new version rebuilds the branch from main with the new
change, moves it there, and retitles the open pull request to the new version,
or opens one where none is open. The checks of the version it replaced are
cancelled by the workflows’ own concurrency group, which cancels a superseded
run on the same branch. The pin fan-out keeps ci/take-the-shared-workflows;
the client and contract bumps already keep a branch each.
The bump’s own pull request, and nobody else’s
Each run finds the pull request on its branch again, to retitle it and arm it to
merge. gh pr list --head <branch> matches the branch name alone, so it also
lists a fork’s pull request whose branch is named the same, and a bump that
edits that pull request and arms auto-merge on it lands a fork’s code once its
checks pass. Eleven bumps across the organisation looked their pull request up
that way.
So a bump touches a pull request only where all of these hold (OPS-R87):
| Held | Why |
|---|---|
| The commit given is the branch’s tip, authored as the release app and verified | A caller cannot aim the bump at a branch or a commit somebody else made |
Its head is the repository’s own branch, listed by head=<owner>:<branch> |
A fork’s branch of the same name is somebody else’s |
| It is open, unmerged, and the release app’s bot opened it | A pull request a person opened on the branch is theirs to merge, and a closed one is never edited |
| Its head is the commit the run has just made | Auto-merge is armed at that commit and no other, so a commit pushed in between is never what merges |
Anything else edits nothing and fails the run, naming what it found. Arming is pinned to the commit just checked, so a push landing between the check and the merge cannot ride in. A run asked to disarm switches off arming the pull request already carries, which is how a regeneration that did not come out clean is kept from merging unread: an armed pull request stays armed across the app’s own pushes. A run asked for neither leaves auto-merge as it finds it, a maintainer’s arming included.
This is done in one place. rolling_pull_request.py
holds the rules, and every repository’s bump calls it through the reusable
rolling-pull-request.yml,
pinned like the other shared workflows, in a job after the one that makes the
commit. The pin fan-out runs the same script from this repository. A bump that
looks for its pull request in a way of its own is a second set of rules, and
eleven of them are how the lookup above came to be copied.
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 gate refuses, naming the row and the commit. A row naming a
commit nobody has is a record nobody can check, whether or not its goal is met
another way. 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
| 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-R79 | A version manifest’s comments MUST say why a goal is in the version and MUST NOT state how far a goal has got; a check MUST refuse a comment sentence that names a requirement and says it is built, met, done or partial, or names where it landed, and MUST name the file, the line and the words. |
| OPS-R83 | Every run of the report from OPS-R76 MUST run the release gate on the version in flight and MUST move a staged or in_progress version to releasable when every goal it locks is satisfied and its manifest lists no blocker; where the version stands MUST be read from the frontpage’s page for it, and no issue MUST be opened to track it. |
| 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 refuse, naming the row and the commit, wherever a done row names 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 | Superseded by OPS-R83: the report moves a version to releasable, the frontpage shows its progress, and no tracking issue is opened. The number is not reused. |
| OPS-R44 | A version’s manifest MUST list what blocks its release under blockers, each entry saying what blocks it and where it is being dealt with, and the releasable transition, a pre-release and execute MUST refuse while the list is not empty, naming each entry. |
| OPS-R45 | Withdrawn: the frontpage’s roadmap shows the next version and its goals, so no planning issue is opened. The number is not reused. |
| 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, and MUST arm that pull request to merge itself at the commit it made once its required checks pass. A fan-out a tag starts MUST first wait twenty minutes in an environment that holds no runner, and MUST then go on only where its tag is still the newest; one started by hand with a tag MUST NOT wait and MUST NOT be held to the newest tag. |
| OPS-R85 | An automated bump MUST keep at most one open pull request per repository, on one branch it rebuilds from the default branch for each new version and whose pull request it retitles to that version; it MUST NOT open a pull request for a version beside one still open for an earlier version, and a pull request it opened for an earlier version on another branch MUST be closed, pointing at the one that replaces it. |
| OPS-R87 | An automated bump MUST edit, retitle or arm to merge only an open, unmerged pull request whose head is its own repository’s branch, listed by that repository’s owner and branch together, which the release app’s bot opened, and whose head is the commit the bump has just put on that branch, with the branch itself at that commit and that commit authored as the release app and verified; it MUST open a pull request only from a branch whose tip is such a commit; it MUST arm auto-merge only where asked and pinned to that commit, MUST switch off auto-merge the pull request carries where asked to disarm it, and MUST leave a pull request’s auto-merge as it is where asked for neither; a bump whose change did not come out clean MUST ask to disarm. Anything else MUST fail the run without editing the pull request, naming what was found. Every bump MUST do this through scripts/rolling_pull_request.py, which a repository other than this one runs through the shared rolling-pull-request workflow. |
| OPS-R71 | An automated dependency bump MUST NOT merge itself while a repository that depends on it has an open automated bump whose required checks are still running and which that merge would discard; the deferral MUST be stated on the pull request, naming what it waits on. |
| 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 any drift issue from OPS-R46 opened for it, so an open issue about a version means something is still owed. |
| OPS-R54 | A version MUST NOT be released while a requirement it locks is not built, and a refusal MUST name those requirements. A requirement is built where the implementation-status tracker ticks it and the feature holding it is building, built or shipped; where that feature is planned or withdrawn, none of them is, whatever the tracker says. The catalogue’s built or shipped does not answer for a requirement on its own, because a requirement added to a finished feature is not built by being added (OPS-R73). A major ships no stubs, and neither does any version before it. The subject is the requirements a version carries, not the whole of every feature it touches: partial locking is the norm — 24 of 25 manifests lock part of at least one feature, and 0.1.0 locks two of B1’s fifteen — so a feature spanning two versions could never be finished when the first of them shipped, and the wider rule was one nothing had ever satisfied. built is the state that makes this checkable at all: shipped means out in a released version, so requiring it before release would be a gate no version could ever pass. A version MUST NOT be released or pre-released either while a feature it touches holds a requirement no version locks, and that refusal MUST name each such requirement. |
| OPS-R73 | A feature that is maturity: built or maturity: shipped MUST have every requirement it defines ticked by the implementation-status tracker, withdrawn numbers apart. A requirement added to a finished feature MUST return it to building, and it stays there until the tracker ticks each requirement. A check MUST refuse a catalogue that calls a feature finished while the tracker leaves one of its requirements unticked, and MUST name the feature and the requirements. |
| OPS-R74 | Every repository named in some version’s satisfied_in MUST keep its implementation status in a status.toml at its root — or, where its rows would run past a thousand lines, in one status/<feature>.toml per feature holding only that feature’s rows — one row per requirement, each naming the requirement, its state (done, partial or open), the evidence that holds it by path (the code and the test) and, optionally, the commit it landed in. A pull request that changes whether a requirement is met MUST change that row. The release gate and the no-stubs gate MUST read every searched repository’s tracker, and a requirement is done where any of them records it done. |
| OPS-R75 | A repository’s tracker MUST be refused where it keeps both a status.toml and a status/ directory, where a file under status/ holds another feature’s row, or where a row names a requirement the specification does not define, names one it retired before any version locked it, names one twice, records one done with no evidence, names evidence that does not exist, or names a landed commit its repository’s history does not hold. A tracker that cannot be read MUST stop the gate rather than be read as empty. |
| OPS-R76 | A report MUST say, for every goal of every version, released ones included, whether it is met, cited and recorded done nowhere, recorded done and cited nowhere, claimed by an open pull request, or open, read from the same sources and revisions as the release gate. It MUST be published where a person and a script can read it without a checkout, refreshed at least hourly, and the same report MUST be producible locally from checkouts at their default branch rather than their working trees. Its machine-readable form MUST name its format and the commit of every source it was read from, each generation MUST stay fetchable once published, and publishing it MUST NOT commit to the specification. A repository it cannot read MUST be named in it with the reason, and a goal searched there that the repositories it could read do not show met MUST be reported unknown, rather than the report stopping. |
| OPS-R78 | A feature’s maturity MUST be derived from every repository’s tracker and every version manifest — planned where no requirement it defines is done, building where some are, built where all are, and shipped, with shipped: naming the latest released version locking one, where all are done and every one is locked by a released version — retired requirements left out, and withdrawn alone set by hand. A check MUST refuse a catalogue that says otherwise, naming each feature, and an automated pull request MUST rewrite it whenever the trackers move one. |
| 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-R60 | A version MAY be published as a pre-release before it is released, once every goal it locks is met (OPS-R66). A pre-release MUST NOT move the version’s status, MUST NOT write released_on or released_as, and MUST NOT be recorded as the release; the manifest MUST go on answering where the version is. |
| OPS-R61 | A pre-release MUST be identifiable as one in its tag, in what the release it publishes says, and in the manifest record, and MUST NOT carry the version’s own tag. Its tag MUST be that version with a pre-release identifier appended, so it orders below the version it precedes; the identifier MUST NOT be one ARCH-R43 gives another meaning to. The distinction MUST NOT rest on the forge’s own pre-release flag, which every version below 1.0.0 carries and which therefore separates nothing. |
| OPS-R62 | A pre-release MUST carry the goal gate’s verdict for its version on the artefact it publishes. One whose artefact does not carry the verdict MUST NOT be published, and one whose verdict names an unmet goal MUST be refused before anything is recorded or tagged. |
| OPS-R63 | A pre-release MUST NOT be announced outside the organisation — OPS-R23’s subject is a published release and a pre-release is not one — and the announcing path MUST refuse on the pre-release itself rather than depend on being unconfigured. It MUST announce to the maintainer channel instead. |
| OPS-R64 | A pre-release MUST NOT be offered as an available update, and a reader that cannot order a tag MUST pass over it rather than rank it. |
| OPS-R65 | Every pre-release MUST be recorded in its version’s manifest with its tag, the date it was cut and the submodule pins it embedded, so the manifest answers what went out before the release without reading the forge. |
| OPS-R66 | A pre-release MUST pass every check execute-version runs before tagging — the goal gate and the no-stub gate at the commit being tagged, cross-stream compatibility, release blockers, the declared version and the plugin gate — and MUST be refused, with nothing recorded or tagged, where any of them fails; it MUST record its pins the way OPS-R35 requires of a release. Only the bundle pin differs (OPS-R86). |
| OPS-R67 | Every plugin the release train gates on MUST be registered under 70-operations/ with the repository holding it and the paths of the files the gate reads. The manifest generation a plugin is written in and the release it is validated against MUST be declared by the plugin itself and MUST NOT be restated in the registry. A registered plugin MUST NOT be a stream the train cuts and MUST NOT be named by any version manifest; it is an input to the gate and MUST NOT be tagged by it. |
| OPS-R68 | Every run that would cut a tag MUST build the candidate binary from the core commit it cuts and MUST prove every registered plugin riding that version itself, running that plugin’s own proofs with the candidate in the gate’s own checkout of the plugin through the reader the registry pins to a commit of the plugin template, and MUST validate each against the manifest generation the release carries. It MUST refuse the run where one no longer validates, naming the plugin and what failed, and MUST tag the core at the commit the candidate was built from. The report a plugin commits is its record against the release it targets and MUST NOT be required to name the version being cut. The run MUST NOT execute code from a registered plugin’s repository. |
| OPS-R69 | Superseded by OPS-R72: a proof failing as declared is named and does not fail the run. The number is not reused. |
| OPS-R70 | Every lane that cuts a release tag or a pre-release tag MUST run the goal gate against the commit being tagged and MUST refuse unless every goal that version locks is satisfied, naming the unmet ones. A verdict taken at any other commit MUST NOT stand in for it, and a lane that cannot run the gate MUST refuse rather than tag — a gate that does not run MUST NOT read as one that passed. The gate MUST report anything it finds wrong as an error and MUST NOT print a warning, and a lane MUST refuse wherever the gate’s output carries an error or a warning. execute-version is one such lane and MUST NOT be the only one gated. A hotfix to an already-released version is exempt, because it delivers no goals of its own (OPS-R37). |
| OPS-R72 | A registered plugin the gate cannot reach, that cannot say which release it targets, whose manifest is absent or unreadable, whose proofs do not run with the candidate or write no readable report naming the version being cut, or whose report names no proof or names one that is neither passed nor failing as declared (F10-R13), MUST fail the run by name. A proof failing as declared MUST be named in what the gate reports and MUST NOT be counted as passed. The gate MUST NOT pass over what it could not read, and MUST NOT report success about the part it could. |
| OPS-R88 | A registered plugin’s targets.toml MAY name a commit of the core’s main, in full, in place of a release. The gate MUST hold a plugin naming a commit to ride every version it cuts and MUST prove it with the candidate as it proves every other, and MUST fail by name a targets.toml naming both a release and a commit, or a commit not in full. |
| 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
- releasing.md — the tag-triggered mechanics this orchestrates
- plugins.toml — the plugins this train gates on, and what each pins
- 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
- ../20-architecture/contracts/versioning.md —
ARCH-R43, and why a pre-release does not call itself a release candidate