Release Process

Follow these steps to cut a NovaFabric release.

0. Migration compatibility rule (ADR-0180, expand-contract)

Every Alembic migration shipped in a release MUST be backward-compatible with the previous minor version's code (the N/N+1 window): additive changes first (new tables/columns nullable or defaulted), destructive contraction (drops, renames, type narrowing) only after one full minor cycle in which no shipped code path needs the old shape. A release containing a migration that breaks the previous minor's code against the new schema is blocked until the migration is split. Rationale and the full posture (single-writer active-passive, fencing invariant): ADR-0180.

0a. API removal rule (ADR-0188, deprecation register)

A /v0 endpoint listed in the deprecation register (docs/api-reference.md, "Deprecation register") may be removed only in a release that is ≥ its earliest-removal release and is a minor bump pre-1.0 (0.Y.0) / major bump post-1.0 (X.0.0). The removal lands in the same change that bumps pyproject.toml to that release, and its register row stays, recording the release in the Removed in column. tests/test_deprecation_removal_gate.py (part of the unit suite, so of every gate in step 1) fails the release otherwise.

1. Run tests

make test-par   # the whole suite as CI's `unit` shards run it (--cov=novafabric, --cov-fail-under=90)

Required: all tests pass, coverage ≥ 90%.

In CI the same suite runs as six duration-balanced unit shards (ADR-0316); a coverage job then enforces the 90% floor over their union and proves that every test ran in exactly one shard. To reproduce one shard locally: make test-shard SHARD=3/6.

1a. Run the NovaSeal latency gate (median gate + NovaSeal p99 tail alarm)

make benchmark
# or directly:
uv run pytest tests/seal/test_benchmark.py -v --benchmark-json=.benchmark-results/seal_latency.json

Required: NovaSeal.seal() median < 50 ms over 100 rounds (seal.seal-call.median in docs/slo.md). A p99 ≥ 200 ms (seal.seal-call.p99) is a non-blocking tail alarm — a SealTailLatencyAlarm warning and a ::warning:: annotation in CI. One alarm on a shared runner is scheduler noise; the same alarm on consecutive runs is a regression to investigate before tagging.

1b. Verify the remaining CI gates are green

More blocking gates run in CI on every push. A release must not be tagged while any is red on main — and since ADR-0316 it cannot be published either: every tag-triggered workflow first waits for ci.yml on the tagged commit and refuses to publish unless it concluded success (see step 7). Check the Actions tab, or run locally as below:

This section exists so the documented release gate names every job in ci.yml — guarded by tests/docs/test_support_policy.py, which fails when a job is added there without being documented here. Note the guard's scope: it reads ci.yml only, so a job in a separate workflow file is not covered by it. (The unit job's exact command parity is separately guarded by tests/docs/test_makefile_matches_ci_gate.py.)

1c. What the tag run will be the first to execute

publish-pypi.yml, publish-image.yml and publish-chart.yml fire only on a v* tag (PyPI wheel + sdist, GHCR image, Helm chart). Nothing on a pull request runs them, so their actions are the one part of the pipeline a green PR says nothing about.

release-toolchain.yml covers most of that gap: on any change to the publish path it runs the same toolchain with publishing disabled — buildx build with push: false passing every input the release step passes, cosign installed and run, chart linted and packaged. Its action versions are held identical to the publish workflows by tests/docs/test_release_toolchain_matches_publish.py, because a smoke test pinned to different versions is a green check that proves nothing.

Three things it still cannot cover, so a tag run is their first execution:

After a release, check those three in the run log rather than assuming them.

2. Run ruff

uv run ruff check src tests scripts

Required: zero errors.

3. Run mypy

uv run mypy src

Required: zero errors.

4. CLI smoke test

NOVAFABRIC_DB_PATH=/tmp/nf_smoke.db uv run novafabric register tests/fixtures/valid_model.yaml
NOVAFABRIC_DB_PATH=/tmp/nf_smoke.db uv run novafabric list
NOVAFABRIC_DB_PATH=/tmp/nf_smoke.db uv run novafabric inspect fraud-model@1.0.0
NOVAFABRIC_DB_PATH=/tmp/nf_smoke.db uv run novafabric validate tests/fixtures/valid_agent.yaml
NOVAFABRIC_DB_PATH=/tmp/nf_smoke.db uv run novafabric report
NOVAFABRIC_DB_PATH=/tmp/nf_smoke.db uv run novafabric report --format json
rm /tmp/nf_smoke.db

Required: all commands exit 0.

5. Update CHANGELOG.md

Add a ## [x.y.z] — YYYY-MM-DD section with Added, Improved, and Fixed entries.

6. Bump version

Edit pyproject.toml:

version = "x.y.z"

7. Commit and tag

git add CHANGELOG.md pyproject.toml
git commit -m "chore: release vx.y.z"
git tag vx.y.z
git push origin main --tags

A tag on a commit whose CI is red, cancelled or missing publishes nothing: the ci-green job in release.yml, publish-pypi.yml, publish-image.yml and publish-chart.yml (.github/workflows/require-green-ci.yml) waits up to 100 minutes for CI on that exact commit and fails closed otherwise. Fix CI (or re-run it if it was cancelled), then re-run the failed tag workflows from the Actions tab — the gate re-checks and the publish proceeds.

GitHub runs push CI only on the head of a push. If the tagged commit was not a head (it was pushed together with later commits), it has no CI run: start one with gh workflow run ci.yml --ref vx.y.z, then re-run the failed tag workflows.

8. GitHub release

There is no manual step: pushing the tag runs .github/workflows/release.yml, which creates the GitHub Release from docs/releases/<tag>.md. Write that file before tagging — its first H1 becomes the release title and the rest the body; the tag run fails if the file is missing.