Skip to content

fix(release): a stale local tag can no longer silently widen the changelog - #4208

Merged
vanceingalls merged 2 commits into
mainfrom
release-tag-guard
Sep 20, 2026
Merged

vanceingalls merged 2 commits into
mainfrom
release-tag-guard

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

The bug

release:prepare creates vX.Y.Z locally at the branch commit. The publish workflow then creates the same tag at the merge SHA. After a squash merge those are different commits, and the local one is not an ancestor of main:

$ git rev-list -n1 v0.8.51          # local  — the pre-merge branch commit
d99a93c3d
$ git ls-remote --tags origin       # remote — the squash-merge commit on main
1dce615c5  refs/tags/v0.8.51

git describe only returns a tag it can reach, so it skipped v0.8.51 and silently fell back to v0.8.50. The v0.8.52 draft came out as v0.8.50...v0.8.52 and re-listed ~100 already-shipped commits.

git fetch --tags does not fix this. It refuses to move a tag that already exists locally — only --force does. That matters because the playbook already said "fetch tags before drafting" after an identical symptom on the v0.7.83 cut; following it would have produced no change and a false all-clear.

Nothing in the output flagged the wrong baseline. The changelog is user-facing, and it was caught by a human asking why the release PR touched so much.

The guard

After the baseline is resolved, fail if a stable tag exists locally that sits strictly between it and the release:

export function findSkippedReleaseTags(stableVersions, from, version) {
  const fromVersion = from.replace(/^v/, "");
  return stableVersions
    .filter((tag) => compareSemver(tag, fromVersion) > 0 && compareSemver(tag, version) < 0)
    .sort(compareSemver);
}

describe only ever returns a reachable tag, so a tag in that range is proof it skipped one. Offline, no network call, and it names the fix:

Baseline v0.8.51 skips v0.8.52 — tagged locally, but not in this history.
Those tags are stale, so the draft would re-list work that already shipped.

  git fetch origin --tags --force

--from still overrides, for a deliberate baseline. Reuses compareSemver from set-version.ts; prereleases are excluded by the same - filter that file already uses, so an alpha tag can't false-positive.

An ancestry check would not work here — my first attempt. describe returns only ancestors, so merge-base --is-ancestor <from> HEAD can never fail. The stale tag is invisible to it; the range is what exposes it.

Also: the captures gate

Every release bumps packages/studio/package.json and packages/player/package.json, which scripts/check-pr-captures.mjs watches — so it demands Before/After media for a version bump. The gate's own hatch is the intended answer (a ## No visible change section, valid under 20 lines with no .tsx/.css/.html; a release is 4 lines). The gate landed in #4131 after v0.8.51, so v0.8.52 was the first release PR to meet it and every future one will. set-version now prints that with the gh pr create instructions. The gate itself is untouched.

Verification

End to end, by moving a local tag onto the genuinely-off-main pre-squash commit of v0.8.51:

state result
correct tags drafts normally, compare/v0.8.52...v0.8.53
v0.8.52 moved off main guard fires, names v0.8.52, prints the fetch command

Mutation-tested — all 6 caught: gut the function, drop either bound, make the bounds inclusive, lexical instead of semver sort, drop the v-prefix strip.

Two things that made the first harness lie, both worth knowing: a git tag -f without -a -m dies with fatal: no tag message? on this repo (already documented at set-version.ts:145) so nothing moved; and PR #4202 was merge-merged rather than squashed, so its pre-merge commit is on main and isn't a stale state. Both strategies are in use, and only squash triggers this bug.

39 tests pass across draft-changelog, release-prepare and set-version. oxlint 0/0, oxfmt clean, typecheck clean.

No visible change

Three scripts/ files. Nothing under packages/studio or packages/player, so the captures gate doesn't apply — this is the change that makes the next release PR declare it.

🤖 Generated with Claude Code

vanceingalls and others added 2 commits September 19, 2026 16:50
…gelog

