From ef7cd5ba79cb626fb85c2c876db318a267bf63d8 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 9 Apr 2026 15:15:58 +0100 Subject: [PATCH] ci: restructure release notes to group by npm package (#9011) --- .github/prompts/release-notes-rewrite.md | 42 ++- .github/scripts/lib/pr-analyzer.ts | 101 +++++++ .github/scripts/release-notes.ts | 321 +++++++++++++++++------ .github/workflows/release.yml | 1 + 4 files changed, 377 insertions(+), 88 deletions(-) diff --git a/.github/prompts/release-notes-rewrite.md b/.github/prompts/release-notes-rewrite.md index e4a17365d9..bde251ec11 100644 --- a/.github/prompts/release-notes-rewrite.md +++ b/.github/prompts/release-notes-rewrite.md @@ -7,8 +7,9 @@ authentication framework for TypeScript. Read the raw changelog at: __RAW_CHANGELOG_PATH__ This raw changelog was generated from git history and PR metadata. -Each entry has a description, a PR link, and an author attribution. -Entries are grouped by domain (Core, Database, Identity, etc.). +Each entry has a description and a PR link. +Entries are grouped by npm package (`better-auth`, `@better-auth/sso`, +etc.), then by change type (`Breaking Changes`, `Features`, `Bug Fixes`). Entries come from two sources: - Changeset descriptions (may already look clean but often need tense @@ -53,19 +54,42 @@ User focus: context (the outcome they intended, not just the technical detail) Breaking changes: -- Entries with `**BREAKING:**` prefix must clearly explain what changed - and what users need to do (migration steps if applicable) -- Keep the `**BREAKING:**` prefix exactly as-is +- Entries prefixed with `**BREAKING:**` must be transformed into a rich format: + 1. Replace the `**BREAKING:**` prefix with a bold title extracted from the description + 2. Add " — " after the title, followed by user-focused context + 3. Keep the PR link at the end of the description line + 4. Below the description, add a code block showing the migration action + (the opt-out config, the before/after import change, or the new required option) + 5. Inspect the PR diff (`gh pr diff `) to find the exact migration action +- Example transformation: + ``` + Before (raw): + **BREAKING:** enable InResponseTo validation by default for SAML flows ([#8736](url)) + + After (rewritten): + **SAML InResponseTo validation enabled by default** — `enableInResponseToValidation` is now `true` for SP-initiated SAML flows ([#8736](url)). To restore the previous behavior: + + ```ts + sso({ saml: { enableInResponseToValidation: false } }) + ``` + ``` ## Structural rules (do NOT violate) - Do NOT add or remove entries; keep every entry from the raw changelog -- Do NOT modify PR links `([#NNNN](url))` or author attributions `by @username` -- Do NOT modify the `## Domain` headings or their order -- Do NOT use em dashes; use parentheses, commas, or colons instead -- Keep the install banner line and full changelog link exactly as-is +- Do NOT modify PR links `([#NNNN](url))` +- Do NOT modify the `## \`package-name\`` headings or their order +- Do NOT modify the `### ⚠️ Breaking Changes`, `### Features`, or + `### Bug Fixes` sub-headings or their order within a package +- Do NOT add author attributions (`by @username`) to entries +- Do NOT use em dashes (—); use parentheses, commas, or colons instead + (exception: the " — " separator in breaking change titles is allowed) +- Keep the blog post link, contributors section, and full changelog + link exactly as-is - Remove duplicate PR number suffixes from description text (the PR link in parentheses already provides this; e.g., change "fixed foo (#8289)" to "Fixed foo") +- Do NOT duplicate an entry across multiple sub-sections; each entry + appears exactly once under the change type it was classified as Write the final release notes to: __RAW_CHANGELOG_PATH__.final diff --git a/.github/scripts/lib/pr-analyzer.ts b/.github/scripts/lib/pr-analyzer.ts index 723095ace8..0023552cdd 100644 --- a/.github/scripts/lib/pr-analyzer.ts +++ b/.github/scripts/lib/pr-analyzer.ts @@ -244,3 +244,104 @@ export const DOMAIN_ORDER = [ /** Domains excluded from release notes */ export const FILTERED_DOMAINS = new Set(["docs", "devops"]); + +// ── Package resolution (for release notes output) ───────────────────── + +/** + * Maps commit scopes to npm package names. + * Used by release-notes.ts to group entries by the package users install. + */ +const SCOPE_TO_PACKAGE: Record = { + sso: "@better-auth/sso", + scim: "@better-auth/scim", + passkey: "@better-auth/passkey", + "oauth-provider": "@better-auth/oauth-provider", + stripe: "@better-auth/stripe", + "api-key": "@better-auth/api-key", + expo: "@better-auth/expo", + electron: "@better-auth/electron", + i18n: "@better-auth/i18n", + "test-utils": "@better-auth/test-utils", + "drizzle-adapter": "@better-auth/drizzle-adapter", + "prisma-adapter": "@better-auth/prisma-adapter", + "kysely-adapter": "@better-auth/kysely-adapter", + "mongo-adapter": "@better-auth/mongo-adapter", + "memory-adapter": "@better-auth/memory-adapter", + "redis-storage": "@better-auth/redis-storage", + cli: "auth", +}; + +/** + * Maps file path prefixes to npm package names. + * Order matters: more specific paths must come before catch-alls. + */ +const PATH_TO_PACKAGE: [string, string][] = [ + ["packages/sso/", "@better-auth/sso"], + ["packages/scim/", "@better-auth/scim"], + ["packages/passkey/", "@better-auth/passkey"], + ["packages/oauth-provider/", "@better-auth/oauth-provider"], + ["packages/stripe/", "@better-auth/stripe"], + ["packages/api-key/", "@better-auth/api-key"], + ["packages/expo/", "@better-auth/expo"], + ["packages/electron/", "@better-auth/electron"], + ["packages/i18n/", "@better-auth/i18n"], + ["packages/redis-storage/", "@better-auth/redis-storage"], + ["packages/test-utils/", "@better-auth/test-utils"], + ["packages/telemetry/", "@better-auth/telemetry"], + ["packages/drizzle-adapter/", "@better-auth/drizzle-adapter"], + ["packages/prisma-adapter/", "@better-auth/prisma-adapter"], + ["packages/kysely-adapter/", "@better-auth/kysely-adapter"], + ["packages/mongo-adapter/", "@better-auth/mongo-adapter"], + ["packages/memory-adapter/", "@better-auth/memory-adapter"], + // Catch-all: everything in better-auth or core maps to the main package + ["packages/better-auth/", "better-auth"], + ["packages/core/", "better-auth"], + ["packages/cli/", "auth"], +]; + +/** + * Resolves the npm package name for release notes grouping. + * Priority: scope match > file path match > "better-auth" fallback. + */ +export function resolvePackage( + scope: string | undefined, + changedFiles: string[], +): string { + if (scope) { + const pkg = SCOPE_TO_PACKAGE[scope]; + if (pkg) return pkg; + } + + const counts: Record = {}; + for (const file of changedFiles) { + for (const [prefix, pkg] of PATH_TO_PACKAGE) { + if (file.startsWith(prefix)) { + counts[pkg] = (counts[pkg] ?? 0) + 1; + break; + } + } + } + + const packages = Object.keys(counts); + if (packages.length === 0) return "better-auth"; + + // If files span multiple external packages, return the one with the most hits. + // If all files are in better-auth, return better-auth. + return packages.sort((a, b) => { + // Prefer non-better-auth packages (they're more specific) + const aIsCore = a === "better-auth" ? 1 : 0; + const bIsCore = b === "better-auth" ? 1 : 0; + if (aIsCore !== bIsCore) return aIsCore - bIsCore; + return (counts[b] ?? 0) - (counts[a] ?? 0); + })[0]!; +} + +/** Classifies a conventional commit type into a release notes category. */ +export function classifyChangeType( + type: string, + breaking: boolean, +): "breaking" | "feat" | "fix" { + if (breaking) return "breaking"; + if (type === "feat") return "feat"; + return "fix"; +} diff --git a/.github/scripts/release-notes.ts b/.github/scripts/release-notes.ts index c9bf23899e..76513338f4 100644 --- a/.github/scripts/release-notes.ts +++ b/.github/scripts/release-notes.ts @@ -24,10 +24,12 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { ghJSON, REPO, setOutput } from "./lib/github.ts"; import { + classifyChangeType, DOMAIN_ORDER, FILTERED_DOMAINS, parseConventionalCommit, resolveDomain, + resolvePackage, } from "./lib/pr-analyzer.ts"; // ── Types ────────────────────────────────────────────────────────────── @@ -38,6 +40,8 @@ interface ReleaseEntry { prNumber: number | null; author: string; domain: string; + packageName: string; + changeType: "breaking" | "feat" | "fix"; breaking: boolean; } @@ -48,21 +52,12 @@ interface PRInfo { files: string[]; } -// ── Constants ────────────────────────────────────────────────────────── +interface ChangesetSnapshot { + ids: string[]; + ref: string; +} -const DOMAIN_DISPLAY_NAMES: Record = { - core: "Core", - database: "Database", - oauth: "OAuth", - credentials: "Credentials", - identity: "Identity", - organization: "Organization", - security: "Security", - enterprise: "Enterprise", - payments: "Payments", - platform: "Platform", - devtools: "Devtools", -}; +// ── Constants ────────────────────────────────────────────────────────── // ── CLI argument parsing ─────────────────────────────────────────────── @@ -223,6 +218,7 @@ function parseChangesetFile(content: string): { // ── PR metadata resolution ───────────────────────────────────────────── const prCache = new Map(); +const releaseBodyCache = new Map(); function fetchPR(prNumber: number): PRInfo { const cached = prCache.get(prNumber); @@ -254,6 +250,41 @@ function fetchPR(prNumber: number): PRInfo { return info; } +function fetchReleaseBody(tag: string): string | null { + const cached = releaseBodyCache.get(tag); + if (cached !== undefined) return cached; + + try { + const data = ghJSON<{ body: string | null }>([ + "release", + "view", + tag, + "--repo", + REPO, + "--json", + "body", + ]); + if (data.body === null) { + releaseBodyCache.set(tag, null); + return null; + } + + releaseBodyCache.set(tag, data.body); + return data.body; + } catch { + releaseBodyCache.set(tag, null); + return null; + } +} + +function extractReleasePRNumbers(body: string): Set { + const prNumbers = new Set(); + for (const match of body.matchAll(/\[#(\d+)\]\([^)]*\/pull\/\d+\)/g)) { + prNumbers.add(match[1]!); + } + return prNumbers; +} + // ── Domain classification ────────────────────────────────────────────── function classifyEntry( @@ -281,6 +312,29 @@ interface ChangesetEntry { packageNames: string[]; } +function findChangesetSourcePR(id: string, ref: string): number | null { + try { + const subject = execFileSync( + "git", + [ + "log", + "--diff-filter=A", + "--format=%s", + "-n", + "1", + ref, + "--", + `.changeset/${id}.md`, + ], + { encoding: "utf-8" }, + ).trim(); + const prMatch = subject.match(/\(#(\d+)\)$/); + return prMatch ? Number(prMatch[1]) : null; + } catch { + return null; + } +} + /** Build a map of PR number to changeset description from .changeset/ files and pre.json. */ function buildChangesetIndex(branch: string): { byPR: Map; @@ -292,10 +346,12 @@ function buildChangesetIndex(branch: string): { const byDescription = new Map(); const ids = new Set(); + let hasPreJSON = false; try { const raw = readFileFromRef(".changeset/pre.json", branch); const preJSON = JSON.parse(raw) as { changesets: string[] }; + hasPreJSON = true; for (const id of preJSON.changesets) ids.add(id); } catch { // No pre.json — scan the directory instead @@ -308,38 +364,56 @@ function buildChangesetIndex(branch: string): { const skipFiles = new Set(["README", "config"]); const baseRef = branch || "HEAD"; let effectiveBranch = branch; - try { - const revs = execFileSync("git", ["rev-list", "--max-count=15", baseRef], { - encoding: "utf-8", - }) - .trim() - .split("\n") - .filter(Boolean); + if (!hasPreJSON) { + try { + const revs = execFileSync( + "git", + ["rev-list", "--max-count=15", baseRef], + { + encoding: "utf-8", + }, + ) + .trim() + .split("\n") + .filter(Boolean); - for (const rev of revs) { - try { - const listing = execFileSync( - "git", - ["ls-tree", "-r", "--name-only", rev, ".changeset/"], - { encoding: "utf-8" }, - ); - let foundAny = false; - for (const file of listing.split("\n")) { - const name = file.replace(/^\.changeset\//, "").replace(/\.md$/, ""); - if (!name || skipFiles.has(name) || !file.endsWith(".md")) continue; - ids.add(name); - foundAny = true; + let bestSnapshot: ChangesetSnapshot | null = null; + + for (const rev of revs) { + try { + const listing = execFileSync( + "git", + ["ls-tree", "-r", "--name-only", rev, ".changeset/"], + { encoding: "utf-8" }, + ); + const snapshotIds = listing + .split("\n") + .map((file) => + file.replace(/^\.changeset\//, "").replace(/\.md$/, ""), + ) + .filter( + (name) => + name && !skipFiles.has(name) && /^[a-z0-9-]+$/.test(name), + ); + + if ( + snapshotIds.length > 0 && + (!bestSnapshot || snapshotIds.length > bestSnapshot.ids.length) + ) { + bestSnapshot = { ids: snapshotIds, ref: rev }; + } + } catch { + // listing failed — try next } - if (foundAny) { - effectiveBranch = rev; - break; - } - } catch { - // listing failed — try next } + + if (bestSnapshot) { + effectiveBranch = bestSnapshot.ref; + for (const id of bestSnapshot.ids) ids.add(id); + } + } catch { + // rev-list failed — proceed with whatever pre.json gave us } - } catch { - // rev-list failed — proceed with whatever pre.json gave us } for (const id of ids) { @@ -360,9 +434,14 @@ function buildChangesetIndex(branch: string): { if (prMatch) { byPR.set(Number(prMatch[1]), entry); } else { - orphans.push(entry); - const firstLine = description.split("\n")[0]!.trim().toLowerCase(); - if (firstLine) byDescription.set(firstLine, entry); + const sourcePrNumber = findChangesetSourcePR(id, effectiveBranch); + if (sourcePrNumber && !byPR.has(sourcePrNumber)) { + byPR.set(sourcePrNumber, entry); + } else { + orphans.push(entry); + const firstLine = description.split("\n")[0]!.trim().toLowerCase(); + if (firstLine) byDescription.set(firstLine, entry); + } } } catch { // File not found — skip @@ -437,6 +516,7 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { let log: string; const alreadyReleasedPRs = new Set(); + let alreadyPublishedPRs: Set | null = null; if (isDirectAncestor) { log = execFileSync( @@ -461,6 +541,13 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { for (const match of tagLog.matchAll(/\(#(\d+)\)/g)) { alreadyReleasedPRs.add(match[1]!); } + const previousReleaseBody = fetchReleaseBody(previousTag); + if (previousReleaseBody !== null) { + alreadyPublishedPRs = extractReleasePRNumbers(previousReleaseBody); + console.log( + ` Previous release body references ${alreadyPublishedPRs.size} PRs`, + ); + } log = execFileSync( "git", @@ -476,9 +563,15 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { lines = lines.filter((line) => { const prMatch = line.match(/\(#(\d+)\)/); if (!prMatch) return true; - return !alreadyReleasedPRs.has(prMatch[1]!); + const prNumber = prMatch[1]!; + if (!alreadyReleasedPRs.has(prNumber)) return true; + if (alreadyPublishedPRs) return !alreadyPublishedPRs.has(prNumber); + return false; }); - console.log(` Filtered ${before - lines.length} already-released PRs`); + const filterLabel = alreadyPublishedPRs + ? "already-published" + : "already-released"; + console.log(` Filtered ${before - lines.length} ${filterLabel} PRs`); } // Cancel out revert/original pairs @@ -551,6 +644,7 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { let author = "unknown"; let domain: string; + let packageName: string; let breaking = parsed.breaking; const description = @@ -561,9 +655,11 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { const prInfo = fetchPR(prNumber); author = prInfo.author; domain = classifyEntry(prInfo, parsed.scope || undefined, prInfo.files); + packageName = resolvePackage(parsed.scope || undefined, prInfo.files); if (prInfo.labels.includes("breaking")) breaking = true; } catch { domain = resolveDomain(parsed.scope || undefined, []); + packageName = resolvePackage(parsed.scope || undefined, []); } entries.push({ @@ -572,6 +668,8 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { prNumber, author, domain, + packageName, + changeType: classifyChangeType(parsed.type, breaking), breaking, }); } @@ -601,6 +699,8 @@ function collectEntries(version: string, branch: string): ReleaseEntry[] { prNumber: null, author: "unknown", domain, + packageName: resolvePackage(undefined, pkgPaths), + changeType: classifyChangeType("fix", changeset.breaking), breaking: changeset.breaking, }); } @@ -614,54 +714,118 @@ interface FormatOptions { version: string; entries: ReleaseEntry[]; previousTag: string; - distTag: string; } +const CHANGE_TYPE_HEADINGS: Record = { + breaking: "### ⚠️ Breaking Changes", + feat: "### Features", + fix: "### Bug Fixes", +}; + +const CHANGE_TYPE_ORDER: ("breaking" | "feat" | "fix")[] = [ + "breaking", + "feat", + "fix", +]; + function formatReleaseBody(opts: FormatOptions): string { - const { version, entries, previousTag, distTag } = opts; + const { version, entries, previousTag } = opts; const lines: string[] = []; + const isBeta = version.includes("-"); - const channelMatch = version.match(/-(beta|alpha|rc)\./); - const installTag = distTag || channelMatch?.[1] || "latest"; - lines.push(`> Install: \`npm i better-auth@${installTag}\``); - lines.push(""); - - const grouped = new Map(); - for (const entry of entries) { - if (FILTERED_DOMAINS.has(entry.domain)) continue; - const list = grouped.get(entry.domain) ?? []; - list.push(entry); - grouped.set(entry.domain, list); + // Blog post link for stable releases + if (!isBeta) { + const majorMinor = version.match(/^(\d+)\.(\d+)/); + if (majorMinor) { + const blogSlug = `${majorMinor[1]}-${majorMinor[2]}`; + lines.push( + `**Blog post:** [Better Auth ${majorMinor[1]}.${majorMinor[2]}](https://better-auth.com/blog/${blogSlug})`, + ); + lines.push(""); + } } - for (const domain of DOMAIN_ORDER) { - const domainEntries = grouped.get(domain); - if (!domainEntries?.length) continue; + // Group entries by package + const grouped = new Map(); + const contributors = new Set(); - const displayName = DOMAIN_DISPLAY_NAMES[domain] ?? domain; - lines.push(`## ${displayName}`); + for (const entry of entries) { + if (FILTERED_DOMAINS.has(entry.domain)) continue; + const list = grouped.get(entry.packageName) ?? []; + list.push(entry); + grouped.set(entry.packageName, list); + if (entry.author !== "unknown") contributors.add(entry.author); + } + + // Sort packages: better-auth first, then by breaking count desc, + // then by total entry count desc, then alphabetically + const packageOrder = [...grouped.keys()].sort((a, b) => { + if (a === "better-auth") return -1; + if (b === "better-auth") return 1; + const aBreaking = grouped + .get(a)! + .filter((e) => e.changeType === "breaking").length; + const bBreaking = grouped + .get(b)! + .filter((e) => e.changeType === "breaking").length; + if (aBreaking !== bBreaking) return bBreaking - aBreaking; + const aTotal = grouped.get(a)!.length; + const bTotal = grouped.get(b)!.length; + if (aTotal !== bTotal) return bTotal - aTotal; + return a.localeCompare(b); + }); + + for (const pkg of packageOrder) { + const pkgEntries = grouped.get(pkg)!; + + lines.push(`## \`${pkg}\``); lines.push(""); - domainEntries.sort((a, b) => { - if (a.breaking !== b.breaking) return a.breaking ? -1 : 1; - return a.description.localeCompare(b.description); - }); + // Group by change type within this package + for (const changeType of CHANGE_TYPE_ORDER) { + const typeEntries = pkgEntries.filter((e) => e.changeType === changeType); + if (typeEntries.length === 0) continue; - for (const entry of domainEntries) { - const prefix = entry.breaking ? "**BREAKING:** " : ""; - const prLink = entry.prNumber - ? ` ([#${entry.prNumber}](https://github.com/${REPO}/pull/${entry.prNumber}))` - : ""; - const authorAttr = - entry.author !== "unknown" ? ` by @${entry.author}` : ""; - lines.push(`- ${prefix}${entry.description}${prLink}${authorAttr}`); + typeEntries.sort((a, b) => a.description.localeCompare(b.description)); + + lines.push(CHANGE_TYPE_HEADINGS[changeType]!); + lines.push(""); + + for (const entry of typeEntries) { + const prLink = entry.prNumber + ? ` ([#${entry.prNumber}](https://github.com/${REPO}/pull/${entry.prNumber}))` + : ""; + + if (changeType === "breaking") { + // Breaking changes get bold description for the AI to expand + lines.push(`**BREAKING:** ${entry.description}${prLink}`); + } else { + lines.push(`- ${entry.description}${prLink}`); + } + } + lines.push(""); } + + lines.push("---"); + lines.push(""); + } + + // Contributors + if (contributors.size > 0) { + lines.push("## Contributors"); + lines.push(""); + lines.push("Thanks to everyone who contributed to this release:"); + lines.push(""); + const sorted = [...contributors].sort((a, b) => + a.toLowerCase().localeCompare(b.toLowerCase()), + ); + lines.push(sorted.map((c) => `@${c}`).join(", ")); lines.push(""); } const currentTag = `v${version}`; lines.push( - `**Full changelog**: [\`${previousTag}...${currentTag}\`](https://github.com/${REPO}/compare/${previousTag}...${currentTag})`, + `**Full changelog:** [\`${previousTag}...${currentTag}\`](https://github.com/${REPO}/compare/${previousTag}...${currentTag})`, ); return lines.join("\n"); @@ -689,7 +853,6 @@ const body = formatReleaseBody({ version, entries, previousTag, - distTag, }); if (dryRun) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7ce6e1ea6..f8bb13edd8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,7 @@ jobs: github_token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} prompt: ${{ steps.ai-prompt.outputs.prompt }} claude_args: --max-turns 100 --allowedTools "Read Write Bash(gh pr diff*) Bash(gh pr view*)" + allowed_bots: "github-merge-queue" - name: Create GitHub Release if: steps.changesets.outputs.published == 'true'