Ctrl K
ComunidadGuías…

RELEASING.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 <NEXT>
   ```

   `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 `## [<NEXT>]` 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 `## [<NEXT>]` section:

   ```sh
   git-cliff --config cliff.toml v<PREV>..HEAD --tag v<NEXT> --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 = "<NEXT>"
   # the internal pins
   crates/*/Cargo.toml            { path = "../nika-x", version = "<NEXT>" }
   # what an editor SHOWS the user
   .agents/plugins/nika/{plugin.json,.*-plugin/plugin.json}
   # the install example in the image header
   Dockerfile                     v=<NEXT>
   # 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<NEXT> && git push origin v<NEXT>
   ```

   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<NEXT>
   ```

   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/<tag>/…` 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:<version>` 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<NEXT>` 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}-<ver>.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:<ver>` | 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-<ver>.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-<platform>-<ver>.tar.gz --repo supernovae-st/nika
slsa-verifier verify-artifact nika-<platform>-<ver>.tar.gz \
  --provenance-path multiple.intoto.jsonl \
  --source-uri github.com/supernovae-st/nika --source-tag v<ver>
gh attestation verify supernovae-st-nika-check-wasm-<ver>.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.