Releasing CLANKERNET

A customer-facing release is a tag cut from the trunk (plan D22 §4, T051). Pushing it runs .github/workflows/release.yml, which is everything a release is: the two images with a bill of materials and provenance, the manifest schema as a downloadable asset, the GitHub release with notes that say exactly what was built by what, and the floating tags moved forward. Nothing else in the repo publishes an image, moves a v* tag or creates a release — tests/release-workflow.test.ts pins that, and CLAUDE.md’s “Deployment” table says where everything else ships.

There is ONE trunk, main (renamed 2026-09-17, plan D22 erratum). Push to main publishes the web surface (deploy.yml); a tag publishes what an adopter pins.

Tag discipline

TagWho pushes itWhat it is
v1.0.0-rc.1a maintainerA release. vMAJOR.MINOR.PATCH, optional -prerelease (semver 2.0.0; no +build — not a Docker tag)
v1.0.0a maintainerA release
v1, v1.0release.yml onlyFLOATS: the highest release in their line. Refused as a release; refused from a person by the ruleset

The first tag is v1.0.0-rc.1. It is a pre-release (the GitHub release is flagged so) and it CREATES v1 and v1.0 — a consumer on @v1 or factory-base:1 runs the rc until v1.0.0 lands. That is deliberate: the plan needs v1 to exist before the first adopter (host.ref: repo:clankerlabs/CLANKERNET@v1:…, T060) can resolve anything, and an rc is the only thing there is to point it at. Pin the exact tag or the digest if that is not acceptable.

git switch main && git pull --ff-only
git tag v1.0.0-rc.1            # lightweight or annotated; the commit must be ON main
git push origin v1.0.0-rc.1    # release.yml starts

Rules verify enforces, each a ::error:: that names itself:

  • The tag is release-shaped. v1, v1.0, 1.0.0, v1.0.0+build are refused (scripts/release-tags.mjs, pinned by tests/release-tags.test.ts).
  • The tagged commit is an ancestor of main. A tag on a feature branch is not a release; cut it after the merge.
  • The tag still points where the event said (a tag moved while the run queued is refused rather than built).
  • npm run schema:check and the full suite pass at the tagged commit. The trunk already gated the commit; the tag is the promotion gate and the minutes are cheap next to a bad v1.

To re-run a release whose first run died after the tag was pushed (a registry outage, a transient in the attestation service), dispatch release.yml with the tag name. Dispatch never creates a tag; it re-runs the release for one that exists, and a release that already exists is EDITED (notes refreshed, assets re-uploaded with --clobber) rather than duplicated.

What moves, and when

scripts/release-tags.mjs decides, from the release tag and the full tag list, and release.yml does nothing the decision did not list:

Existing releasesNew tagv1v1.0 / v1.2Why
(none)v1.0.0-rc.1createcreate v1.0the first release creates its floats
v1.0.0-rc.1v1.0.0movemove v1.0rc < GA for the same core
v1.0.0v1.0.0-rc.2staysstaysnever backward
v1.0.0, v1.2.0v1.0.1staysmove v1.0a patch behind the line moves only its minor float
v1.2.0v1.2.1movemove v1.2the highest in the line moves both
v1.9.9v2.0.0v2 / v2.0 start their own line; v1 is untouched

The image floats follow the git floats exactly, without the v: when v1 moves, ghcr.io/clankerlabs/factory-base:1 and factory-gateway:1 are retagged to the same build; likewise :1.0. The exact git tag is always an image tag too (factory-base:v1.0.0-rc.1).

What v1 promises

The floats are the promotion gate BYOC users pin (D22 §4). Across every v1.x release:

  • uses: clankerlabs/CLANKERNET/.github/actions/setup-factory@v1 (T050) keeps its inputs and outputs; factory keeps its verbs and their exit codes; the factory.yml and host-spec schemas only GAIN optional keys (a manifest that validated against v1.0 validates against v1.9).
  • FROM ghcr.io/clankerlabs/factory-base:1 keeps the entrypoint contract in images/README.md (FACTORY_MANIFEST, FACTORY_ENV_FILE, the reserved env, the five volumes, the boot order) and the Debian release it is pinned to.
  • host/docker-compose.gateway.yml runs factory-gateway:1 by default (FACTORY_GATEWAY_TAG); a rollback is that variable set to the previous exact tag (D22 §1).

