Skip to content

Commit ece0d9f

Browse files
authored
fix(release): baseline preview notes on last prior release (lidge-jun#579)
* fix(release): baseline preview notes on last prior release Preview generate-notes was channel-isolated (preview to preview only), so cutting 2.7.43-preview after a stable 2.7.42 with no matching preview restated the stable changelog. Preview baselines now use the newest prior stable or preview tag; stable still baselines prior stable and carries matching preview notes. * fix(release): SemVer-order prior tags for notes baseline Exclude tags newer than the target and rank stable after matching previews so generate-notes does not pick the wrong previous release.
1 parent da212d3 commit ece0d9f

4 files changed

Lines changed: 165 additions & 33 deletions

File tree

.github/workflows/release.yml

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,15 @@ jobs:
154154
155155
echo "Cross-platform CI passed for ${GITHUB_SHA}: ${ci_url}"
156156
157-
# Compare within the release channel so preview and stable gates use matching history.
158-
if [[ "$RELEASE_VERSION" == *-preview.* ]]; then
159-
previous_tag="$(
160-
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
161-
grep -- '-preview\.' |
162-
head -n 1 || true
163-
)"
164-
else
165-
previous_tag="$(
166-
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
167-
grep -v -- '-preview\.' |
168-
head -n 1 || true
169-
)"
170-
fi
157+
# Notes / service baseline:
158+
# - Preview: newest prior release of either channel (stable or preview). A
159+
# preview→preview-only baseline skips a shipped stable and restates it.
160+
# - Stable: newest prior stable only (matching preview carry adjusts the
161+
# generate-notes range separately below).
162+
previous_tag="$(
163+
git tag --merged HEAD --list 'v[0-9]*' |
164+
bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"
165+
)"
171166
if [ -n "$previous_tag" ]; then
172167
changed_files="$(git diff --name-only "${previous_tag}..HEAD")"
173168
else
@@ -299,22 +294,12 @@ jobs:
299294
exit 1
300295
fi
301296
302-
# Channel previous tag (stable↔stable / preview↔preview) for the Full Changelog link.
303-
if [[ "$RELEASE_VERSION" == *-preview.* ]]; then
304-
previous_tag="$(
305-
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
306-
grep -- '-preview\.' |
307-
grep -vx "$release_tag" |
308-
head -n 1 || true
309-
)"
310-
else
311-
previous_tag="$(
312-
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
313-
grep -v -- '-preview\.' |
314-
grep -vx "$release_tag" |
315-
head -n 1 || true
316-
)"
317-
fi
297+
# Channel previous tag for Full Changelog + default notes baseline.
298+
# Preview baselines any prior release; stable baselines prior stable only.
299+
previous_tag="$(
300+
git tag --merged HEAD --list 'v[0-9]*' |
301+
bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"
302+
)"
318303
npm_metadata="Published to npm as \`@bitkyc08/opencodex@${RELEASE_VERSION}\` with dist-tag \`${NPM_DIST_TAG}\`."
319304
320305
# Preview builds must be marked prerelease so GitHub "latest" keeps pointing at the

scripts/release-notes.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,77 @@
88
* bun scripts/release-notes.ts strip-carried <body-file>
99
* bun scripts/release-notes.ts matching-preview-tag <version>
1010
* bun scripts/release-notes.ts matching-preview-tags <version>
11+
* bun scripts/release-notes.ts previous-release-tag <version>
1112
* bun scripts/release-notes.ts has-meaningful [body-file]
1213
* bun scripts/release-notes.ts assemble --npm-metadata ... --out ...
1314
*/
1415

