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 --> gates[Release gates, integration stage, on a candidate built from the tag]
gates --> server[Server image: amd64 + arm64 -> GHCR]
gates --> web[Console image: amd64 + arm64 -> GHCR]
server --> binaries[Binary archives from the published image]
binaries --> artifact[Release gates, candidate stage, on the extracted binaries]
server --> smoke[Smoke test the published images on amd64 and arm64]
web --> smoke
binaries --> provenance[Verify attestations]
server --> provenance
web --> provenance
artifact --> decision[Release decision over every result]
smoke --> decision
provenance --> decision
decision --> release[GitHub Release + checksums + SBOMs + gate report]
The GitHub Release is created last and depends on the release decision, which
evaluates every gate in release/gates.toml for this
candidate. A failed or missing gate, an open finding that blocks release, or an
expired quarantine stops the release; the decision is attached to the release
as record-store-X.Y.Z-release-gates.json and .md. Images are pushed only
after the pre-publish gates pass; a failure after that point leaves a tagged
image without a release, which is never repointed.
Run the candidate evaluation before tagging, from the Actions tab (Gates → Run workflow → candidate) on the commit you intend to tag. It runs the same gates the release will, without publishing anything.
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:
The version also appears in documentation examples and as the Compose default, where nothing validates it. Those are not read by the release, but a reader who copies them gets the previous release:
| File | What to update |
|---|---|
deploy/docker/compose.ghcr.yml |
RECORD_STORE_VERSION default, both images |
deploy/docker/docker-compose.ghcr.yaml |
RECORD_STORE_VERSION default, both images |
docs/deployment/container-images.md |
Tag table and examples |
docs/deployment/docker-compose.md, docs/deployment/upgrading.md |
Examples |
docs/getting-started/installation.md, README.md |
Pinned-version examples |
grep -rn "X\.Y\.Z" docs deploy README.md finds the previous release's
leftovers. Leave references that are deliberately historical, such as which
releases predate build attestation.
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.
Release checklist¶
The workflow enforces most of this; the list exists so a human can see what is being enforced and why. Items marked enforced fail the run — the release is not created.
Before tagging:
-
CHANGELOG.mdhas a section for this version, written for the person upgrading, with no entries left under## [Unreleased] - The workspace version matches the tag — enforced by the
validatejob - A candidate run of the Gates workflow on the commit to be tagged is
READY(orREADY WITH EXCEPTIONS, each exception reviewed) — the same gates are enforced again by the release - No open finding in
release/findings/blocks release — enforced - Any upgrade step a deployment must take is stated in the changelog, not only in a pull request description
Produced by the run, and all enforced:
- Every gate of the integration stage passes before any image is pushed
- Both images built for
linux/amd64andlinux/arm64 - Signed provenance on the server image index
- Signed provenance on the console image index
- An SPDX SBOM attested per architecture, bound to the platform manifest it describes rather than to the index
- Signed provenance on every binary archive
- The candidate-stage gates pass against the binaries extracted from the published image, including recovery, upgrade, integrity and redaction
- The published images pass the smoke test on both architectures: correct version, non-root, clean exit on SIGTERM, data persists across a restart
- The release decision is
READYand is attached to the release -
SHA256SUMScovers every asset actually attached
The four attestation items are checked by
.github/scripts/verify-attestations.sh
in the provenance job, which the release job depends on. It queries the same
public attestation service a user would, so a pass means
gh attestation verify will also pass for whoever downloads the release.
If it fails, the release is not published and the run says exactly which subject had no attestation. Fix the attest step; do not skip the check. A release that claims signed provenance and does not carry it is worse than one that claims nothing, because the claim is what people act on.
After the run:
- Verify the release the way a consumer would — see Verifying a Release
- The release notes render correctly and the asset list is complete
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.