Blue uses one semantic version for the CLI, service image, Helm chart, OpenAPI contract, deployment bundle, and versioned documentation. A bot decides that version; nobody edits it by hand.
- Merges land on
main. The Release Please workflow recomputes the next version from the conventional commits since the last release and keeps onechore: release <version>PR open on therelease-please--branches--mainbranch, carrying the CHANGELOG entry. - The same run's
finalizejob writes that version into every version-bearing file (scripts/set-version.sh) and freezes the documentation snapshot (apps/docs/<version>/), as one extra commit on the branch. - Cut release candidates from the PR head as often as you like — see below.
- Review and merge the release PR. Release Please creates the
v<version>tag and a draft GitHub Release holding the changelog. - The tag fires the Release workflow, which builds every artifact, attests it, publishes the image and chart, and flips the draft live.
Nothing publishes on main. A candidate can carry every artifact a release
carries, but it is always a prerelease, which is what keeps it out of every
default resolution — see below.
RELEASE_PLEASE_TOKEN — a fine-grained PAT scoped to this repository, with
Contents: read/write, Pull requests: read/write, and Issues:
read/write (the autorelease: * labels go through the Issues API). Nothing
else. Set the longest expiry you are willing to rotate and put the rotation date
in a calendar: expiry is silent, Release Please simply stops opening PRs.
GITHUB_TOKEN cannot do this job, and no repository setting changes that:
can_approve_pull_request_reviewsisfalseat the organization level, which gates PR creation, not just approval.- Events authored by
GITHUB_TOKENstart no workflow runs, so the release PR's head would carry none of the required checks and could never be merged.
Release Please's own README documents the PAT as the normal path for exactly these reasons.
Two consequences of the Protect main ruleset worth knowing before the first
release:
require_last_push_approval: true— every release PR's last push is the finalize commit, authored by the PAT owner, so the PAT owner cannot be the approving reviewer. Either keep a second maintainer in the loop or hold the PAT on a dedicated machine account.dismiss_stale_reviews_on_push: true— every rebuild and every finalize push dismisses approval. Approve last, merge promptly.
Still outstanding from the pre-public checklist: enable immutable GitHub
Releases — decide candidate retention first, because immutability forecloses
pruning them later — make ghcr.io/blocksorg/blue,
ghcr.io/blocksorg/blue-rc and the Blue OCI chart package publicly readable,
and configure organization-level artifact attestations. Creation of v* tags is not restricted by a ruleset.
Release Please computes the version and the CHANGELOG. It writes no other file, because all three of its declarative mechanisms provably break here:
release-type: rustthrows — the rootCargo.tomlis a virtual[workspace]manifest with no[package], and the updater has no concept of[workspace.package].version.extra-fileswithtype: yamlround-trips the 1600-line contract through js-yaml: it reflows the file, strips every comment, and emitsversion: 0.2.0unquoted, breaking all four readers that match onversion: "X.Y.Z".type: genericannotations have to live in the files forever, and everysed/JS parser here either captures the trailing comment or stops matching. It also cannot produceapps/docs/<version>/.
So scripts/set-version.sh <version> owns the write side, and
scripts/check-release-version.sh v<version> owns the read side. They are exact
mirrors — every file one writes is a file the other reads — and CI runs the
check on every PR, so a file that drifts out of the pair fails packaging. The
writer also updates the small allowlist of release-bearing examples in the live
apps/docs/next tree; other documentation versions, policy examples, and
dependency versions are deliberately outside its ownership.
scripts/set-version.sh needs node and cargo on PATH. Run it by hand if you
ever need to prepare a release without the bot:
scripts/set-version.sh 0.2.0
node apps/docs/scripts/release-version.mjs 0.2.0
scripts/check-release-version.sh v0.2.0Two Release Please config keys look redundant and are not. include-component-in-tag: false defaults to true and only happens to yield v0.2.0 for a root package
— if that ever changes, the tag becomes blue-v0.2.0, misses release.yml's
v*.*.* trigger, and nothing publishes, silently.
pull-request-title-pattern replaces the default chore(main): release X,
whose main scope is not in AGENTS.md's list and would land in main's log via
squash-merge.
packaging (a required check) runs one extra step on
release-please--branches--main: it reads the version from
.release-please-manifest.json — not Cargo.toml, which is still the previous
version and self-consistently wrong until finalize runs — then re-runs
check-release-version.sh and check-release-snapshot.mjs.
So an un-finalized release branch holds the merge button shut, and says why. If
finalize ever fails, use Re-run failed jobs on the Release Please run, or
dispatch the workflow again: finalize takes no inputs, discovers the PR and the
version off the branch, refreshes that current not-yet-released documentation
snapshot from the synchronized next tree, and commits only if the tree
changed. Replacement is automation-only and refuses to touch any historical
release that is not the first/default stable navigation entry. The replacement
flag is accepted only when the CLI is running in the Release Please workflow,
and the finalizer checks GitHub before changing the worktree and refuses to
replace a snapshot when that version's GitHub Release is already published.
To ship unreleased code, cut a candidate from any ref on demand. Two entry points, differing only in how much they build:
| Command | Publishes | Cost | |
|---|---|---|---|
| Image only | gh workflow run dev-image.yml --ref <ref> |
the multi-arch deployment image | ~10 min |
| Full candidate | gh workflow run release.yml --ref <ref> -f candidate=true |
everything a release publishes: six CLI archives, installers, chart, deployment bundle, SBOM, attestation, image | ~30 min |
Both resolve the same name, <workspace version>-rc.g<short sha>, from the
version already in Cargo.toml, so a candidate image and a candidate Release
cut from the same commit carry the identical version. A cut of the release
branch after finalize has run is 0.2.0-rc.g1a2b3c4; a cut of main today
is 0.1.0-rc.g1a2b3c4. Repeated cuts of the same version stay distinct.
Dispatching is the only access control on either workflow — it requires write
access to the repository. release.yml -f candidate=true ignores the tag
input; a candidate names itself off the tree and the commit.
A candidate is not a parallel distribution channel. Every channel already has a stable/unstable split, and in each one the prerelease marker is what selects it:
| Channel | Why the default resolves past a candidate |
|---|---|
| GitHub Release | GET /releases/latest is the most recent non-prerelease, non-draft release, and that redirect is what both installers follow when BLUE_VERSION is unset |
| Helm / OCI chart | With no --version, Helm installs the latest stable version until you pass --devel, and -rc.g… is a semver prerelease |
| GHCR image | Candidates go to the separate ghcr.io/blocksorg/blue-rc package; a release digest only ever lands in ghcr.io/blocksorg/blue, latest never moves, and the {{major}} / {{major}}.{{minor}} rollup tags collapse onto the candidate tag |
So a candidate does create a git tag and a GitHub Release — and neither can be
resolved by accident. What it must never do is carry --latest.
Pin BLUE_VERSION; the installers compose the asset name from it and need no
change:
curl -fsSL https://raw.githubusercontent.com/BlocksOrg/blue/main/scripts/install.sh \
| BLUE_VERSION=0.2.0-rc.g1a2b3c4 sh$env:BLUE_VERSION = "0.2.0-rc.g1a2b3c4"; iwr -useb https://raw.githubusercontent.com/BlocksOrg/blue/main/scripts/install.ps1 | iexThe binary reports the candidate string it was built from — blue version
prints Blue metaharness 0.2.0-rc.g1a2b3c4, and the same string goes out in the
user-agent and in the client_version the control API stores. That is
BLUE_BUILD_VERSION, baked in by the release workflow for candidate runs only;
nothing writes it into the tree.
One asymmetry to know: the candidate chart still defaults image.repository to
the release package, because the chart's values are part of the tree and the
tree does not know it is being cut as a candidate. Point it at -rc yourself:
helm upgrade --install blue oci://ghcr.io/blocksorg/charts/blue \
--version 0.2.0-rc.g1a2b3c4 \
--set image.repository=ghcr.io/blocksorg/blue-rc \
--set image.digest=sha256:...--version is required — Helm skips prereleases otherwise. Production mode
requires image.digest rather than a tag; both workflows print the manifest
digest in their run summary.
ghcr.io/blocksorg/blue-rc is created on the first push and is private until
someone makes it public.
gh workflow run release.yml --ref <ref> -f tag=v<version> — the original
dry-run path, unchanged. It assembles all six archives, the chart and the
deployment bundle as a workflow artifact, pushes nothing, and creates no tag.
The tag you pass must match the version in the tree.
Merging the release PR is the publish. Release Please tags v<version> and
opens the draft Release; the tag fires the Release workflow, which uploads every
artifact and flips the draft live. Confirm anonymous CLI, image, chart, and
deployment-bundle downloads afterwards.
The manual path still works if you need it — the Release workflow creates the draft itself when one does not already exist:
git tag -s v0.2.0 -m "Blue v0.2.0"
git push origin v0.2.0It refuses to rebuild over a tag whose Release is already published, before spending ~30 minutes on builds.