A v2 is where any of that changes. Nothing promises that v1 never moves to a pre-release — it does, when the pre-release is the highest in the line — so a build that must not change pins the exact tag or, better, the digest the release notes list. docs/byoc.md says the same for the reusable workflow and the composite action: an adopter pins a 40-character SHA and lets Dependabot bump it; @v1 is for dogfood and the quickstart.

Provenance and the bill of materials

This is the artifact-provenance / SLSA / SBOM commitment that used to live in clankerengineer’s deploy/ENTERPRISE.md §1.2b (Tier 0), re-homed here because CLANKERNET is the thing that ships into someone else’s account (D21). The question it answers is the one every BYOC customer asks: what is in the thing you put in my account, and how do I know you built it?

Every release ships, for BOTH images:

LayerAlways?What it isHow to read it
BuildKit provenance (mode=max)yesSLSA provenance of the build (materials, the Dockerfile, the builder) pushed to GHCR as an OCI referrer beside the imagedocker buildx imagetools inspect ghcr.io/clankerlabs/factory-base@<digest>
BuildKit SBOMyesSPDX bill of materials of the image, same placesame command
syft SBOM (SPDX JSON)yesA second, independent SBOM of the PUSHED digest, attached to the GitHub release as sbom-<image>.spdx.jsondownload it; grype sbom:sbom-factory-base.spdx.json for a CVE view
Signed build provenancewhen allowedactions/attest-build-provenance: Sigstore-signed SLSA provenance, recorded in the transparency log, bound to this repository and workflowgh attestation verify oci://ghcr.io/clankerlabs/factory-base@<digest> --owner clankerlabs
Signed SBOM attestationwhen allowedactions/attest-sbom: the syft SBOM, signed the same waygh attestation verify … --predicate-type https://spdx.dev/Document/v2.3
Release notesyescommit, the SHA of release.yml as it ran (github.workflow_sha), the run URL, every image digest and tag, which floats moved, and whether the provenance is signed or UNSIGNEDthe GitHub release page

“When allowed” is a plan constraint, not a choice. GitHub’s attestation API is a paid feature on a PRIVATE repository: clankerengineer’s image build added actions/attest-build-provenance and got Feature not available for the clankerlabs organization on every run until it deleted the step. This repository is private until T103 (D1). release.yml therefore runs the two signed steps when the repository is public, or when the repository variable FACTORY_SIGNED_ATTESTATIONS is true, and SKIPS them otherwise — with a ::warning:: in the run and UNSIGNED in the release notes. A release that is red on every run trains people to ignore red; a release that says what it lacks does not. The two BuildKit layers and the syft SBOM ship regardless, so “what is in it” is answerable today; “prove a third party that you built it” is answerable the day the repo goes public or the plan changes, with no workflow edit.

What is deliberately NOT here: cosign key-based signing (Sigstore keyless via the attestation API supersedes it, and a long-lived signing key is a second secret to rotate), OpenSSF Scorecard (public repositories only; T103), and the compliance programme (SOC 2 / ISO — D21 leaves that to whichever product sells it).

Packages are PRIVATE until T103

ghcr.io/clankerlabs/factory-base and factory-gateway inherit the repository’s visibility, so until T103 (coordination lanes split, repo and packages go public) every pull needs a GHCR read:packages credential:

  • clankerengineer’s build-dev-image pulls FROM factory-base:1 (T061) with the service-account PAT it already holds in Pulumi config (app:githubToken, the clanker user) — the same credential its sandbox-build-image.yml pushes with. That works only if the package grants the user (or the repository) read access; see Operator below.
  • Every adopter host needs image.pull.tokenFrom in its factory.yml (images/README.md, docs/factory-yml.md) — a read:packages token in the host’s secrets provider that the container engine uses for docker pull. docs/byoc.md lists this as a v1 limitation lifted by T103; an adopter that cannot hold one is not an adopter until then.

