Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions scripts/draft-changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
escapeForMdx,
findSkippedReleaseTags,
parseArgs,
parseCommit,
renderCommitBullet,
Expand Down Expand Up @@ -108,3 +109,43 @@ describe("draft changelog rendering", () => {
assert.ok(renderCommitBullet(parsed).includes("Support <Update> 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"]);
});
});
63 changes: 56 additions & 7 deletions scripts/draft-changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 <tag> 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 <tag> to override when the baseline is deliberate.",
);
}

function getCommits(from: string, to: string): RawCommit[] {
Expand Down
8 changes: 8 additions & 0 deletions scripts/set-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.` +
Expand Down
Loading