274 lines
7.6 KiB
JavaScript
Executable File
274 lines
7.6 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/**
|
|
* Generates a unified GitHub release body for a trigger.dev version release.
|
|
*
|
|
* Usage:
|
|
* node scripts/generate-github-release.mjs <version>
|
|
*
|
|
* Reads:
|
|
* - The enhanced changeset release PR body (via RELEASE_PR_BODY env var or stdin).
|
|
* By the time this runs, the PR body has already been enhanced by enhance-release-pr.mjs
|
|
* to include server changes, deduplication, and categorization. The .server-changes/ files
|
|
* themselves are already deleted (consumed on the release branch, same as .changeset/ files).
|
|
* - Git log for contributor info
|
|
*
|
|
* Outputs the formatted GitHub release body to stdout.
|
|
*/
|
|
|
|
import { execSync } from "child_process";
|
|
import { readdirSync, readFileSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
const version = process.argv[2];
|
|
if (!version) {
|
|
console.error("Usage: node scripts/generate-github-release.mjs <version>");
|
|
process.exit(1);
|
|
}
|
|
|
|
const ROOT_DIR = join(import.meta.dirname, "..");
|
|
|
|
// --- Parse the enhanced PR body ---
|
|
// The PR body from enhance-release-pr.mjs has sections like:
|
|
// ## Highlights
|
|
// ## Improvements
|
|
// ## Bug fixes
|
|
// ## Server changes
|
|
// ## Breaking changes
|
|
// <details>...</details>
|
|
// We extract the content between the first heading and the <details> block.
|
|
|
|
function extractChangesFromPrBody(body) {
|
|
if (!body) return "";
|
|
|
|
const lines = body.split("\n");
|
|
const outputLines = [];
|
|
let inDetails = false;
|
|
let inSummary = false;
|
|
let foundContent = false;
|
|
|
|
for (const line of lines) {
|
|
// Skip the title line (# trigger.dev vX.Y.Z)
|
|
if (line.startsWith("# trigger.dev v")) continue;
|
|
|
|
// Skip the entire Summary section (heading + content until next heading)
|
|
if (line.startsWith("## Summary")) {
|
|
inSummary = true;
|
|
continue;
|
|
}
|
|
if (inSummary) {
|
|
if (line.startsWith("## ")) {
|
|
inSummary = false;
|
|
} else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Stop before raw changeset output
|
|
if (line.trim() === "<details>") {
|
|
inDetails = true;
|
|
continue;
|
|
}
|
|
if (inDetails) continue;
|
|
|
|
// Collect everything from the first ## heading onward
|
|
if (line.startsWith("## ") && !foundContent) {
|
|
foundContent = true;
|
|
}
|
|
|
|
if (foundContent) {
|
|
outputLines.push(line);
|
|
}
|
|
}
|
|
|
|
return outputLines.join("\n").trim();
|
|
}
|
|
|
|
// --- Get contributors from git log ---
|
|
|
|
function getContributors(previousVersion) {
|
|
try {
|
|
const range = previousVersion ? `v${previousVersion}...HEAD` : "HEAD~50..HEAD";
|
|
const log = execSync(`git log ${range} --format="%aN|%aE" --no-merges`, {
|
|
cwd: ROOT_DIR,
|
|
encoding: "utf-8",
|
|
});
|
|
|
|
const contributors = new Map();
|
|
for (const line of log.split("\n").filter(Boolean)) {
|
|
const [name, email] = line.split("|");
|
|
if (!name || email?.endsWith("@users.noreply.github.com")) {
|
|
// Try to extract username from noreply email
|
|
const match = email?.match(/(\d+\+)?(.+)@users\.noreply\.github\.com/);
|
|
if (match) {
|
|
const username = match[2];
|
|
contributors.set(username, (contributors.get(username) || 0) + 1);
|
|
}
|
|
continue;
|
|
}
|
|
contributors.set(name, (contributors.get(name) || 0) + 1);
|
|
}
|
|
|
|
return [...contributors.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// --- Get published packages ---
|
|
|
|
function getPublishedPackages() {
|
|
try {
|
|
const packagesDir = join(ROOT_DIR, "packages");
|
|
const names = [];
|
|
for (const dir of readdirSync(packagesDir, { withFileTypes: true })) {
|
|
if (!dir.isDirectory()) continue;
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(join(packagesDir, dir.name, "package.json"), "utf-8"));
|
|
if (pkg.name && !pkg.private) {
|
|
names.push(pkg.name);
|
|
}
|
|
} catch {
|
|
// skip directories without package.json
|
|
}
|
|
}
|
|
return names.sort();
|
|
} catch {
|
|
return [
|
|
"@trigger.dev/build",
|
|
"@trigger.dev/core",
|
|
"@trigger.dev/react-hooks",
|
|
"@trigger.dev/sdk",
|
|
"trigger.dev",
|
|
];
|
|
}
|
|
}
|
|
|
|
function getPreviousVersion(version) {
|
|
const parts = version.split(".").map(Number);
|
|
if (parts[2] > 0) {
|
|
parts[2]--;
|
|
} else if (parts[1] > 0) {
|
|
parts[1]--;
|
|
parts[2] = 0;
|
|
} else if (parts[0] > 0) {
|
|
parts[0]--;
|
|
parts[1] = 0;
|
|
parts[2] = 0;
|
|
} else {
|
|
return null;
|
|
}
|
|
return parts.join(".");
|
|
}
|
|
|
|
// --- Format the release body ---
|
|
|
|
function formatRelease({ version, changesContent, contributors, packages }) {
|
|
const lines = [];
|
|
|
|
lines.push(`# trigger.dev v${version}`);
|
|
lines.push("");
|
|
lines.push("## Upgrade");
|
|
lines.push("");
|
|
lines.push("```sh");
|
|
lines.push("npx trigger.dev@latest update # npm");
|
|
lines.push("pnpm dlx trigger.dev@latest update # pnpm");
|
|
lines.push("yarn dlx trigger.dev@latest update # yarn");
|
|
lines.push("bunx trigger.dev@latest update # bun");
|
|
lines.push("```");
|
|
lines.push("");
|
|
// The Docker image link initially points to the container page without a tag filter.
|
|
// After Docker images are built, the update-release job patches this with the exact tag URL.
|
|
lines.push(
|
|
`Self-hosted Docker image: [\`ghcr.io/triggerdotdev/trigger.dev:v${version}\`](https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev)`
|
|
);
|
|
lines.push("");
|
|
lines.push("## Release notes");
|
|
lines.push("");
|
|
lines.push(
|
|
`Read the full release notes: https://trigger.dev/changelog/v${version.replace(/\./g, "-")}`
|
|
);
|
|
lines.push("");
|
|
|
|
// What's changed — extracted from the enhanced PR body
|
|
if (changesContent) {
|
|
lines.push("## What's changed");
|
|
lines.push("");
|
|
lines.push(changesContent);
|
|
lines.push("");
|
|
}
|
|
|
|
// Packages
|
|
if (packages.length > 0) {
|
|
lines.push(`## All packages: v${version}`);
|
|
lines.push("");
|
|
lines.push(packages.join(", "));
|
|
lines.push("");
|
|
}
|
|
|
|
// Contributors
|
|
if (contributors.length > 0) {
|
|
lines.push("## Contributors");
|
|
lines.push("");
|
|
lines.push(
|
|
contributors.map((c) => (/^[A-Za-z0-9][-A-Za-z0-9]*$/.test(c) ? `@${c}` : c)).join(", ")
|
|
);
|
|
lines.push("");
|
|
}
|
|
|
|
// Comparison link
|
|
const prevVersion = getPreviousVersion(version);
|
|
if (prevVersion) {
|
|
lines.push(
|
|
`**Full changelog**: https://github.com/triggerdotdev/trigger.dev/compare/v${prevVersion}...v${version}`
|
|
);
|
|
}
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
// --- Main ---
|
|
|
|
async function main() {
|
|
// Read PR body from env or stdin
|
|
let prBody = process.env.RELEASE_PR_BODY || "";
|
|
if (!prBody && !process.stdin.isTTY) {
|
|
const chunks = [];
|
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
prBody = Buffer.concat(chunks).toString("utf-8");
|
|
}
|
|
|
|
const changesContent = extractChangesFromPrBody(prBody);
|
|
|
|
// Fail loudly when a stable (non-prerelease) release resolved no changelog. This
|
|
// guards against publishing an empty "What's changed" section, which previously
|
|
// happened silently on workflow_dispatch re-runs where RELEASE_PR_BODY was empty.
|
|
// Prereleases (any version with a hyphen, e.g. 4.5.0-rc.0) are allowed to be empty.
|
|
const isPrerelease = version.includes("-");
|
|
if (!changesContent && !isPrerelease) {
|
|
console.error(
|
|
`Error: no "What's changed" content could be resolved for v${version}. ` +
|
|
`RELEASE_PR_BODY was empty or contained no changelog entries. ` +
|
|
`Refusing to publish a release with an empty changelog.`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const contributors = getContributors(getPreviousVersion(version));
|
|
const packages = getPublishedPackages();
|
|
|
|
const body = formatRelease({
|
|
version,
|
|
changesContent,
|
|
contributors,
|
|
packages,
|
|
});
|
|
|
|
process.stdout.write(body);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|