scripts/build-release-notes.mjs
Rama main · réplica de lectura del repositorio original.
// Builds a GitHub Release body for one version. CHANGELOG.md stays the
// detailed record (Technical details included); the release body is the
// short, user-facing version of the same section:
//
// ## Highlights <- .github/release-highlights/vX.Y.Z.md, if present
// ## What's changed <- CHANGELOG.md section, <details> blocks stripped
// ## Upgrading <- docker pull line, plus a note if migrations ran
// Full changelog link <- previous tag ... this tag
//
// Usage:
// node scripts/build-release-notes.mjs <version> [previousTag]
// node scripts/build-release-notes.mjs --check-highlights <version>
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
export function highlightsPath(version) {
return path.join(rootDir, ".github", "release-highlights", `v${version}.md`);
}
// HTML comments are stripped so the reminder template (all comments) counts
// as "not written yet" rather than publishing an empty Highlights section.
function stripHtmlComments(text) {
let previous;
do {
previous = text;
text = text.replace(/<!--[\s\S]*?-->/g, "");
} while (text !== previous);
const unclosed = text.indexOf("<!--");
return unclosed === -1 ? text : text.slice(0, unclosed);
}
export function readHighlights(version) {
const file = highlightsPath(version);
if (!existsSync(file)) return null;
const content = stripHtmlComments(readFileSync(file, "utf8")).trim();
return content || null;
}
function readChangelogSection(version) {
const lines = readFileSync(path.join(rootDir, "CHANGELOG.md"), "utf8").split(/\r?\n/);
const start = lines.findIndex((line) => line.startsWith(`## [${version}]`));
if (start === -1) throw new Error(`No CHANGELOG.md section found for version ${version}`);
const next = lines.findIndex((line, i) => i > start && line.startsWith("## ["));
return lines
.slice(start + 1, next === -1 ? undefined : next)
.join("\n")
.trim();
}
function stripTechnicalDetails(section) {
return section
.replace(/[ \t]*<details>[\s\S]*?<\/details>/g, "")
.replace(/[ \t]+$/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function git(args) {
try {
return execFileSync("git", args, { cwd: rootDir, encoding: "utf8" }).trim();
} catch {
return "";
}
}
function compareVersions(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
return 0;
}
function findPreviousTag(version) {
const tags = git(["tag", "--list", "v*"]).split("\n").filter((t) => /^v\d+\.\d+\.\d+$/.test(t));
const older = tags
.map((t) => t.slice(1))
.filter((v) => compareVersions(v, version) < 0)
.sort(compareVersions);
return older.length ? `v${older[older.length - 1]}` : null;
}
function addedMigrations(fromTag, toRef) {
const out = git(["diff", "--name-only", "--diff-filter=A", `${fromTag}..${toRef}`, "--", "packages/db/drizzle"]);
return out.split("\n").filter((f) => f.endsWith(".sql"));
}
function repoSlug() {
const config = JSON.parse(readFileSync(path.join(rootDir, ".changeset", "config.json"), "utf8"));
return Array.isArray(config.changelog) ? config.changelog[1]?.repo : undefined;
}
export function buildReleaseNotes(version, previousTag = findPreviousTag(version)) {
const repo = repoSlug();
const tag = `v${version}`;
const toRef = git(["rev-parse", "--verify", "--quiet", tag]) ? tag : "HEAD";
const parts = [];
const highlights = readHighlights(version);
if (highlights) parts.push(`## Highlights\n\n${highlights}`);
parts.push(`## What's changed\n\n${stripTechnicalDetails(readChangelogSection(version))}`);
const upgrading = [];
if (previousTag && addedMigrations(previousTag, toRef).length > 0) {
upgrading.push(
"This release includes database changes. They're applied automatically the first time the new version starts. Back up your data volume first if you want an easy way to roll back.",
);
}
if (repo) {
upgrading.push(
`\`\`\`bash\ndocker pull ghcr.io/${repo.toLowerCase()}:${tag}\n\`\`\`\n\nOr, with Docker Compose: \`docker compose pull && docker compose up -d\`.`,
);
}
if (upgrading.length) parts.push(`## Upgrading\n\n${upgrading.join("\n\n")}`);
if (repo && previousTag) {
parts.push(
`**Full changelog** (including technical details): [${previousTag}...${tag}](https://github.com/${repo}/compare/${previousTag}...${tag}) · [CHANGELOG.md](https://github.com/${repo}/blob/${tag}/CHANGELOG.md)`,
);
}
return parts.join("\n\n") + "\n";
}
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const args = process.argv.slice(2);
const versionArg = args[0] === "--check-highlights" ? args[1] : args[0];
if (versionArg && !/^\d+\.\d+\.\d+$/.test(versionArg)) {
console.error(`Expected a version like 1.2.3, got "${versionArg}"`);
process.exit(1);
}
if (args[0] === "--check-highlights") {
const version = args[1];
if (!version) {
console.error("Usage: node build-release-notes.mjs --check-highlights <version>");
process.exit(1);
}
if (!readHighlights(version)) {
const rel = path.relative(rootDir, highlightsPath(version)).replace(/\\/g, "/");
console.error(
`Release highlights for v${version} are missing or still only the template. Write them in ${rel} (see CONTRIBUTING.md, "Release highlights").`,
);
process.exit(1);
}
console.log(`Release highlights for v${version} found.`);
} else {
const [version, previousTag] = args;
if (!version) {
console.error("Usage: node build-release-notes.mjs <version> [previousTag]");
process.exit(1);
}
if (previousTag && !/^v\d+\.\d+\.\d+$/.test(previousTag)) {
console.error(`Expected a previous tag like v1.2.3, got "${previousTag}"`);
process.exit(1);
}
try {
process.stdout.write(buildReleaseNotes(version, previousTag || undefined));
} catch (err) {
console.error(err.message);
process.exit(1);
}
}
}