mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
#!/usr/bin/env node
|
|
/**
|
|
* `changeset version` + a grouped, conventional-commits changelog with a bottom
|
|
* `## Contributors` list. Used as the `version:` command for `changesets/action`
|
|
* (see .github/workflows/release.yml).
|
|
*
|
|
* Changesets' default changelog groups by bump level (Minor/Patch Changes), not
|
|
* by commit type, and has no end-of-release contributor hook. So after running
|
|
* `changeset version` we rewrite each bumped package's newest CHANGELOG section:
|
|
* the release commits are regrouped into `### Features` / `### Bug Fixes` / etc.
|
|
* and a deduped, bot-filtered `## Contributors` list is appended. The pure
|
|
* builders (groupedChangelogBody, rewriteReleaseSection, dedupeSortLogins) are
|
|
* unit-tested; the git/gh glue is CI-only.
|
|
*
|
|
* Runs on Node >=24 via native type stripping: `node scripts/version.mts`.
|
|
* Env: GITHUB_TOKEN (for `gh api`), GITHUB_REPOSITORY ("owner/repo").
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
type Commit,
|
|
type ConventionalParts,
|
|
collectReleaseCommits,
|
|
conventionalParts,
|
|
discoverPublishablePackages,
|
|
releaseRangeStart,
|
|
} from "./create-changeset.mts";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = resolve(__dirname, "..");
|
|
|
|
/** Conventional-commit type → changelog section heading (### level), in order. */
|
|
const GROUPS: { type: string; heading: string }[] = [
|
|
{ type: "feat", heading: "Features" },
|
|
{ type: "fix", heading: "Bug Fixes" },
|
|
{ type: "perf", heading: "Performance" },
|
|
{ type: "revert", heading: "Reverts" },
|
|
];
|
|
|
|
/** An area (commit scope) needs at least this many items for its own sub-group. */
|
|
const AREA_MIN = 3;
|
|
|
|
/** Special-cased area names that shouldn't be naively title-cased. */
|
|
const AREA_ACRONYMS: Record<string, string> = {
|
|
i18n: "i18n",
|
|
css: "CSS",
|
|
ppr: "PPR",
|
|
rsc: "RSC",
|
|
cdn: "CDN",
|
|
ssr: "SSR",
|
|
api: "API",
|
|
og: "OG",
|
|
url: "URL",
|
|
html: "HTML",
|
|
cli: "CLI",
|
|
};
|
|
|
|
/** Turn a commit scope like `app-router` into a human-readable area ("App Router"). */
|
|
export function humanizeArea(scope: string): string {
|
|
return scope
|
|
.split(/[-_/]+/)
|
|
.filter(Boolean)
|
|
.map((w) => AREA_ACRONYMS[w.toLowerCase()] ?? w.charAt(0).toUpperCase() + w.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function pushTo<T>(map: Map<string, T[]>, key: string, value: T): void {
|
|
const arr = map.get(key);
|
|
if (arr) arr.push(value);
|
|
else map.set(key, [value]);
|
|
}
|
|
|
|
const itemLine = (p: ConventionalParts): string =>
|
|
p.scope ? `- **${humanizeArea(p.scope)}:** ${p.description}` : `- ${p.description}`;
|
|
|
|
/**
|
|
* Within a type section, give every area with 3+ items its own `#### <Area>`
|
|
* sub-group (items listed without the now-redundant scope prefix); everything
|
|
* else goes under `#### Misc`. If no area qualifies, the list stays flat.
|
|
*/
|
|
function renderAreaGroups(items: ConventionalParts[]): string {
|
|
const byArea = new Map<string, ConventionalParts[]>();
|
|
for (const it of items) pushTo(byArea, it.scope ?? "", it);
|
|
|
|
const big = [...byArea.entries()]
|
|
.filter(([scope, v]) => scope !== "" && v.length >= AREA_MIN)
|
|
.sort((a, b) => humanizeArea(a[0]).localeCompare(humanizeArea(b[0])));
|
|
if (big.length === 0) return items.map(itemLine).join("\n");
|
|
|
|
const bigScopes = new Set(big.map(([scope]) => scope));
|
|
const sub = big.map(
|
|
([scope, v]) =>
|
|
`#### ${humanizeArea(scope)}\n\n${v.map((p) => `- ${p.description}`).join("\n")}`,
|
|
);
|
|
const other = items.filter((it) => !(it.scope && bigScopes.has(it.scope)));
|
|
if (other.length) sub.push(`#### Misc\n\n${other.map(itemLine).join("\n")}`);
|
|
return sub.join("\n\n");
|
|
}
|
|
|
|
/**
|
|
* Group release commits into `### <Heading>` type sections, each sub-grouped by
|
|
* area. Only the known release types (GROUPS) are rendered; in practice callers
|
|
* pass collectReleaseCommits output, which is already bump-worthy.
|
|
*/
|
|
export function groupedChangelogBody(commits: Commit[]): string {
|
|
const byType = new Map<string, ConventionalParts[]>();
|
|
for (const c of commits) {
|
|
const parts = conventionalParts(c.subject);
|
|
if (parts) pushTo(byType, parts.type, parts);
|
|
}
|
|
return GROUPS.filter((g) => byType.get(g.type)?.length)
|
|
.map((g) => `### ${g.heading}\n\n${renderAreaGroups(byType.get(g.type) ?? [])}`)
|
|
.join("\n\n");
|
|
}
|
|
|
|
/**
|
|
* Replace the body of the newest `## <version>` section with `body`, then append
|
|
* a `### Contributors` list. Older sections are untouched. Only `## <digit>`
|
|
* counts as a section boundary, so re-running is idempotent. Pure.
|
|
*
|
|
* Contributors is `###` (not `##`) deliberately: changesets' getChangelogEntry
|
|
* extracts the GitHub Release body by slicing from the `## <version>` heading to
|
|
* the next *same-depth* (`##`) heading, so a `## Contributors` would truncate
|
|
* the release notes and drop the list. `###` keeps it inside the release body.
|
|
*/
|
|
export function rewriteReleaseSection(
|
|
changelog: string,
|
|
body: string,
|
|
contributors: string[],
|
|
): string {
|
|
const lines = changelog.split("\n");
|
|
const isVersionHeading = (l: string) => /^##\s+\d/.test(l);
|
|
const start = lines.findIndex(isVersionHeading);
|
|
if (start === -1) return changelog;
|
|
let end = lines.length;
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
if (isVersionHeading(lines[i])) {
|
|
end = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const block = [lines[start]]; // the `## <version>` heading
|
|
if (body.trim()) block.push("", body);
|
|
const logins = dedupeSortLogins(contributors);
|
|
if (logins.length) block.push("", "### Contributors", "", ...logins.map((l) => `- @${l}`));
|
|
block.push("");
|
|
|
|
const rebuilt = [...lines.slice(0, start), ...block, ...lines.slice(end)].join("\n");
|
|
return `${rebuilt.replace(/\n{3,}/g, "\n\n").replace(/\s+$/, "")}\n`;
|
|
}
|
|
|
|
/**
|
|
* Strip a leading `@`, keep only valid GitHub login shapes, dedupe (ci) and
|
|
* sort. The `[a-zA-Z0-9-]` check drops `[bot]` accounts and any non-login
|
|
* fallback (e.g. a git display name with spaces) that would render a broken
|
|
* `@`-mention.
|
|
*/
|
|
export function dedupeSortLogins(logins: string[]): string[] {
|
|
const byLower = new Map<string, string>();
|
|
for (const raw of logins ?? []) {
|
|
if (typeof raw !== "string") continue;
|
|
const login = raw.trim().replace(/^@+/, "");
|
|
if (!/^[a-zA-Z0-9-]+$/.test(login)) continue;
|
|
if (!byLower.has(login.toLowerCase())) byLower.set(login.toLowerCase(), login);
|
|
}
|
|
return [...byLower.values()].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
|
|
}
|
|
|
|
// ───────────────────────────── CI glue ─────────────────────────────
|
|
|
|
function readVersions(packageDirToName: Record<string, string>): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const [dir, name] of Object.entries(packageDirToName)) {
|
|
const pkg = JSON.parse(readFileSync(join(REPO_ROOT, dir, "package.json"), "utf8")) as {
|
|
version?: string;
|
|
};
|
|
out[name] = pkg.version ?? "";
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Pick the deduped, sorted GitHub logins for exactly `commits`, looking each
|
|
* commit's sha up in a `sha → login` map. Commits with no mapped login (or an
|
|
* empty one) are dropped. Pure — no git/gh. Bots and non-login shapes are
|
|
* filtered by dedupeSortLogins.
|
|
*/
|
|
export function contributorsForCommits(
|
|
shaToLogin: Map<string, string>,
|
|
commits: Commit[],
|
|
): string[] {
|
|
const logins = commits.map((c) => shaToLogin.get(c.sha)).filter((l): l is string => !!l);
|
|
return dedupeSortLogins(logins);
|
|
}
|
|
|
|
/**
|
|
* GitHub contributor logins for exactly `commits` (the same per-package set used
|
|
* to build the changelog body), not the whole `from..HEAD` range. One paginated
|
|
* compare call builds a `sha → login` map; only the package's own commits are
|
|
* then selected. Returns [] on any failure.
|
|
*/
|
|
function resolveContributors(from: string, repository: string, commits: Commit[]): string[] {
|
|
try {
|
|
const out = execFileSync(
|
|
"gh",
|
|
[
|
|
"api",
|
|
"--paginate",
|
|
`repos/${repository}/compare/${from}...HEAD`,
|
|
// sha/login pairs for the whole range; `// ""` keeps a placeholder for
|
|
// commits whose author is an unlinked email (no valid login).
|
|
"--jq",
|
|
'.commits[] | [.sha, (.author.login // "")] | @tsv',
|
|
],
|
|
{ cwd: REPO_ROOT, encoding: "utf8" },
|
|
);
|
|
const shaToLogin = new Map<string, string>();
|
|
for (const line of out.split("\n")) {
|
|
if (!line) continue;
|
|
const [sha, login = ""] = line.split("\t");
|
|
if (sha && login) shaToLogin.set(sha, login);
|
|
}
|
|
return contributorsForCommits(shaToLogin, commits);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function main(): void {
|
|
const repository = process.env.GITHUB_REPOSITORY || "";
|
|
const packages = discoverPublishablePackages();
|
|
const before = readVersions(packages);
|
|
|
|
console.log("[version] Running `changeset version`...");
|
|
// `vp exec` runs the pinned, installed @changesets/cli — not a floating `dlx` fetch.
|
|
execFileSync("vp", ["exec", "changeset", "version"], { cwd: REPO_ROOT, stdio: "inherit" });
|
|
|
|
const after = readVersions(packages);
|
|
|
|
for (const [dir, name] of Object.entries(packages)) {
|
|
if (!after[name] || after[name] === before[name]) continue; // not bumped
|
|
const changelogPath = join(REPO_ROOT, dir, "CHANGELOG.md");
|
|
if (!existsSync(changelogPath)) {
|
|
console.warn(`[version] ${name}: bumped but no CHANGELOG.md; skipping rewrite.`);
|
|
continue;
|
|
}
|
|
|
|
// Resolve the range from the latest *existing* tag (or first commit when the
|
|
// package has never been released), matching the changeset generator. Using
|
|
// before[name] would derive a ref like `v0.0.1` that need not exist — for a
|
|
// brand-new package that throws and yields an empty changelog (see #1759).
|
|
const from = releaseRangeStart(name);
|
|
const commits = collectReleaseCommits(from, name, packages);
|
|
const body = groupedChangelogBody(commits);
|
|
const contributors = repository ? resolveContributors(from, repository, commits) : [];
|
|
|
|
const original = readFileSync(changelogPath, "utf8");
|
|
const updated = rewriteReleaseSection(original, body, contributors);
|
|
if (updated !== original) {
|
|
writeFileSync(changelogPath, updated, "utf8");
|
|
console.log(`[version] ${name}: grouped changelog + ${contributors.length} contributors.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) main();
|