chore: import upstream snapshot with attribution
This commit is contained in:
Executable
+273
@@ -0,0 +1,273 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user