Status: Accepted
How a release happens — the one page that answers “I want to cut a version, what do I do and what happens?” The version model lives in versioning; this is the process.
Satisfies: roadmap M10, E1, E2.
Each repo releases on its own clock
The implementation repos are the independent release streams — that’s the point of the
decoupling. (Distinct from the three version identifiers the binary and manifest
carry — lemonfiber, stack_version, schema_version — defined in
versioning; a “stream” is a repo
that tags, a “version” is a number it carries.)
| Repo | Release trigger | Produces |
|---|---|---|
lemonfiber |
git tag vX.Y.Z |
Binaries, installers, updated formula, changelog, docs |
lemonfiber-media-stack |
git tag vX.Y.Z |
A pinned stack the binary can embed; stack_version moves |
lemonfiber-web |
git tag vX.Y.Z |
A pinned web surface the binary embeds at build time |
sdk-ts |
git tag vX.Y.Z |
@lemonfiber/sdk-ts published to npm. Not yet tagged; consumers pin a commit |
sdk-php |
git tag vX.Y.Z |
The PHP client, published to Packagist. Not yet tagged |
brand |
git tag vX.Y.Z |
@lemonfiber/brand published to npm. Not yet tagged; consumers pin a commit |
lemonfiber-decline, lemonfiber-request-gate |
git tag vX.Y.Z or vX.Y.Z-<identifier>, made by the train before the core’s (ADR-0033); and every commit on main, which is not a release (OPS-R90) |
The service’s image, by digest: under the tag, with a pull request pinning it in the repository’s own plugin.toml, which the bundle pins (ADR-0045); under main-<commit> for a commit, with that manifest’s pin moved and the bundle left alone |
plugin-<id>, first-party, building an image |
git tag vX.Y.Z, made by the train before the core’s release tag and never at a pre-release (OPS-R86); and every commit on main, which is not a release (OPS-R90) |
Each image the plugin builds, by digest: under vX.Y.Z for a tag, with its manifest pinned in the bundle after its pin pull request merges; under main-<commit> for a commit, with its manifest’s pin moved and the bundle left alone |
plugin-<id>, first-party, building no image |
— | Never tagged. The bundle pins a revision of it and the digest of its tree (OPS-R86) |
lemonfiber-plugins |
git tag vX.Y.Z |
A signed catalogue release. Not on the version train (OPS-R59); a version manifest names it only in satisfied_in, which tags nothing. The train moves its bundle’s pins (OPS-R86) |
homebrew-tap |
— | Never released directly; regenerated by lemonfiber’s pipeline |
A brand recolour does not force a binary release; a stack service bump does not force one either — the binary picks up a new embedded stack only when it is released with the submodule re-pinned.
Cutting a lemonfiber release
The binary is the one with real release engineering. The flow is tag-triggered — everything downstream is automatic:
flowchart TD
tag["git tag v0.4.0 && push"] --> plan[cargo-dist plan]
plan --> build[Build matrix:<br/>macOS arm64+x86 · Linux gnu+musl · Windows]
build --> smoke[Smoke test: run the binary on each OS]
smoke --> art[Signed artifacts + checksums]
art --> rel["Draft release: installers, artifacts, attestations"]
art --> tap[Regenerate homebrew-tap formula → PR/push]
art --> inst[Shell + PowerShell installers]
rel --> log[git-cliff: changelog from commits]
log --> body["Release body: the changelog, then the installers"]
body --> pub["A maintainer publishes"]
pub --> docs[Docs site rebuilds on main]
Concretely, a maintainer does three things; CI does the rest:
- Merge the version bump. The workspace’s
Cargo.tomlmust already declare the version being released, onmain, before anything is tagged —cargo-distreleases the tag whose version the workspace carries, so a tag cut over a stale one names a version nothing will build. This is a step in its own right rather than a thing to remember while doing the next one, because it is the one part of the sequence whose order matters and whose failure is silent until the release job runs.execute-versionrefuses to tag a repo that declares anything else, so getting it wrong costs a re-run rather than a deleted tag. - Dispatch
execute-versionwith the version,dry_runfirst. It checks the goal gate, the no-stub rule, cross-stream compatibility, release blockers and the declared version, then tags every repo the manifest names. A version left atreleasableblocks the next one from being staged, so this is not optional bookkeeping. - Review the drafted GitHub Release, and the homebrew-tap PR once that publish
job is on (
L1-R3), then publish. Publishing firesrelease-finalize, which records what shipped, pins the stack commit the release embedded, and regenerates the public roadmap.
Everything between step 2 and step 3 is cargo-dist and git-cliff. A draft is
published by a person on purpose: it is the one step that reaches the outside
world, and it is the last place to notice that what was built is not what was
meant.
The version-bump decision
What moves, and when:
| You changed… | Bump |
|---|---|
| A bug fix, no behaviour change | binary patch |
| New behaviour, backward-compatible CLI/flags | binary minor |
A removed/renamed flag, changed default, dropped schema_version support |
binary major |
| A service image tag, a new service or form | lemonfiber-media-stack minor (or major if a form’s meaning changes) |
| A manifest field added-optional | schema_version unchanged, stack_version minor |
| A manifest field required/removed/retyped, or a new enum value | schema_version +1 (why) |
| A token value (recolour) | brand minor |
| A token renamed/removed | brand major |
The one that needs coordination is schema_version: bumping it means the next
lemonfiber release must support it, and build.rs will fail the build until the
embedded stack and the binary agree (ARCH-R6).
That failure is the safety net — an incompatible pairing cannot ship.
Every release is verifiable
Artifacts don’t just carry checksums — they carry provenance:
| Artefact | What it proves |
|---|---|
| SHA-256 checksums | The download wasn’t altered in transit |
| SLSA build provenance (attestation) | The binary was built by this pipeline from this commit, not tampered with |
Both are carried today: cargo-dist emits the attestation via GitHub’s artifact
attestations, so a consumer can verify a downloaded binary came from the real
pipeline. It also raises the OpenSSF Scorecard signal
the project already publishes.
OPS-R20 also obliges an SBOM (software bill of materials) per release, and
that half is not produced yet — answering “are we affected?” still means reading
Cargo.lock at the tag rather than a manifest of what went in.
The changelog is generated, not written
Commits follow a conventional-commit shape and carry Spec: trailers, so
git-cliff produces the changelog from history. Nobody maintains
CHANGELOG.md by hand — which also means a commit message is the release note,
so write it for a reader.
It is generated after the build, onto the draft the pipeline leaves, because
the pipeline is generated code: cargo-dist owns release.yml and rewrites it
whole, so the changelog cannot live inside it. The release body ends up as the
changelog first and cargo-dist’s install instructions second — the install
half is the same every time, and the announcement that fires on publish is the
body, so what changed is what a reader meets first.
Pre-1.0 versioning
Until 1.0.0, the binary is 0.y.z: y moves for features, z for fixes, and
breaking changes are allowed within 0.x as the roadmap milestones land. 1.0.0
is cut when the M10 exit criteria are met — a non-contributor can install and run
on all three platforms from the README alone.
The companion’s identities
The core trusts a device key only from an app it names
(C10-R29). Its list of the
official companion’s identities is empty until the companion’s first store release, so
until then a stack trusts only the builds its operator adds to LEMONFIBER_DEVICE_KEY_APPS.
The first store release adds a step to the core release that follows it:
- Read the identities the stores sign the companion under. On Android the package and the SHA-256 of the app signing certificate Google Play signs with, not the upload key’s; on iOS the Team ID and the bundle ID.
- Write them into the core’s official list in a pull request of their own, before the release is tagged.
- Register a device key from the store build against a stack running that release, once on each platform, before the release is published.
A store build the shipped list does not name cannot register a device key on any stack that has not added it by hand, so this step is part of the release rather than a later fix.
The attestation status list
The core refuses an Android device key whose attestation chain Google lists as revoked or suspended, against a snapshot of Google’s attestation status list the build carries, since it fetches nothing at run time (C10-R34). Each core release takes a fresh snapshot:
- Refresh the snapshot with the core repository’s refresh script, which reads the list from Google, checks its shape, and writes it into the tree with where it was read from and when.
- Land it in a pull request of its own before the release is tagged, so the snapshot a release carries is one a reviewer saw.
- Read its date back from
lemonfiber versionon the release build.
A snapshot only ages: a build released months ago still refuses what was revoked before it was cut and nothing since, which is why the version says when it was taken.
What a release must not do
| Never | Because |
|---|---|
| Ship a floating image tag in the embedded stack | Defeats reproducibility (E1-R1) |
| Publish artifacts without checksums | Integrity (Q-R44) |
| Release a binary that fails its own smoke test | “Builds” ≠ “runs” (Q-R36) |
Bump schema_version without the binary supporting it |
build.rs blocks it, but don’t force it |
| Overwrite the homebrew formula by hand | It’s generated (REPO-R24) |
| Release the core after the companion’s first store release without the companion’s identities in its official list | A store build would register no device key (C10-R29) |
The one-time setup
Releasing needs two things configured once (they can’t be scaffolded):
| Setup | Where |
|---|---|
A token for the pipeline to push to homebrew-tap |
lemonfiber repo secret |
npm publish auth for @lemonfiber/brand |
brand repo secret |
Both are documented as manual steps (Q-R60), like SONAR_TOKEN.
Requirements
| ID | Requirement |
|---|---|
| OPS-R1 | A lemonfiber release MUST be triggered by a version tag and MUST require no manual build steps beyond tagging and publishing the drafted release. |
| OPS-R2 | The release pipeline MUST regenerate the Homebrew formula and open a change against homebrew-tap; the formula MUST NOT be edited by hand. |
| OPS-R3 | The changelog MUST be generated from commit history, not hand-maintained. |
| OPS-R4 | A release MUST NOT proceed if the binary fails its cross-platform smoke test. |
| OPS-R5 | The four version streams MUST be releasable independently; one MUST NOT force a release of another. |
| OPS-R6 | Secrets required for releasing MUST be documented as one-time manual setup. |
| OPS-R20 | Every release MUST publish SLSA build provenance (an attestation) and an SBOM alongside the checksummed artifacts. |
| OPS-R59 | The plugin catalogue MUST release on its own clock and MUST NOT be a stream the version train cuts; no version manifest may name it, and a contribution to it MUST NOT move a version number in this organisation. The bundle’s pins are moved by the train under OPS-R86, and that move is not a catalogue release. |
| OPS-R86 | The train MUST tag the repository of each first-party plugin that builds an image at the core’s version before it tags the core’s release, MUST NOT tag one that builds none, and MUST NOT tag any at a pre-release’s tag. Once the plugin’s manifest names what that tag published, the train MUST move the plugin’s pin and copy in the catalogue’s bundle with one rolling pull request. The bundle MAY pin a first-party plugin that builds no image by revision and tree digest alone, with no release. The core’s release tag MUST embed the bundle at a catalogue commit whose bundle pins every first-party plugin that builds an image at that version, and none of them by revision alone. A pre-release MAY embed a bundle that pins a first-party plugin that builds an image by revision alone, and MUST NOT record the bundle pin, or anything else, as an unmet goal: a pre-release with an unmet goal is refused (OPS-R66). |
| OPS-R89 | The bundle MAY pin a first-party plugin at a revision whose targets.toml names a commit of the core’s main, whether or not the pin names a release, only where the core commit the release tags holds that commit. Before tagging the core’s release, the train MUST read targets.toml at each pinned revision and MUST refuse, naming the plugin, the revision and the commit, a pin targeting a commit the core commit being tagged does not hold, and one whose revision cannot say what it targets. Once that release is published, the plugin MUST move its targets.toml to it. |
| OPS-R90 | A first-party plugin’s repository, lemonfiber-decline and lemonfiber-request-gate included, MUST build and publish each image it builds from every commit on its main as a train tag builds it — each platform on a runner of its own architecture, one multi-architecture index carrying BuildKit’s SBOM and provenance, and a build-provenance attestation and an SBOM attestation per platform — tagged main- followed by the first twelve characters of the commit, and MUST NOT move that tag. A commit whose change from the commit before it on main touches nothing of the plugin but plugin.toml, proofs.json, targets.toml and what is under recordings/ MUST NOT publish an image, so merging a pin’s pull request never starts another. A commit image MUST NOT be announced and is not a release. |
| OPS-R91 | Each commit image’s publish MUST update one rolling pull request on the plugin’s repository setting the digest of each service that runs the image to what it published and its tag to the commit tag, so the manifest pins a published build by digest, with every recording the manifest names taken again from that image by lemonfiber plugin record at the lemonfiber its targets.toml names and proofs.json written from them in the same commit (ARCH-R285). The image MUST run and lemonfiber MUST be built in a job holding no secret, and the job holding the release App’s token MUST run nothing that job wrote, MUST work the move out again from the manifest on main and what was published, and MUST commit only plain files, read through no link: that manifest with exactly that move, proofs.json, and recordings the manifest names taken from exactly that pin. The pull request MUST be disarmed, naming each, where a recording still names the image it replaces (OPS-R87). Merging that pull request MUST NOT move the bundle’s pin, which only a train tag’s pin moves (OPS-R86). |
A plugin is still gated by the train even though it is not cut by it: every
run that would tag proves each registered plugin riding the version with the
candidate build of the core commit it tags, through the reader the registry pins,
and refuses on one that no longer validates (staging.md).
Being an input to the gate and being a stream the gate tags are different things,
and OPS-R59 is about the second.
A plugin builds an image where one of its services runs an image under
ghcr.io/lemonfiber/ (bundle). Only such a
plugin is a stream the train tags; one whose image is the upstream’s is pinned by
revision and tree digest, with no release, because a tag adds nothing to a commit pin
that is already content-addressed (OPS-R86).
A first-party plugin written against what the core had on main before any release
carried it names that commit in its targets.toml rather than a release
(F10). The bundle may
pin it there, whether or not the pin names a release, because the release that embeds
the bundle is cut from the same main: before the core is tagged, the train reads
targets.toml at each pinned revision and refuses a pin whose commit the core commit
being tagged does not hold, so everything the plugin was proved against is inside that
release (OPS-R89). Once the release is published, the plugin names it instead of the
commit.
Each image a first-party plugin builds is published from every commit on its main
as well as from a train tag (OPS-R90), lemonfiber-decline’s and
lemonfiber-request-gate’s included. It is built the way a tag builds it and
tagged main- and the commit’s first twelve characters, and the publish moves the
manifest’s pin to it through one rolling pull request (OPS-R91), so the manifest
pins a real build before any release carries it. That pull request carries every
recording the manifest names taken again from the new image, and the proofs.json
written from them, so the plugin’s own check judges the pin it proposes
(ADR-0049).
A commit that changes nothing but a pin, its recordings, its proofs and its
targets.toml publishes no image, so merging the pull request does not open the next.
A commit image is not a release: nothing announces it, and merging its pin never
moves the bundle. A train tag publishes the versioned image and its pin is the one
the bundle follows.
Related
- staging.md — the spec-led, cross-repo release train this cut sits inside
- versioning — the version model this process applies
- ci-cd.md — the pipeline stages
- tooling.md — cargo-dist, git-cliff
- 30-repos/homebrew-tap.md — the generated formula
- roadmap M10