fix(release): a stale local tag can no longer silently widen the changelog - #4208
Conversation
…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
left a comment
There was a problem hiding this comment.
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) matchesv[0-9]*. That glob admits hyphenated tags —v0.8.53-desktop.0matches it (v, then0, 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 changeinstruction is correct.CAPTURE_TITLES.noVisibleChange = /^no visible change\b/i(scripts/check-pr-captures.mjs:42) tests the heading title, so## No visible changematches. - And the hatch genuinely applies, including the "4 lines" claim.
updatePackageVersions(set-version.ts:105-111) assigns onlycontent.version; the sibling deps in both watched packages areworkspace:*, so they never move. That's 1 changed line × 2 files = 4 lines underWATCHED_PREFIXES, againstNO_VISIBLE_CHANGE_MAX_LINES = 20, and no.tsx/.css/.htmlin the release diff to tripisVisualFile. The comment atset-version.ts:377is accurate. - The central inference holds:
describeonly 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. compareSemveris numerically right for stable triples, and the["0.8.9", "0.8.10"]test actually discriminates: a lexical comparator drops0.8.10at 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,
--fromdocumented in the error text as the override, and the guard is gated on!options.fromso an explicit baseline is never second-guessed.
— Rames
|
Following up post-merge, because The placement change is a real improvement. Moving It does not close the hole I flagged. That finding was about which tag
So compareSemver("0.8.54", "0.8.53-desktop.0")
// "53-desktop" -> Number(...) -> NaN
// 53 !== NaN -> returns 53 - NaN -> NaN
// NaN > 0 -> falseEvery candidate is dropped by the Still latent on Minimal fix, mirroring the shape already at 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 — Rames |
The bug
release:preparecreatesvX.Y.Zlocally 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 ofmain:git describeonly returns a tag it can reach, so it skippedv0.8.51and silently fell back tov0.8.50. The v0.8.52 draft came out asv0.8.50...v0.8.52and re-listed ~100 already-shipped commits.git fetch --tagsdoes not fix this. It refuses to move a tag that already exists locally — only--forcedoes. 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:
describeonly 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:--fromstill overrides, for a deliberate baseline. ReusescompareSemverfromset-version.ts; prereleases are excluded by the same-filter that file already uses, so analphatag can't false-positive.An ancestry check would not work here — my first attempt.
describereturns only ancestors, somerge-base --is-ancestor <from> HEADcan 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.jsonandpackages/player/package.json, whichscripts/check-pr-captures.mjswatches — so it demands Before/After media for a version bump. The gate's own hatch is the intended answer (a## No visible changesection, 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-versionnow prints that with thegh pr createinstructions. 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:
compare/v0.8.52...v0.8.53v0.8.52moved off mainv0.8.52, prints the fetch commandMutation-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 -fwithout-a -mdies withfatal: no tag message?on this repo (already documented atset-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-prepareandset-version. oxlint 0/0, oxfmt clean, typecheck clean.No visible change
Three
scripts/files. Nothing underpackages/studioorpackages/player, so the captures gate doesn't apply — this is the change that makes the next release PR declare it.🤖 Generated with Claude Code