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
45 changes: 15 additions & 30 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,20 +154,15 @@ jobs:

echo "Cross-platform CI passed for ${GITHUB_SHA}: ${ci_url}"

# Compare within the release channel so preview and stable gates use matching history.
if [[ "$RELEASE_VERSION" == *-preview.* ]]; then
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
grep -- '-preview\.' |
head -n 1 || true
)"
else
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
grep -v -- '-preview\.' |
head -n 1 || true
)"
fi
# Notes / service baseline:
# - Preview: newest prior release of either channel (stable or preview). A
# preview→preview-only baseline skips a shipped stable and restates it.
# - Stable: newest prior stable only (matching preview carry adjusts the
# generate-notes range separately below).
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' |
bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"
)"
if [ -n "$previous_tag" ]; then
changed_files="$(git diff --name-only "${previous_tag}..HEAD")"
else
Expand Down Expand Up @@ -299,22 +294,12 @@ jobs:
exit 1
fi

# Channel previous tag (stable↔stable / preview↔preview) for the Full Changelog link.
if [[ "$RELEASE_VERSION" == *-preview.* ]]; then
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
grep -- '-preview\.' |
grep -vx "$release_tag" |
head -n 1 || true
)"
else
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' --sort=-v:refname |
grep -v -- '-preview\.' |
grep -vx "$release_tag" |
head -n 1 || true
)"
fi
# Channel previous tag for Full Changelog + default notes baseline.
# Preview baselines any prior release; stable baselines prior stable only.
previous_tag="$(
git tag --merged HEAD --list 'v[0-9]*' |
bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"
)"
npm_metadata="Published to npm as \`@bitkyc08/opencodex@${RELEASE_VERSION}\` with dist-tag \`${NPM_DIST_TAG}\`."

# Preview builds must be marked prerelease so GitHub "latest" keeps pointing at the
Expand Down
100 changes: 97 additions & 3 deletions scripts/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,77 @@
* bun scripts/release-notes.ts strip-carried <body-file>
* bun scripts/release-notes.ts matching-preview-tag <version>
* bun scripts/release-notes.ts matching-preview-tags <version>
* bun scripts/release-notes.ts previous-release-tag <version>
* bun scripts/release-notes.ts has-meaningful [body-file]
* bun scripts/release-notes.ts assemble --npm-metadata ... --out ...
*/

type ParsedReleaseTag = {
major: number;
minor: number;
patch: number;
/** null = stable release; otherwise the SemVer prerelease identifier string. */
prerelease: string | null;
};

function parseReleaseTag(tag: string): ParsedReleaseTag | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(tag.trim());
if (!match) return null;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null,
};
}

/** SemVer identifier compare: numeric parts by number; numeric < non-numeric. */
function comparePrereleaseIds(a: string, b: string): number {
const aParts = a.split(".");
const bParts = b.split(".");
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i += 1) {
const ap = aParts[i];
const bp = bParts[i];
if (ap === undefined) return -1;
if (bp === undefined) return 1;
const aNum = /^\d+$/.test(ap);
const bNum = /^\d+$/.test(bp);
if (aNum && bNum) {
const diff = Number(ap) - Number(bp);
if (diff !== 0) return diff;
continue;
}
if (aNum !== bNum) return aNum ? -1 : 1;
const cmp = ap.localeCompare(bp);
if (cmp !== 0) return cmp;
}
return 0;
}

/**
* Ascending SemVer-aware tag compare. Stable ranks after prereleases with the
* same core version (`v2.7.42-preview.*` < `v2.7.42`).
*/
export function compareReleaseTags(a: string, b: string): number {
const pa = parseReleaseTag(a);
const pb = parseReleaseTag(b);
if (!pa || !pb) {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}
if (pa.major !== pb.major) return pa.major - pb.major;
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
if (pa.prerelease === null && pb.prerelease === null) return 0;
if (pa.prerelease === null) return 1;
if (pb.prerelease === null) return -1;
return comparePrereleaseIds(pa.prerelease, pb.prerelease);
}

function sortVersionTagsAscending(tags: string[]): string[] {
return [...tags].sort(compareReleaseTags);
}

