#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; const DEFAULT_REPO = "earendil-works/pi"; const DEFAULT_BASE_PATH = "packages/coding-agent"; const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md"; const DEFAULT_FIX_SINCE_TAG = "v0.74.0"; const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/; const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g; function printUsage() { console.log(`Usage: node scripts/release-notes.mjs [options] Commands: extract Extract release notes from the coding-agent changelog fix-github-releases Rewrite existing GitHub release note links in place extract options: --version Version to extract --tag Release tag used for repository links (defaults to v) --changelog Changelog path (default: ${DEFAULT_CHANGELOG}) --out Output file (default: stdout) --repo GitHub repository for generated links (default: ${DEFAULT_REPO}) --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) fix-github-releases options: --repo GitHub repository to patch (default: ${DEFAULT_REPO}) --tag Patch only one release tag --since-tag Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG}) --base-path Base path for relative changelog links (default: ${DEFAULT_BASE_PATH}) --dry-run Print releases that would change without updating GitHub `); } function commandForPlatform(command) { return process.platform === "win32" ? `${command}.cmd` : command; } function run(command, args, options = {}) { const result = spawnSync(commandForPlatform(command), args, { cwd: options.cwd, encoding: "utf8", maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024, stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit", }); if (result.status !== 0) { const output = [result.stdout, result.stderr].filter(Boolean).join("\n"); throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`); } return result.stdout ?? ""; } function parseOptions(args) { const options = { basePath: DEFAULT_BASE_PATH, changelog: DEFAULT_CHANGELOG, dryRun: false, out: undefined, repo: DEFAULT_REPO, sinceTag: DEFAULT_FIX_SINCE_TAG, tag: undefined, version: undefined, }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--help") { printUsage(); process.exit(0); } if (arg === "--dry-run") { options.dryRun = true; continue; } const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]); if (!optionNames.has(arg)) { throw new Error(`Unknown option: ${arg}`); } const value = args[++i]; if (!value) { throw new Error(`${arg} requires a value`); } if (arg === "--base-path") options.basePath = value; if (arg === "--changelog") options.changelog = value; if (arg === "--out") options.out = value; if (arg === "--repo") options.repo = value; if (arg === "--since-tag") options.sinceTag = value; if (arg === "--tag") options.tag = value; if (arg === "--version") options.version = value; } return options; } function normalizeTag(tagOrVersion) { if (!tagOrVersion) { return undefined; } return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`; } function versionFromTag(tag) { return tag.startsWith("v") ? tag.slice(1) : tag; } function compareVersions(a, b) { const aParts = versionFromTag(a).split(".").map(Number); const bParts = versionFromTag(b).split(".").map(Number); for (let i = 0; i < 3; i++) { const diff = (aParts[i] || 0) - (bParts[i] || 0); if (diff !== 0) { return diff; } } return 0; } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function extractChangelogSection(changelog, version) { const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m"); const heading = headingRe.exec(changelog); if (!heading) { return ""; } const sectionStart = heading.index + heading[0].length; const rest = changelog.slice(sectionStart); const nextHeading = rest.search(/^## \[/m); const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); return section.trim(); } function splitLocalTarget(target) { const hashIndex = target.indexOf("#"); const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex); const fragment = hashIndex === -1 ? "" : target.slice(hashIndex); const queryIndex = beforeHash.indexOf("?"); if (queryIndex === -1) { return { fragment, pathPart: beforeHash, query: "" }; } return { fragment, pathPart: beforeHash.slice(0, queryIndex), query: beforeHash.slice(queryIndex), }; } function normalizePathPart(value) { return value.replaceAll("\\", "/"); } function normalizeBasePath(basePath) { const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, ""); return normalized === "." ? "" : normalized; } function resolveRepositoryPath(targetPath, basePath) { const normalizedTarget = normalizePathPart(targetPath); const joined = normalizedTarget.startsWith("/") ? path.posix.normalize(normalizedTarget.replace(/^\/+/, "")) : path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget)); if (joined === "." || joined.startsWith("../") || joined === "..") { return undefined; } return joined; } function isDirectoryTarget(originalPath, repositoryPath) { if (originalPath.endsWith("/")) { return true; } const basename = path.posix.basename(repositoryPath); return !basename.includes("."); } function normalizeLinkTarget(target, options) { let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`); const repoUrl = `https://github.com/${options.repo}`; for (const route of ["blob", "tree"]) { for (const branch of ["main", "master"]) { const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`; if (canonicalTarget.startsWith(floatingRefPrefix)) { canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`; } } } if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) { return canonicalTarget; } const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget); if (!pathPart) { return canonicalTarget; } const repositoryPath = resolveRepositoryPath(pathPart, options.basePath); if (!repositoryPath) { return canonicalTarget; } const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob"; return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`; } function normalizeReleaseNoteLinks(markdown, options) { const changes = []; const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => { const normalizedTarget = normalizeLinkTarget(target, options); if (normalizedTarget !== target) { changes.push({ from: target, to: normalizedTarget }); } return `${prefix}${normalizedTarget}${suffix}`; }); return { changes, markdown: normalized }; } function writeOutput(content, outPath) { if (outPath) { writeFileSync(outPath, content); return; } process.stdout.write(content); } function extractReleaseNotes(options) { const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined); if (!version) { throw new Error("extract requires --version or --tag"); } if (!existsSync(options.changelog)) { throw new Error(`Changelog does not exist: ${options.changelog}`); } const tag = normalizeTag(options.tag ?? version); const changelog = readFileSync(options.changelog, "utf8"); const section = extractChangelogSection(changelog, version); const rawNotes = section ? `${section}\n` : `Release ${version}\n`; const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag }); writeOutput(markdown, options.out); } function listGithubReleases(repo) { const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], { capture: true, }); return output .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line)); } function uniqueChanges(changes) { const seen = new Set(); const unique = []; for (const change of changes) { const key = `${change.from}\n${change.to}`; if (seen.has(key)) { continue; } seen.add(key); unique.push(change); } return unique; } function updateGithubRelease(repo, tag, body) { const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-")); try { const notesPath = path.join(tempDir, "notes.md"); writeFileSync(notesPath, body); run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true }); } finally { rmSync(tempDir, { force: true, recursive: true }); } } function fixGithubReleases(options) { const tagFilter = normalizeTag(options.tag); const sinceTag = normalizeTag(options.sinceTag); const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter); if (tagFilter && matchingReleases.length === 0) { throw new Error(`Release not found: ${tagFilter}`); } const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0); if (tagFilter && releases.length === 0) { console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`); console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`); return; } let changedCount = 0; for (const release of releases) { const tag = release.tag_name; const body = release.body ?? ""; const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag }); if (result.markdown === body) { continue; } changedCount++; const unique = uniqueChanges(result.changes); console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`); for (const change of unique) { console.log(` ${change.from}`); console.log(` -> ${change.to}`); } if (!options.dryRun) { updateGithubRelease(options.repo, tag, result.markdown); } } const prefix = options.dryRun ? "Would update" : "Updated"; console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`); } try { const [command, ...args] = process.argv.slice(2); if (!command || command === "--help") { printUsage(); process.exit(command ? 0 : 1); } const options = parseOptions(args); if (command === "extract") { extractReleaseNotes(options); } else if (command === "fix-github-releases") { fixGithubReleases(options); } else { throw new Error(`Unknown command: ${command}`); } } catch (error) { console.error(error instanceof Error ? error.message : error); process.exit(1); }