Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
250 changes: 248 additions & 2 deletions scripts/release-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* bun scripts/release-notes.ts matching-preview-tags <version>
* bun scripts/release-notes.ts previous-release-tag <version>
* bun scripts/release-notes.ts has-meaningful [body-file]
* bun scripts/release-notes.ts commit-fallback [commit-log-file]
* bun scripts/release-notes.ts credit-takeovers --repo <owner/name> --in <file> --out <file>
* 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 ...]
Expand Down Expand Up @@ -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<string, string> = {
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<K, V> | 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<string, string[]>();
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("<!--")) continue;
if (line.startsWith("## ") || line.startsWith("### ")) {
flush();
const title = line.replace(/^#{2,3}\s+/, "").trim();
if (!SCAFFOLD_HEADINGS.has(title)) current = { title, lines: [] };
continue;
}
if (!current) continue;
if (!line.startsWith("- ")) continue;
// Anything carrying a PR reference belongs to the PR pipeline, not here.
if (/\(#\d+(?:\s*,\s*#\d+)*\)\s*$/.test(line)) continue;
if (/^-\s+#\d+\s/.test(line)) continue;
current.lines.push(line);
}
flush();
return out.join("\n\n").replace(/\n+$/, "") + (out.length > 0 ? "\n" : "");
}

/**
* Merge several already-rendered commit-bullet bodies into one set of category
* sections, preserving order within a category and de-duplicating identical
* bullets. Concatenating the bodies directly would repeat a shared heading.
*/
export function mergeCommitBulletSections(bodies: string[]): string {
const buckets = new Map<string, string[]>();
const seen = new Set<string>();
for (const body of bodies) {
let current: string | null = null;
for (const rawLine of (body ?? "").replace(/\r\n/g, "\n").split("\n")) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("## ") || line.startsWith("### ")) {
current = line.replace(/^#{2,3}\s+/, "").trim();
if (!buckets.has(current)) buckets.set(current, []);
continue;
}
if (!current || !line.startsWith("- ")) continue;
const key = `${current}\u0000${line}`;
if (seen.has(key)) continue;
seen.add(key);
buckets.get(current)!.push(line);
}
}
const titles = [...buckets.keys()].sort((x, y) => {
const ix = RENDER_CATEGORY_ORDER.indexOf(x);
const iy = RENDER_CATEGORY_ORDER.indexOf(y);
const rx = ix === -1 ? RENDER_CATEGORY_ORDER.length : ix;
const ry = iy === -1 ? RENDER_CATEGORY_ORDER.length : iy;
return rx - ry;
});
const merged: string[] = [];
for (const title of titles) {
const lines = buckets.get(title)!;
if (lines.length === 0) continue;
merged.push([`## ${title}`, "", ...lines].join("\n"));
}
return merged.join("\n\n").trim();
}

/**
* Parse `git log -z --format=%H%x00%s%x00%an` output into commits.
*
* Records and fields are NUL-separated. Git forbids NUL in commit content, so
* — unlike the unit separator, which Git accepts in both subjects and author
* names — no field value can forge a boundary. Every record is read as exactly
* three fields.
*/
export function parseCommitLog(raw: string): ReleaseNoteCommit[] {
const commits: ReleaseNoteCommit[] = [];
const fields = raw.split("\u0000");
// Trailing separator from `git log -z` leaves an empty final element.
if (fields.length > 0 && fields[fields.length - 1]!.trim() === "") fields.pop();
for (let i = 0; i + 2 < fields.length + 1; i += 3) {
const sha = (fields[i] ?? "").replace(/^\n+/, "").trim();
const subject = fields[i + 1] ?? "";
const author = fields[i + 2] ?? "";
if (!sha || !subject.trim()) continue;
commits.push({ sha, subject, author });
}
return commits;
}

export function hasNonWhitespace(text: string): boolean {
return text.replace(/\s+/g, "").length > 0;
}
Expand Down Expand Up @@ -425,8 +643,6 @@ export function groupPrsByScope(prs: ReleaseNotePr[]): Array<{ scope: string | n
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
Expand All @@ -439,6 +655,12 @@ export function renderReleaseNotes(input: {
npmMetadata: string;
carriedPreviewNotes?: string;
deltaPrNotes?: string;
/**
* Pre-rendered category sections for commit-based entries (no PR numbers).
* Used only when the PR pipeline yields nothing, so a release body can never
* collapse to the npm line plus a compare link.
*/
commitFallbackNotes?: string;
compareFrom?: string | null;
compareTo?: string;
repository?: string;
Expand Down Expand Up @@ -494,6 +716,20 @@ export function renderReleaseNotes(input: {
parts.push(lines.join("\n"));
}

// Commit fallback: only when the PR pipeline produced no category content at
// all. Its sections are already rendered, so they are appended verbatim.
const renderedAnyPrSection = parts.length > (npmMetadata ? 1 : 0);
if (!renderedAnyPrSection) {
Comment on lines +721 to +722

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve fallback commits when carried previews contain PRs

When a stable release carries any preview PR section but its post-preview range contains only direct commits, the Create GitHub release workflow deliberately populates commitFallbackNotes; however, the carried PR makes renderedAnyPrSection true, so this branch discards the current fallback and any carried commit-only preview bullets. The resulting stable notes silently omit all direct changes in that delta. Merge the commit bullets into the existing category output, or only let PR content from the same delta suppress its fallback.

Useful? React with 👍 / 👎.

// Carried commit bullets first (older preview work), then this range's own.
// They are merged BY CATEGORY: concatenating two rendered bodies would emit
// `## Bug Fixes` twice when both halves touched the same category.
const merged = mergeCommitBulletSections([
extractCommitBulletSections(input.carriedPreviewNotes ?? ""),
input.commitFallbackNotes ?? "",
]);
if (merged) parts.push(merged);
}

const allPrs = [...categories.values()].flat().sort((a, b) => a.number - b.number);
const from = input.compareFrom?.trim();
const to = input.compareTo?.trim();
Expand Down Expand Up @@ -734,6 +970,13 @@ async function main(argv: string[]): Promise<void> {
process.exit(hasMeaningfulCarriedNotes(stripped) ? 0 : 1);
}

if (cmd === "commit-fallback") {
// stdin: `git log --format=%H%x1f%s%x1f%an <range>` output.
const rendered = renderCommitFallbackNotes(parseCommitLog(await readStdinOrFile(rest[0])));
process.stdout.write(rendered);
return;
}

if (cmd === "join-carried") {
let out: string | undefined;
const files: string[] = [];
Expand Down Expand Up @@ -888,6 +1131,7 @@ async function main(argv: string[]): Promise<void> {
"out",
"carried",
"delta",
"commit-fallback",
"compare-from",
"compare-to",
"repository",
Expand All @@ -909,6 +1153,7 @@ async function main(argv: string[]): Promise<void> {
npmMetadata,
carriedPreviewNotes: await readOptional("carried"),
deltaPrNotes: await readOptional("delta"),
commitFallbackNotes: await readOptional("commit-fallback"),
compareFrom: args.get("compare-from") ?? null,
compareTo: args.get("compare-to"),
repository: args.get("repository"),
Expand Down Expand Up @@ -972,6 +1217,7 @@ async function main(argv: string[]): Promise<void> {
Usage:
bun scripts/release-notes.ts strip-carried [body-file]
bun scripts/release-notes.ts has-meaningful [body-file]
bun scripts/release-notes.ts commit-fallback [commit-log-file]
bun scripts/release-notes.ts join-carried --out <file> <part-file>...
bun scripts/release-notes.ts matching-preview-tag <version> # tags on stdin
bun scripts/release-notes.ts matching-preview-tags <version> # tags on stdin, oldest→newest
Expand Down
Loading
Loading