Skip to content

fix(release): stop a stale release cut from freezing an rc tag on an outdated commit - #641

Merged
joshua-temple merged 3 commits into
mainfrom
fix/rc-tag-advancement
Jul 19, 2026
Merged

joshua-temple merged 3 commits into
mainfrom
fix/rc-tag-advancement

Conversation

@joshua-temple

Copy link
Copy Markdown
Collaborator

Problem

An rc tag froze at a stale commit for a full day: a release cut recomputed an rc number that already existed and then silently failed to move the tag. Two compounding defects.

Defect 1 - version derivation had no collision guard. internal/orchestrate/orchestrator.go calculateVersion (and calculateComponentVersion) set the rc number from recorded state alone (state.Version fed to version.CalculateNext, which computes rc.N+1) and never consulted existing tags. When Finalize state-writes stalled behind a 409, the recorded version stuck at rc.0 and every cut recomputed the same rc.1, which already existed as a tag at an outdated commit.

Defect 2 - tag creation no-oped on an existing tag at a different commit. internal/release/release.go createGitTag treated a 422 reference already exists from POST /git/refs as unconditional success. That only holds when the existing tag already points at the target commit; when it points elsewhere the ref create is a silent no-op, so the tag stayed frozen on the stale commit while the cut reported success.

Fix

Defect 1 (advance): calculateVersion now resolves the candidate against the repository's tags. While a tag of the same name exists at a commit other than HEAD, the rc number advances until it is free. A tag already at HEAD is the same cut re-running and is reused unchanged; a candidate with no rc segment or an unresolvable HEAD is left untouched, so the no-collision path is byte-identical. Chosen semantics: advance rather than fail, because a stalled state resolving is a normal recoverable condition and the single-flight model wants the cut to make forward progress on the next free rc, not wedge.

Defect 2 (fail closed): on a 422, createGitTag now resolves the existing tag via GET /git/refs/tags/<name> and returns nil only when its target matches the requested commit; a mismatch (or an unresolvable target) fails loudly. Chosen semantics: fail loud, not force-move. An rc tag is immutable in the single-flight release model, so a fresh cut that needs a different commit takes a new rc number (Defect 1's job); no caller legitimately moves a tag, and there is no PATCH /git/refs force-update in the tree. This is the fail-closed safety net that turns the original silent freeze into a visible error. Same-sha 422 stays the genuinely idempotent success.

Verification

  • Regression tests fail before / pass after both fixes (internal/release: existing tag at a different sha now errors instead of returning a silent nil; internal/orchestrate: a stuck-at-rc.0 state with an existing rc.1 tag at another commit now yields rc.2).
  • go build ./..., go test ./... (3651 pass), go test -race on the affected packages, and golangci-lint run all clean.
  • No generated-workflow drift: no CLI flag, manifest field, or generator surface changed; the full generator/schema suite passes unchanged.

…commit

A release cut that materializes a git tag (action=update, tag-only,
create-tag) treated a 422 'reference already exists' from POST /git/refs
as an unconditional success. That only holds when the existing tag already
points at the target commit. When it points elsewhere the ref create is a
silent no-op, leaving the tag frozen on a stale commit while the cut reports
success, which stranded an rc tag on an outdated sha for a full day.

createGitTag now resolves the existing tag on a 422 and returns nil only when
its target matches the requested commit; a mismatch (or an unresolvable target)
fails loudly. An rc tag is immutable in the single-flight release model, so a
fresh cut needing a different commit takes a new rc number rather than moving
an existing tag.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
Version derivation set the rc number from recorded state alone (state.Version
fed to CalculateNext, which computes rc.N+1) and never consulted existing tags.
When Finalize state-writes stalled behind a 409, the recorded version stuck at
rc.0 and every cut recomputed the same rc.1, which already existed as a tag at
an outdated commit. Nothing guarded the collision, so a fresh cut could re-mint
an rc number already published at a different sha.

calculateVersion (and the component path) now resolve the candidate against the
repository's tags: while a tag of the same name exists at a commit other than
HEAD, the rc number advances until it is free. A tag already at HEAD is the same
cut re-running and is reused unchanged, and a candidate with no rc segment or an
unresolvable HEAD is left untouched, so the no-collision path is byte-identical.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@github-actions

github-actions Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

All PR Validation checks passed.

Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
@joshua-temple
joshua-temple merged commit 2231b2b into main Jul 19, 2026
21 checks passed
@joshua-temple
joshua-temple deleted the fix/rc-tag-advancement branch July 19, 2026 17:41
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