// Changesets also writes its own per-package CHANGELOG.md files (one per // workspace package, cross-referencing internal "Updated dependencies" // bumps) — `changesets/action` reads those to build the "Version // Packages" PR's own description, so they have to keep existing even // though this is a single self-hosted app, not a set of independently- // consumed libraries. This script instead gives *people* a single // source of truth: one consolidated entry in the root CHANGELOG.md, // written from the pending changeset files *before* `changeset version` // deletes them. That's what the release workflow pulls from for actual // release notes — the scattered per-package files are internal // bookkeeping a self-hoster never needs to look at. import { execFileSync } from "node:child_process"; import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { getCommitInfo } from "@changesets/get-github-info"; const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const changesetDir = path.join(rootDir, ".changeset"); const changelogPath = path.join(rootDir, "CHANGELOG.md"); const rootPkgPath = path.join(rootDir, "package.json"); const changesetConfigPath = path.join(changesetDir, "config.json"); // Matches the category tags CONTRIBUTING.md's "Writing a changeset" // section asks every changeset to start with. A changeset that doesn't // follow the convention (e.g. one written before this convention existed) // still renders — it just lands in "Other" with no lede/details split — // rather than being silently dropped. const CATEGORY_ORDER = ["New", "Improved", "Fixed", "Other"]; function parseChangeset(filePath) { // Windows checkouts may have CRLF line endings. const raw = readFileSync(filePath, "utf8").replace(/\r\n/g, "\n"); const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return null; const [, frontmatter, body] = match; const bumps = [...frontmatter.matchAll(/"[^"]+":\s*(major|minor|patch)/g)].map((m) => m[1]); const summary = body.trim(); return { bumps, summary }; } function bumpVersion(version, bumpType) { const [major, minor, patch] = version.split(".").map(Number); if (bumpType === "major") return `${major + 1}.0.0`; if (bumpType === "minor") return `${major}.${minor + 1}.0`; return `${major}.${minor}.${patch + 1}`; } // Every changeset in this repo lands on main as a single squash-merge // commit, so the commit that added its .md file (not a later edit to its // wording) *is* the commit // `getCommitInfo` needs to look up the PR it came from. Returns null // (never throws) if git can't find one — e.g. a changeset file staged // but not yet committed during local testing. function findIntroducingCommit(filePath) { try { const out = execFileSync("git", ["log", "--diff-filter=A", "-1", "--format=%H", "--", filePath], { cwd: rootDir, encoding: "utf8", }).trim(); return out || null; } catch { return null; } } // Resolves a changeset file to the PR that carried it, for an inline // "([#123](...))" link on its CHANGELOG.md bullet. Best-effort only — // this script runs as part of the automated release pipeline, and a // missing link is a cosmetic issue, never a reason to fail a release: // no GITHUB_TOKEN (e.g. a local dry run), a network hiccup, or GitHub not // yet having indexed a very fresh commit all degrade to "no link". async function resolvePrLink(filePath, repo) { if (!repo || !process.env.GITHUB_TOKEN) return null; const commit = findIntroducingCommit(filePath); if (!commit) return null; try { const info = await getCommitInfo({ repo, commit }); return info?.pull ? { number: info.pull.number, url: info.pull.url } : null; } catch (err) { console.warn(`Couldn't resolve a PR link for ${path.basename(filePath)}: ${err.message}`); return null; } } // Splits a changeset body into one entry per category tag: its category, // its plain-language lede (the first paragraph, on one line with the PR // link), what follows it (bullets and paragraphs, kept as written), and a //
technical block if there is one. A body with more than one tag // ("**Fixed:** ... **New:** ...") becomes one entry under each heading // instead of the second tag being buried in the first; the details block // stays with the first. function splitChangeset(summary) { const text = summary.replace(/\r\n/g, "\n").trim(); const detailsIndex = text.indexOf("
"); const ledePart = (detailsIndex === -1 ? text : text.slice(0, detailsIndex)).trim(); const details = detailsIndex === -1 ? "" : text.slice(detailsIndex).trim(); const tags = [...ledePart.matchAll(/\*\*(New|Improved|Fixed):\*\*\s*/g)]; const parts = tags.length === 0 || tags[0].index !== 0 ? [{ category: "Other", body: ledePart }] : tags.map((tag, i) => ({ category: tag[1], // A tag that opens a bullet ("- **Fixed:** ...") leaves that // bullet's dash behind at the end of the previous part. body: ledePart .slice(tag.index + tag[0].length, tags[i + 1]?.index) .replace(/\n[ \t]*-[ \t]*$/, "") .trim(), })); return parts.map(({ category, body }, i) => { const lines = body.split("\n"); // The lede runs until a blank line or the first bullet. const end = lines.findIndex((line, n) => n > 0 && (line.trim() === "" || /^\s*[-*] /.test(line))); const ledeLines = end === -1 ? lines : lines.slice(0, end); return { category, lede: ledeLines.map((line) => line.trim()).join(" "), more: end === -1 ? "" : lines.slice(end).join("\n").trim(), details: i === 0 ? details : "", }; }); } // Indents a block so it belongs to the list item above it. function indent(block) { return block .split("\n") .map((line) => (line.trim() ? ` ${line}` : "")) .join("\n"); } function formatBullet({ lede, more, details }, prLink) { const link = prLink ? ` ([#${prLink.number}](${prLink.url}))` : ""; return [`- ${lede}${link}`, more && indent(more), details && indent(details)].filter(Boolean).join("\n\n"); } const files = readdirSync(changesetDir) .filter((f) => f.endsWith(".md") && f !== "README.md") .sort(); const changesets = files .map((f) => ({ file: f, ...parseChangeset(path.join(changesetDir, f)) })) .filter((c) => c && c.summary && c.summary.length > 0); if (changesets.length === 0) { console.log("No pending changesets — nothing to write to CHANGELOG.md"); process.exit(0); } const highestBump = changesets.some((c) => c.bumps.includes("major")) ? "major" : changesets.some((c) => c.bumps.includes("minor")) ? "minor" : "patch"; const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8")); const newVersion = bumpVersion(rootPkg.version, highestBump); const today = new Date().toISOString().slice(0, 10); // Single source of truth for the GitHub repo slug, shared with // changeset version's own per-package changelogs (see .changeset/config.json). const changesetConfig = JSON.parse(readFileSync(changesetConfigPath, "utf8")); const repo = Array.isArray(changesetConfig.changelog) ? changesetConfig.changelog[1]?.repo : undefined; let linksResolved = 0; const entries = []; for (const c of changesets) { const prLink = await resolvePrLink(path.join(changesetDir, c.file), repo); if (prLink) linksResolved++; for (const split of splitChangeset(c.summary)) { entries.push({ ...split, bullet: formatBullet(split, prLink) }); } } const byCategory = new Map(); for (const entry of entries) { if (!byCategory.has(entry.category)) byCategory.set(entry.category, []); byCategory.get(entry.category).push(entry.bullet); } const sections = CATEGORY_ORDER.filter((category) => byCategory.has(category)) .map((category) => `### ${category}\n\n${byCategory.get(category).join("\n\n")}`) .join("\n\n"); const newSection = `## [${newVersion}] - ${today}\n\n${sections}\n\n`; const changelog = readFileSync(changelogPath, "utf8"); const firstEntryIndex = changelog.search(/^## \[/m); const insertAt = firstEntryIndex === -1 ? changelog.length : firstEntryIndex; const updated = changelog.slice(0, insertAt) + newSection + changelog.slice(insertAt); writeFileSync(changelogPath, updated); console.log( `Wrote CHANGELOG.md entry for ${newVersion} (${changesets.length} changeset(s), ${highestBump} bump, ${linksResolved} PR link(s) resolved)`, );