ADR-0035: Automated versioning (release-please) + dev/nightly/stable build channels
Date: 2026-06-29 / Status: Accepted (supersedes the manual xtask release flow; extends ADR-0021’s build-output layout with build identity)
ADR-0021 put every build artifact under one build/ tree, but builds had no identity: fmf --version and the app’s version label reported a bare 0.1.0 whether the binary was a contributor’s local build, a future nightly, or an official release — all indistinguishable. And cutting a release meant a human ran xtask release X.Y.Z, hand-picking the number and hand-editing engine/Cargo.toml + the csproj in lockstep. That “version management is human-driven” shape is the thing to remove pre-v0.1, while builds are still free to change.
The decision criterion, set explicitly by the maintainer, is convenience + how industry-standard/recommended a workflow is — not daily build-loop cost (a small fluctuation there is acceptable). The dual-language reality (Rust Cargo.toml + C# csproj) is the practical filter: the tool must bump both.
Decision
-
release-please owns the version, CHANGELOG, tag, and draft release. Conventional Commits on
maindrive a bot (googleapis/release-please-action, SHA-pinned) that keeps a “Release PR” open; merging it bumps the version, updatesCHANGELOG.md, createsvX.Y.Zimmediately ("force-tag-creation": true), and creates the GitHub Release as a draft ("draft": true). The forced tag is required so later Release PR calculations can find a draft release; GitHub otherwise delays the tag until publication. The maintainer never hand-picks or hand-edits a number — they merge a PR. The Release PR diff is the release preview (no local CLI needed).release-please.ymldispatches the trustedmainperformance workflow with the exact tag and commit as inputs; its default-branch completed-success handler invokes the trusted reusablerelease.yml, which builds the explicit commit, signs, attaches assets to the draft, and publishes it (assets before publish, the order immutable releases demand).release-please-config.json+.release-please-manifest.jsonare the config. -
Version stays declared in the files; the bot edits them (not git-derived). The package is the repo root (
.) withrelease-type: "simple"andextra-files: atomlupdater setsengine/Cargo.toml$.workspace.package.version, and agenericupdater (keyed on anx-release-please-versionannotation) sets the csproj<Version>. We do not use release-please’srustrelease-type: it can’t write a workspace-inherited version (version.workspace = true) and fails with “value at path package.version is not tagged” (googleapis/release-please#2478, #1170). Because thetomlupdater bumpsCargo.tomlbut notengine/Cargo.lock(and CI is--locked),release-please.ymlrunscargo update --workspaceon the Release PR branch to sync the lock (no compile; re-runs on every PR rebuild so it self-heals). The package must be the repo root, notengine:extra-filespaths are resolved relative to the package dir and cannot use.., so reaching bothengine/Cargo.tomlandapp/.../FindMyFiles.csprojrequires the package to sit above both — which also letsCHANGELOG.mdlive at the repo root. The manifest keeps the version present and reproducible (tarball/.git-less builds,cargo metadata, debuggability) — the “shackle” was the human driver, already removed by (1), not the stored number. -
Three channels, stamped at build time. A new leaf crate
fmf-buildstamp(depended on only byfmf+fmf-service, neverfmf-core/fmf-ffi) resolvesVERSIONinbuild.rs; the C# csproj computesInformationalVersion. The baseX.Y.Zis the release-please-managed number; the channel suffix is layered at build time:- dev (local
just build) →X.Y.Z-dev+g<sha>(.dirtywhen the tree is dirty) - nightly →
X.Y.Z-nightly.<date>+g<sha> - stable → clean
X.Y.Zxtask version --channel <dev|nightly|stable> [--date]is the single source of the string format; CI exports it asFMF_BUILD_VERSION(Rust) /FmfChannel(C#).
- dev (local
-
Conventional Commits are enforced. Locally via a lefthook
commit-msghook (committed, mise-pinned); on PRs via the existingamannn/action-semantic-pull-requesttitle gate (squash-merge → the PR title becomes the commit, so the title is what release-please reads). -
Nightly = unsigned, 14-day GitHub Actions artifact — not a Release.
nightly.ymlbuilds the bundle frommain(skipping whenmainis unchanged in 24h), stamps it nightly, and uploadsfind-my-files-nightly-<date>. Artifacts keep nightlies off the Releases list (no confusion with stable) and sidestep Immutable Releases (no rolling tag to overwrite). Nightlies are deliberately unsigned; the approval-gated signing pipeline (ADR-0029) is stable-only. -
GitHub App credentials are fail-closed.
release-please.ymlmints a short-lived, repo-scoped installation token with explicit contents/issues/pull-request permissions and hands it to release-please. Those secrets live only in a dedicatedrelease-pleaseenvironment with amain-only deployment policy; an ordinary bot run fails visibly if either credential is absent. Release mutation and workflow dispatch are separate jobs. The API-only job validates tag/draft/target and protected-mainlineage, then dispatchesrelease.ymlon--ref mainwith that exact tag, commit, and numeric draft ID (ADR-0048; previously a hosted-only request workflow at the head of a two-hopworkflow_runchain).release.yml’s ownpreflightjob re-derives and repeats every one of those bindings before any other job starts, and it has no tag trigger. Recovery of an already-created draft deliberately needs no App token. -
A real release requires multiple deliberate, independent actions — defence in depth so an ambiguous instruction can’t ship one. Opening the Release PR does nothing. Cutting a release takes, in order: (a) adding
release: approved; (b) merging the Release PR; (c) runningjust perf-gateon the reference machine (ADR-0048 replaced the automated gate with this human step); (d) approvingsign; and (e) separately approving secretlesspublish-approval. The approval check is the independent required workflowrelease-gate.yml; it recognizes a Release PR from its manifest diff/bot branch as well as the mutable pending label, invalidates a surviving approval whenever the head changes, and keeps label events away fromci-required.release.ymlis dispatchable only from protectedmain, checks out the explicit dispatched SHA, and revalidates dispatched SHA = tag = draft target inpreflightand again before build, signing, attestations, and publication. A stray tag starts nothing and cannot supply workflow code to the signing chain. The agent never merges the Release PR, pushes a version tag, approves an environment, or invokes the credentialed release path without an explicit version-named instruction.
Rationale
- release-please over in-tree (git-cliff + xtask) [the earlier lean]: once daily-loop cost is not a criterion, a bespoke in-tree release script is neither the most convenient nor the most standard option — it is a maintained reinvention. The Release-PR bot is the lower-friction, more-recommended 2024+ workflow and is what the maintainer chose. The reversal is deliberate, recorded here, not drift.
- release-please over release-plz: release-plz is the Rust-native gold standard but only bumps Cargo crates; the C# csproj would be a bolt-on. release-please’s
generic/extra-filesupdater bumps both languages from one config — the decisive factor for a dual-language repo. - Declared-and-bot-edited over git-derived (nbgv/vergen): a height/
git describeversion is not Conventional-Commits-semantic (it can’t turnfeat:into a minor bump), breaks on.git-less source builds, and needs two separate tools for two languages. The stored number costs almost nothing and keeps reproducibility/debuggability. - Channel suffix at build time, base in the file: the stamp (
fmf-buildstamp/InformationalVersion) is the right home for the derived part (channel + sha); the declared base never needs git at build time.fmf-buildstampis a leaf off the two front-end binaries so the.git/HEADrerun never rebuilds the hot engine crates. - Artifacts for nightly: with Immutable Releases on, a rolling
nightlytag can’t overwrite assets; dated prereleases would accumulate and need GC. A 14-day artifact auto-expires and is the least-moving-parts “separate bucket”.
Rejected alternatives
- In-tree git-cliff + xtask (self-authored release command) — maximum control and reuses the existing
xtaskversion-edit code, but non-standard and a maintenance burden; loses on the chosen “convenience + recommended” axis. Rejected (was the prior lean; consciously overturned). - release-plz (Rust-native bot) — idiomatic for the engine, but Rust-only: the C# version would need a second mechanism. Rejected for a dual-language repo.
- nbgv + vergen (git-derived, no stored version) — the purest “no version to manage” model, but non-composable with CC-semantic bumps,
.git-dependent, and two-tool. Rejected. - Keep manual
xtask release X.Y.Z— simplest diff, but it is the human-driven shackle this ADR removes. Rejected. - Nightly as dated GitHub pre-releases — publicly downloadable without a login, but accumulates under Immutable Releases and needs a GC workflow. Deferred behind a trigger.
- Nightly as a rolling
nightlyRelease — the common “always-latest” pattern, but incompatible with Immutable Releases (can’t overwrite the asset). Rejected outright. - CalVer (
YYYY.MM.x) — used by some apps, but a filename-search engine/CLI benefits from SemVer’s change-magnitude signal, and CC→SemVer is the mainstream pairing. Rejected.
Consequences
- The maintainer’s release ritual becomes “write Conventional Commits, merge the Release PR.”
just releaseand thextaskversion-edit modules (release.rs,version/{cargo_toml,csproj}.rs) are removed — versioning has one owner (release-please), not two. release.ymlis startable only from protectedmainand checks out the exact Release Please tag SHA as data; it stamps the stable build cleanly (FMF_BUILD_VERSION/FmfChannel=stable).- Release automation is split across the version bot and the release workflow it
dispatches. Amended by ADR-0048
(2026-07-28): the trusted-main performance gate and hosted completion
dispatcher between them are retired — their measurement instrument cannot
exist on a user-owned repository — and the gate is now the manual
just perf-gate. No job both executes self-hosted repository code and holds publication authority, because no job is self-hosted at all. - Every contributor commit must be a Conventional Commit (local hook + PR-title gate).
--no-verifyremains forbidden. - The wire contract and golden corpus are untouched — the version string is not part of the wire format, so no golden re-capture.
- First activation must be verified: the first Release PR should show
engine/Cargo.toml[workspace.package] versionbumped (by thetomlupdater),engine/Cargo.locksynced (by thecargo update --workspacestep, as a follow-up commit on the PR branch), the csproj<Version>bumped, andCHANGELOG.mdwritten. Three real gotchas were hit and fixed during bring-up: release-please’srustrelease-type cannot write workspace-inherited versions (→simple+tomlupdater + the lock-sync step);extra-filespaths are package-relative and reject..(→ the package is the repo root so it can reach bothengine/andapp/); andchangelog-pathlikewise rejects...
Re-examination triggers
- Anonymous public nightly downloads wanted → promote nightly artifacts to dated GitHub pre-releases + a retention/GC workflow.
- Signed nightly wanted → add a
signjob tonightly.yml(reusing ADR-0029’s pipeline). Per ADR-0040, nightly now carries the rest of the supply chain (CycloneDX SBOMs, the osv-scanner gate, and keyless build-provenance + SBOM attestations); signing is the only remaining stable-only supply-chain gate, so this trigger is the sole nightly/release difference left. - release-please’s Cargo-workspace handling proves insufficient (version or
Cargo.locknot bumped correctly) → switch the Rust side to atomlextra-file updater + an explicitcargo update -plock-refresh, or adopt thecargo-workspaceplugin. - crates.io / NuGet publishing begins → re-evaluate release-plz (Rust) and a real package-publish step; the current config publishes nothing to a registry.
- The C# csproj surface grows complex (multiple version-bearing props) → reconsider Nerdbank.GitVersioning for the .NET side specifically.