Releasing¶
Record Store releases are produced by GitHub Actions from a version tag. A maintainer decides the version, writes the changelog, and pushes a tag; nothing is built or uploaded by hand.
What a tag triggers¶
Pushing vX.Y.Z runs .github/workflows/release.yml:
flowchart TB
tag[Tag vX.Y.Z] --> validate[Validate tag against repository version]
validate --> rust[Rust: fmt, clippy, tests]
validate --> console[Console: format, lint, typecheck, tests, build]
rust --> server[Server image: amd64 + arm64 -> GHCR]
console --> server
rust --> web[Console image: amd64 + arm64 -> GHCR]
console --> web
server --> binaries[Binary archives from the published image]
server --> smoke[Smoke test the published images]
web --> smoke
binaries --> release[GitHub Release + checksums + SBOMs]
smoke --> release
The GitHub Release is created last and depends on everything before it, so a failed build never leaves a release behind. Images are pushed before the release exists, but only after every gate has passed and both architectures have built.
The procedure¶
1. Choose the version¶
Semantic versioning. A patch release for fixes, a minor release for additions, a major release for breaking changes. Before 1.0, judgement applies: a rename or a configuration break deserves at least a minor bump.
2. Update the version and the changelog¶
The version lives in two places, and the release fails if they disagree with the tag:
| File | Field |
|---|---|
Cargo.toml |
[workspace.package] version — every crate inherits it |
console/package.json |
version |
Update the lockfiles too:
Then add the section to CHANGELOG.md. It becomes the release notes verbatim, so
write it for the people upgrading, and note anything that requires action on their
part. A missing changelog section fails the release — that is deliberate.
Check the versions agree before you tag:
3. Merge the release preparation¶
Open a pull request and let CI pass. Release preparation is an ordinary change and belongs on the default branch before it is tagged.
4. Tag the merge commit¶
Tag on main, at the commit the release is cut from, with a clean working tree:
-s signs the tag with GPG; git tag -s with gpg.format=ssh signs with an SSH
key.
Sign the tag. Image provenance is attested automatically (see below), but the tag signature is the only statement about who cut the release rather than what built it. It is not enforced by the workflow, because a release that fails at the last step for want of a key on the right machine helps nobody — but an unsigned release tag leaves consumers one check short.
5. Push¶
6. Verify¶
Watch the run, then check the result the way a consumer would:
gh run watch
docker pull ghcr.io/openelementslabs/record-store:X.Y.Z
docker run --rm --entrypoint record-store \
ghcr.io/openelementslabs/record-store:X.Y.Z --version
git tag -v vX.Y.Z
See Verifying a Release.
Never repoint a version tag¶
A published version is immutable. If 0.1.1 is wrong, release 0.1.2.
Do not delete and recreate a Git tag, and do not rebuild an image under a version tag that has already been published. Anyone who pinned a digest is unaffected by a repointed tag, but everyone else silently gets different software under a name they already trusted.
Images are published with signed provenance¶
The repository is public, which makes GitHub's artifact attestation service
available to it. While it was private, actions/attest-build-provenance failed
the job outright with Feature not available for the … organization; that is no
longer the case, and the workflow attests every image it publishes.
| Step | Subject | Job |
|---|---|---|
actions/attest-build-provenance |
The merged multi-platform index digest | merge |
actions/attest-sbom |
Each platform manifest digest, bound to its SBOM | sbom |
Both need id-token: write and attestations: write. Those are declared twice
on purpose: once on the jobs inside container-image.yml, and once on the
server-image and console-image jobs in release.yml that call it. A called
workflow cannot hold more permission than its caller grants, so dropping either
copy breaks attestation with a missing OIDC token rather than a clear error.
The provenance attestation is pushed to the registry as an OCI referrer. The SBOM
attestations are not, because that needs packages: write in a job that
otherwise only reads; they are still recorded against the repository, which is
what gh attestation verify reads.
BuildKit's provenance: mode=max remains unused: it is unsigned metadata that
anyone who can push to the registry could forge, so it proves nothing while
looking like it does.
Releases published before this was enabled stay unsigned
Attestation covers artifacts built after it was turned on. Images released
earlier, 0.1.1 included, have no attestation and never will — there is
nothing to backfill, because the attestation is produced by the build. Do not
describe those as signed or verified.
One-time GitHub configuration¶
Some of this cannot be expressed in the repository and has to be set once in the GitHub UI by someone with admin rights.
| Setting | Where | Why |
|---|---|---|
| Immutable releases | Repository → Settings → General → Releases | Prevents a published release's assets and tag from being changed after the fact. The workflow treats versions as immutable, but only this setting enforces it. |
| Package visibility | Each package → Package settings → Change visibility | Done for both packages. Visibility is per package and does not follow the repository, so a new package starts private and needs setting explicitly before anonymous docker pull works. |
| Package repository link | Each package → Package settings | Usually automatic: the images carry org.opencontainers.image.source, which GitHub uses to attach the package to this repository. Link it by hand if it does not appear. |
| Actions permissions | Repository → Settings → Actions → Workflow permissions | The release workflow needs GITHUB_TOKEN to be allowed to write packages. Organisation policy can override the workflow's own permissions block. |
Until the immutable-releases setting is enabled, a release is immutable by convention only. Do not describe it as enforced.
Runners¶
The container jobs build linux/arm64 on ubuntu-24.04-arm, a GitHub-hosted
Arm runner, rather than under QEMU: emulating a release build of the Rust
workspace turns a ten-minute job into an hour-long one. These runners are
available to the organisation's plan. If that ever changes, the alternative is
QEMU via docker/setup-qemu-action, at that cost.