The registry credential the release PUSHES with is the ambient GITHUB_TOKEN (packages: write on the images job only). The first push from this repository creates each package linked to it; nothing in the workflow needs the service-account PAT, and no second GitHub secret exists (rule 9).

Operator

Steps nothing in CI can do, each once. Do the first two TOGETHER: the ruleset without the App locks release.yml out of the floats; the App without the ruleset protects nothing.

  1. Tag ruleset on v* (Settings → Rules → Rulesets → New tag ruleset): target refs/tags/v*; rules Restrict updates and Restrict deletions; leave creation open (a maintainer pushes vX.Y.Z); bypass list = the release App from step 2 and nobody else. From then on a person’s git push origin v1 (or --force of any v*) is refused, which is the T051 acceptance, and release.yml is the ONLY mover.

    github-actions[bot] cannot be a bypass actor — GitHub offers Apps, deploy keys and roles, not the workflow token — which is why step 2 exists.

  2. The release App: create an organisation GitHub App (clankernet-release; permissions: Repository → Contents: read & write, nothing else; no webhook), install it on clankerlabs/CLANKERNET only, generate a private key, and seed BOTH values into the host stack deploy.yml reads (whitehatgg/clanker-infra/dev-host, or whatever FACTORY_HOST names) as releaseAppId (the numeric App ID) and releaseAppPrivateKey (the PEM, as one --secret value on stdin), beside the Cloudflare values — then commit the two new rows of pulumi/Pulumi.dev-host.yaml (docs/operator/seeding.md). release.yml mints a one-hour installation token scoped to contents: write for the tag push and nothing else. Until these are seeded the run WARNS and moves the floats with GITHUB_TOKEN — which works exactly as long as step 1 has not happened.

  3. GHCR access grants (after the first release has created the packages — Packages → factory-base → Package settings, then the same for factory-gateway):

    • Manage Actions access: add the repository clankerlabs/clankerengineer, role Read (T061’s build-dev-image pulls factory-base:1).
    • Manage access: add the service-account user clanker (the holder of CE’s app:githubToken), role Read.
    • Confirm each package is linked to clankerlabs/CLANKERNET (it is when the workflow’s first push created it) — the link is what lets GITHUB_TOKEN keep pushing; a package created out of band is not linked and the next release fails with denied.
  4. Signed attestations, one of: make the repository public (T103 — the default detection turns them on), or upgrade the org plan and set the repository variable FACTORY_SIGNED_ATTESTATIONS=true. Setting it false switches them off again without a workflow edit if the service misbehaves.

  5. The first release: git tag v1.0.0-rc.1 && git push origin v1.0.0-rc.1 from a main commit, then check: git ls-remote origin refs/tags/v1 equals the tagged commit; the release page lists both digests; docker pull ghcr.io/clankerlabs/factory-base:1 works with a read:packages token; docker buildx imagetools inspect …@<digest> shows the provenance and SBOM referrers; and, if step 4 applies, gh attestation verify oci://ghcr.io/clankerlabs/factory-base@<digest> --owner clankerlabs passes.

Pre-production for the images

There is no pre-production registry channel and no :staging-style tag (D22). What stands in for it is ci.yml’s gateway-image job on every pull request: it builds factory-gateway from the same Dockerfile and root context release.yml uses, never pushes it, boots it in docker mode (the production default) against a throwaway coordination repo, and asserts /gf/v1/node answers 200 on 8470, /auth/github redirects to GitHub on 8471, and the sentinel GIT_TOKEN is nowhere on disk. A Dockerfile that forgets a COPY fails there, on the PR, not at the first release. The base image is not booted in PR CI (its apt layers take minutes and its boot needs a manifest and a tailnet); images/README.md records the manual acceptance.