# Releasing Nika The release train is one tag push; everything after is CI. This file is the ceremony: what a releaser does, what the machine does, and what a user can prove afterwards. The lineage law applies throughout: a published release is a historical record: **never rewrite a live release body retroactively** without an explicit operator decision. ## The ceremony (human side) 1. **Preview the changelog, then run the one release sweep.** Each change already described itself in its own file under `changelog.d/` (`changelog.d/README.md` is the contract). Read the assembled body first; then let the sweep own the fold and every version carrier as one act: ```sh bash scripts/release/changelog-assemble.sh --check bash scripts/release/changelog-assemble.sh # preview it bash scripts/release/wave-sweep.sh ``` `wave-sweep.sh` delegates the engine section to `changelog-assemble.sh --fold`, so the two mechanisms cannot create two headings. The assembler splices the body in as `## []` with its compare link, restores the `[Unreleased]` stub, and **`git rm`s** the fragments it consumed — the deletions land STAGED, so `git add CHANGELOG.md` beside them and the fold is one commit. (Plain `rm` left them unstaged, and since this file stages by explicit path and never `git add -A`, the release would have shipped the assembled section *and* the fragments it was made from; the next fold would then emit each one twice.) Hand-curated narrative (a BREAKING window, an era note) may still be edited into the section afterwards: the section, not the release page, is where curation lives. `CHANGELOG.md` stays the single source the release body renders from. **Why fragments.** `CHANGELOG.md` was a shared append target, so two branches that shared no source file still collided there — measured 2026-08-24 on four security pull requests (#1162 #1163 #1164 #1165): `git merge-tree` reported one conflict each, always this file, and zero overlap across the nine crate files they touched. Same shape as the `estate.yaml` collision of 2026-08-20, same fix: stop sharing the target. `scripts/ci/check-changelog-fragments.sh` refuses a bullet written back into `[Unreleased]` (pre-push and in CI). `git-cliff` stays the fallback for a tag nobody curated — `changelog-cliff.yml` still runs on tag push and its idempotence guard skips whenever the fold already landed a `## []` section: ```sh git-cliff --config cliff.toml v..HEAD --tag v --strip all ``` 2. **Review every surface the sweep moved.** The workspace manifest is the authority and the release workflow refuses a tag that disagrees with it (first gate), but the complete carrier set is larger: ```sh # the authority Cargo.toml version = "" # the internal pins crates/*/Cargo.toml { path = "../nika-x", version = "" } # what an editor SHOWS the user .agents/plugins/nika/{plugin.json,.*-plugin/plugin.json} # the install example in the image header Dockerfile v= # all three lock families follow the workspace Cargo.lock fuzz/Cargo.lock crates/nika-acp/Cargo.lock ``` Then `bash scripts/refresh-status.sh --write` · it regenerates the block AND writes it into `ROADMAP.md` and `.claude/CLAUDE.md` (the loop closed 2026-08-14 · before that the script only printed and two releases carried a stale HEAD). Vector 23 refuses a tag while those blocks name the old version; **vector 47** refuses one while any surface above disagrees. Run `bash scripts/hygiene/check-all.sh` before tagging: both fire there, and both were earned by a release that shipped without them. 3. **Regenerate the estate manifest.** ```sh python3 scripts/estate.py --write && git add estate.yaml ``` The manifest is a WHOLE-TREE projection, so requiring it per-commit made every pair of concurrent branches collide on a file neither had edited — four pull requests, four conflicts, 2026-08-20, and `git merge-tree` named the projection as the only overlap. It is therefore no longer a commit-time refusal: the pre-commit hook and the `estate` CI job block on COVERAGE (a path no rule classifies) and merely report FRESHNESS. Freshness is owned HERE instead, where it is the deliverable rather than a formality: `release.yml` refuses a tag whose tree does not match its manifest, because that tree is the one whose binaries a user will verify. Forget this step and the release stops at the guard — which is the same shape as the version-match guard above, and for the same reason. The manifest goes into the **last** commit before the tag. `v0.117.1` regenerated it in the prep commit, then one more commit moved a tracked file, and all four build legs refused the tag. Step 4 now asks this question too (`next-tag-project.sh --check` counts a stale manifest as a claim without proof), so a tag cut after a red step 4 is the only way to meet the guard again. 4. **Project the next tag (the anti-08-08 question).** Before the tag exists, ask what it would actually contain and what it would claim without proof. The command reads `release.yml`, `wiring.yaml`, the CHANGELOG `[Unreleased]` section and the CI job keys. If it had existed on 2026-08-08 it would have said: the CHANGELOG promises the harness · `release.yml` does not build it. ```sh bash scripts/ci/next-tag-project.sh --check ``` `--check` exits 1 on UNPROVEN claims. Do not tag while it is red. A `✅` in `[Unreleased]` without a release build line and a named CI job is a `🟡`. 5. **Tag and push.** The tag is the shared GitHub/npm/Homebrew/OCI coordinate. It accepts strict SemVer core plus prerelease identifiers (`v1.0.0-rc.1`), but not build metadata (`+build`): SemVer ignores metadata for precedence while the four registries must agree on one spelling. The workflow validates that coordinate in its first job, before builds and before any publication write. ```sh git tag v && git push origin v ``` Nothing else. `workflow_dispatch` can replay an existing tag. Dispatch it from the current workflow on `main`, while the `tag` input identifies the immutable source tag: ```sh gh workflow run release.yml --repo supernovae-st/nika --ref main \ -f tag=v ``` Never select the historical tag as the workflow ref: that executes the workflow YAML stored in the tag and can predate the immutable uploader and concurrency guards. This cannot be retrofitted into already-published tags; the operator command and the current workflow's ref guard are both part of the replay boundary. All live and replay release trains share one global publication lane because Homebrew and the container `latest` tag are cross-version mutable pointers. GitHub publication explicitly uses `make_latest=legacy` for stable releases and `make_latest=false` for prereleases, so delayed recovery of an older stable cannot force it to Latest. The workflow is a **visibility barrier, not a cross-registry transaction**: npm and GHCR writes are irreversible and may already be public while the GitHub Release remains a draft. A failure therefore converges forward under the same immutable coordinate; it never rolls a registry back or overwrites a divergent identity. GitHub retains only one pending train and may replace it with a newer one, so never queue more than one train behind the active run. Replay refuses to replace an occupied release asset with different bytes, so timestamped or otherwise non-reproducible rebuilds stop rather than silently refresh public bytes. A missing asset is filled only after the occupied set compares byte-for-byte. A stable replay always converges Homebrew and `latest`, including recovery after either post-public job failed. Already-correct pointers no-op; prereleases never run these jobs. Before either possible write, the workflow proves this release is the newest public stable SemVer, so an old-tag replay fails rather than downgrading a floating pointer. There is an unavoidable short post-public window before the downstream Homebrew commit lands, because the formula cannot safely point at a draft. The Homebrew leg reads its four digests from the payload's `SHA256SUMS` manifest (proof 1, the file a user verifies), never from the per-build `.tar.gz.sha256` sidecars, which stay in the build artifacts: v0.119.0 went public on 2026-09-13 and then died on that sidecar lookup, leaving the tap one version behind until a stable replay converged it. While a tag's release is still a draft, `releases/latest` names the previous release and `releases/download//…` answers 404: `install.sh --version` pinned to that tag fails at the download (#1574), which is the barrier holding, not a lost asset. The installer lives in the site repository (`public/install.sh`); its early refusal names the latest published release before any byte is fetched. The replay helper comes from the exact workflow commit, so a historical tag does not need to contain future release tooling. SLSA provenance is created only by the original tag-push context and exposed as a verified run artifact; the isolated asset-convergence writer then attaches it with the other seven exact assets, so a later run can recover it. Draft release reads require push access even in a public repository. The existing draft preparer alone reads the initial OCI marker and, on manual replay, downloads exactly one existing statement by immutable asset ID. It supplies the marker decision as job outputs and the unverified statement as a run artifact, without executing a Docker or SLSA verifier. A statement download is not proof. The event selector accepts only the selected provenance lane's success and the other lane's intentional skip. Asset convergence explicitly rejoins that result: GitHub's transitive skip cannot suppress it, but any failed, cancelled or skipped direct prerequisite still refuses the writer. Stable pointers likewise require successful finalization before their rejoin. Manual replay refuses branch-context regeneration. Every push and replay provenance lane, plus the read-only final proof, runs the pinned `slsa-verifier` against the four native subjects, repository, and exact source tag before proceeding. `workflow_dispatch` cannot generate missing tag-context provenance: if the statement was never staged, rerun the original tag-push run while that run and its artifacts are retained, provided its immutable workflow can complete. A defect in that historical workflow cannot be repaired by rerunning it; fix and test the ceremony on main and use a new version rather than moving the old tag. The exact GHCR digest is durably recorded in a single release-body marker only after both digest-addressed Linux container binaries, copied from stopped containers without executing image content, hash identically to their matching native tarballs. The proof job has read-only contents/packages. The OCI index must contain exactly the two Linux runnable images plus one BuildKit attestation descriptor bound to each image digest. Those `unknown/unknown` metadata entries are not runnable platforms. Unknown entries, missing or duplicate subjects, and duplicate platform or manifest digests refuse; provenance generation stays enabled. This index census proves structure and binding, not the authenticity of attestation contents. Payload extraction resolves each Linux child's digest from that same validated parent index, then pulls and creates a stopped container by the child digest. It never loads two architectures under one parent digest or deletes daemon references to work around a classic Docker image store. Container creation cannot implicitly pull a replacement; no image content is executed, and both native binary hashes must still match. The marker job has contents-write only and performs no Docker operation. Both release metadata PATCHes (marker and final publication) explicitly carry the tag and target SHA just verified by the shared release-state reader. Omitting these fields is not an identity-preservation guarantee: the 0.118.5 marker write returned success but left an `untagged-*` draft. Its Git tag remained intact and the marker was written, so this was a partial mutation, not a rollback. Post-write reads still refuse drift; they never repair an observed mismatched tag or retry a publication by blindly rebinding it. GitHub exposes no conditional release PATCH, so these checks cannot eliminate an administrator's concurrent-write window. Only then may `image:` be created. If the marker survives but that tag is absent, replay heals it from the exact `image@digest`; if the marker is absent, an occupied version tag is never adopted as authority. Release publication then crosses three disjoint authorities. The asset-convergence job has contents-write only, downloads the exact run artifacts, and stages/verifies the eight GitHub assets with workflow-SHA first-party tooling; it receives no SLSA, npm, Docker, package, or deploy-key authority. Asset census and upload use the immutable release ID, occupied bytes download by asset ID, and every write is preceded by a fresh release-ID/tag/SHA check and followed by convergence revalidation. A move visible to the pre-write check refuses with zero uploads; a move inside the unavoidable read/POST gap still cannot redirect the ID-scoped upload and is refused by the post-write read. The final proof has contents/attestations/packages read only and verifies the checksum manifest, native attestations, tag-bound SLSA, npm SRI, the marker owner's digest, OCI identity, and stopped-container payload bytes. Finally, the contents/discussions writer downloads the exact artifacts again and independently compares all eight current GitHub assets byte-for-byte, checks the checksum manifest, and re-reads release identity, state, and marker immediately before either accepting an already-public replay or PATCHing the draft. It trusts only the read-only proof's digest for immutable external registries and invokes no SLSA, npm, or Docker verifier. The tap deploy key is reduced to a boolean readiness result and unset before the step that receives the step-local GitHub token. OCI labels are identity metadata, not proof of binary bytes. Repository administrators can still mutate release metadata between separate GitHub API calls. Neither asset upload nor the release PATCH supports the conditional precondition this workflow would need to combine its identity read with the write. That minimal admin API TOCTOU is residual authority: the ID-scoped upload cannot resolve a different tag, and post-operation reads refuse visible drift, but the workflow cannot lock administrators out. This is distinct from cross-registry atomicity, which this visibility barrier does not claim. This barrier is future-only: **v0.116.2 is not retroactively atomic**, and its already-public registry history is not rewritten to pretend otherwise. npm authority is the package's **trusted publisher** on npmjs.com (`@supernovae-st/nika-check-wasm` → GitHub Actions · `supernovae-st/nika` · `release.yml`): the job publishes through GitHub OIDC with `--provenance` and carries no token. A granular automation token stopped publishing on 2026-09-12 (npm's 2FA-bypass restriction; the registry answers `E404` on the PUT), which is how the v0.119.0 train first stopped at the barrier. The repository-scoped `TAP_DEPLOY_KEY` stays. Missing npm authority blocks an absent package version; missing tap authority blocks a stable draft before visibility. Identical occupied npm bytes need no authority. The portable Agent Plugins mirror is downstream of this immutable tag. After the release assets are green, its release-heal lane runs `python3 scripts/resync-mirror.py --ref v` in `nika-plugins` and opens a reviewable PR. Never copy `nika-plugins@main` back into `.agents/`: that reverses ownership and can downgrade a prepared engine release. 6. **Close the release record.** Add the engine release entry to the pinned `nika-spec/timeline/timeline.yaml`, let its timeline CI re-prove the tag and publication claims, and verify the published release index at [docs.nika.sh/changelog/releases](https://docs.nika.sh/changelog/releases) includes the new version before declaring the release complete. The [timeline guide](https://docs.nika.sh/reference/timeline) explains the separate specification record; it is not a live projection of every event. The binary train may finish before this cross-repository record update, but the release contract does not. ## What the machine publishes (per tag) | Asset | Proof it carries | |---|---| | `nika-{macos,linux}-{arm64,x64}-.tar.gz` | the four platform binaries | | `SHA256SUMS` | checksum manifest (proof 1) | | GitHub native attestation | `gh attestation verify` (proof 2) | | `multiple.intoto.jsonl` | SLSA provenance asset, offline-verifiable (proof 3) | | `ghcr.io/supernovae-st/nika:` | immutable multi-arch image, bit-identical to the tarballs | | `ghcr.io/supernovae-st/nika:latest` | stable-only floating pointer, moved after finalization | | Homebrew formula bump | `supernovae-st/homebrew-tap` (deploy-key scoped) | | `supernovae-st-nika-check-wasm-.tgz` (+ `.sha256`) | the npm tarball, byte-identical to what `npm publish` ships — attested like the binaries | | `@supernovae-st/nika-check-wasm` on npm | immutable browser checker; first publication requests npm provenance, while replay independently proves exact SRI (first publication requires configured npm trusted-publisher authority) | The release body starts with the curated **What / Install / Verify / Provenance** front page rendered by `scripts/release/render-notes.sh`, with GitHub's generated PR list appended. The workflow then appends one hidden GHCR digest marker while preserving that body and refuses a malformed, duplicate, or changed marker on replay. ## What a user can prove ```sh sha256sum -c SHA256SUMS --ignore-missing # macOS: shasum -a 256 -c gh attestation verify nika--.tar.gz --repo supernovae-st/nika slsa-verifier verify-artifact nika--.tar.gz \ --provenance-path multiple.intoto.jsonl \ --source-uri github.com/supernovae-st/nika --source-tag v gh attestation verify supernovae-st-nika-check-wasm-.tgz --repo supernovae-st/nika npm audit signatures # in a project depending on the package ``` Three independent chains cover the native artifacts: the checksum manifest, GitHub's signed attestation, and the SLSA generator's intoto statement. npm's registry provenance is requested during the original publish, but this release barrier's replay proof for npm is the registry's exact sha512 SRI. Any claimed chain failing is a stop-the-line event. ## The record Every release is also a claim on the machine-verified timeline · the spec's `timeline/timeline.yaml` carries the record, and CI re-proves its provable claims (GitHub · crates.io) on every push and weekly. The [documentation timeline guide](https://docs.nika.sh/reference/timeline) explains its evidence rules; the [release index](https://docs.nika.sh/changelog/releases) is projected from published GitHub releases. A release that isn't in the record isn't released; a record that can't be re-proven isn't a record.