Skip to content

feat(release): cut release candidates before the final 1.0.0 (ADR-1201) - #1353

Merged
lusoris merged 1 commit into
masterfrom
feat/release-candidates
Sep 7, 2026
Merged

feat(release): cut release candidates before the final 1.0.0 (ADR-1201)#1353
lusoris merged 1 commit into
masterfrom
feat/release-candidates

Conversation

@lusoris

@lusoris lusoris commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The plan was to go from "never released" straight to 1.0.0 final. For a fork shipping four GPU backends, an FFmpeg patch stack, an MCP server and a tiny-AI surface, the first cut is the risky one — and the pipeline has never cut anything. Per maintainer direction, 4–8 release candidates come first.

The pipeline could not do that. Six places refused or mishandled a prerelease. None was a policy statement; each was a guard written when a prerelease could only be a mistake:

# Where What it did
1 verify-release-version.sh accepted only ^v<major>.<minor>.<patch>$
2 same script marker extractor matched [0-9]+\.[0-9]+\.[0-9]+, reading 1.0.0-rc.1 as 1.0.0 and then reporting the marker as disagreeing with the tag it actually matched
3 supply-chain.yml exit 1 on prerelease == true
4 supply-chain.yml separately required the release to have prerelease == false
5 docker-publish-production.yml its own copy of the rejection
6 docker-publish-operator-node.yml a third copy

The latent trap worth flagging

The ADR-1151 contract gate allows the one-shot release-as / bootstrap-sha fields "until the manifest reaches 1.0.0", decided with sort -V. But:

$ printf '1.0.0\n1.0.0-rc.1\n' | sort -V | head -1
1.0.0

So the gate would have concluded the RC line had already reached 1.0.0 and failed every RC build while release-as was legitimately still present. The prerelease suffix is now tested before that comparison, and the rebase note marks that ordering as load-bearing.

What replaces the rejections

Each blanket rejection becomes a consistency check: an -rc.N tag must be published as a prerelease, and a final tag must not. That is the property worth enforcing — the dangerous states are the mismatched ones. An RC published as stable becomes latest for every consumer; a final published as a prerelease silently never does.

A release candidate can never take the latest image tag. GitHub's /releases/latest already excludes prereleases, so the existing resolution could not have picked one — but the failure is severe and silent for anyone pulling latest, so the suffix is refused explicitly rather than trusting one endpoint's semantics to stay that way.

The accepted shape is deliberately narrow: rc only, dotted integer, no leading zero.

tag
v1.0.0-rc.1, v1.0.0-rc.12 accepted
v1.0.0-beta, v1.0.0-rc, v1.0.0-rc.01, v1.0.0-rc.1.2, v1.0.0-RC.1 refused

SemVer permits all of the refused ones. This fork ships exactly one prerelease channel, and every extra accepted shape is another way to mis-tag a release.

Cutting the final release is then two edits — prerelease: false, release-as: 1.0.0 — and the contract gate starts enforcing one-shot-field removal the moment the manifest loses its suffix.

Type

  • feat — new feature
  • build / ci — tooling / infra

Checklist

  • Commits follow Conventional Commits.
  • make format && make lint green locally — pre-commit clean on all 14 files; actionlint clean on all four workflows; check-aggregator-names.sh OK (35 required checks).
  • Unit tests pass — scripts/release/tests/test-verify-release-version.sh: 25 passed, 0 failed (was 18).
  • SIMD/GPU — none touched.
  • Feature extractor twins — none touched.
  • New C/C++ source — none added.
  • Breaking change — no. A final vX.Y.Z tag behaves exactly as before; the new shape is additive.
  • ADR row in docs/adr/_index_fragments/ with the slug in _order.txt; README.md regenerated and --checked.

Bug-status hygiene (ADR-0165)

  • docs/state.md — no state delta: release policy and pipeline capability, not a tracked runtime defect.

Netflix golden-data gate (ADR-0024)

  • I did not modify any assertAlmostEqual(...) score.

Deep-dive deliverables (ADR-0108)

  • Research digest — no digest needed: trivial. The six blockers were found by reading the pipeline; the reasoning is in ADR-1201's Context.
  • Decision matrixADR-1201 ## Alternatives considered weighs five options, including 0.9.x betas and draft-only RCs (which rehearse nothing, since a draft never fires release: published).
  • AGENTS.md invariant note — in docs/rebase-notes.md: the narrow pattern, consistency-not-absence in the publishing workflows, and the sort -V ordering trap.
  • Reproducer / smoke-test command — below.
  • CHANGELOG fragmentchangelog.d/added/release-candidates.md.
  • Rebase notefeat/release-candidates — rc.N prereleases before 1.0.0 (2026-09-06).

