Skip to content

Cut a Platform release

A release of Nerthus.Platform is a tag, the image and wheels the tag builds, and one merge request that pins them. This guide is the procedure and which job refuses what. The rules it executes: an image is pinned only when a release tag built it, the release merge request is the one write path to versions.lock, a build of main is never pinned, and no job writes a release file.

Versions follow semver by contract impact. A rollback ships as a higher version: a pin only moves forward, so undoing 0.3.0 means 0.3.1 carrying the revert.

Two lanes

Job Runs on Builds Pushes
image:smoke every merge request, and main both variants, and runs the container nothing
image:release protected v* tags only the same, identically v<semver>, and writes image-digest.txt

image:smoke names what it builds sha-<12 hex>, a grammar ci/image.py check_release_tag refuses by name, so a main build cannot be pinned even by a hand that means to.

The procedure

  1. Check the numbers. On a clean checkout of origin/main:

    ./tools/release-check.sh
    

    It prints the three declarations that must agree (VERSION, pyproject.toml, crates/nerthus-core/Cargo.toml), the last release tags on the remote, and what versions.lock pins today. It runs the code lock:check runs, so it cannot disagree with it. It cuts nothing and edits nothing.

  2. Bump, in one commit. VERSION, [project] version in pyproject.toml and [package] version in crates/nerthus-core/Cargo.toml, plus Cargo.lock. lock:check then refuses the stale uv.lock; press lock:update on that pipeline to push the re-locked one. Do not touch versions.lock: it describes the previous release's artifacts, correctly, until the new ones exist. lock:check lets it lag and refuses it to lead.

  3. Merge that, and wait for main to be green. A tag on a red tree is a release nobody measured.

  4. Tag it, with notes.

    git fetch origin
    git tag -a v0.3.0 origin/main -m "…"
    git push origin v0.3.0
    

    The notes say what a converging host does differently: a new environment variable, a migration that expands, a role that now refuses to start without something. The reader is somebody deciding whether to pin this on a host, not somebody reading commits.

  5. Wait for image:release. It builds, runs the container against a bundled and an external Postgres, asserts the registry credential is in neither the image's history nor its filesystem, pushes registry.gitlab.com/margonem/nerthus/it/nerthus.platform:v0.3.0 and publishes image-digest.txt. publish:wheels runs beside it and uploads both wheels and the source distribution.

  6. Open the release merge request. One commit, the only kind that may change versions.lock:

    • version: the new number;
    • [wheels]: each published wheel's sha256, read off the registry (glab api "projects/86196907/packages/<id>/package_files"), never recomputed locally, because what is pinned is what a consumer downloads;
    • [image]: repository, tag (the release tag), digest (from image-digest.txt) and commit (the tagged commit, in full).

    Merge it on green.

  7. Move a site's pin separately. A site runs what its pin in Nerthus.Infra names, and moving that pin is a deploy: its own merge request in that repository, after this one merged. No Platform site exists yet, so no release moves one.

Which job refuses what

Refusal Where Why there
image.tag is not a release tag, the tag does not name image.commit, or that commit is not in this history lock:check (ci/release.py pin) git ls-remote needs no clone; the ancestry half needs the history, so the job fetches it whole
VERSION, pyproject.toml and the crate disagree, the tag does not name the tree, or versions.lock leads it lock:check (ci/release.py versions) cheap, and a contributor learns it in the lint stage
image.commit does not exist reference:check (nerthus lock verify versions.lock) the check asks the GitLab API and makes a depth-1 fetch, and needs a built nerthus, which that job has; git cat-file on a shallow clone reports an old, present commit as missing
a wheel's sha256 is not what the registry serves, or versions.lock pins no image verify:versions-lock it already downloads both wheels from the registry to hash them

lock:check skips the pin check on a tag pipeline, and verify:versions-lock does not run there: while a release tag builds, the artifacts those checks verify are the ones that pipeline is producing, and versions.lock still describes the previous release. main runs both on every push, which is where a wrong or unpinned file is refused, and why a release tag's pipeline can be green.

The credential in the image build

The image build installs from the group registry, and its credential arrives as a BuildKit secret: two NAME=value lines mounted at /run/secrets/registry_credential and sourced inside one RUN, written into no layer and no history, never a build argument. ci/image.py refuses a docker/Dockerfile that names a credential as an ARG or an ENV, and image:release asserts, with a positive control, that neither the token nor either variable name is in the image's history, configuration or filesystem.