Files
rune/docs/release-flow.md
Nick 3c9a3f034f fix(pipeline): anchor attribution guard to the in-flight bump version (#192)
Quality-gate finding 7a: bump_branch_in_flight matched ANY chore/auto-bump-*
branch, so one stale historical branch (branches are never cleaned up) stood
the safety net down permanently. The guard now matches exactly
chore/auto-bump-{NEW} — the in-flight version — with new_version=None as the
documented legacy escape hatch; wait_out_closed_path passes NEW_VERSION
through.

Quality-gate finding 7b (docs): release-flow.md claimed the closed path was
'genuinely absent, not merely slow' and that the paths 'can never
double-bump'. Rewritten: attribution is best-effort; a >90s delayed closed
path can race into a double-bump, bounded by tag idempotency (duplicate
release creation is a no-op); stale-branch cleanup is a known limitation.

Red evidence: anchored-match tests failed against the prefix-wide guard.
Green: 12/12.
2026-09-07 15:08:02 +01:00

3.7 KiB

Rune release flow: two paths to a release (#192)

Master __version__ can never be allowed to silently diverge from the latest release tag. Two workflows cooperate to guarantee that; both share the same machinery in scripts/.

Path 1 — pull_request:closed (authoritative)

.gitea/workflows/auto-version.yml, job release:

  1. Semver is decided from the merged PR's title (feat: → minor, feat!:/BREAKING CHANGE → major, but the 0.x convention bumps the minor; everything else → patch).
  2. If the PR did not already carry a version bump (__version__ still equals the latest tag), scripts/auto_bump_pr.py runs the protected-branch-safe bump-PR machinery: branch → contents-API edits of the five version surfaces → PR → wait for the protected check → API merge.
  3. scripts/create_release.py cuts the release (idempotent: skips if the tag already exists).

This path is authoritative when the event fires — no changes to its decision logic were made in #192.

Path 2 — push (safety net)

The pull_request:closed event is intermittently dropped (PR #190 merged with no run while #189 fired minutes earlier). The same workflow therefore also triggers on every push to master, job safety-net:

  1. scripts/version_convergence.py compares master's __version__ with the latest tag and classifies commit subjects since the tag (feat/feat! → minor, fix/perf → patch, docs/chore/test/refactor → nothing). It prints the decision as JSON: {"converged": bool, "warranted_bump": "minor"|"patch"|null, "commits_since_tag": N, "sample": [...]}.
  2. If drift is detected (bump never rode the merge) and no chore/auto-bump-{NEW} branch appears within the attribution grace window (best-effort evidence that the closed path is genuinely absent), the safety net runs the same scripts/auto_bump_pr.py machinery with the subject-warranted kind, then cuts the release via the same scripts/create_release.py.
  3. If master is ahead of the tag but no subject warrants a bump, the job only logs an advisory — the maintenance reconciliation owns that case.

Path 3 — scheduled drift reconciliation

.gitea/workflows/maintenance.yml, job release-drift-reconciliation (weekly + workflow_dispatch):

  • Runs scripts/version_convergence.py; on drift it files or updates a deduped forge issue titled Release drift: master vX.Y.Z vs tag vA.B.C (scripts/file_drift_issue.py).
  • Strictly advisory: it never auto-releases without the PR flow. Humans/agent land the bump PR; the next merge or the safety net cuts the release.

Idempotency & conservation

  • scripts/create_release.py skips when a release for the tag exists (retry/replay safety).
  • Attribution between the two paths is best-effort, not airtight. The safety net (scripts/auto_bump_pr.py --safety-net) stands down when the bump branch for the exact in-flight version (chore/auto-bump-{NEW}) exists, and the guard is anchored to that version so one stale historical branch cannot stand the net down forever. Two misattributions remain possible: a closed path delayed beyond the 90s grace window can produce a double-bump race, and a stale branch of the same version (never cleaned up — stale-branch cleanup is a known limitation) can stand the net down incorrectly.
  • Double-bump is bounded by tag idempotency, not by the guard. If the race happens, both paths compute the same next version; whichever release creation lands first wins and the other is a no-op (release_exists skip). Version surfaces can only converge to the same value, so the worst case is a redundant PR, never a wrong version.
  • Docs/chore-only merges still produce no release, same as the pre-#192 no-bump PR path.