diff --git a/scripts/draft-changelog.test.ts b/scripts/draft-changelog.test.ts index 0ee894ccf9..df3227bb8f 100644 --- a/scripts/draft-changelog.test.ts +++ b/scripts/draft-changelog.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { escapeForMdx, + findSkippedReleaseTags, parseArgs, parseCommit, renderCommitBullet, @@ -108,3 +109,43 @@ describe("draft changelog rendering", () => { assert.ok(renderCommitBullet(parsed).includes("Support blocks with {tags}")); }); }); + +describe("skipped release tags", () => { + const TAGS = ["0.8.49", "0.8.50", "0.8.51"]; + + it("names a tag the baseline skipped", () => { + // The v0.8.52 cut: a stale local v0.8.51 was unreachable, so describe fell + // back to v0.8.50 and the draft re-listed ~100 already-released commits. + assert.deepEqual(findSkippedReleaseTags(TAGS, "v0.8.50", "0.8.52"), ["0.8.51"]); + }); + + it("passes when the baseline is the immediately preceding release", () => { + assert.deepEqual(findSkippedReleaseTags(TAGS, "v0.8.51", "0.8.52"), []); + }); + + it("excludes the baseline and the release being drafted", () => { + // Both ends are exclusive: v0.8.50 is the baseline and v0.8.51 is the + // release, so neither counts as skipped. + assert.deepEqual(findSkippedReleaseTags(TAGS, "v0.8.50", "0.8.51"), []); + }); + + it("reports every skipped release in ascending order", () => { + assert.deepEqual(findSkippedReleaseTags(TAGS, "v0.8.48", "0.8.52"), [ + "0.8.49", + "0.8.50", + "0.8.51", + ]); + }); + + it("compares numerically, not lexically", () => { + // "0.8.9" > "0.8.10" as strings; a string sort would miss this entirely. + assert.deepEqual(findSkippedReleaseTags(["0.8.9", "0.8.10"], "v0.8.8", "0.8.11"), [ + "0.8.9", + "0.8.10", + ]); + }); + + it("accepts a baseline with or without the v prefix", () => { + assert.deepEqual(findSkippedReleaseTags(TAGS, "0.8.50", "0.8.52"), ["0.8.51"]); + }); +}); diff --git a/scripts/draft-changelog.ts b/scripts/draft-changelog.ts index cc902f7349..b30988a58c 100644 --- a/scripts/draft-changelog.ts +++ b/scripts/draft-changelog.ts @@ -11,7 +11,7 @@ import { validateCliVersion, type InlineValueOption, } from "./cli-options.ts"; -import { CHANGELOG_REVIEW_TODO, CHANGELOG_STYLE_NOTE } from "./set-version.ts"; +import { CHANGELOG_REVIEW_TODO, CHANGELOG_STYLE_NOTE, compareSemver } from "./set-version.ts"; /** * The generator only ever produces mechanical bullets from commit subjects. The @@ -108,7 +108,7 @@ function main() { function createDraft(options: Options): DraftOutput { const versionTag = `v${options.version}`; const to = options.to ?? (tagExists(versionTag) ? versionTag : "HEAD"); - const from = options.from ?? resolvePreviousTag(versionTag, to); + const from = options.from ?? resolvePreviousTag(versionTag, to, options.version); const commits = getCommits(from, to).filter((commit) => !shouldSkipCommit(commit)); const parsedCommits = commits.map(parseCommit); @@ -212,15 +212,64 @@ function tagExists(tag: string) { } } -function resolvePreviousTag(versionTag: string, to: string) { +function resolvePreviousTag(versionTag: string, to: string, version: string) { + const ref = tagExists(versionTag) ? `${versionTag}^` : to; + let from: string; try { - if (tagExists(versionTag)) { - return git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", `${versionTag}^`]); - } - return git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", to]); + from = git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", ref]); } catch { fail("Could not resolve the previous release tag. Pass --from explicitly."); } + // Only guards an auto-resolved baseline; an explicit --from never reaches here. + assertNoSkippedTags(from, version); + return from; +} + +/** + * Stable tags that belong between `from` and the release being drafted. + * + * `git describe` only ever returns a tag *reachable* from its argument, so a + * tag in this range means describe could not reach it and silently fell back + * to an older baseline. That happens when a local tag points at a commit that + * is not in this history: `release:prepare` creates `vX.Y.Z` locally at the + * branch commit, then the publish workflow creates the same tag at the merge + * SHA, and after a squash merge those differ. `git fetch --tags` will not move + * a tag that already exists locally, so the stale one survives and the next + * draft re-lists every commit of the release it skipped. + */ +export function findSkippedReleaseTags( + stableVersions: string[], + from: string, + version: string, +): string[] { + const fromVersion = from.replace(/^v/, ""); + return stableVersions + .filter((tag) => compareSemver(tag, fromVersion) > 0 && compareSemver(tag, version) < 0) + .sort(compareSemver); +} + +/** Stable `vX.Y.Z` tags in this worktree; prereleases carry a `-` and are excluded. */ +function listStableTags(): string[] { + try { + return git(["tag", "--list", "v[0-9]*"]) + .split("\n") + .filter((tag) => tag && !tag.includes("-")) + .map((tag) => tag.replace(/^v/, "")); + } catch { + return []; + } +} + +function assertNoSkippedTags(from: string, version: string) { + const skipped = findSkippedReleaseTags(listStableTags(), from, version); + if (skipped.length === 0) return; + const names = skipped.map((tag) => `v${tag}`).join(", "); + fail( + `Baseline v${from.replace(/^v/, "")} skips ${names} — tagged locally, but not in this history.\n` + + "Those tags are stale, so the draft would re-list work that already shipped.\n\n" + + " git fetch origin --tags --force\n\n" + + "Then re-run. Pass --from to override when the baseline is deliberate.", + ); } function getCommits(from: string, to: string): RawCommit[] { diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 150d3e1db7..0f23d61eb7 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -372,6 +372,14 @@ function printReleaseNextSteps(version: string) { console.log(`\nDo NOT push the local tag. Run:`); console.log(` git push origin HEAD:refs/heads/release/v${version}`); console.log(` gh pr create --base main --head release/v${version} --fill`); + // Every release bumps packages/studio and packages/player, which the + // captures gate watches, so it asks a version bump for before/after media. + // Its own hatch covers this: the watched diff is 4 lines of package.json. + console.log( + `\nThe PR body needs a '## No visible change' section — a release bumps` + + `\npackages/studio and packages/player, so scripts/check-pr-captures.mjs` + + `\notherwise demands before/after captures for a version bump.`, + ); console.log( `\nMerging that PR publishes: the workflow checks out the merge SHA, creates` + `\nthe v${version} tag there, publishes npm, and cuts the GitHub release.` +