diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ba8cbd998..e567559f4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -374,8 +374,10 @@ jobs: notes_file="$(mktemp)" carried_file="$(mktemp)" delta_file="$(mktemp)" + commit_fallback_file="$(mktemp)" : > "$carried_file" : > "$delta_file" + : > "$commit_fallback_file" # Stable releases after matching previews: aggregate every matching preview # changelog (oldest→newest; each preview body is incremental), then only @@ -449,6 +451,31 @@ jobs: pr_notes="$(gh api "${generate_notes_api[@]}" --jq '.body')" # Drop generate-notes' trailing compare link; we re-append it after the commit list. printf '%s\n' "$pr_notes" | sed '/^\*\*Full Changelog\*\*:/d' > "$delta_file" + + # generate-notes counts MERGED PULL REQUESTS in the tag range. Work that lands + # as direct commits on the integration branch (or via PRs based on `dev` rather + # than this release branch) leaves that range with nothing to aggregate, and the + # body collapses to the npm line plus a compare link — v2.17.0..v2.18.2 shipped + # exactly that, 0 of 36 commits PR-associated. Fall back to the commit log so a + # release can never publish an empty changelog. + # The renderer only keeps entries carrying a PR number, so the fallback + # travels in its own channel (--commit-fallback). The decision depends on + # THIS range's PR delta only: carried preview notes cover the pre-preview + # span, so gating on them too would silently drop every post-preview + # direct commit. The renderer decides whether to emit the channel. + if ! bun scripts/release-notes.ts has-meaningful "$delta_file"; then + commit_log_file="$(mktemp)" + # NUL-delimited: Git forbids NUL in commit content, so neither a crafted + # subject nor an author name can forge a field boundary. + git log -z --format='%H%x00%s%x00%an' "${notes_range_start}..${GITHUB_SHA}" > "$commit_log_file" + bun scripts/release-notes.ts commit-fallback "$commit_log_file" > "$commit_fallback_file" + if bun scripts/release-notes.ts has-meaningful "$commit_fallback_file"; then + echo "::notice::generate-notes returned no PR categories for ${notes_range_start}..${release_tag}; using the commit-based changelog fallback" + else + : > "$commit_fallback_file" + echo "::notice::No PR categories and no eligible commits in ${notes_range_start}..${release_tag}; release notes stay minimal" + fi + fi 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. @@ -476,6 +503,7 @@ jobs: --npm-metadata "$npm_metadata" --carried "$carried_file" --delta "$delta_file" + --commit-fallback "$commit_fallback_file" --out "$notes_file" --compare-to "$release_tag" --repository "$GITHUB_REPOSITORY" diff --git a/scripts/release-notes.ts b/scripts/release-notes.ts index 98aa308731..877ffd1a89 100644 --- a/scripts/release-notes.ts +++ b/scripts/release-notes.ts @@ -10,6 +10,7 @@ * bun scripts/release-notes.ts matching-preview-tags * bun scripts/release-notes.ts previous-release-tag * bun scripts/release-notes.ts has-meaningful [body-file] + * bun scripts/release-notes.ts commit-fallback [commit-log-file] * bun scripts/release-notes.ts credit-takeovers --repo --in --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 ...] @@ -177,6 +178,223 @@ export function hasMeaningfulCarriedNotes(stripped: string): boolean { return !isEmptyGeneratedNotes(stripped); } +/** + * A single commit considered for the commit-based changelog fallback. + * `sha` is the full or short hash; `subject` is the commit subject line. + */ +export type ReleaseNoteCommit = { + sha: string; + subject: string; + author: string; +}; + +/** Category order shared by the PR renderer and the commit fallback. */ +const RENDER_CATEGORY_ORDER = ["New Features", "Bug Fixes", "Documentation", "Chores", "Other Changes"]; + +/** Conventional-commit type -> release.yml category title. */ +const COMMIT_TYPE_CATEGORY: Record = { + feat: "New Features", + fix: "Bug Fixes", + perf: "Bug Fixes", + docs: "Documentation", + chore: "Chores", + build: "Chores", + ci: "Chores", + refactor: "Chores", + style: "Chores", + test: "Chores", +}; + +/** + * Commits that are release plumbing rather than shipped work. A merge commit's + * content is already represented by the commits it brings in, and a `release:` + * bump is the release itself. + */ +export function isReleasePlumbingCommit(subject: string): boolean { + const text = subject.trim(); + if (/^Merge\s/i.test(text)) return true; + // Real two-parent merges in this repo also use a `merge:` conventional prefix. + if (/^merge(?:\([^)]*\))?!?:\s/i.test(text)) return true; + if (/^release(?:\([^)]*\))?!?:\s/i.test(text)) return true; + return false; +} + +/** + * Neutralize Markdown and mention syntax from untrusted commit text before it + * lands in a release body. Commit subjects and author names are attacker- or + * accident-controlled: a bare `@name` renders as a real GitHub mention (and + * notifies that account), and backticks/brackets can restructure the notes. + */ +export function sanitizeCommitText(text: string): string { + return text + .replace(/\r?\n/g, " ") + // Strip the ASCII unit separator so a subject can never forge a log field. + .replace(/[\u0000\u001f]/g, " ") + // Escape rather than delete: `Map | CLI` must stay readable. + .replace(/([`<>|[\]\\])/g, "\\$1") + // `@name` -> `@\u200bname`: reads identically, never notifies. + .replace(/@(?=[A-Za-z0-9_-])/g, "@\u200b") + .replace(/\s+/g, " ") + .trim(); +} + +/** + * Render commits as a generate-notes-shaped body so the existing category + * parser/renderer can consume them unchanged. + * + * Why this exists: `releases/generate-notes` aggregates MERGED PULL REQUESTS + * against the compared tag range. When work lands as direct commits on the + * integration branch (or through PRs whose base is `dev` rather than the + * release branch), that range contains no PRs the API will count and the body + * collapses to the npm line plus a compare link — v2.17.0..v2.18.2 had 0 of 36 + * commits associated with a main-merged PR, and both releases shipped an empty + * changelog. The fallback keeps the release body honest regardless of how the + * work reached the branch. + * + * Commits carry no PR number, so the synthetic entries use `#0` — a sentinel + * the renderer never prints as a link because these are emitted as plain + * bullets under their category heading. + */ +export function renderCommitFallbackNotes(commits: ReleaseNoteCommit[]): string { + const buckets = new Map(); + for (const commit of commits) { + const subject = commit.subject.trim(); + if (!subject) continue; + if (isReleasePlumbingCommit(subject)) continue; + const match = /^([a-zA-Z]+)(?:\(([^)]*)\))?!?:\s*(.+)$/.exec(subject); + const type = match?.[1]?.toLowerCase(); + const scope = sanitizeCommitText(match?.[2] ?? ""); + const summary = sanitizeCommitText(match?.[3] ?? subject); + if (!summary) continue; + const category = (type && COMMIT_TYPE_CATEGORY[type]) ?? "Other Changes"; + // Hex-only short hash: a crafted `sha` field can never inject markup. + const shortSha = /^[0-9a-f]{7,40}$/i.test(commit.sha.trim()) + ? commit.sha.trim().slice(0, 9) + : ""; + const scopePrefix = scope ? `${scope}: ` : ""; + // `%an` is a free-form Git display name, not a GitHub login, so it is + // rendered as plain text rather than an @mention that would notify a + // same-named (or non-existent) account. + const author = sanitizeCommitText(commit.author).replace(/^@\u200b/, ""); + const trailer = [shortSha, author].filter(Boolean).join(", "); + const line = trailer ? `- ${scopePrefix}${summary} (${trailer})` : `- ${scopePrefix}${summary}`; + const existing = buckets.get(category); + if (existing) existing.push(line); + else buckets.set(category, [line]); + } + if (buckets.size === 0) return ""; + const parts: string[] = []; + for (const title of RENDER_CATEGORY_ORDER) { + const lines = buckets.get(title); + if (!lines || lines.length === 0) continue; + parts.push([`## ${title}`, "", ...lines].join("\n")); + } + return parts.join("\n\n").replace(/\n+$/, "") + "\n"; +} + +/** + * Extract commit-style category sections (bullets with no `(#N)` reference) + * from an already-rendered body. + * + * A preview release whose notes came from the commit fallback carries bullets + * like `- gui: fix a thing (abc1234, Name)`. Those are meaningful prose, so the + * workflow keeps them as carried notes and skips regenerating a fallback — but + * the PR renderer only retains entries carrying a PR number, so without this + * the stable release would silently collapse back to the npm-line stub. + */ +export function extractCommitBulletSections(body: string): string { + const out: string[] = []; + let current: { title: string; lines: string[] } | null = null; + const flush = (): void => { + if (current && current.lines.length > 0) { + out.push([`## ${current.title}`, "", ...current.lines].join("\n")); + } + current = null; + }; + for (const rawLine of body.replace(/\r\n/g, "\n").split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("\n\n\n"; + expect(hasMeaningfulCarriedNotes(emptyGenerateNotes)).toBe(false); + expect(hasMeaningfulCarriedNotes(renderCommitFallbackNotes(parseCommitLog(log)))).toBe(true); + }); + + test("fallback sections flow through the real renderer into a non-empty body", () => { + // The whole point: an empty generate-notes delta must still produce a body + // with categorized content rather than the npm line plus a compare link. + const rendered = renderReleaseNotes({ + npmMetadata: "Published to npm as \`pkg@9.9.9\` with dist-tag \`latest\`.", + commitFallbackNotes: renderCommitFallbackNotes(parseCommitLog(log)), + compareFrom: "v9.9.8", + compareTo: "v9.9.9", + repository: "owner/name", + }); + expect(rendered).toContain("## New Features"); + expect(rendered).toContain("## Bug Fixes"); + expect(rendered.length).toBeGreaterThan(400); + }); +});