mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-22 14:21:55 -05:00
ci: restructure release notes to group by npm package (#9011)
This commit is contained in:
101
.github/scripts/lib/pr-analyzer.ts
vendored
101
.github/scripts/lib/pr-analyzer.ts
vendored
@@ -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<string, string> = {
|
||||
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<string, number> = {};
|
||||
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";
|
||||
}
|
||||
|
||||
321
.github/scripts/release-notes.ts
vendored
321
.github/scripts/release-notes.ts
vendored
@@ -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<string, string> = {
|
||||
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<number, PRInfo>();
|
||||
const releaseBodyCache = new Map<string, string | null>();
|
||||
|
||||
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<string> {
|
||||
const prNumbers = new Set<string>();
|
||||
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<number, ChangesetEntry>;
|
||||
@@ -292,10 +346,12 @@ function buildChangesetIndex(branch: string): {
|
||||
const byDescription = new Map<string, ChangesetEntry>();
|
||||
|
||||
const ids = new Set<string>();
|
||||
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<string>();
|
||||
let alreadyPublishedPRs: Set<string> | 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<string, string> = {
|
||||
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<string, ReleaseEntry[]>();
|
||||
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<string, ReleaseEntry[]>();
|
||||
const contributors = new Set<string>();
|
||||
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user