From ecdb94348dbdba634b3b777af15266fcad3f409e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 07:42:30 +0200 Subject: [PATCH 1/4] fix(release): carry preview changelog into latest notes When a matching vX.Y.Z-preview.* GitHub Release exists, stable latest notes copy that preview changelog and only append the preview-to-latest delta, instead of relying on an empty previous-stable generate-notes range. --- .github/workflows/release.yml | 80 ++++++++++---- scripts/release-notes.ts | 193 ++++++++++++++++++++++++++++++++++ tests/ci-workflows.test.ts | 23 ++-- tests/release-notes.test.ts | 113 ++++++++++++++++++++ 4 files changed, 378 insertions(+), 31 deletions(-) create mode 100644 scripts/release-notes.ts create mode 100644 tests/release-notes.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89a1d66043a..9d6f14d10f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -299,7 +299,7 @@ jobs: exit 1 fi - # Generate notes against the previous tag from the same release channel. + # 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 | @@ -329,47 +329,81 @@ jobs: # tag need not exist yet. Preflight already rejects an existing GitHub Release for # non-dry runs, so this step only creates. notes_file="$(mktemp)" - pr_notes="" - if [ -n "$previous_tag" ]; then + carried_file="$(mktemp)" + delta_file="$(mktemp)" + commits_file="$(mktemp)" + : > "$carried_file" + : > "$delta_file" + : > "$commits_file" + + # Stable releases after a matching preview: copy that preview changelog, then only + # generate-notes / commits for the preview→latest delta. Preview tags often are not + # ancestors of main, so match by version prefix (not --merged). + notes_range_start="$previous_tag" + if [[ "$RELEASE_VERSION" != *-preview.* ]]; then + preview_carry_tag="$( + git tag --list "v${RELEASE_VERSION}-preview.*" --sort=-v:refname | + head -n 1 || true + )" + if [ -n "$preview_carry_tag" ]; then + if gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>/dev/null; then + bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "$carried_file" + notes_range_start="$preview_carry_tag" + echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}" + else + echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; using channel notes only" + : > "$carried_file" + fi + fi + fi + + if [ -n "$notes_range_start" ]; then generate_notes_api=( "repos/${GITHUB_REPOSITORY}/releases/generate-notes" -f "tag_name=${release_tag}" -f "target_commitish=${GITHUB_SHA}" - -f "previous_tag_name=${previous_tag}" + -f "previous_tag_name=${notes_range_start}" ) # Fail closed: missing PR categories is a broken release note, not a soft skip. pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')" # Drop generate-notes' trailing compare link; we re-append it after the commit list. - pr_notes="$(printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d')" + printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file" else # First release on this channel: never call generate-notes without previous_tag_name. # GitHub would baseline the newest repo tag, which may belong to the other channel. echo "::notice::No previous channel tag; skipping generate-notes (commits-only notes)" fi - if [ -n "$previous_tag" ]; then - commit_range="${previous_tag}..${GITHUB_SHA}" - commits="$(git log --pretty=format:'- %s (%h)' "$commit_range")" + if [ -n "$notes_range_start" ]; then + commit_range="${notes_range_start}..${GITHUB_SHA}" + git log --pretty=format:'- %s (%h)' "$commit_range" > "$commits_file" + # git log may omit a trailing newline; normalize empty output for assemble. + if [ ! -s "$commits_file" ]; then + : > "$commits_file" + fi else # First release on a channel has no previous tag; cap history so the notes # body stays within GitHub release size limits. - commits="$(git log --pretty=format:'- %s (%h)' --max-count=100 "$GITHUB_SHA")" + git log --pretty=format:'- %s (%h)' --max-count=100 "$GITHUB_SHA" > "$commits_file" fi - { - printf '%s\n\n' "$npm_metadata" - if [ -n "$(printf '%s' "$pr_notes" | tr -d '[:space:]')" ]; then - printf '%s\n\n' "$pr_notes" - fi - if [ -n "$commits" ]; then - printf '## Commits\n\n' - printf '%s\n\n' "$commits" - fi - if [ -n "$previous_tag" ]; then - printf '**Full Changelog**: https://github.com/%s/compare/%s...%s\n' \ - "$GITHUB_REPOSITORY" "$previous_tag" "$release_tag" - fi - } > "$notes_file" + assemble_args=( + bun scripts/release-notes.ts assemble + --npm-metadata "$npm_metadata" + --carried "$carried_file" + --delta "$delta_file" + --commits "$commits_file" + --out "$notes_file" + --compare-to "$release_tag" + --repository "$GITHUB_REPOSITORY" + ) + # Prefer the stable-channel previous tag for the compare link when present. + if [ -n "$previous_tag" ]; then + assemble_args+=(--compare-from "$previous_tag") + elif [ -n "$notes_range_start" ]; then + assemble_args+=(--compare-from "$notes_range_start") + fi + "${assemble_args[@]}" if [ -z "$existing_tag_sha" ]; then git tag "$release_tag" "$GITHUB_SHA" diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts new file mode 100644 index 00000000000..cade7f4c178 --- /dev/null +++ b/scripts/release-notes.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env bun +/** + * Pure helpers for GitHub release note assembly. + * Used by `.github/workflows/release.yml` so stable (latest) releases can carry + * the matching preview changelog, plus any delta since that preview. + * + * CLI: + * bun scripts/release-notes.ts strip-carried + * bun scripts/release-notes.ts matching-preview-tag + * bun scripts/release-notes.ts assemble --npm-metadata ... --out ... + */ + +export function matchingPreviewTag(version: string, tags: string[]): string | null { + if (!version || version.includes("-")) return null; + const prefix = `v${version}-preview.`; + const matches = tags + .map(tag => tag.trim()) + .filter(tag => tag.startsWith(prefix)); + if (matches.length === 0) return null; + matches.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" })); + return matches[0] ?? null; +} + +/** Drop npm blurb, Commits section, and Full Changelog link from a prior release body. */ +export function stripCarriedReleaseNotes(body: string): string { + const lines = body.replace(/\r\n/g, "\n").split("\n"); + const kept: string[] = []; + let inCommits = false; + + for (const line of lines) { + if (/^Published to npm as /.test(line)) continue; + if (/^\*\*Full Changelog\*\*:/.test(line)) continue; + if (/^## Commits\s*$/.test(line)) { + inCommits = true; + continue; + } + if (inCommits) { + if (/^## /.test(line)) { + inCommits = false; + } else { + continue; + } + } + kept.push(line); + } + + return kept.join("\n").replace(/^\n+/, "").replace(/\n+$/, "").trim(); +} + +/** Drop generate-notes trailing compare link (workflow re-appends its own). */ +export function stripGenerateNotesCompareLink(body: string): string { + return body + .replace(/\r\n/g, "\n") + .split("\n") + .filter(line => !/^\*\*Full Changelog\*\*:/.test(line)) + .join("\n") + .replace(/\n+$/, "") + .trim(); +} + +/** True when generate-notes returned only the config comment / blank lines. */ +export function isEmptyGeneratedNotes(body: string): boolean { + const withoutComment = stripGenerateNotesCompareLink(body) + .split("\n") + .filter(line => !/^$/.test(line.trim())) + .join("\n"); + return !hasNonWhitespace(withoutComment); +} + +export function hasNonWhitespace(text: string): boolean { + return text.replace(/\s+/g, "").length > 0; +} + +export function assembleReleaseNotes(input: { + npmMetadata: string; + carriedPreviewNotes?: string; + deltaPrNotes?: string; + commits?: string; + compareFrom?: string | null; + compareTo?: string; + repository?: string; +}): string { + const parts: string[] = []; + parts.push(input.npmMetadata.trim()); + + const carried = (input.carriedPreviewNotes ?? "").trim(); + if (hasNonWhitespace(carried)) { + parts.push(carried); + } + + const deltaRaw = (input.deltaPrNotes ?? "").trim(); + const delta = isEmptyGeneratedNotes(deltaRaw) ? "" : stripGenerateNotesCompareLink(deltaRaw); + if (hasNonWhitespace(delta)) { + if (hasNonWhitespace(carried)) { + parts.push("## Since preview\n\n" + delta); + } else { + parts.push(delta); + } + } + + const commits = (input.commits ?? "").trim(); + if (commits) { + parts.push("## Commits\n\n" + commits); + } + + const from = input.compareFrom?.trim(); + const to = input.compareTo?.trim(); + const repo = input.repository?.trim(); + if (from && to && repo) { + parts.push(`**Full Changelog**: https://github.com/${repo}/compare/${from}...${to}`); + } + + return parts.join("\n\n").replace(/\n+$/, "") + "\n"; +} + +async function readStdinOrFile(path: string | undefined): Promise { + if (path && path !== "-") { + return await Bun.file(path).text(); + } + return await new Response(Bun.stdin).text(); +} + +async function main(argv: string[]): Promise { + const [cmd, ...rest] = argv; + if (cmd === "strip-carried") { + const stripped = stripCarriedReleaseNotes(await readStdinOrFile(rest[0])); + process.stdout.write(stripped.endsWith("\n") ? stripped : stripped + "\n"); + return; + } + + if (cmd === "matching-preview-tag") { + const version = rest[0]; + if (!version) { + console.error("Usage: bun scripts/release-notes.ts matching-preview-tag "); + process.exit(1); + } + const tagsText = await new Response(Bun.stdin).text(); + const tag = matchingPreviewTag(version, tagsText.split(/\r?\n/)); + if (tag) process.stdout.write(tag + "\n"); + return; + } + + if (cmd === "assemble") { + const args = new Map(); + for (let i = 0; i < rest.length; i += 1) { + const key = rest[i]; + if (!key?.startsWith("--")) continue; + const value = rest[i + 1]; + if (!value || value.startsWith("--")) { + console.error(`Missing value for ${key}`); + process.exit(1); + } + args.set(key.slice(2), value); + i += 1; + } + + const npmMetadata = args.get("npm-metadata"); + const out = args.get("out"); + if (!npmMetadata || !out) { + console.error("Usage: bun scripts/release-notes.ts assemble --npm-metadata --out [--carried ] [--delta ] [--commits ] [--compare-from ] [--compare-to ] [--repository ]"); + process.exit(1); + } + + const readOptional = async (name: string): Promise => { + const path = args.get(name); + if (!path) return ""; + return await Bun.file(path).text(); + }; + + const notes = assembleReleaseNotes({ + npmMetadata, + carriedPreviewNotes: await readOptional("carried"), + deltaPrNotes: await readOptional("delta"), + commits: await readOptional("commits"), + compareFrom: args.get("compare-from") ?? null, + compareTo: args.get("compare-to"), + repository: args.get("repository"), + }); + await Bun.write(out, notes); + return; + } + + console.error(`Unknown command: ${cmd ?? "(none)"} +Usage: + bun scripts/release-notes.ts strip-carried [body-file] + bun scripts/release-notes.ts matching-preview-tag # tags on stdin + bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`); + process.exit(1); +} + +if (import.meta.main) { + await main(process.argv.slice(2)); +} diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index b53ae0574b5..fa179d2ee5b 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -126,28 +126,35 @@ describe("GitHub Actions hardening", () => { // Release notes must include PR categories and the full channel commit range // (branch merges + direct commits). Preflight forbids an existing release, so - // only create (not edit) is wired. + // only create (not edit) is wired. Stable releases also carry matching preview notes. expect(workflow).toContain("releases/generate-notes"); - expect(workflow).toContain("## Commits"); expect(workflow).toContain("git log --pretty=format:'- %s (%h)'"); - expect(workflow).toContain('commit_range="${previous_tag}..${GITHUB_SHA}"'); - expect(workflow).toContain('previous_tag_name=${previous_tag}'); + expect(workflow).toContain('commit_range="${notes_range_start}..${GITHUB_SHA}"'); + expect(workflow).toContain('previous_tag_name=${notes_range_start}'); expect(workflow).toContain("skipping generate-notes (commits-only notes)"); + expect(workflow).toContain("bun scripts/release-notes.ts strip-carried"); + expect(workflow).toContain("bun scripts/release-notes.ts assemble"); + expect(workflow).toContain("--commits"); + expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"'); + expect(workflow).toContain("Carrying preview release notes from"); + const releaseNotesHelper = await readText("scripts/release-notes.ts"); + expect(releaseNotesHelper).toContain("## Commits"); + expect(releaseNotesHelper).toContain("## Since preview"); expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); expect(workflow).not.toContain("gh release edit"); expect(workflow).not.toContain("--generate-notes"); - // Fail closed when generate-notes fails (no soft skip). - expect(workflow).not.toMatch(/generate-notes[\s\S]*?\|\| true/); // Notes must be assembled before tagging so a notes API failure does not leave // a remote tag that blocks release retries at preflight. const createStep = workflow.split("- name: Create GitHub release")[1]!.split(/\n {6}- name:/)[0]!; + // Fail closed when generate-notes fails (no soft skip on the API call itself). + expect(createStep).not.toMatch(/pr_notes="\$\(gh api[\s\S]*?\|\| true\)"/); expect(createStep.indexOf("gh api")).toBeGreaterThan(-1); expect(createStep.indexOf('git tag "$release_tag"')).toBeGreaterThan(-1); expect(createStep.indexOf("gh api")).toBeLessThan(createStep.indexOf('git tag "$release_tag"')); - // First-channel releases must not call generate-notes without an explicit channel baseline + // First-channel releases must not call generate-notes without an explicit baseline // (GitHub would otherwise pick the newest repo tag, possibly from the other channel). expect(createStep).toMatch( - /if \[ -n "\$previous_tag" \]; then[\s\S]*previous_tag_name=\$\{previous_tag\}[\s\S]*else[\s\S]*skipping generate-notes/, + /if \[ -n "\$notes_range_start" \]; then[\s\S]*previous_tag_name=\$\{notes_range_start\}[\s\S]*else[\s\S]*skipping generate-notes/, ); }); diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts new file mode 100644 index 00000000000..fbd6f8ca02a --- /dev/null +++ b/tests/release-notes.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "bun:test"; +import { + assembleReleaseNotes, + hasNonWhitespace, + matchingPreviewTag, + stripCarriedReleaseNotes, + stripGenerateNotesCompareLink, +} from "../scripts/release-notes"; + +describe("matchingPreviewTag", () => { + test("picks the newest matching preview tag for a stable version", () => { + expect(matchingPreviewTag("2.7.39", [ + "v2.7.38-preview.20260724", + "v2.7.39-preview.20260724", + "v2.7.39-preview.20260725", + "v2.7.40-preview.20260725", + "v2.7.39", + ])).toBe("v2.7.39-preview.20260725"); + }); + + test("returns null for preview versions and when no match exists", () => { + expect(matchingPreviewTag("2.7.39-preview.1", ["v2.7.39-preview.1"])).toBeNull(); + expect(matchingPreviewTag("2.7.41", ["v2.7.40-preview.20260725"])).toBeNull(); + }); +}); + +describe("stripCarriedReleaseNotes", () => { + test("keeps PR categories and drops npm blurb, commits, and compare link", () => { + const body = [ + "Published to npm as `@bitkyc08/opencodex@2.7.39-preview.20260724` with dist-tag `preview`.", + "", + "", + "", + "## What's Changed", + "### Bug Fixes", + "* fix(release): full channel changelog by @Wibias in https://example.test/pull/364", + "", + "## New Contributors", + "* @someone made their first contribution", + "", + "## Commits", + "", + "- release: v2.7.39-preview.20260724 (8894e40e)", + "- Merge branch 'dev' into preview (9077f7c1)", + "", + "**Full Changelog**: https://github.com/lidge-jun/opencodex/compare/v2.7.38-preview.20260724...v2.7.39-preview.20260724", + "", + ].join("\n"); + + expect(stripCarriedReleaseNotes(body)).toBe([ + "", + "", + "## What's Changed", + "### Bug Fixes", + "* fix(release): full channel changelog by @Wibias in https://example.test/pull/364", + "", + "## New Contributors", + "* @someone made their first contribution", + ].join("\n")); + }); +}); + +describe("assembleReleaseNotes", () => { + test("copies preview notes and appends only the since-preview delta", () => { + const notes = assembleReleaseNotes({ + npmMetadata: "Published to npm as `@bitkyc08/opencodex@2.7.39` with dist-tag `latest`.", + carriedPreviewNotes: "## What's Changed\n### Bug Fixes\n* fix A", + deltaPrNotes: "## What's Changed\n### Bug Fixes\n* fix B", + commits: "- release: v2.7.39 (357acee6)", + compareFrom: "v2.7.37", + compareTo: "v2.7.39", + repository: "lidge-jun/opencodex", + }); + + expect(notes).toContain("dist-tag `latest`"); + expect(notes).toContain("## What's Changed\n### Bug Fixes\n* fix A"); + expect(notes).toContain("## Since preview\n\n## What's Changed\n### Bug Fixes\n* fix B"); + expect(notes).toContain("## Commits\n\n- release: v2.7.39 (357acee6)"); + expect(notes).toContain("**Full Changelog**: https://github.com/lidge-jun/opencodex/compare/v2.7.37...v2.7.39"); + }); + + test("omits empty generate-notes delta that is only the config comment", () => { + const notes = assembleReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: "## What's Changed\n* fix A", + deltaPrNotes: "\n\n\n**Full Changelog**: https://example/compare/a...b\n", + commits: "- release: v1.0.0 (abc)", + compareFrom: "v0.9.0", + compareTo: "v1.0.0", + repository: "acme/pkg", + }); + + expect(notes).toContain("* fix A"); + expect(notes).not.toContain("## Since preview"); + expect(notes).not.toContain("Full Changelog**: https://example/compare/a...b"); + }); + + test("falls back to channel notes when no preview body is carried", () => { + const notes = assembleReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + deltaPrNotes: "## What's Changed\n* feat X\n\n**Full Changelog**: https://example/compare/a...b", + commits: "- feat X (abc1234)", + compareFrom: "v0.9.0", + compareTo: "v1.0.0", + repository: "acme/pkg", + }); + + expect(notes).not.toContain("## Since preview"); + expect(notes).toContain("## What's Changed\n* feat X"); + expect(notes).not.toContain("https://example/compare/a...b"); + expect(hasNonWhitespace("")).toBe(false); + }); +}); From 40ced9b5ae12ae38bd8d7cc8f076f455a294d791 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:19:07 +0200 Subject: [PATCH 2/4] fix(release): harden preview note carry for Codex review Aggregate all matching preview changelogs, keep the channel baseline when carried notes are empty or the preview tag is not an ancestor, and fail closed on gh release view operational errors. --- .github/workflows/release.yml | 57 ++++++++++++++++++++++++------- scripts/release-notes.ts | 64 +++++++++++++++++++++++++++++------ tests/ci-workflows.test.ts | 7 ++++ tests/release-notes.test.ts | 45 ++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9d6f14d10f5..e1dfca47a6a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -336,23 +336,54 @@ jobs: : > "$delta_file" : > "$commits_file" - # Stable releases after a matching preview: copy that preview changelog, then only - # generate-notes / commits for the preview→latest delta. Preview tags often are not - # ancestors of main, so match by version prefix (not --merged). + # Stable releases after matching previews: aggregate every matching preview + # changelog (oldest→newest; each preview body is incremental), then only + # generate-notes / commits for the post-preview delta when the newest preview + # tag is an ancestor of this commit. Preview tags often are not ancestors of + # main, so we never use a divergent preview tag as a two-dot baseline. notes_range_start="$previous_tag" if [[ "$RELEASE_VERSION" != *-preview.* ]]; then - preview_carry_tag="$( - git tag --list "v${RELEASE_VERSION}-preview.*" --sort=-v:refname | - head -n 1 || true + preview_carry_tags="$( + git tag --list "v${RELEASE_VERSION}-preview.*" | + bun scripts/release-notes.ts matching-preview-tags "$RELEASE_VERSION" )" - if [ -n "$preview_carry_tag" ]; then - if gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>/dev/null; then - bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "$carried_file" - notes_range_start="$preview_carry_tag" - echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}" + newest_preview_tag="" + carried_any=false + while IFS= read -r preview_carry_tag; do + [ -n "$preview_carry_tag" ] || continue + newest_preview_tag="$preview_carry_tag" + view_err="$(mktemp)" + set +e + gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>"$view_err" + view_status=$? + set -e + if [ "$view_status" -eq 0 ]; then + bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "${carried_file}.one" + if bun scripts/release-notes.ts has-meaningful "${carried_file}.one"; then + if [ -s "$carried_file" ]; then + printf '\n\n' >> "$carried_file" + fi + cat "${carried_file}.one" >> "$carried_file" + carried_any=true + echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}" + else + echo "::notice::Preview release ${preview_carry_tag} has no usable changelog after strip; leaving channel baseline unchanged for this tag" + fi + elif grep -qiE 'could not find|not found|HTTP[[:space:]]*404|Not Found' "$view_err"; then + echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; skipping" + else + echo "::error::Failed to look up GitHub Release for ${preview_carry_tag} (operational error, not a missing release)" + cat "$view_err" >&2 || true + exit 1 + fi + done <<< "$preview_carry_tags" + + if [ "$carried_any" = true ] && [ -n "$newest_preview_tag" ]; then + if git merge-base --is-ancestor "$newest_preview_tag" "$GITHUB_SHA"; then + notes_range_start="$newest_preview_tag" + echo "::notice::Using preview tag ${newest_preview_tag} as notes/commits baseline (ancestor of ${GITHUB_SHA})" else - echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; using channel notes only" - : > "$carried_file" + echo "::notice::Preview tag ${newest_preview_tag} is not an ancestor of ${GITHUB_SHA}; keeping channel baseline ${previous_tag:-none} for generate-notes/commits" fi fi fi diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index cade7f4c178..4072a69864f 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -2,23 +2,35 @@ /** * Pure helpers for GitHub release note assembly. * Used by `.github/workflows/release.yml` so stable (latest) releases can carry - * the matching preview changelog, plus any delta since that preview. + * matching preview changelogs, plus any delta since the last carried preview. * * CLI: * bun scripts/release-notes.ts strip-carried * bun scripts/release-notes.ts matching-preview-tag + * bun scripts/release-notes.ts matching-preview-tags + * bun scripts/release-notes.ts has-meaningful [body-file] * bun scripts/release-notes.ts assemble --npm-metadata ... --out ... */ +/** Newest matching preview tag for a stable version, or null. */ export function matchingPreviewTag(version: string, tags: string[]): string | null { - if (!version || version.includes("-")) return null; + const matches = matchingPreviewTags(version, tags); + return matches.length === 0 ? null : matches[matches.length - 1]!; +} + +/** + * All matching preview tags for a stable version, oldest → newest. + * Each preview's notes are incremental vs the previous preview, so stable + * releases must aggregate in this order to avoid dropping earlier preview work. + */ +export function matchingPreviewTags(version: string, tags: string[]): string[] { + if (!version || version.includes("-")) return []; const prefix = `v${version}-preview.`; const matches = tags .map(tag => tag.trim()) .filter(tag => tag.startsWith(prefix)); - if (matches.length === 0) return null; - matches.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: "base" })); - return matches[0] ?? null; + matches.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })); + return matches; } /** Drop npm blurb, Commits section, and Full Changelog link from a prior release body. */ @@ -67,10 +79,28 @@ export function isEmptyGeneratedNotes(body: string): boolean { return !hasNonWhitespace(withoutComment); } +/** + * True when stripped carried notes contain a usable changelog (not blank / + * comment-only). Commits-only preview releases strip down to empty and must not + * move the stable notes baseline. + */ +export function hasMeaningfulCarriedNotes(stripped: string): boolean { + return !isEmptyGeneratedNotes(stripped); +} + export function hasNonWhitespace(text: string): boolean { return text.replace(/\s+/g, "").length > 0; } +/** Join multiple stripped preview bodies in chronological order. */ +export function joinCarriedPreviewNotes(parts: string[]): string { + return parts + .map(part => part.trim()) + .filter(part => hasMeaningfulCarriedNotes(part)) + .join("\n\n") + .trim(); +} + export function assembleReleaseNotes(input: { npmMetadata: string; carriedPreviewNotes?: string; @@ -128,15 +158,27 @@ async function main(argv: string[]): Promise { return; } - if (cmd === "matching-preview-tag") { + if (cmd === "has-meaningful") { + const stripped = (await readStdinOrFile(rest[0])).trim(); + process.exit(hasMeaningfulCarriedNotes(stripped) ? 0 : 1); + } + + if (cmd === "matching-preview-tag" || cmd === "matching-preview-tags") { const version = rest[0]; if (!version) { - console.error("Usage: bun scripts/release-notes.ts matching-preview-tag "); + console.error(`Usage: bun scripts/release-notes.ts ${cmd} `); process.exit(1); } const tagsText = await new Response(Bun.stdin).text(); - const tag = matchingPreviewTag(version, tagsText.split(/\r?\n/)); - if (tag) process.stdout.write(tag + "\n"); + const tags = tagsText.split(/\r?\n/); + if (cmd === "matching-preview-tag") { + const tag = matchingPreviewTag(version, tags); + if (tag) process.stdout.write(tag + "\n"); + return; + } + for (const tag of matchingPreviewTags(version, tags)) { + process.stdout.write(tag + "\n"); + } return; } @@ -183,7 +225,9 @@ async function main(argv: string[]): Promise { console.error(`Unknown command: ${cmd ?? "(none)"} Usage: bun scripts/release-notes.ts strip-carried [body-file] - bun scripts/release-notes.ts matching-preview-tag # tags on stdin + bun scripts/release-notes.ts has-meaningful [body-file] + bun scripts/release-notes.ts matching-preview-tag # tags on stdin + bun scripts/release-notes.ts matching-preview-tags # tags on stdin, oldest→newest bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`); process.exit(1); } diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index fa179d2ee5b..a6a9def3d74 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -134,12 +134,19 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("skipping generate-notes (commits-only notes)"); 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 has-meaningful"); + expect(workflow).toContain("git merge-base --is-ancestor"); + expect(workflow).toContain("operational error, not a missing release"); + expect(workflow).toContain("not an ancestor"); expect(workflow).toContain("--commits"); expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"'); expect(workflow).toContain("Carrying preview release notes from"); const releaseNotesHelper = await readText("scripts/release-notes.ts"); expect(releaseNotesHelper).toContain("## Commits"); expect(releaseNotesHelper).toContain("## Since preview"); + expect(releaseNotesHelper).toContain("matchingPreviewTags"); + expect(releaseNotesHelper).toContain("hasMeaningfulCarriedNotes"); expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); expect(workflow).not.toContain("gh release edit"); expect(workflow).not.toContain("--generate-notes"); diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index fbd6f8ca02a..cf17a807f1c 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from "bun:test"; import { assembleReleaseNotes, + hasMeaningfulCarriedNotes, hasNonWhitespace, + joinCarriedPreviewNotes, matchingPreviewTag, + matchingPreviewTags, stripCarriedReleaseNotes, stripGenerateNotesCompareLink, } from "../scripts/release-notes"; @@ -24,6 +27,20 @@ describe("matchingPreviewTag", () => { }); }); +describe("matchingPreviewTags", () => { + test("returns all matching preview tags oldest to newest", () => { + expect(matchingPreviewTags("2.7.39", [ + "v2.7.39-preview.20260725", + "v2.7.38-preview.20260724", + "v2.7.39-preview.20260724", + "v2.7.39", + ])).toEqual([ + "v2.7.39-preview.20260724", + "v2.7.39-preview.20260725", + ]); + }); +}); + describe("stripCarriedReleaseNotes", () => { test("keeps PR categories and drops npm blurb, commits, and compare link", () => { const body = [ @@ -58,6 +75,33 @@ describe("stripCarriedReleaseNotes", () => { "* @someone made their first contribution", ].join("\n")); }); + + test("commits-only preview bodies strip to non-meaningful notes", () => { + const body = [ + "Published to npm as `@bitkyc08/opencodex@2.7.39-preview.20260724` with dist-tag `preview`.", + "", + "", + "", + "## Commits", + "", + "- release: v2.7.39-preview.20260724 (8894e40e)", + "", + "**Full Changelog**: https://example/compare/a...b", + ].join("\n"); + const stripped = stripCarriedReleaseNotes(body); + expect(hasMeaningfulCarriedNotes(stripped)).toBe(false); + }); +}); + +describe("joinCarriedPreviewNotes", () => { + test("aggregates multiple incremental preview bodies in order", () => { + const joined = joinCarriedPreviewNotes([ + "## What's Changed\n* fix A", + "", + "## What's Changed\n* fix B", + ]); + expect(joined).toBe("## What's Changed\n* fix A\n\n## What's Changed\n* fix B"); + }); }); describe("assembleReleaseNotes", () => { @@ -109,5 +153,6 @@ describe("assembleReleaseNotes", () => { expect(notes).toContain("## What's Changed\n* feat X"); expect(notes).not.toContain("https://example/compare/a...b"); expect(hasNonWhitespace("")).toBe(false); + expect(stripGenerateNotesCompareLink("x\n**Full Changelog**: y")).toBe("x"); }); }); From fd83ecd542666f3a3b54ccc43b04504e7af9f6d0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:22:44 +0200 Subject: [PATCH 3/4] fix(release): baseline only on newest meaningfully carried preview Do not advance notes_range_start to a later empty or missing preview tag after an earlier preview contributed changelog text. --- .github/workflows/release.yml | 25 ++++++++++---------- scripts/release-notes.ts | 18 +++++++++++++++ tests/ci-workflows.test.ts | 3 +++ tests/release-notes.test.ts | 43 +++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1dfca47a6a..b3cc9675a82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -338,20 +338,19 @@ jobs: # Stable releases after matching previews: aggregate every matching preview # changelog (oldest→newest; each preview body is incremental), then only - # generate-notes / commits for the post-preview delta when the newest preview - # tag is an ancestor of this commit. Preview tags often are not ancestors of - # main, so we never use a divergent preview tag as a two-dot baseline. + # generate-notes / commits for the post-preview delta when the newest + # *successfully carried* preview tag is an ancestor of this commit. Never + # advance the baseline to a later preview that is missing/empty — that would + # drop the gap between the last carried preview and that later tag. notes_range_start="$previous_tag" if [[ "$RELEASE_VERSION" != *-preview.* ]]; then preview_carry_tags="$( git tag --list "v${RELEASE_VERSION}-preview.*" | bun scripts/release-notes.ts matching-preview-tags "$RELEASE_VERSION" )" - newest_preview_tag="" - carried_any=false + newest_carried_preview_tag="" while IFS= read -r preview_carry_tag; do [ -n "$preview_carry_tag" ] || continue - newest_preview_tag="$preview_carry_tag" view_err="$(mktemp)" set +e gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>"$view_err" @@ -364,10 +363,10 @@ jobs: printf '\n\n' >> "$carried_file" fi cat "${carried_file}.one" >> "$carried_file" - carried_any=true + newest_carried_preview_tag="$preview_carry_tag" echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}" else - echo "::notice::Preview release ${preview_carry_tag} has no usable changelog after strip; leaving channel baseline unchanged for this tag" + echo "::notice::Preview release ${preview_carry_tag} has no usable changelog after strip; leaving carried baseline unchanged for this tag" fi elif grep -qiE 'could not find|not found|HTTP[[:space:]]*404|Not Found' "$view_err"; then echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; skipping" @@ -378,12 +377,12 @@ jobs: fi done <<< "$preview_carry_tags" - if [ "$carried_any" = true ] && [ -n "$newest_preview_tag" ]; then - if git merge-base --is-ancestor "$newest_preview_tag" "$GITHUB_SHA"; then - notes_range_start="$newest_preview_tag" - echo "::notice::Using preview tag ${newest_preview_tag} as notes/commits baseline (ancestor of ${GITHUB_SHA})" + if [ -n "$newest_carried_preview_tag" ]; then + if git merge-base --is-ancestor "$newest_carried_preview_tag" "$GITHUB_SHA"; then + notes_range_start="$newest_carried_preview_tag" + echo "::notice::Using preview tag ${newest_carried_preview_tag} as notes/commits baseline (ancestor of ${GITHUB_SHA})" else - echo "::notice::Preview tag ${newest_preview_tag} is not an ancestor of ${GITHUB_SHA}; keeping channel baseline ${previous_tag:-none} for generate-notes/commits" + echo "::notice::Preview tag ${newest_carried_preview_tag} is not an ancestor of ${GITHUB_SHA}; keeping channel baseline ${previous_tag:-none} for generate-notes/commits" fi fi fi diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 4072a69864f..db3de2095f8 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -101,6 +101,24 @@ export function joinCarriedPreviewNotes(parts: string[]): string { .trim(); } +/** + * Newest preview tag whose GitHub Release body strips to a meaningful changelog. + * Missing releases (`releaseBody === null`) and empty/commits-only bodies must not + * advance the baseline — otherwise a later empty preview.2 would hide the + * preview.1→preview.2 gap from both carried notes and the generated delta. + */ +export function selectNewestCarriedPreviewTag( + entries: Array<{ tag: string; releaseBody: string | null }>, +): string | null { + let newest: string | null = null; + for (const entry of entries) { + if (entry.releaseBody === null) continue; + const stripped = stripCarriedReleaseNotes(entry.releaseBody); + if (hasMeaningfulCarriedNotes(stripped)) newest = entry.tag; + } + return newest; +} + export function assembleReleaseNotes(input: { npmMetadata: string; carriedPreviewNotes?: string; diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index a6a9def3d74..de09c429eb0 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -139,6 +139,8 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("git merge-base --is-ancestor"); expect(workflow).toContain("operational error, not a missing release"); expect(workflow).toContain("not an ancestor"); + expect(workflow).toContain("newest_carried_preview_tag"); + expect(workflow).not.toMatch(/newest_preview_tag="\$preview_carry_tag"/); expect(workflow).toContain("--commits"); expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"'); expect(workflow).toContain("Carrying preview release notes from"); @@ -147,6 +149,7 @@ describe("GitHub Actions hardening", () => { expect(releaseNotesHelper).toContain("## Since preview"); expect(releaseNotesHelper).toContain("matchingPreviewTags"); expect(releaseNotesHelper).toContain("hasMeaningfulCarriedNotes"); + expect(releaseNotesHelper).toContain("selectNewestCarriedPreviewTag"); expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); expect(workflow).not.toContain("gh release edit"); expect(workflow).not.toContain("--generate-notes"); diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index cf17a807f1c..21586674d8a 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -6,6 +6,7 @@ import { joinCarriedPreviewNotes, matchingPreviewTag, matchingPreviewTags, + selectNewestCarriedPreviewTag, stripCarriedReleaseNotes, stripGenerateNotesCompareLink, } from "../scripts/release-notes"; @@ -104,6 +105,48 @@ describe("joinCarriedPreviewNotes", () => { }); }); +describe("selectNewestCarriedPreviewTag", () => { + const meaningfulBody = [ + "Published to npm as `@pkg@1.0.0-preview.1` with dist-tag `preview`.", + "", + "## What's Changed", + "* fix from preview.1", + "", + "## Commits", + "- release preview.1", + ].join("\n"); + + const emptyBody = [ + "Published to npm as `@pkg@1.0.0-preview.2` with dist-tag `preview`.", + "", + "", + "", + "## Commits", + "- release preview.2", + "", + "**Full Changelog**: https://example/compare/a...b", + ].join("\n"); + + test("keeps preview.1 as baseline when preview.2 is missing or empty", () => { + expect(selectNewestCarriedPreviewTag([ + { tag: "v1.0.0-preview.1", releaseBody: meaningfulBody }, + { tag: "v1.0.0-preview.2", releaseBody: null }, + ])).toBe("v1.0.0-preview.1"); + + expect(selectNewestCarriedPreviewTag([ + { tag: "v1.0.0-preview.1", releaseBody: meaningfulBody }, + { tag: "v1.0.0-preview.2", releaseBody: emptyBody }, + ])).toBe("v1.0.0-preview.1"); + }); + + test("advances to preview.2 only when its body is meaningful", () => { + expect(selectNewestCarriedPreviewTag([ + { tag: "v1.0.0-preview.1", releaseBody: meaningfulBody }, + { tag: "v1.0.0-preview.2", releaseBody: "## What's Changed\n* fix from preview.2" }, + ])).toBe("v1.0.0-preview.2"); + }); +}); + describe("assembleReleaseNotes", () => { test("copies preview notes and appends only the since-preview delta", () => { const notes = assembleReleaseNotes({ From 11b2933c52f2bb40baa01a396ca23c53ee303750 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:31:41 +0200 Subject: [PATCH 4/4] fix(release): classify preview lookup by HTTP status, not stderr Fail closed on auth/permission 404s, wire join-carried into the workflow, and tighten CI contract tests. --- .github/workflows/release.yml | 32 ++++++++++++++++++++----------- scripts/release-notes.ts | 32 +++++++++++++++++++++++++++++++ tests/ci-workflows.test.ts | 36 +++++++++++++++++++++++++---------- tests/release-notes.test.ts | 10 ++++++++++ 4 files changed, 89 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3cc9675a82..edeaaba4357 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -349,26 +349,32 @@ jobs: bun scripts/release-notes.ts matching-preview-tags "$RELEASE_VERSION" )" newest_carried_preview_tag="" + carried_part_files=() + # Probe repo readability once so a token/permission 404 cannot be + # mistaken for "this preview tag has no release". + gh api "repos/${GITHUB_REPOSITORY}" --jq '.full_name' > /dev/null + view_err="$(mktemp)" while IFS= read -r preview_carry_tag; do [ -n "$preview_carry_tag" ] || continue - view_err="$(mktemp)" + : > "$view_err" set +e - gh release view "$preview_carry_tag" --json body --jq .body > "${carried_file}.raw" 2>"$view_err" + # Prefer HTTP status over stderr prose: auth failures also say "Not Found". + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${preview_carry_tag}" \ + --jq '.body' > "${carried_file}.raw" 2>"$view_err" view_status=$? set -e if [ "$view_status" -eq 0 ]; then bun scripts/release-notes.ts strip-carried "${carried_file}.raw" > "${carried_file}.one" if bun scripts/release-notes.ts has-meaningful "${carried_file}.one"; then - if [ -s "$carried_file" ]; then - printf '\n\n' >> "$carried_file" - fi - cat "${carried_file}.one" >> "$carried_file" + part_file="${carried_file}.part.${#carried_part_files[@]}" + cp "${carried_file}.one" "$part_file" + carried_part_files+=("$part_file") newest_carried_preview_tag="$preview_carry_tag" echo "::notice::Carrying preview release notes from ${preview_carry_tag} into ${release_tag}" else echo "::notice::Preview release ${preview_carry_tag} has no usable changelog after strip; leaving carried baseline unchanged for this tag" fi - elif grep -qiE 'could not find|not found|HTTP[[:space:]]*404|Not Found' "$view_err"; then + elif grep -qE 'HTTP 404' "$view_err"; then echo "::notice::Matching preview tag ${preview_carry_tag} has no GitHub Release; skipping" else echo "::error::Failed to look up GitHub Release for ${preview_carry_tag} (operational error, not a missing release)" @@ -377,6 +383,10 @@ jobs: fi done <<< "$preview_carry_tags" + if [ "${#carried_part_files[@]}" -gt 0 ]; then + bun scripts/release-notes.ts join-carried --out "$carried_file" "${carried_part_files[@]}" + fi + if [ -n "$newest_carried_preview_tag" ]; then if git merge-base --is-ancestor "$newest_carried_preview_tag" "$GITHUB_SHA"; then notes_range_start="$newest_carried_preview_tag" @@ -407,15 +417,15 @@ jobs: if [ -n "$notes_range_start" ]; then commit_range="${notes_range_start}..${GITHUB_SHA}" git log --pretty=format:'- %s (%h)' "$commit_range" > "$commits_file" - # git log may omit a trailing newline; normalize empty output for assemble. - if [ ! -s "$commits_file" ]; then - : > "$commits_file" - fi else # First release on a channel has no previous tag; cap history so the notes # body stays within GitHub release size limits. git log --pretty=format:'- %s (%h)' --max-count=100 "$GITHUB_SHA" > "$commits_file" fi + # `--pretty=format:` omits the trailing newline; normalize for downstream readers. + if [ -s "$commits_file" ]; then + printf '\n' >> "$commits_file" + fi assemble_args=( bun scripts/release-notes.ts assemble diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index db3de2095f8..f4e40d8765b 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -181,6 +181,37 @@ async function main(argv: string[]): Promise { process.exit(hasMeaningfulCarriedNotes(stripped) ? 0 : 1); } + if (cmd === "join-carried") { + let out: string | undefined; + const files: string[] = []; + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]; + if (arg === "--out") { + const value = rest[i + 1]; + if (!value || value.startsWith("--")) { + console.error("Missing value for --out"); + process.exit(1); + } + out = value; + i += 1; + continue; + } + if (arg?.startsWith("--")) { + console.error(`Unknown flag: ${arg}`); + process.exit(1); + } + if (arg) files.push(arg); + } + if (!out || files.length === 0) { + console.error("Usage: bun scripts/release-notes.ts join-carried --out ..."); + process.exit(1); + } + const parts = await Promise.all(files.map(path => Bun.file(path).text())); + const joined = joinCarriedPreviewNotes(parts); + await Bun.write(out, joined ? joined + "\n" : ""); + return; + } + if (cmd === "matching-preview-tag" || cmd === "matching-preview-tags") { const version = rest[0]; if (!version) { @@ -244,6 +275,7 @@ async function main(argv: string[]): Promise { Usage: bun scripts/release-notes.ts strip-carried [body-file] bun scripts/release-notes.ts has-meaningful [body-file] + bun scripts/release-notes.ts join-carried --out ... bun scripts/release-notes.ts matching-preview-tag # tags on stdin bun scripts/release-notes.ts matching-preview-tags # tags on stdin, oldest→newest bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index de09c429eb0..8a89b6ef6b5 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -136,6 +136,9 @@ describe("GitHub Actions hardening", () => { 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 has-meaningful"); + expect(workflow).toContain("bun scripts/release-notes.ts join-carried"); + expect(workflow).toContain("releases/tags/"); + expect(workflow).toContain('gh api "repos/${GITHUB_REPOSITORY}" --jq \'.full_name\''); expect(workflow).toContain("git merge-base --is-ancestor"); expect(workflow).toContain("operational error, not a missing release"); expect(workflow).toContain("not an ancestor"); @@ -144,28 +147,41 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("--commits"); expect(workflow).toContain('git tag --list "v${RELEASE_VERSION}-preview.*"'); expect(workflow).toContain("Carrying preview release notes from"); + // Every subcommand the workflow invokes must be dispatched by the CLI. const releaseNotesHelper = await readText("scripts/release-notes.ts"); - expect(releaseNotesHelper).toContain("## Commits"); - expect(releaseNotesHelper).toContain("## Since preview"); - expect(releaseNotesHelper).toContain("matchingPreviewTags"); - expect(releaseNotesHelper).toContain("hasMeaningfulCarriedNotes"); - expect(releaseNotesHelper).toContain("selectNewestCarriedPreviewTag"); + const invoked = [...workflow.matchAll(/bun scripts\/release-notes\.ts ([a-z-]+)/g)] + .map(m => m[1]!); + expect(invoked.length).toBeGreaterThan(0); + for (const cmd of new Set(invoked)) { + expect(releaseNotesHelper).toContain(`"${cmd}"`); + } expect(workflow).toMatch(/gh release create[\s\S]*?--notes-file "\$notes_file"/); expect(workflow).not.toContain("gh release edit"); expect(workflow).not.toContain("--generate-notes"); // Notes must be assembled before tagging so a notes API failure does not leave // a remote tag that blocks release retries at preflight. const createStep = workflow.split("- name: Create GitHub release")[1]!.split(/\n {6}- name:/)[0]!; - // Fail closed when generate-notes fails (no soft skip on the API call itself). - expect(createStep).not.toMatch(/pr_notes="\$\(gh api[\s\S]*?\|\| true\)"/); + // Preview carry lookup must use tag-specific API status, not `gh release view` stderr prose. + expect(createStep).toContain("releases/tags/"); + expect(createStep).not.toContain("gh release view"); + // Fail closed: no soft-skip in any spelling around gh api calls in this step. + for (const line of createStep.split("\n").filter(l => l.includes("gh api"))) { + expect(line).not.toMatch(/\|\|\s*(true|echo|:)/); + } + expect(createStep).not.toContain("set +e\n pr_notes"); expect(createStep.indexOf("gh api")).toBeGreaterThan(-1); expect(createStep.indexOf('git tag "$release_tag"')).toBeGreaterThan(-1); expect(createStep.indexOf("gh api")).toBeLessThan(createStep.indexOf('git tag "$release_tag"')); // First-channel releases must not call generate-notes without an explicit baseline // (GitHub would otherwise pick the newest repo tag, possibly from the other channel). - expect(createStep).toMatch( - /if \[ -n "\$notes_range_start" \]; then[\s\S]*previous_tag_name=\$\{notes_range_start\}[\s\S]*else[\s\S]*skipping generate-notes/, - ); + // Scope to the single if-block that owns generate-notes; createStep has two + // `[ -n "$notes_range_start" ]` blocks, so an unanchored [\s\S]* can straddle them. + const notesBlock = createStep + .split(/if \[ -n "\$notes_range_start" \]; then/)[1]! + .split(/\n {10}if \[/)[0]!; + expect(notesBlock).toContain("previous_tag_name=${notes_range_start}"); + expect(notesBlock).toContain("skipping generate-notes"); + expect(notesBlock).toMatch(/\n {10}else\n/); }); test("docs deployment is pinned, bounded, and scoped to Pages", async () => { diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index 21586674d8a..ef732918f14 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -40,6 +40,16 @@ describe("matchingPreviewTags", () => { "v2.7.39-preview.20260725", ]); }); + + test("orders same-day previews numerically, not lexicographically", () => { + expect(matchingPreviewTags("2.7.39", [ + "v2.7.39-preview.20260725.10", + "v2.7.39-preview.20260725.2", + ])).toEqual([ + "v2.7.39-preview.20260725.2", + "v2.7.39-preview.20260725.10", + ]); + }); }); describe("stripCarriedReleaseNotes", () => {