Files
better-auth/.github/scripts/auto-changeset.ts
2026-04-06 10:16:37 +00:00

205 lines
6.3 KiB
TypeScript

/**
* Auto-changeset analysis — deterministic phase of changeset generation.
*
* Separated from the workflow so that secrets-dependent steps (AI, commit)
* never execute code from the PR branch. This script runs from the base
* branch checkout and fetches all PR data via the GitHub API.
*
* Usage: GITHUB_TOKEN=... PR_NUMBER=... npx tsx .github/scripts/auto-changeset.ts
*/
import { gh, ghJSON, REPO, setOutput } from "./lib/github.ts";
import { mapTypeToBump, parseConventionalCommit } from "./lib/pr-analyzer.ts";
// ── Types ──────────────────────────────────────────────────────────────
interface PRData {
number: number;
title: string;
body: string;
headRef: string;
baseRef: string;
labels: string[];
isFork: boolean;
changedFiles: string[];
}
// ── Constants ──────────────────────────────────────────────────────────
const CUBIC_OPEN = "<!-- This is an auto-generated description by cubic. -->";
const CUBIC_CLOSE = "<!-- End of auto-generated description by cubic. -->";
// ── PR data fetching ───────────────────────────────────────────────────
function fetchPR(prNumber: number): PRData {
const pr = ghJSON<{
title: string;
body: string;
headRefName: string;
baseRefName: string;
labels: { name: string }[];
isCrossRepository: boolean;
}>([
"pr",
"view",
String(prNumber),
"--repo",
REPO,
"--json",
"title,body,headRefName,baseRefName,labels,isCrossRepository",
]);
const filesRaw = gh([
"api",
`repos/${REPO}/pulls/${prNumber}/files`,
"--paginate",
"-q",
".[] | .filename",
]);
const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
return {
number: prNumber,
title: pr.title,
body: pr.body ?? "",
headRef: pr.headRefName,
baseRef: pr.baseRefName,
labels: pr.labels.map((l) => l.name),
isFork: pr.isCrossRepository,
changedFiles: files,
};
}
function extractCubicSummary(body: string): string {
const start = body.indexOf(CUBIC_OPEN);
const end = body.indexOf(CUBIC_CLOSE);
if (start === -1 || end === -1) return "";
const block = body.slice(start + CUBIC_OPEN.length, end).trim();
const cleaned = block
.replace(/^---\s*\n/, "")
.replace(/^## Summary by cubic\s*\n+/, "");
const summaryEnd = cleaned.search(/\n\s*- (\*\*|\w)|<sup>/);
return (summaryEnd === -1 ? cleaned : cleaned.slice(0, summaryEnd)).trim();
}
function hasPackageChanges(files: string[]): boolean {
return files.some((f) => f.startsWith("packages/"));
}
// ── Main ───────────────────────────────────────────────────────────────
function main() {
const prNumber = Number(process.env.PR_NUMBER);
if (!prNumber) {
console.error("PR_NUMBER environment variable required");
process.exit(1);
}
console.log(`Analyzing PR #${prNumber}`);
const pr = fetchPR(prNumber);
// Promote PRs (next → main) already carry versioned changesets — skip entirely
if (pr.headRef === "next" && pr.baseRef === "main" && !pr.isFork) {
console.log("Skipping: promote PR (next → main) — already versioned");
setOutput("skip", "true");
setOutput(
"skip_reason",
"promote PR (next → main) already contains versioned changesets",
);
return;
}
const commit = parseConventionalCommit(pr.title);
const bump = mapTypeToBump(commit.type, commit.breaking);
const touchesPackages = hasPackageChanges(pr.changedFiles);
// Auto-generated changesets (pr-{N}.md) can be safely regenerated.
// Only manually-created changesets (different filename) block re-generation.
const autoChangesetPath = `.changeset/pr-${prNumber}.md`;
const changesetFiles = pr.changedFiles.filter(
(f) =>
f.startsWith(".changeset/") &&
f.endsWith(".md") &&
!f.endsWith("README.md"),
);
const hasAutoChangeset = changesetFiles.includes(autoChangesetPath);
const hasManualChangeset = changesetFiles.some(
(f) => f !== autoChangesetPath,
);
// FORCE mode (set by /changeset command) bypasses most skip gates
// but still respects hard constraints (no packages, policy violations)
const force = process.env.FORCE === "true";
function skip(reason: string): void {
console.log(`Skipping: ${reason}`);
setOutput("skip", "true");
setOutput("skip_reason", reason);
}
if (!force) {
if (hasManualChangeset) {
return skip("manual changeset already exists");
}
if (pr.labels.includes("skip-changeset")) {
return skip("skip-changeset label");
}
if (bump === "skip") {
return skip(`type "${commit.type}" does not need a changeset`);
}
if (!touchesPackages) {
return skip("no package files changed");
}
} else {
console.log("FORCE mode: skip gates bypassed");
if (hasManualChangeset) {
setOutput("has_existing", "true");
}
if (!touchesPackages) {
return skip("no package files changed — nothing to release");
}
}
if (hasAutoChangeset) {
setOutput("has_existing", "true");
}
let resolvedBump = bump === "skip" ? "patch" : bump;
// main and release/* only accept patch
const patchOnly = pr.baseRef === "main" || pr.baseRef.startsWith("release/");
if (patchOnly && resolvedBump !== "patch") {
if (force) {
console.log(
`Capping ${resolvedBump} to patch on ${pr.baseRef} (patch-only branch)`,
);
resolvedBump = "patch";
} else {
return skip(
`${resolvedBump} bump on ${pr.baseRef} (patch only). Retarget this PR to next.`,
);
}
}
const cubicSummary = extractCubicSummary(pr.body);
const fallback = cubicSummary || commit.subject || pr.title;
// All packages are in one changesets fixed group — listing any one
// bumps them all together. "better-auth" is the representative.
const frontmatter = `"better-auth": ${resolvedBump}`;
console.log("Analysis complete:");
setOutput("skip", "false");
setOutput("bump", resolvedBump);
setOutput("frontmatter", frontmatter);
setOutput("pr_title", pr.title);
setOutput("cubic_summary", cubicSummary);
setOutput("fallback_description", fallback);
setOutput("changed_files", pr.changedFiles.slice(0, 50).join("\n"));
}
main();