scripts/write-changelog.mjs
Rama main · réplica de lectura del repositorio original.
// 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
// <details> 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("<details>");
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)`,
);