#!/usr/bin/env node /** * Enhances the changeset release PR with a well-written, deduplicated summary. * * Reads: * - The raw changeset PR body (via CHANGESET_PR_BODY env var or stdin) * - .server-changes/*.md files for server-only changes * * Outputs a formatted PR body to stdout that includes: * - A clean summary with categories * - Server changes section * - The raw changeset output in a collapsed
section * * Usage: * CHANGESET_PR_BODY="..." node scripts/enhance-release-pr.mjs * echo "$PR_BODY" | node scripts/enhance-release-pr.mjs */ import { promises as fs } from "fs"; import { execFile } from "child_process"; import { join } from "path"; const version = process.argv[2]; if (!version) { console.error("Usage: node scripts/enhance-release-pr.mjs "); process.exit(1); } const ROOT_DIR = join(import.meta.dirname, ".."); // --- Parse changeset PR body --- function parsePrBody(body) { const entries = []; if (!body) return entries; // Deduplicate by entry content. A single changeset that targets multiple // packages is rendered once per package section, so the same text repeats and // we collapse it. But several distinct changesets from one PR have distinct // text (and each still carries that PR's link), so keying on content keeps // them all instead of dropping every entry after the first for that PR. const seen = new Set(); // A standalone dependency-bump list item, e.g. "`@trigger.dev/core@4.5.0-rc.7`" // or "trigger.dev@4.5.0-rc.7". These normally appear nested under // "Updated dependencies:" (and so get swallowed into that item below), but we // guard against them showing up on their own too. Crucially this only matches // a line that is *entirely* a package bump, so a real changeset that merely // begins with a package name (e.g. "`@trigger.dev/sdk` now bundles ...") is // kept. const depBumpPattern = /^`?(?:@trigger\.dev\/[\w-]+|trigger\.dev)@[\w.+-]+`?$/; // Group lines into top-level list items. A top-level item starts with a bullet // at column 0 ("- " / "* "); every indented or blank line below it (sub-bullets, // fenced code blocks, continuation paragraphs) belongs to that same item. const items = []; let current = null; const flush = () => { if (!current) return; while (current.length > 1 && current[current.length - 1].trim() === "") { current.pop(); } items.push(current); current = null; }; for (const line of body.split("\n")) { const isTopLevelBullet = /^[-*]\s+/.test(line); if (isTopLevelBullet) { flush(); current = [line]; } else if (current) { if (line.trim() === "" || /^\s/.test(line)) { current.push(line); } else { // A non-indented, non-blank, non-bullet line (heading or prose) ends the item flush(); } } } flush(); for (const itemLines of items) { const headLine = itemLines[0].replace(/^[-*]\s+/, "").trim(); if (!headLine) continue; // Skip dependency-only updates if (headLine.startsWith("Updated dependencies")) continue; if (depBumpPattern.test(headLine)) continue; // Reconstruct the full item: head line + dedented continuation lines, so // code blocks and sub-bullets survive. Continuation under a "- " item is // indented 4 spaces; strip up to 4 to bring it back to the base level. const continuation = itemLines.slice(1).map((l) => l.replace(/^ {1,4}/, "")); const text = [headLine, ...continuation].join("\n").replace(/\s+$/, ""); // Deduplicate on the full entry text (which embeds the PR link). The same // changeset echoed across package sections collapses to one, while multiple // distinct changesets from a single PR are each preserved. const dedupeKey = text.replace(/\s+/g, " ").trim(); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); // Categorize off the head line const lower = headLine.toLowerCase(); let type = "improvement"; if (lower.startsWith("fix") || lower.includes("bug fix")) { type = "fix"; } else if ( lower.startsWith("feat") || lower.includes("new feature") || lower.includes("add support") || lower.includes("added support") || lower.includes("expose") || lower.includes("allow") ) { type = "feature"; } else if (lower.includes("breaking")) { type = "breaking"; } entries.push({ text, type }); } return entries; } // --- Git + GitHub helpers for finding PR numbers --- const REPO = "triggerdotdev/trigger.dev"; function gitExec(args) { return new Promise((resolve, reject) => { execFile("git", args, { cwd: ROOT_DIR, maxBuffer: 1024 * 1024 }, (err, stdout) => { if (err) reject(err); else resolve(stdout.trim()); }); }); } async function getCommitForFile(filePath) { try { // Find the commit that added this file const sha = await gitExec(["log", "--diff-filter=A", "--format=%H", "--", filePath]); return sha.split("\n")[0] || null; } catch { return null; } } async function getPrForCommit(commitSha) { const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; if (!token || !commitSha) return null; try { const res = await fetch(`https://api.github.com/repos/${REPO}/commits/${commitSha}/pulls`, { headers: { Authorization: `token ${token}`, Accept: "application/vnd.github.v3+json", }, }); if (!res.ok) return null; const pulls = await res.json(); if (!pulls.length) return null; // Prefer merged PRs, earliest merge first (same logic as @changesets/get-github-info) const sorted = pulls.sort((a, b) => { if (!a.merged_at && !b.merged_at) return 0; if (!a.merged_at) return 1; if (!b.merged_at) return -1; return new Date(a.merged_at) - new Date(b.merged_at); }); return sorted[0].number; } catch { return null; } } // --- Parse .server-changes/ files --- async function parseServerChanges() { const entries = []; const fileData = await getServerChangeFileData(); // Look up commits for all files in parallel const commits = await Promise.all(fileData.map((f) => getCommitForFile(f.filePath))); // Look up PRs for all commits in parallel const prNumbers = await Promise.all(commits.map((sha) => getPrForCommit(sha))); for (let i = 0; i < fileData.length; i++) { const { parsed } = fileData[i]; let text = parsed.body.trim(); const pr = prNumbers[i]; // Append PR link if we found one and it's not already in the text if (pr && !text.includes(`#${pr}`)) { text += ` ([#${pr}](https://github.com/${REPO}/pull/${pr}))`; } entries.push({ text, type: parsed.frontmatter.type || "improvement", area: parsed.frontmatter.area || "webapp", }); } return entries; } async function getServerChangeFileData() { // The changesets version command deletes .server-changes before this script // enhances the release PR body. We combine files still live on disk with the // ones recovered from the release branch diff, deduped by filename, rather // than picking one source or the other. This is additive so a partial cleanup // (some files deleted, some still live) can't silently drop entries. Live // files win on collision since they are the current on-disk truth. const [live, deleted] = await Promise.all([ getLiveServerChangeFileData(), getDeletedServerChangeFileDataFromReleaseBranch(), ]); const byName = new Map(); for (const fileData of deleted) { byName.set(fileData.filePath.split("/").pop(), fileData); } for (const fileData of live) { byName.set(fileData.filePath.split("/").pop(), fileData); } return [...byName.values()].sort((a, b) => a.filePath.localeCompare(b.filePath)); } async function getLiveServerChangeFileData() { const dir = join(ROOT_DIR, ".server-changes"); let files; try { files = await fs.readdir(dir); } catch { return []; } const fileData = []; for (const file of files.sort()) { if (!file.endsWith(".md") || file === "README.md") continue; const filePath = join(".server-changes", file); const content = await fs.readFile(join(dir, file), "utf-8"); const parsed = parseFrontmatter(content); if (!parsed.body.trim()) continue; fileData.push({ filePath, parsed }); } return fileData; } async function getDeletedServerChangeFileDataFromReleaseBranch() { const baseRef = process.env.SERVER_CHANGES_BASE_REF || "origin/main"; const releaseRef = process.env.SERVER_CHANGES_RELEASE_REF || "origin/changeset-release/main"; let mergeBase; let deletedFiles; try { mergeBase = await gitExec(["merge-base", baseRef, releaseRef]); deletedFiles = await gitExec([ "diff", "--name-only", "--diff-filter=D", `${mergeBase}..${releaseRef}`, "--", ".server-changes", ]); } catch (err) { console.error( "[enhance-release-pr] failed to recover deleted server-changes from release branch:", err ); return []; } const fileData = []; for (const filePath of deletedFiles.split("\n").filter(Boolean).sort()) { const file = filePath.split("/").pop(); if (!file?.endsWith(".md") || file === "README.md") continue; try { const content = await gitExec(["show", `${mergeBase}:${filePath}`]); const parsed = parseFrontmatter(content); if (!parsed.body.trim()) continue; fileData.push({ filePath, parsed }); } catch (err) { // If an individual file cannot be recovered, skip it rather than hiding // all other server changes. console.error(`[enhance-release-pr] failed to read deleted server-change ${filePath}:`, err); } } return fileData; } function parseFrontmatter(content) { const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!match) return { frontmatter: {}, body: content }; const frontmatter = {}; for (const line of match[1].split("\n")) { const [key, ...rest] = line.split(":"); if (key && rest.length) { frontmatter[key.trim()] = rest.join(":").trim(); } } return { frontmatter, body: match[2] }; } // --- Format the enhanced PR body --- // Render an entry as a list item, re-indenting continuation lines (code blocks, // sub-bullets, paragraphs) by 2 spaces so they stay inside the "- " bullet. function renderEntry(text) { return `- ${text.replace(/\n/g, "\n ")}`; } function formatPrBody({ version, packageEntries, serverEntries, rawBody }) { const lines = []; const features = packageEntries.filter((e) => e.type === "feature"); const fixes = packageEntries.filter((e) => e.type === "fix"); const improvements = packageEntries.filter((e) => e.type === "improvement" || e.type === "other"); const breaking = packageEntries.filter((e) => e.type === "breaking"); const serverFeatures = serverEntries.filter((e) => e.type === "feature"); const serverFixes = serverEntries.filter((e) => e.type === "fix"); const serverImprovements = serverEntries.filter((e) => e.type === "improvement"); const serverBreaking = serverEntries.filter((e) => e.type === "breaking"); const totalFeatures = features.length + serverFeatures.length; const totalFixes = fixes.length + serverFixes.length; const totalImprovements = improvements.length + serverImprovements.length; // Summary line const parts = []; if (totalFeatures > 0) parts.push(`${totalFeatures} new feature${totalFeatures > 1 ? "s" : ""}`); if (totalImprovements > 0) parts.push(`${totalImprovements} improvement${totalImprovements > 1 ? "s" : ""}`); if (totalFixes > 0) parts.push(`${totalFixes} bug fix${totalFixes > 1 ? "es" : ""}`); if (parts.length > 0) { lines.push(`## Summary`); lines.push(`${parts.join(", ")}.`); lines.push(""); } // Breaking changes if (breaking.length > 0 || serverBreaking.length > 0) { lines.push("## Breaking changes"); for (const entry of [...breaking, ...serverBreaking]) lines.push(renderEntry(entry.text)); lines.push(""); } // Highlights (features) if (features.length > 0) { lines.push("## Highlights"); lines.push(""); for (const entry of features) { lines.push(renderEntry(entry.text)); } lines.push(""); } // Improvements if (improvements.length > 0) { lines.push("## Improvements"); for (const entry of improvements) lines.push(renderEntry(entry.text)); lines.push(""); } // Bug fixes if (fixes.length > 0) { lines.push("## Bug fixes"); for (const entry of fixes) lines.push(renderEntry(entry.text)); lines.push(""); } // Server changes const allServer = [...serverFeatures, ...serverImprovements, ...serverFixes]; if (allServer.length > 0) { lines.push("## Server changes"); lines.push(""); lines.push("These changes affect the self-hosted Docker image and Trigger.dev Cloud:"); lines.push(""); for (const entry of allServer) { lines.push(renderEntry(entry.text)); } lines.push(""); } // Raw changeset output in collapsed section if (rawBody) { // Strip the Changesets action boilerplate from the raw body const cleanedBody = rawBody .replace( /This PR was opened by the \[Changesets release\].*?If you're not ready to do a release yet.*?\n/gs, "" ) .trim(); if (cleanedBody) { lines.push("
"); lines.push("Raw changeset output"); lines.push(""); lines.push(cleanedBody); lines.push(""); lines.push("
"); } } return lines.join("\n"); } // --- Main --- async function main() { let rawBody = process.env.CHANGESET_PR_BODY || ""; if (!rawBody && !process.stdin.isTTY) { const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); rawBody = Buffer.concat(chunks).toString("utf-8"); } const packageEntries = parsePrBody(rawBody); const serverEntries = await parseServerChanges(); const body = formatPrBody({ version, packageEntries, serverEntries, rawBody, }); process.stdout.write(body); } main().catch((err) => { console.error(err); process.exit(1); });