The Platform pipeline¶
What Nerthus.Platform's CI runs, what it holds, and which of its jobs may change anything outside
the pipeline. The source is .gitlab-ci.yml; every check longer than a line is a module under ci/
with its own test, so a rule is exercised on every run rather than once, by a commit that broke it.
One lane per change¶
A task branch never runs a branch pipeline. The merge request's pipeline is its one lane; main
and v* tags keep theirs. Run formatters, linters, the gate scripts and the focused tests locally
before a push: CI confirms, it does not discover.
CI holds no secrets¶
Nerthus.Platform's project carries no CI variables. Everything its pipeline needs is the job
token, CI_JOB_TOKEN: it reads Nerthus.Docs and the corpus projects, each of which lists this
project on its inbound job-token allowlist, and it authenticates the group package registry as
UV_INDEX_NERTHUS_PASSWORD: "$CI_JOB_TOKEN". A job token is minted per job, dies with it, and is
scoped by an allowlist a person maintains.
A masked variable is not a secret store. GitLab masks a value in job logs; any pipeline that can run on a ref the variable reaches can still read it and print it in a form the masker does not recognise. The property that matters is protected: GitLab withholds the variable from every pipeline whose ref is not protected. Across the estate, every variable that grants access is protected, and every job that uses one runs on a protected ref.
Privileged jobs run on protected refs alone¶
A job is privileged when it writes somewhere the pipeline cannot take back: an image into a
registry, a wheel into an index, a commit onto a remote, a write method of an API. ci/pipeline.py
holds the list of acts; gates:protected-refs and tests/test_pipeline.py judge the pipeline file
against it.
Each privileged job is guarded by $CI_COMMIT_REF_PROTECTED == "true", not by a pattern over the
ref's name. v* and archive/* tags are protected, Maintainers only; if that protection is ever
removed, the guarded jobs stop running rather than running unprotected.
| Job | Runs when |
|---|---|
image:release |
a protected v* tag |
publish:wheels |
a protected final-release tag |
mirror:pypi |
manually, on protected main |
ci:image-push |
manually, on protected main |
image:smoke and ci:image share a template with a pushing sibling and are exempt by name, on a
job-level lane variable that disarms the push; the test asserts the exemption is load-bearing.
The regime gates¶
ci/gates.py holds the gates as pure functions with a thin command line; tests/test_gates.py
exercises them.
| Gate | Refuses |
|---|---|
gates:commit-grammar |
a subject outside <type>[(<scope>)]: <summary> or over 72 characters |
gates:branch-name |
a branch outside the regime's namespaces |
gates:task-trailer |
a commit whose Task: trailer is missing or names another task; a title without the id (history) |
gates:regime-digest |
a digest block in CLAUDE.md that differs from Nerthus.Docs' |
gates:regime-page |
advisory: the regime page moved since ci/regime-page.sha256 pinned it |
The regime page belongs to Nerthus.Docs, so an edit there is a notice in Platform's pipeline, not a stop on every merge; the digest comparison is the half that blocks.
The lockfile¶
morfeusz2 and pl_core_news_lg are on no public index, so uv lock resolves this project only
with a credential for the group registry, and CI is where the lockfile is made.
lock:checkrunsuv lockon every pipeline and refuses auv.lockthat disagrees withpyproject.toml.lock:updateis its fix: a manual job that runsuv lockand pushes the refresheduv.lock, alone, to the same branch with the job token. The project allows that push throughci_push_repository_for_job_token_allowed, a declared attribute of the project in Nerthus.Infra. The commit carries the branch'sTask:trailer. A push made with the job token starts no pipeline, so the merge request's pipeline is run once more.
The CI image¶
docker/ci.Dockerfile is the image the pipeline's own jobs run on, not what a host runs. It carries
the pinned CPython, a linker, git, uv, the pinned Rust toolchain with rustfmt, clippy and
llvm-tools-preview, cargo-llvm-cov, the shared libraries the bundled Postgres loads, and a
nerthus user (uid 1000). $CI_IMAGE pins it by digest, so no job installs a toolchain over the
network.
Its default user is root: the runner's helper makes the checkout as root, and an unprivileged
default would meet a root-owned .git that git refuses. test:python steps down into nerthus for
the suite alone, because Postgres refuses to run as root. It starts the venv's own pytest rather
than uv run, which would write to a root-owned cache first.
ci/image.py ci-dockerfile asserts that UV_VERSION, RUST_VERSION, CARGO_LLVM_COV_VERSION and
the base-image digest are the same strings in the pipeline and in the Dockerfile, that the
Dockerfile installs a package for every shared library the bundled Postgres needs, and that no job
has grown an apt-get or a curl … | sh back.
Refresh it¶
- Open the merge request changing
docker/ci.Dockerfile.ci:imagebuilds it and pushes nothing. - Merge it. Jobs on
mainstill run on the old$CI_IMAGE: nothing is pinned to bytes nobody has built. - On
main, playci:image-push. It pushes…/ci:pin-<12 hex>and writes the digest into theci-image-reference.txtartifact. - Open a second merge request committing that digest as
CI_IMAGE.
Refreshing AMD64_PYTHON_IMAGE means rebuilding this image, because it is built from that digest;
the check above makes the Dockerfile edit unavoidable. A fork has its own empty registry and must run
step 3 once before any toolchain job can pull.
Two builds of one commit give different digests: the package archive and the layer timestamps move. The digest guarantees identity, not reproducibility: every job that names it runs the same bytes, and a change to those bytes is a line in a diff.
Wheels and the mirror¶
ci/wheels.py declares the wheel matrix: manylinux_2_28_x86_64 and manylinux_2_28_aarch64,
both cp313-abi3, both cross-compiled with zig to the same glibc floor, plus the source
distribution macOS builds from. The check is a comparison against that declaration, never a loop
over dist/*.whl, which would pass a build that produced nothing. tools/check-wheels.sh installs
a wheel into a clean environment and calls into it.
ci/mirror.py fetches the third-party wheels packages/nerthus/language/wheels.py declares,
verifies each against its declared size and sha256, and refuses the model unless its GPL licence
text is inside the wheel, before mirror:pypi uploads anything. ci/build-morfeusz-aarch64.sh
builds the one Morfeusz wheel no upstream publishes, inside an aarch64 job container; it is manual,
slow, and run when the dictionary version moves.