16+
type ParsedReleaseTag = {
17+
major: number;
18+
minor: number;
19+
patch: number;
20+
/** null = stable release; otherwise the SemVer prerelease identifier string. */
21+
prerelease: string | null;
22+
};
23+
24+
function parseReleaseTag(tag: string): ParsedReleaseTag | null {
25+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(tag.trim());
26+
if (!match) return null;
27+
return {
28+
major: Number(match[1]),
29+
minor: Number(match[2]),
30+
patch: Number(match[3]),
31+
prerelease: match[4] ?? null,
32+
};
33+
}
34+
35+
/** SemVer identifier compare: numeric parts by number; numeric < non-numeric. */
36+
function comparePrereleaseIds(a: string, b: string): number {
37+
const aParts = a.split(".");
38+
const bParts = b.split(".");
39+
const len = Math.max(aParts.length, bParts.length);
40+
for (let i = 0; i < len; i += 1) {
41+
const ap = aParts[i];
42+
const bp = bParts[i];
43+
if (ap === undefined) return -1;
44+
if (bp === undefined) return 1;
45+
const aNum = /^\d+$/.test(ap);
46+
const bNum = /^\d+$/.test(bp);
47+
if (aNum && bNum) {
48+
const diff = Number(ap) - Number(bp);
49+
if (diff !== 0) return diff;
50+
continue;
51+
}
52+
if (aNum !== bNum) return aNum ? -1 : 1;
53+
const cmp = ap.localeCompare(bp);
54+
if (cmp !== 0) return cmp;
55+
}
56+
return 0;
57+
}
58+
59+
/**
60+
* Ascending SemVer-aware tag compare. Stable ranks after prereleases with the
61+
* same core version (`v2.7.42-preview.*` < `v2.7.42`).
62+
*/
63+
export function compareReleaseTags(a: string, b: string): number {
64+
const pa = parseReleaseTag(a);
65+
const pb = parseReleaseTag(b);
66+
if (!pa || !pb) {
67+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
68+
}
69+
if (pa.major !== pb.major) return pa.major - pb.major;
70+
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
71+
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
72+
if (pa.prerelease === null && pb.prerelease === null) return 0;
73+
if (pa.prerelease === null) return 1;
74+
if (pb.prerelease === null) return -1;
75+
return comparePrereleaseIds(pa.prerelease, pb.prerelease);
76+
}
77+
78+
function sortVersionTagsAscending(tags: string[]): string[] {
79+
return [...tags].sort(compareReleaseTags);
80+
}
81+
1582
/** Newest matching preview tag for a stable version, or null. */
1683
export function matchingPreviewTag(version: string, tags: string[]): string | null {
1784
const matches = matchingPreviewTags(version, tags);
@@ -29,8 +96,29 @@ export function matchingPreviewTags(version: string, tags: string[]): string[] {
2996
const matches = tags
3097
.map(tag => tag.trim())
3198
.filter(tag => tag.startsWith(prefix));
32-
matches.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
33-
return matches;
99+
return sortVersionTagsAscending(matches);
100+
}
101+
102+
/**
103+
* Previous release tag used as the generate-notes / changelog baseline.
104+
*
105+
* - Preview releases: newest prior tag of either channel (stable or preview).
106+
* Channel-isolated preview→preview baselines skip a shipped stable and restate
107+
* that stable's changelog (e.g. 2.7.41-preview → 2.7.43-preview after 2.7.42).
108+
* - Stable releases: newest prior stable only. Matching preview carry adjusts the
109+
* notes range start separately when assembling latest notes.
110+
*/
111+
export function previousReleaseNotesTag(version: string, tags: string[]): string | null {
112+
if (!version) return null;
113+
const releaseTag = version.startsWith("v") ? version : `v${version}`;
114+
const candidates = tags
115+
.map(tag => tag.trim())
116+
.filter(tag => /^v\d/.test(tag) && compareReleaseTags(tag, releaseTag) < 0);
117+
const filtered = version.includes("-preview.")
118+
? candidates
119+
: candidates.filter(tag => !tag.includes("-preview."));
120+
const sorted = sortVersionTagsAscending(filtered);
121+
return sorted.length === 0 ? null : sorted[sorted.length - 1]!;
34122
}
35123

36124
/** Drop npm blurb, Commits section, and Full Changelog link from a prior release body. */
@@ -212,7 +300,7 @@ async function main(argv: string[]): Promise<void> {
212300
return;
213301
}
214302

215-
if (cmd === "matching-preview-tag" || cmd === "matching-preview-tags") {
303+
if (cmd === "matching-preview-tag" || cmd === "matching-preview-tags" || cmd === "previous-release-tag") {
216304
const version = rest[0];
217305
if (!version) {
218306
console.error(`Usage: bun scripts/release-notes.ts ${cmd} <version>`);
@@ -225,6 +313,11 @@ async function main(argv: string[]): Promise<void> {
225313
if (tag) process.stdout.write(tag + "\n");
226314
return;
227315
}
316+
if (cmd === "previous-release-tag") {
317+
const tag = previousReleaseNotesTag(version, tags);
318+
if (tag) process.stdout.write(tag + "\n");
319+
return;
320+
}
228321
for (const tag of matchingPreviewTags(version, tags)) {
229322
process.stdout.write(tag + "\n");
230323
}
@@ -278,6 +371,7 @@ Usage:
278371
bun scripts/release-notes.ts join-carried --out <file> <part-file>...
279372
bun scripts/release-notes.ts matching-preview-tag <version> # tags on stdin
280373
bun scripts/release-notes.ts matching-preview-tags <version> # tags on stdin, oldest→newest
374+
bun scripts/release-notes.ts previous-release-tag <version> # tags on stdin
281375
bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`);
282376
process.exit(1);
283377
}

tests/ci-workflows.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,14 @@ describe("GitHub Actions hardening", () => {
266266
expect(workflow).toContain("bun scripts/release-notes.ts strip-carried");
267267
expect(workflow).toContain("bun scripts/release-notes.ts assemble");
268268
expect(workflow).toContain("bun scripts/release-notes.ts matching-preview-tags");
269+
expect(workflow).toContain("bun scripts/release-notes.ts previous-release-tag");
269270
expect(workflow).toContain("bun scripts/release-notes.ts has-meaningful");
270271
expect(workflow).toContain("bun scripts/release-notes.ts join-carried");
272+
// Preview notes must baseline any prior release (stable or preview), not preview-only.
273+
expect(workflow).toContain('bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"');
274+
expect(workflow).not.toMatch(
275+
/RELEASE_VERSION" == \*-preview\.\*[\s\S]{0,200}grep -- '-preview\\.'/,
276+
);
271277
expect(workflow).toContain("releases/tags/");
272278
expect(workflow).toContain('gh api "repos/${GITHUB_REPOSITORY}" --jq \'.full_name\'');
273279
expect(workflow).toContain("git merge-base --is-ancestor");

tests/release-notes.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
joinCarriedPreviewNotes,
77
matchingPreviewTag,
88
matchingPreviewTags,
9+
previousReleaseNotesTag,
910
selectNewestCarriedPreviewTag,
1011
stripCarriedReleaseNotes,
1112
stripGenerateNotesCompareLink,
@@ -52,6 +53,52 @@ describe("matchingPreviewTags", () => {
5253
});
5354
});
5455

56+
describe("previousReleaseNotesTag", () => {
57+
test("preview baselines the newest prior release of either channel", () => {
58+
expect(previousReleaseNotesTag("2.7.43-preview.20260728", [
59+
"v2.7.41-preview.20260726",
60+
"v2.7.41",
61+
"v2.7.42",
62+
"v2.7.43-preview.20260728",
63+
])).toBe("v2.7.42");
64+
});
65+
66+
test("preview after preview (no stable in between) keeps the previous preview", () => {
67+
expect(previousReleaseNotesTag("2.7.43-preview.20260729", [
68+
"v2.7.42",
69+
"v2.7.43-preview.20260728",
70+
"v2.7.43-preview.20260729",
71+
])).toBe("v2.7.43-preview.20260728");
72+
});
73+
74+
test("stable baselines the newest prior stable only", () => {
75+
expect(previousReleaseNotesTag("2.7.43", [
76+
"v2.7.42",
77+
"v2.7.43-preview.20260728",
78+
"v2.7.43",
79+
])).toBe("v2.7.42");
80+
});
81+
82+
test("returns null when no eligible prior tag exists", () => {
83+
expect(previousReleaseNotesTag("2.7.43-preview.1", ["v2.7.43-preview.1"])).toBeNull();
84+
expect(previousReleaseNotesTag("2.7.43", ["v2.7.43-preview.1"])).toBeNull();
85+
});
86+
87+
test("ignores candidate tags newer than the target release", () => {
88+
expect(previousReleaseNotesTag("2.7.43-preview.20260728", [
89+
"v2.7.42",
90+
"v2.8.0-preview.1",
91+
])).toBe("v2.7.42");
92+
});
93+
94+
test("ranks a stable tag after its matching preview when both are prior", () => {
95+
expect(previousReleaseNotesTag("2.7.43-preview.20260728", [
96+
"v2.7.42-preview.20260727",
97+
"v2.7.42",
98+
])).toBe("v2.7.42");
99+
});
100+
});
101+
55102
describe("stripCarriedReleaseNotes", () => {
56103
test("keeps PR categories and drops npm blurb, commits, and compare link", () => {
57104
const body = [

0 commit comments

Comments
 (0)