diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89a1d66043a..edeaaba4357 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,121 @@ 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 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 + # *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_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" + set +e + # 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 + 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 -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)" + cat "$view_err" >&2 || true + exit 1 + 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" + echo "::notice::Using preview tag ${newest_carried_preview_tag} as notes/commits baseline (ancestor of ${GITHUB_SHA})" + else + 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 + + 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" 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 + # `--pretty=format:` omits the trailing newline; normalize for downstream readers. + if [ -s "$commits_file" ]; then + printf '\n' >> "$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..f4e40d8765b --- /dev/null +++ b/scripts/release-notes.ts @@ -0,0 +1,287 @@ +#!/usr/bin/env bun +/** + * Pure helpers for GitHub release note assembly. + * Used by `.github/workflows/release.yml` so stable (latest) releases can carry + * 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 { + 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)); + 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. */ +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); +} + +/** + * 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(); +} + +/** + * 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; + 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 === "has-meaningful") { + const stripped = (await readStdinOrFile(rest[0])).trim(); + 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) { + console.error(`Usage: bun scripts/release-notes.ts ${cmd} `); + process.exit(1); + } + const tagsText = await new Response(Bun.stdin).text(); + 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; + } + + 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 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 ...`); + 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..8a89b6ef6b5 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -126,29 +126,62 @@ 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("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"); + 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"); + // Every subcommand the workflow invokes must be dispatched by the CLI. + const releaseNotesHelper = await readText("scripts/release-notes.ts"); + 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"); - // 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]!; + // 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 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/, - ); + // 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 new file mode 100644 index 00000000000..ef732918f14 --- /dev/null +++ b/tests/release-notes.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, test } from "bun:test"; +import { + assembleReleaseNotes, + hasMeaningfulCarriedNotes, + hasNonWhitespace, + joinCarriedPreviewNotes, + matchingPreviewTag, + matchingPreviewTags, + selectNewestCarriedPreviewTag, + 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("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", + ]); + }); + + 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", () => { + 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")); + }); + + 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("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({ + 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); + expect(stripGenerateNotesCompareLink("x\n**Full Changelog**: y")).toBe("x"); + }); +});