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
| Tag | Who pushes it | What it is |
|---|---|---|
v1.0.0-rc.1 | a maintainer | A release. vMAJOR.MINOR.PATCH, optional -prerelease (semver 2.0.0; no +build — not a Docker tag) |
v1.0.0 | a maintainer | A release |
v1, v1.0 | release.yml only | FLOATS: 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+buildare refused (scripts/release-tags.mjs, pinned bytests/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:checkand 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 badv1.
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 releases | New tag | v1 | v1.0 / v1.2 | Why |
|---|---|---|---|---|
| (none) | v1.0.0-rc.1 | create | create v1.0 | the first release creates its floats |
v1.0.0-rc.1 | v1.0.0 | move | move v1.0 | rc < GA for the same core |
v1.0.0 | v1.0.0-rc.2 | stays | stays | never backward |
v1.0.0, v1.2.0 | v1.0.1 | stays | move v1.0 | a patch behind the line moves only its minor float |
v1.2.0 | v1.2.1 | move | move v1.2 | the highest in the line moves both |
v1.9.9 | v2.0.0 | — | — | v2 / 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;factorykeeps its verbs and their exit codes; thefactory.ymland host-spec schemas only GAIN optional keys (a manifest that validated againstv1.0validates againstv1.9).FROM ghcr.io/clankerlabs/factory-base:1keeps the entrypoint contract inimages/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.ymlrunsfactory-gateway:1by 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:
| Layer | Always? | What it is | How to read it |
|---|---|---|---|
BuildKit provenance (mode=max) | yes | SLSA provenance of the build (materials, the Dockerfile, the builder) pushed to GHCR as an OCI referrer beside the image | docker buildx imagetools inspect ghcr.io/clankerlabs/factory-base@<digest> |
| BuildKit SBOM | yes | SPDX bill of materials of the image, same place | same command |
| syft SBOM (SPDX JSON) | yes | A second, independent SBOM of the PUSHED digest, attached to the GitHub release as sbom-<image>.spdx.json | download it; grype sbom:sbom-factory-base.spdx.json for a CVE view |
| Signed build provenance | when allowed | actions/attest-build-provenance: Sigstore-signed SLSA provenance, recorded in the transparency log, bound to this repository and workflow | gh attestation verify oci://ghcr.io/clankerlabs/factory-base@<digest> --owner clankerlabs |
| Signed SBOM attestation | when allowed | actions/attest-sbom: the syft SBOM, signed the same way | gh attestation verify … --predicate-type https://spdx.dev/Document/v2.3 |
| Release notes | yes | commit, 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 UNSIGNED | the 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-imagepullsFROM factory-base:1(T061) with the service-account PAT it already holds in Pulumi config (app:githubToken, theclankeruser) — the same credential itssandbox-build-image.ymlpushes with. That works only if the package grants the user (or the repository) read access; see Operator below. - Every adopter host needs
image.pull.tokenFromin itsfactory.yml(images/README.md,docs/factory-yml.md) — aread:packagestoken in the host’s secrets provider that the container engine uses fordocker pull.docs/byoc.mdlists 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.
-
Tag ruleset on
v*(Settings → Rules → Rulesets → New tag ruleset): targetrefs/tags/v*; rules Restrict updates and Restrict deletions; leave creation open (a maintainer pushesvX.Y.Z); bypass list = the release App from step 2 and nobody else. From then on a person’sgit push origin v1(or--forceof anyv*) is refused, which is the T051 acceptance, andrelease.ymlis 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. -
The release App: create an organisation GitHub App (
clankernet-release; permissions: Repository → Contents: read & write, nothing else; no webhook), install it onclankerlabs/CLANKERNETonly, generate a private key, and seed BOTH values into the host stackdeploy.ymlreads (whitehatgg/clanker-infra/dev-host, or whateverFACTORY_HOSTnames) asreleaseAppId(the numeric App ID) andreleaseAppPrivateKey(the PEM, as one--secretvalue on stdin), beside the Cloudflare values — then commit the two new rows ofpulumi/Pulumi.dev-host.yaml(docs/operator/seeding.md).release.ymlmints a one-hour installation token scoped tocontents: writefor the tag push and nothing else. Until these are seeded the run WARNS and moves the floats withGITHUB_TOKEN— which works exactly as long as step 1 has not happened. -
GHCR access grants (after the first release has created the packages — Packages →
factory-base→ Package settings, then the same forfactory-gateway):- Manage Actions access: add the repository
clankerlabs/clankerengineer, role Read (T061’sbuild-dev-imagepullsfactory-base:1). - Manage access: add the service-account user
clanker(the holder of CE’sapp: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 letsGITHUB_TOKENkeep pushing; a package created out of band is not linked and the next release fails withdenied.
- Manage Actions access: add the repository
-
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 itfalseswitches them off again without a workflow edit if the service misbehaves. -
The first release:
git tag v1.0.0-rc.1 && git push origin v1.0.0-rc.1from amaincommit, then check:git ls-remote origin refs/tags/v1equals the tagged commit; the release page lists both digests;docker pull ghcr.io/clankerlabs/factory-base:1works with aread:packagestoken;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 clankerlabspasses.
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.