← The specification

Releasing

Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page

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:

  1. Merge the version bump. The workspace’s Cargo.toml must already declare the version being released, on main, before anything is tagged — cargo-dist releases 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-version refuses to tag a repo that declares anything else, so getting it wrong costs a re-run rather than a deleted tag.
  2. Dispatch execute-version with the version, dry_run first. 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 at releasable blocks the next one from being staged, so this is not optional bookkeeping.
  3. Review the drafted GitHub Release, and the homebrew-tap PR once that publish job is on (L1-R3), then publish. Publishing fires release-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:

  1. 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.
  2. Write them into the core’s official list in a pull request of their own, before the release is tagged.
  3. 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:

  1. 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.
  2. 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.
  3. Read its date back from lemonfiber version on 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.