/** Newest matching preview tag for a stable version, or null. */
export function matchingPreviewTag(version: string, tags: string[]): string | null {
const matches = matchingPreviewTags(version, tags);
Expand All @@ -29,8 +96,29 @@ export function matchingPreviewTags(version: string, tags: string[]): string[] {
const matches = tags
.map(tag => tag.trim())
.filter(tag => tag.startsWith(prefix));
matches.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
return matches;
return sortVersionTagsAscending(matches);
}

/**
* Previous release tag used as the generate-notes / changelog baseline.
*
* - Preview releases: newest prior tag of either channel (stable or preview).
* Channel-isolated preview→preview baselines skip a shipped stable and restate
* that stable's changelog (e.g. 2.7.41-preview → 2.7.43-preview after 2.7.42).
* - Stable releases: newest prior stable only. Matching preview carry adjusts the
* notes range start separately when assembling latest notes.
*/
export function previousReleaseNotesTag(version: string, tags: string[]): string | null {
if (!version) return null;
const releaseTag = version.startsWith("v") ? version : `v${version}`;
const candidates = tags
.map(tag => tag.trim())
.filter(tag => /^v\d/.test(tag) && compareReleaseTags(tag, releaseTag) < 0);
const filtered = version.includes("-preview.")
? candidates
: candidates.filter(tag => !tag.includes("-preview."));
const sorted = sortVersionTagsAscending(filtered);
return sorted.length === 0 ? null : sorted[sorted.length - 1]!;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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

if (cmd === "matching-preview-tag" || cmd === "matching-preview-tags") {
if (cmd === "matching-preview-tag" || cmd === "matching-preview-tags" || cmd === "previous-release-tag") {
const version = rest[0];
if (!version) {
console.error(`Usage: bun scripts/release-notes.ts ${cmd} <version>`);
Expand All @@ -225,6 +313,11 @@ async function main(argv: string[]): Promise<void> {
if (tag) process.stdout.write(tag + "\n");
return;
}
if (cmd === "previous-release-tag") {
const tag = previousReleaseNotesTag(version, tags);
if (tag) process.stdout.write(tag + "\n");
return;
}
for (const tag of matchingPreviewTags(version, tags)) {
process.stdout.write(tag + "\n");
}
Expand Down Expand Up @@ -278,6 +371,7 @@ Usage:
bun scripts/release-notes.ts join-carried --out <file> <part-file>...
bun scripts/release-notes.ts matching-preview-tag <version> # tags on stdin
bun scripts/release-notes.ts matching-preview-tags <version> # tags on stdin, oldest→newest
bun scripts/release-notes.ts previous-release-tag <version> # tags on stdin
bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`);
process.exit(1);
}
Expand Down
6 changes: 6 additions & 0 deletions tests/ci-workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,14 @@ describe("GitHub Actions hardening", () => {
expect(workflow).toContain("bun scripts/release-notes.ts strip-carried");
expect(workflow).toContain("bun scripts/release-notes.ts assemble");
expect(workflow).toContain("bun scripts/release-notes.ts matching-preview-tags");
expect(workflow).toContain("bun scripts/release-notes.ts previous-release-tag");
expect(workflow).toContain("bun scripts/release-notes.ts has-meaningful");
expect(workflow).toContain("bun scripts/release-notes.ts join-carried");
// Preview notes must baseline any prior release (stable or preview), not preview-only.
expect(workflow).toContain('bun scripts/release-notes.ts previous-release-tag "$RELEASE_VERSION"');
expect(workflow).not.toMatch(
/RELEASE_VERSION" == \*-preview\.\*[\s\S]{0,200}grep -- '-preview\\.'/,
);
expect(workflow).toContain("releases/tags/");
expect(workflow).toContain('gh api "repos/${GITHUB_REPOSITORY}" --jq \'.full_name\'');
expect(workflow).toContain("git merge-base --is-ancestor");
Expand Down
47 changes: 47 additions & 0 deletions tests/release-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
joinCarriedPreviewNotes,
matchingPreviewTag,
matchingPreviewTags,
previousReleaseNotesTag,
selectNewestCarriedPreviewTag,
stripCarriedReleaseNotes,
stripGenerateNotesCompareLink,
Expand Down Expand Up @@ -52,6 +53,52 @@ describe("matchingPreviewTags", () => {
});
});

describe("previousReleaseNotesTag", () => {
test("preview baselines the newest prior release of either channel", () => {
expect(previousReleaseNotesTag("2.7.43-preview.20260728", [
"v2.7.41-preview.20260726",
"v2.7.41",
"v2.7.42",
"v2.7.43-preview.20260728",
])).toBe("v2.7.42");
});

test("preview after preview (no stable in between) keeps the previous preview", () => {
expect(previousReleaseNotesTag("2.7.43-preview.20260729", [
"v2.7.42",
"v2.7.43-preview.20260728",
"v2.7.43-preview.20260729",
])).toBe("v2.7.43-preview.20260728");
});

test("stable baselines the newest prior stable only", () => {
expect(previousReleaseNotesTag("2.7.43", [
"v2.7.42",
"v2.7.43-preview.20260728",
"v2.7.43",
])).toBe("v2.7.42");
});

test("returns null when no eligible prior tag exists", () => {
expect(previousReleaseNotesTag("2.7.43-preview.1", ["v2.7.43-preview.1"])).toBeNull();
expect(previousReleaseNotesTag("2.7.43", ["v2.7.43-preview.1"])).toBeNull();
});

test("ignores candidate tags newer than the target release", () => {
expect(previousReleaseNotesTag("2.7.43-preview.20260728", [
"v2.7.42",
"v2.8.0-preview.1",
])).toBe("v2.7.42");
});

test("ranks a stable tag after its matching preview when both are prior", () => {
expect(previousReleaseNotesTag("2.7.43-preview.20260728", [
"v2.7.42-preview.20260727",
"v2.7.42",
])).toBe("v2.7.42");
});
});

describe("stripCarriedReleaseNotes", () => {
test("keeps PR categories and drops npm blurb, commits, and compare link", () => {
const body = [
Expand Down
Loading