Skip to content

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:check runs uv lock on every pipeline and refuses a uv.lock that disagrees with pyproject.toml.
  • lock:update is its fix: a manual job that runs uv lock and pushes the refreshed uv.lock, alone, to the same branch with the job token. The project allows that push through ci_push_repository_for_job_token_allowed, a declared attribute of the project in Nerthus.Infra. The commit carries the branch's Task: 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

  1. Open the merge request changing docker/ci.Dockerfile. ci:image builds it and pushes nothing.
  2. Merge it. Jobs on main still run on the old $CI_IMAGE: nothing is pinned to bytes nobody has built.
  3. On main, play ci:image-push. It pushes …/ci:pin-<12 hex> and writes the digest into the ci-image-reference.txt artifact.
  4. 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.