Reproducer

bash scripts/release/tests/test-verify-release-version.sh   # 25 passed, 0 failed

for w in supply-chain docker-publish-production docker-publish-operator-node rule-enforcement; do
  actionlint ".github/workflows/$w.yml"
done

# the trap, verbatim:
printf '1.0.0\n1.0.0-rc.1\n' | sort -V | head -1     # 1.0.0  <- why the suffix test comes first

# no blanket rejections survive:
grep -rn 'prerelease publication is not supported' .github/workflows/   # no output

Known follow-ups

  • The final cut needs two deliberate edits (prerelease: false, release-as: 1.0.0). Someone can forget them; the contract gate catches the second the moment the manifest loses its suffix, and docs/development/release.md should gain a checklist line when the first RC is actually cut.
  • PyPI needs no separate guard — it accepts prereleases natively and pip will not install them without --pre.

@lusoris lusoris added this to the 1.0.0 — First release milestone Sep 6, 2026
@lusoris
lusoris force-pushed the feat/release-candidates branch 4 times, most recently from 6b05db9 to d4ebadb Compare September 7, 2026 10:16
The fork has never released, and the plan is to go straight from that to a
1.0.0 final. For a fork shipping four GPU backends, an FFmpeg patch stack, an
MCP server and a tiny-AI surface, the first cut is the risky one and the
pipeline has never cut anything. Maintainer direction: cut 4-8 release
candidates first.

The pipeline could not. SIX places refused or mishandled a prerelease, none of
them a policy statement -- each a guard written when a prerelease could only be
a mistake:

  1. verify-release-version.sh accepted only ^v<major>.<minor>.<patch>$
  2. ...and its marker extractor matched [0-9]+\.[0-9]+\.[0-9]+, reading
     1.0.0-rc.1 as "1.0.0" and then reporting the marker as disagreeing with
     the tag it actually matched
  3. supply-chain.yml exited 1 on prerelease == true
  4. ...and separately required the release to have prerelease == false
  5. docker-publish-production.yml had its own copy of the rejection
  6. docker-publish-operator-node.yml had a third copy

Plus a latent trap worth spelling out. The ADR-1151 contract gate allows the
one-shot release-as / bootstrap-sha fields "until the manifest reaches 1.0.0"
and decides that with sort -V. But

  printf '1.0.0\n1.0.0-rc.1\n' | sort -V | head -1   ->   1.0.0

so the gate would have concluded the RC line had already reached 1.0.0 and
failed EVERY RC build while release-as was legitimately still present. The
prerelease suffix is now tested before that comparison.

Each blanket rejection becomes a CONSISTENCY check: an -rc.N tag must be
published as a prerelease and a final tag must not. That is the property worth
enforcing -- the dangerous states are the mismatched ones. An RC published as
stable becomes `latest` for every consumer; a final published as a prerelease
silently never does.

A release candidate can never take the `latest` image tag. GitHub's
/releases/latest already excludes prereleases so the existing resolution could
not have picked one, but the failure is severe and silent for anyone pulling
`latest`, so the suffix is refused explicitly rather than trusting one
endpoint's semantics to stay that way.

The accepted shape is deliberately narrow: rc only, dotted integer, no leading
zero. v1.0.0-rc.1 and v1.0.0-rc.12 pass; -beta, bare -rc, -rc.01, -rc.1.2 and
-RC.1 are refused. SemVer permits all of those; this fork ships exactly one
prerelease channel and every extra accepted shape is another way to mis-tag.

Cutting the final release is then two edits: prerelease: false and
release-as: 1.0.0. The contract gate starts enforcing one-shot-field removal
the moment the manifest loses its suffix.

scripts/release/tests/test-verify-release-version.sh: 25 passed, 0 failed
(was 18) -- five new cases pin the accepted and rejected tag shapes, and they
caught the marker-extractor bug above.

no digest needed: trivial

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lusoris
lusoris force-pushed the feat/release-candidates branch from d4ebadb to 8c7e22c Compare September 7, 2026 12:16
@lusoris
lusoris marked this pull request as ready for review September 7, 2026 12:47
@lusoris
lusoris enabled auto-merge (squash) September 7, 2026 12:47
@lusoris
lusoris merged commit c1680c0 into master Sep 7, 2026
115 of 116 checks passed
@lusoris
lusoris deleted the feat/release-candidates branch September 7, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant