diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0bc641bc88..3037b2f0e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -330,10 +330,8 @@ jobs: notes_file="$(mktemp)" 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 @@ -410,7 +408,7 @@ jobs: 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)" + echo "::notice::No previous channel tag; skipping generate-notes (minimal notes)" fi # Rewrite takeover credits on both carried preview notes and the since-preview @@ -429,36 +427,22 @@ jobs: --out "$delta_file" fi - 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. - 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 + render_args=( + bun scripts/release-notes.ts render --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") + render_args+=(--compare-from "$previous_tag") elif [ -n "$notes_range_start" ]; then - assemble_args+=(--compare-from "$notes_range_start") + render_args+=(--compare-from "$notes_range_start") fi - "${assemble_args[@]}" + "${render_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 index 3b4357b42a..24d24a3d5f 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -11,7 +11,8 @@ * bun scripts/release-notes.ts previous-release-tag * bun scripts/release-notes.ts has-meaningful [body-file] * bun scripts/release-notes.ts credit-takeovers --repo --in --out - * bun scripts/release-notes.ts assemble --npm-metadata ... --out ... + * bun scripts/release-notes.ts render --npm-metadata ... --out ... [--carried ...] [--delta ...] [--compare-from ...] [--compare-to ...] [--repository ...] + * bun scripts/release-notes.ts polish --in --out [--model ...] [--base-url ...] */ type ParsedReleaseTag = { @@ -148,22 +149,13 @@ export function stripCarriedReleaseNotes(body: string): string { 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) + const withoutComment = body + .replace(/\r\n/g, "\n") .split("\n") .filter(line => !/^$/.test(line.trim())) + .filter(line => !/^\*\*Full Changelog\*\*:/.test(line)) .join("\n"); return !hasNonWhitespace(withoutComment); } @@ -222,7 +214,7 @@ export function parseTakeoverSourcePr(title: string, body = ""): number | null { } const GENERATE_NOTES_PR_LINE = - /^(?\* .+? by @)(?[A-Za-z0-9-]+)(? in https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/)(?\d+)(?\s*)$/; + /^(?\* .+? by @)(?[A-Za-z0-9-]+(?:\[bot\])?)(? in https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/)(?\d+)(?\s*)$/; export type TakeoverCreditLookup = { title: string; @@ -287,48 +279,409 @@ export async function rewriteTakeoverCredits( return out.join("\n"); } -export function assembleReleaseNotes(input: { +export type ReleaseNotePr = { + number: number; + title: string; + author: string; +}; + +export type ReleaseNoteCategory = { + title: string; + prs: ReleaseNotePr[]; +}; + +/** + * Parse GitHub generate-notes output (`* by @<author> in …/pull/<N>`, + * including maintainer-takeover lines rewritten by `credit-takeovers`) into + * category sections. Also understands the renderer's own output (`## <Category>` + * sections with `- … (#N)` bullets and a `## Changelog` list of + * `- #N <title> @author` lines), so already-rendered preview bodies carry into + * stable notes losslessly. Scaffolding (`## What's Changed`, `## New + * Contributors`, `## Commits`) never reaches the renderer. Changelog lines + * supply the authoritative title/author for PRs first seen in bullets. + */ +const GENERATED_PR_LINE = + /^\*\s*(?<title>.+?)\s+by\s+@(?<author>[A-Za-z0-9-]+(?:\[bot\])?)(?:\s+\(takeover\s+by\s+@[A-Za-z0-9-]+(?:\[bot\])?\))?\s+in\s+https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(?<pr>\d+)\s*$/; +const GENERATED_BULLET_LINE = + /^-\s+(?<text>.+?)\s+\((?<refs>#\d+(?:\s*,\s*#\d+)*)\)\s*$/; +const CHANGELOG_PR_LINE = + /^-\s+#(?<pr>\d+)\s+(?<title>.+?)\s+@(?<author>[A-Za-z0-9-]+(?:\[bot\])?)\s*$/; +const SCAFFOLD_HEADINGS = new Set(["What's Changed", "New Contributors", "Commits", "Changelog", "Since preview"]); + +export function parseGeneratedNotes(body: string): ReleaseNoteCategory[] { + const sections: ReleaseNoteCategory[] = []; + const globalPrs = new Map<number, ReleaseNotePr>(); + let current: ReleaseNoteCategory | null = null; + for (const rawLine of body.replace(/\r\n/g, "\n").split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("<!--")) continue; + if (line.startsWith("### ")) { + const title = line.slice(4).trim(); + current = { title, prs: [] }; + sections.push(current); + continue; + } + if (line.startsWith("## ")) { + const title = line.slice(3).trim(); + if (SCAFFOLD_HEADINGS.has(title)) { + current = null; + } else { + current = { title, prs: [] }; + sections.push(current); + } + continue; + } + const changelogLine = CHANGELOG_PR_LINE.exec(line); + if (changelogLine?.groups) { + globalPrs.set(Number(changelogLine.groups.pr), { + number: Number(changelogLine.groups.pr), + title: changelogLine.groups.title!, + author: changelogLine.groups.author!, + }); + continue; + } + if (!current) continue; + const match = GENERATED_PR_LINE.exec(line); + if (match?.groups) { + current.prs.push({ + number: Number(match.groups.pr), + title: match.groups.title!, + author: match.groups.author!, + }); + continue; + } + const bullet = GENERATED_BULLET_LINE.exec(line); + if (bullet?.groups) { + const text = bullet.groups.text!; + for (const ref of bullet.groups.refs!.matchAll(/#(\d+)/g)) { + current.prs.push({ number: Number(ref[1]), title: text, author: "" }); + } + } + } + for (const section of sections) { + section.prs = section.prs.map(pr => globalPrs.get(pr.number) ?? pr); + } + return sections; +} + +/** + * Strip a conventional-commit prefix (`feat(scope): …`, `fix: …`, …) and a + * trailing `(#N)` that repeats the PR's own number, then sentence-case the + * remaining title for the curated section bullets. + */ +const CONVENTIONAL_COMMIT_PREFIX = + /^(?:feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert|merge|release)(?:\(([^)]+)\))?:\s*(.+)$/i; + +export function cleanPrTitle(title: string, prNumber: number | null = null): { scope: string | null; text: string } { + let text = title.trim(); + let scope: string | null = null; + const prefix = CONVENTIONAL_COMMIT_PREFIX.exec(text); + if (prefix) { + scope = prefix[1] ?? null; + text = prefix[2]!.trim(); + } + if (prNumber !== null) { + const selfRef = `(#${prNumber})`; + const trimmed = text.trimEnd(); + if (trimmed.endsWith(selfRef)) { + text = trimmed.slice(0, -selfRef.length); + } + } + text = text.trim(); + if (text.length > 0) { + text = text[0]!.toUpperCase() + text.slice(1); + } + return { scope, text }; +} + +/** "release-notes" → "Release-Notes" for group-bullet scope labels. */ +export function scopeLabel(scope: string): string { + return scope + .split("-") + .map(part => (part.length > 0 ? part[0]!.toUpperCase() + part.slice(1) : part)) + .join("-"); +} + +/** Group PRs by conventional-commit scope, preserving first-appearance order. */ +export function groupPrsByScope(prs: ReleaseNotePr[]): Array<{ scope: string | null; prs: ReleaseNotePr[] }> { + const groups: Array<{ scope: string | null; prs: ReleaseNotePr[] }> = []; + for (const pr of prs) { + const { scope } = cleanPrTitle(pr.title, pr.number); + const group = groups.find(candidate => candidate.scope === scope); + if (group) { + group.prs.push(pr); + } else { + groups.push({ scope, prs: [pr] }); + } + } + return groups; +} + +const RENDER_CATEGORY_ORDER = ["New Features", "Bug Fixes", "Documentation", "Chores", "Other Changes"]; + +/** + * Render OpenAI-Codex-style release notes from the generate-notes pieces: + * H2 category sections with scope-grouped, prefix-free summary bullets, then a + * `## Changelog` section with every PR (`- #N <title> @author`) and the compare + * link. Carried preview notes and the since-preview delta merge by category; + * duplicate PR numbers (defensive; ranges are normally disjoint) keep the + * first occurrence. + */ +export function renderReleaseNotes(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 categories = new Map<string, ReleaseNotePr[]>(); + const order: string[] = []; + const claimed = new Set<number>(); + const add = (body: string): void => { + for (const section of parseGeneratedNotes(body)) { + const existing = categories.get(section.title); + if (!existing) { + categories.set(section.title, []); + order.push(section.title); + } + for (const pr of section.prs) { + if (claimed.has(pr.number)) continue; + claimed.add(pr.number); + categories.get(section.title)!.push(pr); + } } - } + }; + add(input.carriedPreviewNotes ?? ""); + add(input.deltaPrNotes ?? ""); - const commits = (input.commits ?? "").trim(); - if (commits) { - parts.push("## Commits\n\n" + commits); + const parts: string[] = []; + const npmMetadata = input.npmMetadata.trim(); + if (npmMetadata) parts.push(npmMetadata); + + const sortedOrder = [...order].sort((a, b) => { + const ia = RENDER_CATEGORY_ORDER.indexOf(a); + const ib = RENDER_CATEGORY_ORDER.indexOf(b); + const rankA = ia === -1 ? RENDER_CATEGORY_ORDER.length : ia; + const rankB = ib === -1 ? RENDER_CATEGORY_ORDER.length : ib; + if (rankA !== rankB) return rankA - rankB; + return order.indexOf(a) - order.indexOf(b); + }); + + for (const title of sortedOrder) { + const prs = categories.get(title)!; + if (prs.length === 0) continue; + const lines: string[] = [`## ${title}`, ""]; + for (const group of groupPrsByScope(prs)) { + if (group.prs.length === 1) { + const pr = group.prs[0]!; + lines.push(`- ${cleanPrTitle(pr.title, pr.number).text} (#${pr.number})`); + } else { + const label = group.scope ? scopeLabel(group.scope) : null; + const texts = group.prs.map(pr => cleanPrTitle(pr.title, pr.number).text); + const refs = group.prs.map(pr => `#${pr.number}`).join(", "); + lines.push(`- ${label ? `${label}: ` : ""}${texts.join("; ")} (${refs})`); + } + } + parts.push(lines.join("\n")); } + const allPrs = [...categories.values()].flat().sort((a, b) => a.number - b.number); 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}`); + const hasCompare = Boolean(from && to && repo); + if (allPrs.length > 0 || hasCompare) { + const changelog: string[] = ["## Changelog", ""]; + if (hasCompare) { + changelog.push(`Full Changelog: https://github.com/${repo}/compare/${from}...${to}`, ""); + } + for (const pr of allPrs) { + changelog.push(`- #${pr.number} ${pr.title.trim()} @${pr.author}`); + } + parts.push(changelog.join("\n")); } + if (parts.length === 0) return ""; return parts.join("\n\n").replace(/\n+$/, "") + "\n"; } +/** Every `#N` reference in a text, deduplicated and ascending. */ +export function extractPrNumbers(text: string): number[] { + const numbers = new Set<number>(); + for (const match of text.matchAll(/#(\d+)/g)) { + numbers.add(Number(match[1])); + } + return [...numbers].sort((a, b) => a - b); +} + +/** + * The leading `#N` identifier of every `- #N <title> @author` changelog entry. + * Used as the polish validation baseline so incidental PR references inside + * titles (e.g. a takeover line mentioning `#424`) never become mandatory. + */ +export function extractChangelogPrNumbers(changelog: string): number[] { + const numbers = new Set<number>(); + for (const rawLine of changelog.replace(/\r\n/g, "\n").split("\n")) { + const match = /^-\s+#(\d+)\s+/.exec(rawLine.trim()); + if (match) numbers.add(Number(match[1])); + } + return [...numbers].sort((a, b) => a - b); +} + +/** Occurrence counts of every `#N` reference in a text. */ +function countPrNumbers(text: string): Map<number, number> { + const counts = new Map<number, number>(); + for (const match of text.matchAll(/#(\d+)/g)) { + const number = Number(match[1]); + counts.set(number, (counts.get(number) ?? 0) + 1); + } + return counts; +} + +/** H2 headings in a section, excluding the machine-rendered Changelog. */ +export function parseSectionHeadings(text: string): string[] { + return text + .replace(/\r\n/g, "\n") + .split("\n") + .map(line => /^##\s+(.+)$/.exec(line.trim())?.[1]) + .filter((title): title is string => typeof title === "string" && title !== "Changelog"); +} + +/** + * Guard rails for the optional LLM polish step: the rewritten head must keep + * the exact PR set and the exact category headings. Any missing/invented PR or + * category is a hard failure so a summarizer can never silently corrupt notes. + * `allowedExtraPrs` tolerates incidental references the original head carried + * inside titles (they may legitimately be dropped or kept by the rewrite). + */ +export function validatePolishedSections( + head: string, + expectedPrs: number[], + expectedHeadings: string[], + allowedExtraPrs: number[] = [], +): string[] { + const errors: string[] = []; + const actualPrs = extractPrNumbers(head); + const missing = expectedPrs.filter(number => !actualPrs.includes(number)); + const unexpected = actualPrs.filter( + number => !expectedPrs.includes(number) && !allowedExtraPrs.includes(number), + ); + if (missing.length > 0) errors.push(`missing PR references: #${missing.join(", #")}`); + if (unexpected.length > 0) errors.push(`unexpected PR references: #${unexpected.join(", #")}`); + + const counts = countPrNumbers(head); + const repeated = expectedPrs.filter(number => (counts.get(number) ?? 0) > 1); + if (repeated.length > 0) errors.push(`repeated PR references: #${repeated.join(", #")}`); + + const headings = parseSectionHeadings(head); + const missingHeadings = expectedHeadings.filter(title => !headings.includes(title)); + const extraHeadings = headings.filter(title => !expectedHeadings.includes(title)); + if (missingHeadings.length > 0) errors.push(`missing headings: ${missingHeadings.join(", ")}`); + if (extraHeadings.length > 0) errors.push(`unexpected headings: ${extraHeadings.join(", ")}`); + return errors; +} + +const POLISH_SYSTEM_PROMPT = `You are the release notes editor for opencodex, a universal provider proxy for OpenAI Codex and Claude Code. +Rewrite the release-notes sections below (everything before "## Changelog") in the style of OpenAI Codex release notes: + +- Keep the exact same markdown headings and their order. +- Group related pull requests into single bullets: one human-readable sentence (or two) summarizing what changed, ending with the full PR reference list in parentheses, e.g. "- Honor configured proxies across authentication, plugin downloads, and redirects. (#123, #456)". +- Every PR number must appear exactly once across the bullets; never invent PR numbers or features. +- Do not add or remove categories. Omit a category only when it has no PRs. +- The npm metadata line is handled outside this rewrite; do not include it. +- Do not output the "## Changelog" section. +Output only the rewritten markdown.`; + +const POLISH_REQUEST_TIMEOUT_MS = 120_000; + +async function callChatCompletion(apiKey: string, baseUrl: string, model: string, head: string): Promise<string> { + let response: Response; + try { + response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + signal: AbortSignal.timeout(POLISH_REQUEST_TIMEOUT_MS), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: POLISH_SYSTEM_PROMPT }, + { role: "user", content: head }, + ], + }), + }); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.error(`✗ polish LLM request did not complete: ${reason}`); + process.exit(1); + } + if (!response.ok) { + const detail = await response.text(); + console.error(`✗ polish LLM request failed (HTTP ${response.status}): ${detail.slice(0, 500)}`); + process.exit(1); + } + let data: { choices?: Array<{ message?: { content?: unknown } }> }; + try { + data = (await response.json()) as { choices?: Array<{ message?: { content?: unknown } }> }; + } catch { + console.error("✗ polish LLM returned a non-JSON response body"); + process.exit(1); + } + const content = data.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content.trim()) { + console.error("✗ polish LLM returned no content"); + process.exit(1); + } + return content; +} + +/** + * Split a rendered body into the npm metadata line (held out of the model + * rewrite and re-attached deterministically), the category head, and the + * machine-rendered changelog tail. + */ +export function splitPolishInput(body: string): { metadata: string; head: string; changelog: string } { + const lines = body.replace(/\r\n/g, "\n").split("\n"); + const index = lines.findIndex(line => /^##\s+Changelog\s*$/.test(line.trim())); + if (index === -1) { + console.error("✗ polish input has no `## Changelog` section to validate against"); + process.exit(1); + } + const headLines = lines.slice(0, index); + const firstContent = headLines.findIndex(line => line.trim().length > 0); + const isMetadata = firstContent !== -1 && /^Published to npm as /.test(headLines[firstContent]!); + return { + metadata: isMetadata ? headLines[firstContent]!.trim() : "", + head: (isMetadata ? headLines.slice(firstContent + 1) : headLines).join("\n").trim(), + changelog: lines.slice(index).join("\n").trim(), + }; +} + +/** + * The polish API key must never travel in plaintext: https is always allowed, + * plain http only for loopback hosts (IPv4, IPv6 bracket form, `localhost`, + * and `.localhost` names). + */ +export function isPolishBaseUrlAllowed(baseUrl: string): boolean { + try { + const parsed = new URL(baseUrl); + const hostname = parsed.hostname.replace(/^\[|\]$/g, ""); + return ( + parsed.protocol === "https:" || + (parsed.protocol === "http:" && + (hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname.endsWith(".localhost"))) + ); + } catch { + return false; + } +} + async function readStdinOrFile(path: string | undefined): Promise<string> { if (path && path !== "-") { return await Bun.file(path).text(); @@ -336,6 +689,30 @@ async function readStdinOrFile(path: string | undefined): Promise<string> { return await new Response(Bun.stdin).text(); } +function parseFlagArgs(rest: string[], known?: readonly string[]): Map<string, string> { + const args = new Map<string, string>(); + for (let i = 0; i < rest.length; i += 1) { + const key = rest[i]; + if (!key?.startsWith("--")) { + console.error(`Unexpected argument: ${key}`); + process.exit(1); + } + const name = key.slice(2); + if (known && !known.includes(name)) { + console.error(`Unknown flag: ${key}`); + process.exit(1); + } + const value = rest[i + 1]; + if (!value || value.startsWith("--")) { + console.error(`Missing value for ${key}`); + process.exit(1); + } + args.set(name, value); + i += 1; + } + return args; +} + async function main(argv: string[]): Promise<void> { const [cmd, ...rest] = argv; if (cmd === "strip-carried") { @@ -405,18 +782,7 @@ async function main(argv: string[]): Promise<void> { } if (cmd === "credit-takeovers") { - const args = new Map<string, string>(); - 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 args = parseFlagArgs(rest, ["repo", "in", "out"]); const repo = args.get("repo"); const inputPath = args.get("in"); const outPath = args.get("out"); @@ -508,38 +874,33 @@ async function main(argv: string[]): Promise<void> { return; } - if (cmd === "assemble") { - const args = new Map<string, string>(); - 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; - } - + if (cmd === "render") { + const args = parseFlagArgs(rest, [ + "npm-metadata", + "out", + "carried", + "delta", + "compare-from", + "compare-to", + "repository", + ]); 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 <text> --out <file> [--carried <file>] [--delta <file>] [--commits <file>] [--compare-from <tag>] [--compare-to <tag>] [--repository <owner/name>]"); + console.error("Usage: bun scripts/release-notes.ts render --npm-metadata <text> --out <file> [--carried <file>] [--delta <file>] [--compare-from <tag>] [--compare-to <tag>] [--repository <owner/name>]"); process.exit(1); } - const readOptional = async (name: string): Promise<string> => { const path = args.get(name); if (!path) return ""; + if (!(await Bun.file(path).exists())) return ""; return await Bun.file(path).text(); }; - const notes = assembleReleaseNotes({ + const notes = renderReleaseNotes({ 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"), @@ -548,6 +909,57 @@ async function main(argv: string[]): Promise<void> { return; } + if (cmd === "polish") { + const args = parseFlagArgs(rest, ["in", "out", "model", "base-url"]); + const inputPath = args.get("in"); + const outPath = args.get("out"); + if (!inputPath || !outPath) { + console.error("Usage: bun scripts/release-notes.ts polish --in <file> --out <file> [--model <model>] [--base-url <url>]"); + process.exit(1); + } + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + console.error("✗ polish needs an OpenAI-compatible API key: set OPENAI_API_KEY"); + process.exit(1); + } + const baseUrl = (args.get("base-url") ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(/\/+$/, ""); + if (!isPolishBaseUrlAllowed(baseUrl)) { + console.error("✗ polish --base-url must be https: or a loopback http: host (the API key must not travel in plaintext)"); + process.exit(1); + } + const model = args.get("model") ?? process.env.OPENAI_MODEL ?? "gpt-5.4"; + + if (!(await Bun.file(inputPath).exists())) { + console.error(`✗ polish input not found: ${inputPath}`); + process.exit(1); + } + const body = await Bun.file(inputPath).text(); + const { metadata, head, changelog } = splitPolishInput(body); + if (!metadata) { + console.error("✗ polish input has no recognizable npm metadata line; refusing to send it to the model"); + process.exit(1); + } + const expectedPrs = extractChangelogPrNumbers(changelog); + const allowedExtraPrs = extractPrNumbers(changelog).filter(number => !expectedPrs.includes(number)); + const expectedHeadings = parseSectionHeadings(head); + if (expectedPrs.length === 0) { + console.error("✗ polish input Changelog contains no PR references"); + process.exit(1); + } + + const rewritten = await callChatCompletion(apiKey, baseUrl, model, head); + const errors = validatePolishedSections(rewritten, expectedPrs, expectedHeadings, allowedExtraPrs); + if (errors.length > 0) { + console.error("✗ polished notes failed validation:"); + for (const error of errors) console.error(` - ${error}`); + process.exit(1); + } + const sections = [metadata, rewritten.trimEnd(), changelog].filter(part => part.length > 0); + const out = sections.join("\n\n"); + await Bun.write(outPath, out.endsWith("\n") ? out : out + "\n"); + return; + } + console.error(`Unknown command: ${cmd ?? "(none)"} Usage: bun scripts/release-notes.ts strip-carried [body-file] @@ -557,7 +969,8 @@ Usage: 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 credit-takeovers --repo <owner/name> --in <file> --out <file> - bun scripts/release-notes.ts assemble --npm-metadata ... --out ...`); + bun scripts/release-notes.ts render --npm-metadata ... --out ... [--carried ...] [--delta ...] [--compare-from ...] [--compare-to ...] [--repository ...] + bun scripts/release-notes.ts polish --in <file> --out <file> [--model ...] [--base-url ...]`); process.exit(1); } diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index b357bd0ef6..fed4d9960b 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -143,6 +143,32 @@ typecheck and GUI build, and `scripts/release.ts` now runs local typecheck, `bun `bun run privacy:scan` before the version bump, commit/push, Cross-platform CI wait, and GitHub Release workflow dispatch. Docs publishing is separate from npm release publishing. +### Release notes + +Release notes are rendered OpenAI-Codex-style by `scripts/release-notes.ts render` inside +`.github/workflows/release.yml`: `## New Features` / `## Bug Fixes` / `## Documentation` / +`## Chores` / `## Other Changes` sections with prefix-free, scope-grouped summary bullets +(`- Providers: Add X; Add Y (#1, #2)`), followed by a `## Changelog` section listing every PR +as `- #N <title> @author`; when a comparison baseline exists, that section also includes a +compare link. Carried preview changelogs and the since-preview delta feed the same renderer, +so stable notes are the aggregate of their preview train. The raw commit dump is +intentionally gone — non-PR commits stay reachable via the Full Changelog compare link when +that link is available. + +The deterministic renderer produces the structure but not curated prose. Maintainers who want +the OpenAI-style grouped summaries can run the optional local polish step against the rendered +body (needs an OpenAI-compatible API key): + +```bash +bun scripts/release-notes.ts render ... --out notes.md +bun scripts/release-notes.ts polish --in notes.md --out notes.md +``` + +`polish` rewrites only the category sections, keeps the machine-rendered Changelog verbatim, +and fails closed when the rewrite drops, invents, or re-heads any PR reference. It is never +called from CI — there is no LLM credential on the runner — so the workflow ships the +deterministic body whenever the maintainer skips it. + ## Release metadata invariants Every npm release version must map cleanly across four surfaces: diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 92c7d4c219..d808000520 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -513,16 +513,19 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("main releases must use a stable semver version"); expect(workflow).toContain("preview releases must use a preview prerelease version"); - // 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. Stable releases also carry matching preview notes. + // Release notes must be OpenAI-Codex-style: PR categories with grouped summary + // bullets plus a full PR changelog (no raw commit dump). Preflight forbids an + // existing release, so only create (not edit) is wired. Stable releases also + // carry matching preview notes. expect(workflow).toContain("releases/generate-notes"); - expect(workflow).toContain("git log --pretty=format:'- %s (%h)'"); - expect(workflow).toContain('commit_range="${notes_range_start}..${GITHUB_SHA}"'); + expect(workflow).not.toContain("git log --pretty=format"); expect(workflow).toContain('previous_tag_name=${notes_range_start}'); - expect(workflow).toContain("skipping generate-notes (commits-only notes)"); + expect(workflow).toContain("skipping generate-notes (minimal 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 render"); + expect(workflow).not.toContain("bun scripts/release-notes.ts assemble"); + expect(workflow).not.toContain("--commits"); + expect(workflow).not.toContain("commits_file"); 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"); @@ -542,7 +545,8 @@ describe("GitHub Actions hardening", () => { 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('--carried "$carried_file"'); + expect(workflow).toContain('--delta "$delta_file"'); 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. diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index e9f69c83a4..ed853635e9 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -1,17 +1,23 @@ import { describe, expect, test } from "bun:test"; import { - assembleReleaseNotes, + cleanPrTitle, + extractChangelogPrNumbers, + extractPrNumbers, hasMeaningfulCarriedNotes, - hasNonWhitespace, + isPolishBaseUrlAllowed, joinCarriedPreviewNotes, matchingPreviewTag, matchingPreviewTags, + parseGeneratedNotes, + parseSectionHeadings, parseTakeoverSourcePr, previousReleaseNotesTag, + renderReleaseNotes, rewriteTakeoverCredits, selectNewestCarriedPreviewTag, + splitPolishInput, stripCarriedReleaseNotes, - stripGenerateNotesCompareLink, + validatePolishedSections, } from "../scripts/release-notes"; describe("matchingPreviewTag", () => { @@ -206,59 +212,6 @@ describe("selectNewestCarriedPreviewTag", () => { }); }); -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: "<!-- Release notes generated using configuration in .github/release.yml at abc -->\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"); - }); -}); - describe("parseTakeoverSourcePr", () => { test("matches common maintainer-takeover title forms", () => { expect(parseTakeoverSourcePr("feat(images): Grok image bridge (maintainer takeover of #424)")).toBe(424); @@ -342,3 +295,391 @@ describe("rewriteTakeoverCredits", () => { expect(rewritten).toBe(line); }); }); + +describe("cleanPrTitle", () => { + test("strips conventional prefix, keeps scope, and sentence-cases the title", () => { + expect(cleanPrTitle("feat(providers): add Baseten Model APIs preset", 653)).toEqual({ + scope: "providers", + text: "Add Baseten Model APIs preset", + }); + }); + + test("keeps non-conventional titles", () => { + expect(cleanPrTitle("clarify Codex pool routing semantics", 5)).toEqual({ + scope: null, + text: "Clarify Codex pool routing semantics", + }); + }); + + test("strips a conventional prefix that has no scope", () => { + expect(cleanPrTitle("fix: drop the stale retry timer", 11)).toEqual({ + scope: null, + text: "Drop the stale retry timer", + }); + }); + + test("strips a trailing reference to the PR's own number", () => { + expect(cleanPrTitle("fix(codex): sentinel on all owner-verification failures (#857)", 857).text).toBe( + "Sentinel on all owner-verification failures", + ); + }); + + test("keeps a trailing reference to another PR", () => { + expect(cleanPrTitle("feat(images): Grok image bridge (#424)", 577).text).toBe("Grok image bridge (#424)"); + }); +}); + +describe("renderReleaseNotes", () => { + const carried = [ + "<!-- Release notes generated using configuration in .github/release.yml at abc -->", + "", + "## What's Changed", + "### New Features", + "* feat(providers): add Baseten Model APIs preset by @olddonkey in https://github.com/lidge-jun/opencodex/pull/653", + "### Bug Fixes", + "* fix(providers): keep Antigravity catalog static by @luvs01 in https://github.com/lidge-jun/opencodex/pull/744", + "", + "## New Contributors", + "* @n3wr1ch made their first contribution", + ].join("\n"); + + const delta = [ + "## What's Changed", + "### New Features", + "* feat(server): advertise reasoning-effort ladders on the raw /v1/models list by @n3wr1ch in https://github.com/lidge-jun/opencodex/pull/853", + "### Documentation", + "* docs(codex): clarify pool routing and account continuity by @luvs01 in https://github.com/lidge-jun/opencodex/pull/862", + ].join("\n"); + + test("renders OpenAI-style sections, scope bullets, and a full PR changelog", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@bitkyc08/opencodex@2.10.0` with dist-tag `latest`.", + carriedPreviewNotes: carried, + deltaPrNotes: delta, + compareFrom: "v2.9.1", + compareTo: "v2.10.0", + repository: "lidge-jun/opencodex", + }); + + expect(notes).toBe([ + "Published to npm as `@bitkyc08/opencodex@2.10.0` with dist-tag `latest`.", + "", + "## New Features", + "", + "- Add Baseten Model APIs preset (#653)", + "- Advertise reasoning-effort ladders on the raw /v1/models list (#853)", + "", + "## Bug Fixes", + "", + "- Keep Antigravity catalog static (#744)", + "", + "## Documentation", + "", + "- Clarify pool routing and account continuity (#862)", + "", + "## Changelog", + "", + "Full Changelog: https://github.com/lidge-jun/opencodex/compare/v2.9.1...v2.10.0", + "", + "- #653 feat(providers): add Baseten Model APIs preset @olddonkey", + "- #744 fix(providers): keep Antigravity catalog static @luvs01", + "- #853 feat(server): advertise reasoning-effort ladders on the raw /v1/models list @n3wr1ch", + "- #862 docs(codex): clarify pool routing and account continuity @luvs01", + "", + ].join("\n")); + }); + + test("groups same-scope PRs into one bullet with all references", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: [ + "## What's Changed", + "### New Features", + "* feat(providers): add A by @a in https://github.com/o/r/pull/1", + "* feat(providers): add B by @b in https://github.com/o/r/pull/2", + "* feat(gui): dark mode by @c in https://github.com/o/r/pull/3", + ].join("\n"), + }); + + expect(notes).toContain("- Providers: Add A; Add B (#1, #2)"); + expect(notes).toContain("- Dark mode (#3)"); + expect(notes).toContain("- #1 feat(providers): add A @a"); + expect(notes).toContain("- #2 feat(providers): add B @b"); + expect(notes).toContain("- #3 feat(gui): dark mode @c"); + }); + + test("merges scope-less PRs into one bullet without a label prefix", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: [ + "## What's Changed", + "### Bug Fixes", + "* fix: drop the stale retry timer by @a in https://github.com/o/r/pull/11", + "* fix: close the idle socket by @b in https://github.com/o/r/pull/12", + ].join("\n"), + }); + + expect(notes).toContain("- Drop the stale retry timer; Close the idle socket (#11, #12)"); + }); + + test("parses bot-authored PRs in generated and rendered notes", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: [ + "## What's Changed", + "### Chores", + "* chore(deps): bump bun by @dependabot[bot] in https://github.com/o/r/pull/20", + ].join("\n"), + }); + + expect(notes).toContain("- Bump bun (#20)"); + expect(notes).toContain("- #20 chore(deps): bump bun @dependabot[bot]"); + }); + + test("deduplicates a PR whose category changed between preview and delta", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: [ + "## What's Changed", + "### Bug Fixes", + "* fix(x): y by @a in https://github.com/o/r/pull/9", + ].join("\n"), + deltaPrNotes: [ + "## What's Changed", + "### New Features", + "* feat(x): y by @a in https://github.com/o/r/pull/9", + ].join("\n"), + }); + + expect(notes.match(/- #9 /g)).toHaveLength(1); + expect(notes).toContain("## Bug Fixes"); + expect(notes).not.toContain("## New Features"); + }); + + test("emits the compare link even when no PRs were parsed", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + deltaPrNotes: + "<!-- Release notes generated using configuration in .github/release.yml at abc -->\n\n\n**Full Changelog**: https://example/compare/a...b\n", + compareFrom: "v1.0.0", + compareTo: "v1.0.1", + repository: "o/r", + }); + + expect(notes).toContain("## Changelog"); + expect(notes).toContain("Full Changelog: https://github.com/o/r/compare/v1.0.0...v1.0.1"); + }); + + test("omits empty categories and the compare link when no range is available", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: [ + "## What's Changed", + "### Bug Fixes", + "* fix(x): y by @a in https://github.com/o/r/pull/9", + ].join("\n"), + }); + + expect(notes).not.toContain("## New Features"); + expect(notes).not.toContain("Full Changelog"); + expect(notes).toContain("- Y (#9)"); + expect(notes).toContain("## Changelog"); + }); + + test("handles maintainer-takeover credited lines", () => { + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: [ + "## What's Changed", + "### New Features", + "* feat(images): Grok image bridge (maintainer takeover of #424) by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", + ].join("\n"), + }); + + expect(notes).toContain("- Grok image bridge (maintainer takeover of #424) (#577)"); + expect(notes).toContain("- #577 feat(images): Grok image bridge (maintainer takeover of #424) @tizerluo"); + }); + + test("deduplicates PRs appearing in both carried and delta", () => { + const duplicate = [ + "## What's Changed", + "### Bug Fixes", + "* fix(x): y by @a in https://github.com/o/r/pull/9", + ].join("\n"); + const notes = renderReleaseNotes({ + npmMetadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + carriedPreviewNotes: duplicate, + deltaPrNotes: duplicate, + }); + + expect(notes.match(/- #9 /g)).toHaveLength(1); + }); + + test("parses rendered bodies: bullets assign categories, changelog lines supply title/author", () => { + const sections = parseGeneratedNotes([ + "## New Features", + "", + "- Providers: Add A; Add B (#1, #2)", + "", + "## Changelog", + "", + "- #1 feat(providers): add A @a", + "- #2 feat(providers): add B @b", + ].join("\n")); + + expect(sections).toEqual([ + { + title: "New Features", + prs: [ + { number: 1, title: "feat(providers): add A", author: "a" }, + { number: 2, title: "feat(providers): add B", author: "b" }, + ], + }, + ]); + }); + + test("carries already-rendered preview bodies losslessly into stable notes", () => { + const preview = renderReleaseNotes({ + npmMetadata: "Published to npm as `@bitkyc08/opencodex@2.10.0-preview.1` with dist-tag `preview`.", + carriedPreviewNotes: carried, + deltaPrNotes: delta, + compareFrom: "v2.9.1", + compareTo: "v2.10.0-preview.1", + repository: "lidge-jun/opencodex", + }); + const stable = renderReleaseNotes({ + npmMetadata: "Published to npm as `@bitkyc08/opencodex@2.10.0` with dist-tag `latest`.", + carriedPreviewNotes: preview, + compareFrom: "v2.9.1", + compareTo: "v2.10.0", + repository: "lidge-jun/opencodex", + }); + + expect(stable).toContain("- Add Baseten Model APIs preset (#653)"); + expect(stable).toContain("- #653 feat(providers): add Baseten Model APIs preset @olddonkey"); + expect(stable).toContain("- #744 fix(providers): keep Antigravity catalog static @luvs01"); + expect(stable).toContain("- #853 feat(server): advertise reasoning-effort ladders on the raw /v1/models list @n3wr1ch"); + expect(stable).toContain("- #862 docs(codex): clarify pool routing and account continuity @luvs01"); + expect(stable).toContain("Full Changelog: https://github.com/lidge-jun/opencodex/compare/v2.9.1...v2.10.0"); + + // The preview's own metadata and compare link must not survive the carry. + expect(stable).not.toContain("2.10.0-preview.1"); + expect(stable).not.toContain("dist-tag `preview`"); + expect(stable.match(/Full Changelog:/g)).toHaveLength(1); + + // Every PR appears exactly once in the changelog. + for (const pr of [653, 744, 853, 862]) { + expect(stable.match(new RegExp(`^- #${pr} `, "gm"))).toHaveLength(1); + } + }); +}); + +describe("polish validation", () => { + const head = [ + "Published to npm as `@bitkyc08/opencodex@2.10.0` with dist-tag `latest`.", + "", + "## New Features", + "", + "- Add Baseten Model APIs preset (#653)", + "- Advertise reasoning-effort ladders on the raw /v1/models list (#853)", + "", + "## Bug Fixes", + "", + "- Keep Antigravity catalog static (#744)", + ].join("\n"); + + test("extractPrNumbers deduplicates and sorts", () => { + expect(extractPrNumbers("(#853, #744, #653, #653)")).toEqual([653, 744, 853]); + }); + + test("extractChangelogPrNumbers reads only leading entry identifiers", () => { + const changelog = [ + "## Changelog", + "", + "- #577 feat(images): Grok image bridge (maintainer takeover of #424) @tizerluo", + "- #653 feat(providers): add Baseten Model APIs preset @olddonkey", + ].join("\n"); + expect(extractChangelogPrNumbers(changelog)).toEqual([577, 653]); + }); + + test("splitPolishInput peels metadata, splits at the first Changelog heading, and normalizes CRLF", () => { + const body = [ + "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + "", + "## Bug Fixes", + "", + "- Keep Antigravity catalog static (#744)", + "", + "## Changelog", + "", + "- #744 fix(providers): keep Antigravity catalog static @luvs01", + "", + ].join("\r\n"); + + expect(splitPolishInput(body)).toEqual({ + metadata: "Published to npm as `@pkg@1.0.0` with dist-tag `latest`.", + head: ["## Bug Fixes", "", "- Keep Antigravity catalog static (#744)"].join("\n"), + changelog: ["## Changelog", "", "- #744 fix(providers): keep Antigravity catalog static @luvs01"].join("\n"), + }); + }); + + test("isPolishBaseUrlAllowed accepts https and loopback http, rejects plaintext remote hosts", () => { + expect(isPolishBaseUrlAllowed("https://api.openai.com/v1")).toBe(true); + expect(isPolishBaseUrlAllowed("http://127.0.0.1:8080/v1")).toBe(true); + expect(isPolishBaseUrlAllowed("http://localhost:8080/v1")).toBe(true); + expect(isPolishBaseUrlAllowed("http://[::1]:8080/v1")).toBe(true); + expect(isPolishBaseUrlAllowed("http://my.localhost:8080/v1")).toBe(true); + expect(isPolishBaseUrlAllowed("http://example.com/v1")).toBe(false); + expect(isPolishBaseUrlAllowed("not a url")).toBe(false); + }); + + test("parseSectionHeadings excludes the machine-rendered Changelog", () => { + expect(parseSectionHeadings("## Changelog\n\n## New Features\n\n## Bug Fixes")).toEqual([ + "New Features", + "Bug Fixes", + ]); + }); + + test("accepts rewritten sections with the same PR set and headings", () => { + expect(validatePolishedSections(head, [653, 744, 853], ["New Features", "Bug Fixes"])).toEqual([]); + }); + + test("rejects missing PR references", () => { + const out = head.replace("(#653)", "(#999)"); + expect(validatePolishedSections(out, [653, 744, 853], ["New Features", "Bug Fixes"])).toContain( + "missing PR references: #653", + ); + }); + + test("rejects invented PR references", () => { + const out = head.replace("(#653)", "(#653, #424242)"); + expect(validatePolishedSections(out, [653, 744, 853], ["New Features", "Bug Fixes"])).toContain( + "unexpected PR references: #424242", + ); + }); + + test("accepts a rewrite that drops a foreign PR reference carried inside a title", () => { + const sections = [ + "## New Features", + "", + "- Grok image bridge (maintainer takeover of #424) (#577)", + ].join("\n"); + const rewritten = sections.replace("(maintainer takeover of #424) ", ""); + + expect(validatePolishedSections(rewritten, [577], ["New Features"], [424])).toEqual([]); + }); + + test("rejects repeated PR references", () => { + const out = head.replace("(#653)", "(#653, #653)"); + expect(validatePolishedSections(out, [653, 744, 853], ["New Features", "Bug Fixes"])).toContain( + "repeated PR references: #653", + ); + }); + + test("rejects removed or invented headings", () => { + const out = head.replace("## Bug Fixes", "## Internal"); + const errors = validatePolishedSections(out, [653, 744, 853], ["New Features", "Bug Fixes"]); + expect(errors).toContain("missing headings: Bug Fixes"); + expect(errors).toContain("unexpected headings: Internal"); + }); +});