Files
better-auth/.github/scripts/release-notes.ts

917 lines
26 KiB
TypeScript

/**
* Release Notes — deterministic stage of release note generation.
*
* Reads changeset files and pre.json to collect entries, resolves PR metadata
* via `gh` CLI for correct attribution and domain classification, then outputs
* a raw structured changelog for the AI rewriting stage.
*
* This is stage 1 of a 2-stage pipeline:
* Stage 1 (this script): deterministic data extraction + domain classification
* Stage 2 (Claude in CI): rewrite descriptions to be user-focused
*
* CI: PUBLISHED_PACKAGES='[...]' node .github/scripts/release-notes.ts
* Local: node --experimental-strip-types .github/scripts/release-notes.ts \
* --version 1.6.0-beta.0 --branch origin/next --dry-run
*
* Revert-cancellation algorithm and two-stage AI pipeline adapted from
* sst/opencode (MIT License, Copyright (c) 2025 opencode):
* https://github.com/anomalyco/opencode
* script/raw-changelog.ts, script/changelog.ts, script/version.ts
*/
import { execFileSync } from "node:child_process";
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 ──────────────────────────────────────────────────────────────
interface ReleaseEntry {
id: string;
/** PR title (preferred for display) or changeset first-line fallback */
title: string;
/** Full changeset description (kept for AI context, not displayed directly) */
changesetDescription: string | null;
prNumber: number | null;
author: string;
domain: string;
packageName: string;
changeType: "breaking" | "feat" | "fix";
breaking: boolean;
}
interface PRInfo {
author: string;
title: string;
labels: string[];
files: string[];
}
interface ChangesetSnapshot {
ids: string[];
ref: string;
}
// ── Constants ──────────────────────────────────────────────────────────
// ── CLI argument parsing ───────────────────────────────────────────────
function parseArgs(): {
version: string;
branch: string;
dryRun: boolean;
} {
const args = process.argv.slice(2);
let version = "";
let branch = "";
let dryRun = false;
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case "--version":
version = args[++i] ?? "";
break;
case "--branch":
branch = args[++i] ?? "";
break;
case "--dry-run":
dryRun = true;
break;
}
}
if (!version) {
const pkgs = process.env.PUBLISHED_PACKAGES;
if (pkgs) {
const parsed = JSON.parse(pkgs) as { name: string; version: string }[];
version = parsed[0]?.version ?? "";
}
}
if (!version) {
console.error(
"Usage: release-notes.ts --version <ver> [--branch <ref>] [--dry-run]",
);
process.exit(1);
}
return { version, branch, dryRun };
}
// ── Git helpers ────────────────────────────────────────────────────────
function gitShow(ref: string, path: string): string {
return execFileSync("git", ["show", `${ref}:${path}`], {
encoding: "utf-8",
});
}
function readFileFromRef(path: string, branch: string): string {
if (branch) {
return gitShow(branch, path);
}
return readFileSync(path, "utf-8");
}
function listTags(): string[] {
const output = execFileSync(
"git",
["tag", "--sort=-version:refname", "--list", "v*"],
{ encoding: "utf-8" },
);
return output.trim().split("\n").filter(Boolean);
}
// ── Previous tag resolution ────────────────────────────────────────────
/** Parse "1.2.3" into [1, 2, 3]. Returns null on invalid input. */
function parseVersionTuple(ver: string): [number, number, number] | null {
const base = ver.replace(/-.*$/, "");
const m = base.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (!m) return null;
return [Number(m[1]), Number(m[2]), Number(m[3])];
}
/** True if a < b by major.minor.patch comparison. */
function isOlderVersion(a: string, b: string): boolean {
const ta = parseVersionTuple(a);
const tb = parseVersionTuple(b);
if (!ta || !tb) return false;
for (let i = 0; i < 3; i++) {
if (ta[i]! < tb[i]!) return true;
if (ta[i]! > tb[i]!) return false;
}
return false;
}
function findPreviousTag(currentVersion: string, isBeta: boolean): string {
const tags = listTags();
if (isBeta) {
const preMatch = currentVersion.match(/^(.+)-(beta|alpha|rc)\.(\d+)$/);
if (preMatch && Number(preMatch[3]) > 0) {
const prevN = Number(preMatch[3]) - 1;
const channel = preMatch[2];
const prevVersion = `${preMatch[1]}-${channel}.${prevN}`;
const prevTag = `v${prevVersion}`;
if (tags.includes(prevTag)) return prevTag;
}
}
const currentTag = `v${currentVersion}`;
const majorMinorMatch = currentVersion.match(/^(\d+\.\d+)\./);
const majorMinor = majorMinorMatch?.[1];
// First prefer the same major.minor line, then fall back to any stable tag
let fallback: string | undefined;
for (const tag of tags) {
if (tag === currentTag) continue;
const ver = tag.replace(/^v/, "");
if (ver.includes("-") || !isOlderVersion(ver, currentVersion)) continue;
if (majorMinor && ver.startsWith(`${majorMinor}.`)) return tag;
fallback ??= tag;
}
if (fallback) return fallback;
throw new Error("No previous stable tag found");
}
// ── Changeset file parsing ─────────────────────────────────────────────
function parseChangesetFile(content: string): {
packages: Record<string, string>;
description: string;
} {
const parts = content.split("---");
if (parts.length < 3) {
return { packages: {}, description: content.trim() };
}
const frontmatter = parts[1]!;
const description = parts.slice(2).join("---").trim();
const packages: Record<string, string> = {};
for (const line of frontmatter.split("\n")) {
const match = line.match(/^"?([^"]+)"?\s*:\s*(.+)$/);
if (match) {
packages[match[1]!.trim()] = match[2]!.trim();
}
}
return { packages, description };
}
// ── 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);
if (cached) return cached;
const data = ghJSON<{
author: { login: string };
title: string;
labels: { name: string }[];
files: { path: string }[];
}>([
"pr",
"view",
String(prNumber),
"--repo",
REPO,
"--json",
"author,title,labels,files",
]);
const info: PRInfo = {
author: data.author.login,
title: data.title,
labels: data.labels.map((l) => l.name),
files: data.files.map((f) => f.path),
};
prCache.set(prNumber, info);
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(
prInfo: PRInfo | null,
scope: string | undefined,
files: string[],
): string {
if (prInfo) {
for (const label of prInfo.labels) {
if (DOMAIN_ORDER.includes(label as (typeof DOMAIN_ORDER)[number])) {
return label;
}
}
}
return resolveDomain(scope, files);
}
// ── Changeset description index ────────────────────────────────────────
interface ChangesetEntry {
id: string;
description: string;
breaking: boolean;
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>;
orphans: ChangesetEntry[];
byDescription: Map<string, ChangesetEntry>;
} {
const byPR = new Map<number, ChangesetEntry>();
const orphans: ChangesetEntry[] = [];
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
}
// Walk recent ancestors (including merge parents via rev-list's full graph
// traversal) to find a commit that still has changeset files. On main after
// promotion, `changeset version` deletes them, so we may need to look at
// the merged next branch side.
const skipFiles = new Set(["README", "config"]);
const baseRef = branch || "HEAD";
let effectiveBranch = branch;
if (!hasPreJSON) {
try {
const revs = execFileSync(
"git",
["rev-list", "--max-count=15", baseRef],
{
encoding: "utf-8",
},
)
.trim()
.split("\n")
.filter(Boolean);
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 (bestSnapshot) {
effectiveBranch = bestSnapshot.ref;
for (const id of bestSnapshot.ids) ids.add(id);
}
} catch {
// rev-list failed — proceed with whatever pre.json gave us
}
}
for (const id of ids) {
try {
const content = readFileFromRef(`.changeset/${id}.md`, effectiveBranch);
const { packages, description } = parseChangesetFile(content);
if (!description) continue;
const breaking = Object.values(packages).some((b) => b === "major");
const entry: ChangesetEntry = {
id,
description,
breaking,
packageNames: Object.keys(packages),
};
const prMatch = id.match(/^pr-(\d+)$/);
if (prMatch) {
byPR.set(Number(prMatch[1]), entry);
} else {
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
}
}
return { byPR, orphans, byDescription };
}
function packageToDir(name: string): string {
if (name === "auth") return "packages/cli";
if (name === "better-auth") return "packages/better-auth";
return `packages/${name.replace(/^@better-auth\//, "")}`;
}
function packageToChangelogUrl(name: string, ref: string): string {
return `https://github.com/${REPO}/blob/${ref}/${packageToDir(name)}/CHANGELOG.md`;
}
/** Load changeset IDs from the previous beta's pre.json to exclude from orphans. */
function loadPreviousPrereleaseChangesets(version: string): Set<string> {
const preMatch = version.match(/^(.+)-(beta|alpha|rc)\.(\d+)$/);
if (!preMatch || Number(preMatch[3]) === 0) return new Set();
const channel = preMatch[2];
const prevTag = `v${preMatch[1]}-${channel}.${Number(preMatch[3]) - 1}`;
try {
const prevPre = JSON.parse(gitShow(prevTag, ".changeset/pre.json")) as {
changesets: string[];
};
return new Set(prevPre.changesets);
} catch {
return new Set();
}
}
// ── Entry collection ───────────────────────────────────────────────────
/**
* Collects release entries using git history as the ground truth,
* enriched with changeset descriptions where available.
*
* Handles the cherry-pick history gap (where the previous tag is not
* a direct ancestor) using PR-number deduplication, same as
* release-previews.sh.
*/
function collectEntries(version: string, branch: string): ReleaseEntry[] {
const previousTag = findPreviousTag(version, version.includes("-"));
const currentTag = `v${version}`;
let targetRef: string;
try {
execFileSync("git", ["rev-parse", `${currentTag}^{}`], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
targetRef = currentTag;
} catch {
targetRef = branch || "HEAD";
}
// Handle cherry-pick history gap: if the previous tag is NOT a direct
// ancestor, use merge-base + PR deduplication to avoid double-counting
// commits already released via cherry-pick.
let isDirectAncestor = false;
try {
execFileSync(
"git",
["merge-base", "--is-ancestor", previousTag, targetRef],
{
encoding: "utf-8",
},
);
isDirectAncestor = true;
} catch {
// Not a direct ancestor
}
let log: string;
const alreadyReleasedPRs = new Set<string>();
let alreadyPublishedPRs: Set<string> | null = null;
if (isDirectAncestor) {
log = execFileSync(
"git",
["log", `${previousTag}..${targetRef}`, "--no-merges", "--oneline"],
{ encoding: "utf-8" },
);
} else {
const mergeBase = execFileSync(
"git",
["merge-base", previousTag, targetRef],
{ encoding: "utf-8" },
).trim();
console.log(` Cherry-pick mode: common ancestor ${mergeBase.slice(0, 7)}`);
const tagLog = execFileSync(
"git",
["log", `${mergeBase}..${previousTag}`, "--oneline"],
{ encoding: "utf-8" },
);
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",
["log", `${mergeBase}..${targetRef}`, "--no-merges", "--oneline"],
{ encoding: "utf-8" },
);
}
let lines = log.trim().split("\n").filter(Boolean);
if (alreadyReleasedPRs.size > 0) {
const before = lines.length;
lines = lines.filter((line) => {
const prMatch = line.match(/\(#(\d+)\)/);
if (!prMatch) return true;
const prNumber = prMatch[1]!;
if (!alreadyReleasedPRs.has(prNumber)) return true;
if (alreadyPublishedPRs) return !alreadyPublishedPRs.has(prNumber);
return false;
});
const filterLabel = alreadyPublishedPRs
? "already-published"
: "already-released";
console.log(` Filtered ${before - lines.length} ${filterLabel} PRs`);
}
// Cancel out revert/original pairs
const seen = new Map<string, { hash: string; msg: string }>();
for (const line of lines) {
const spaceIdx = line.indexOf(" ");
const hash = line.slice(0, spaceIdx);
const msg = line.slice(spaceIdx + 1);
const revertMatch = msg.match(/^Revert "(.+)"$/);
if (revertMatch) {
if (seen.has(revertMatch[1]!)) {
seen.delete(revertMatch[1]!);
} else {
seen.set(msg, { hash, msg });
}
continue;
}
const revertKey = `Revert "${msg}"`;
if (seen.has(revertKey)) {
seen.delete(revertKey);
continue;
}
seen.set(msg, { hash, msg });
}
// Use the tag ref when it exists to avoid reading newer changesets from
// a branch that has advanced past the tagged release.
const changesetRef = targetRef === currentTag ? targetRef : branch;
const {
byPR: changesetByPR,
orphans: changesetOrphans,
byDescription: changesetByDesc,
} = buildChangesetIndex(changesetRef);
if (changesetByPR.size > 0 || changesetOrphans.length > 0) {
console.log(
` Loaded ${changesetByPR.size} changeset descriptions, ${changesetOrphans.length} orphans`,
);
}
const entries: ReleaseEntry[] = [];
const seenPRs = new Set<number>();
const consumedOrphans = new Set<ChangesetEntry>();
for (const { msg } of seen.values()) {
const parsed = parseConventionalCommit(msg);
// Direct commits without PRs are infra/version bumps, not user-facing
const prMatch = msg.match(/\(#(\d+)\)$/);
if (!prMatch) continue;
const prNumber = Number(prMatch[1]);
if (seenPRs.has(prNumber)) continue;
// A PR with a changeset should appear even if its type is docs:/chore:/etc.
const descMatch = changesetByDesc.get(parsed.subject.toLowerCase().trim());
const changeset = changesetByPR.get(prNumber) ?? descMatch;
if (descMatch) consumedOrphans.add(descMatch);
if (
!changeset &&
["chore", "docs", "ci", "test", "style", "build"].includes(parsed.type)
) {
continue;
}
seenPRs.add(prNumber);
let author = "unknown";
let title: string;
let domain: string;
let packageName: string;
let breaking = parsed.breaking;
const changesetDescription = changeset?.description ?? null;
if (changeset?.breaking) breaking = true;
try {
const prInfo = fetchPR(prNumber);
author = prInfo.author;
title = prInfo.title;
domain = classifyEntry(prInfo, parsed.scope || undefined, prInfo.files);
packageName =
changeset?.packageNames.length === 1
? changeset.packageNames[0]!
: resolvePackage(parsed.scope || undefined, prInfo.files);
if (prInfo.labels.includes("breaking")) breaking = true;
} catch {
title = parsed.subject.replace(/\s*\(#\d+\)$/, "");
domain = resolveDomain(parsed.scope || undefined, []);
packageName =
changeset?.packageNames.length === 1
? changeset.packageNames[0]!
: resolvePackage(parsed.scope || undefined, []);
}
entries.push({
id: changeset ? `pr-${prNumber}` : `git-${prNumber}`,
title,
changesetDescription,
prNumber,
author,
domain,
packageName,
changeType: classifyChangeType(parsed.type, breaking),
breaking,
});
}
const previousBetaChangesets = loadPreviousPrereleaseChangesets(version);
const commitHashes = new Set([...seen.values()].map(({ hash }) => hash));
for (const changeset of changesetOrphans) {
if (consumedOrphans.has(changeset)) continue;
if (previousBetaChangesets.has(changeset.id)) continue;
const commitMatch = changeset.id.match(/^commit-([a-f0-9]+)$/);
if (
commitMatch &&
![...commitHashes].some((h) => h.startsWith(commitMatch[1]!))
) {
continue;
}
const pkgPaths = changeset.packageNames.map((n) => `${packageToDir(n)}/`);
const domain = resolveDomain(undefined, pkgPaths);
if (FILTERED_DOMAINS.has(domain)) continue;
entries.push({
id: changeset.id,
title: changeset.description.split("\n")[0]!,
changesetDescription: changeset.description,
prNumber: null,
author: "unknown",
domain,
packageName: resolvePackage(undefined, pkgPaths),
changeType: classifyChangeType("fix", changeset.breaking),
breaking: changeset.breaking,
});
}
return entries;
}
// ── Formatting ─────────────────────────────────────────────────────────
interface FormatOptions {
version: string;
commitRef: string;
entries: ReleaseEntry[];
previousTag: 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, commitRef, entries, previousTag } = opts;
const lines: string[] = [];
// Blog post link for minor releases (x.y.0) only
const minorMatch = version.match(/^(\d+)\.(\d+)\.0$/);
if (minorMatch) {
const blogSlug = `${minorMatch[1]}-${minorMatch[2]}`;
lines.push(
`**Blog post:** [Better Auth ${minorMatch[1]}.${minorMatch[2]}](https://better-auth.com/blog/${blogSlug})`,
);
lines.push("");
}
// Group entries by package
const grouped = new Map<string, ReleaseEntry[]>();
const contributors = new Set<string>();
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("");
for (const changeType of CHANGE_TYPE_ORDER) {
const typeEntries = pkgEntries.filter((e) => e.changeType === changeType);
if (typeEntries.length === 0) continue;
typeEntries.sort((a, b) => a.title.localeCompare(b.title));
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}))`
: "";
lines.push(`- ${entry.title}${prLink}`);
if (changeType === "breaking" && entry.changesetDescription) {
// Include changeset description so the raw fallback still
// carries migration guidance when AI is skipped.
for (const line of entry.changesetDescription.split("\n").slice(1)) {
lines.push(line ? ` ${line}` : "");
}
}
}
lines.push("");
}
const changelogUrl = packageToChangelogUrl(pkg, commitRef);
lines.push(`For detailed changes, see [\`CHANGELOG\`](${changelogUrl})`);
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})`,
);
return lines.join("\n");
}
// ── Main ───────────────────────────────────────────────────────────────
const { version, branch, dryRun } = parseArgs();
const isBeta = version.includes("-");
const previousTag = findPreviousTag(version, isBeta);
const commitRef =
process.env.GITHUB_SHA ??
execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf-8" }).trim();
console.log(`Generating release notes for v${version}`);
console.log(` Previous tag: ${previousTag}`);
console.log(` Release type: ${isBeta ? "pre-release" : "stable"}`);
console.log(` Branch: ${branch || "HEAD"}`);
console.log(` Commit: ${commitRef.slice(0, 12)}`);
console.log("");
console.log("Collecting entries...");
const entries = collectEntries(version, branch);
console.log(` Found ${entries.length} entries`);
console.log("");
const body = formatReleaseBody({
version,
commitRef,
entries,
previousTag,
});
// Build changeset context file for the AI rewriting stage.
// Maps each PR to its full changeset description so the AI can use it
// as background when rewriting the one-line titles.
const changesetContext: Record<number, string> = {};
for (const entry of entries) {
if (entry.prNumber && entry.changesetDescription) {
changesetContext[entry.prNumber] = entry.changesetDescription;
}
}
if (dryRun) {
console.log("=== DRY RUN — Raw changelog ===\n");
console.log(body);
if (Object.keys(changesetContext).length > 0) {
console.log("\n=== Changeset context (for AI) ===\n");
console.log(JSON.stringify(changesetContext, null, 2));
}
} else {
// Write inside the repo directory so claude-code-action can read it
const rawFile = join(process.cwd(), `.release-notes-raw-${version}.md`);
writeFileSync(rawFile, body);
console.log(`Wrote raw changelog to ${rawFile}`);
// Write changeset descriptions as AI context
if (Object.keys(changesetContext).length > 0) {
const contextFile = join(
process.cwd(),
`.release-notes-context-${version}.json`,
);
writeFileSync(contextFile, JSON.stringify(changesetContext, null, 2));
console.log(`Wrote changeset context to ${contextFile}`);
setOutput("context_path", contextFile);
} else {
console.log(
"No changeset context available (AI enrichment will be skipped)",
);
}
setOutput("version", version);
setOutput("previous_tag", previousTag);
setOutput("is_beta", String(isBeta));
setOutput("raw_changelog_path", rawFile);
}