`release:prepare` creates `vX.Y.Z` locally at the branch commit, then the
publish workflow creates the same tag at the merge SHA. After a squash merge
those are different commits and the local one is not an ancestor of main, so
`git describe` cannot reach it and falls back to the previous release without
saying so. `git fetch --tags` does not help: it refuses to move a tag that
already exists locally, and only `--force` does.

On the v0.8.52 cut this produced a `v0.8.50...v0.8.52` draft that re-listed
about a hundred already-shipped commits. The changelog is user-facing, and
nothing in the output said the baseline was wrong.

Adds a guard in the drafter: after resolving the baseline, fail if a stable
tag exists locally that sits strictly between it and the release. `describe`
only ever returns a reachable tag, so a tag in that range is proof it skipped
one. The message names the tags and prints `git fetch origin --tags --force`.
`--from` still overrides, for a deliberate baseline.

Also prints the captures-gate hatch with the release PR instructions. Every
release bumps packages/studio and packages/player, which
scripts/check-pr-captures.mjs watches, so it demands before/after media for a
version bump; its own `## No visible change` section is the intended answer
and applies to a 4-line package.json diff.

Verified end to end by moving a local tag onto the pre-squash commit of
v0.8.51: the drafter fails with the fix, and drafts normally once restored.
Six mutants of the range check are all caught by the tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`resolvePreviousTag` only runs when `--from` was not supplied, so testing
`!options.from` at the call site repeated a condition the `??` already
expressed. Folding the guard into the resolver drops that branch, which had
pushed `createDraft` to a CRAP score of exactly 30.0 and failed the audit.

The assert sits outside the try/catch on purpose: `fail` exits rather than
throwing today, and this keeps a catch from ever swallowing the guard if that
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The core inference is sound and the test for it is a real negative control, not a restatement. One should-fix below that I'd like in before this merges, but I'm not blocking on it — the guard is correct for the mainline case it was written for.

Should-fix — the guard silently no-ops when the baseline is a prerelease tag

resolvePreviousTag and listStableTags disagree about what a tag is, and the disagreement resolves to "pass".

  • resolvePreviousTag (scripts/draft-changelog.ts:219,221) matches v[0-9]*. That glob admits hyphenated tags — v0.8.53-desktop.0 matches it (v, then 0, then *).
  • listStableTags (:251) excludes them: .filter((tag) => tag && !tag.includes("-")).

So from can be a prerelease while the candidate list never is. Then at :246:

compareSemver("0.8.53", "0.8.53-desktop.0")
  pb = "0.8.53-desktop.0".split(".").map(Number)   // ["0","8","53-desktop","0"] -> [0, 8, NaN, 0]
  i=2: 53 !== NaN  ->  return 53 - NaN             // NaN
  NaN > 0  ->  false

Every candidate sharing the baseline's major.minor is dropped, skipped comes back empty, and assertNoSkippedTags returns clean. The guard doesn't warn that it couldn't decide — it reports the same thing it reports when there's genuinely nothing skipped. That's the failure shape this PR exists to remove, reproduced one level up.

Scoping this honestly: it is latent on main today, not firing. v0.8.53-desktop.0 is the newest tag in the repo and v0.7.67-alpha.0 exists, so prerelease cuts are established practice here — but v0.8.53-desktop.0...main compares as diverged (ahead 6, behind 29), so it isn't an ancestor of main and describe won't return it there. It bites when drafting from a branch where such a tag is reachable, which is exactly where those tags live.

The repo already solved this. assertTagMonotonicity — same file, same comparator, same stableVersions projection — opens with if (isPrerelease(version)) return; (scripts/set-version.ts:195), and isPrerelease is exported at :235. The smallest fix mirrors that precedent: bail (or strip the suffix off fromVersion) when isPrerelease(from). A one-line guard at :262 rather than touching compareSemver, which has other callers.

Note — listStableTags duplicates the projection in set-version.ts

:251-259 is scripts/set-version.ts:199-211 again: same git tag --list "v[0-9]*", same !includes("-") filter, same replace(/^v/, ""), same fail-open catch. Two copies of the input projection now feed the two sibling guards (findBlockingTags, findSkippedReleaseTags) that live in the same pair of files. Worth exporting one. Not asking for it in this PR.

(The fail-open catch { return []; } matches the existing one at set-version.ts:204, so I'm reading that as deliberate house style, not a finding.)

Verified, not taken on trust

  • The ## No visible change instruction is correct. CAPTURE_TITLES.noVisibleChange = /^no visible change\b/i (scripts/check-pr-captures.mjs:42) tests the heading title, so ## No visible change matches.
  • And the hatch genuinely applies, including the "4 lines" claim. updatePackageVersions (set-version.ts:105-111) assigns only content.version; the sibling deps in both watched packages are workspace:*, so they never move. That's 1 changed line × 2 files = 4 lines under WATCHED_PREFIXES, against NO_VISIBLE_CHANGE_MAX_LINES = 20, and no .tsx/.css/.html in the release diff to trip isVisualFile. The comment at set-version.ts:377 is accurate.
  • The central inference holds: describe only ever returns a reachable tag, so a stable tag that exists locally and sorts inside the range must be one describe couldn't reach. Correct, and the docstring explains the squash-merge mechanism that produces it.
  • compareSemver is numerically right for stable triples, and the ["0.8.9", "0.8.10"] test actually discriminates: a lexical comparator drops 0.8.10 at the filter, because "0.8.10" < "0.8.8" as strings — so that test fails for the right reason rather than passing on an already-sorted input.
  • Both ends exclusive, --from documented in the error text as the override, and the guard is gated on !options.from so an explicit baseline is never second-guessed.

— Rames

@vanceingalls
vanceingalls merged commit 90e67ff into main Sep 20, 2026
50 checks passed
@vanceingalls
vanceingalls deleted the release-tag-guard branch September 20, 2026 00:14
@jrusso1020

Copy link
Copy Markdown
Collaborator

Following up post-merge, because bab8fd56 landed after my approval at 44a97164 and I want to be clear about what it did and didn't close.

The placement change is a real improvement. Moving assertNoSkippedTags inside resolvePreviousTag ties the guard to the thing it guards, so a future edit at the call site can't decouple them. Gating is unchanged — the resolver is only reached when options.from is undefined, which is exactly the old if (!options.from) condition.

It does not close the hole I flagged. That finding was about which tag from can be, not about where the guard is called, and both ends are byte-identical on main:

  • draft-changelog.ts:219git describe --match "v[0-9]*" still admits hyphenated tags. v0.8.53-desktop.0 matches the glob (v, a digit, then anything).
  • draft-changelog.ts:256listStableTags still drops anything containing -.

So from can be a prerelease that the candidate list structurally cannot contain, and the comparator doesn't error on it — it fails open:

compareSemver("0.8.54", "0.8.53-desktop.0")
// "53-desktop" -> Number(...) -> NaN
// 53 !== NaN -> returns 53 - NaN -> NaN
// NaN > 0 -> false

Every candidate is dropped by the > 0 filter at :247, skipped comes back [], and assertNoSkippedTags passes. The guard's "all clear" and its "I couldn't compare anything" are the same empty array — which is the part worth fixing regardless of which tag shape you decide to support, since a guard that can't distinguish those two can't alarm on its own failure.

Still latent on mainv0.8.53-desktop.0 is diverged from main, not an ancestor, so describe won't land on it there. A desktop release line is where it would bite.

Minimal fix, mirroring the shape already at set-version.ts:195, so the no-op is at least explicit and greppable:

function assertNoSkippedTags(from: string, version: string) {
  if (isPrerelease(from.replace(/^v/, ""))) return; // candidate set is stable-only; nothing to compare against
  ...
}

Whether you'd rather restrict the --match glob so from is always stable, or teach compareSemver about prereleases, is your call — those change the changelog range too, and I don't know whether a desktop line is meant to diff from its own previous prerelease. The bail above doesn't touch the range.

— Rames

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.

2 participants