253 lines
7.7 KiB
JavaScript
253 lines
7.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { spawnSync } = require("child_process");
|
|
|
|
const { findProjectRoot } = require("../lib/project-root");
|
|
const {
|
|
extractChangelogSection,
|
|
getManagedFiles,
|
|
loadWorkflowContract,
|
|
} = require("../lib/workflow-contract");
|
|
|
|
function parseArgs(argv) {
|
|
const [command, version] = argv;
|
|
return {
|
|
command,
|
|
version: version || null,
|
|
};
|
|
}
|
|
|
|
function runCommand(command, args, cwd, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd,
|
|
encoding: "utf8",
|
|
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
shell: options.shell ?? process.platform === "win32",
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
|
|
if (typeof result.status !== "number" || result.status !== 0) {
|
|
const stderr = options.capture ? result.stderr.trim() : "";
|
|
throw new Error(stderr || `${command} ${args.join(" ")} failed with status ${result.status}`);
|
|
}
|
|
|
|
return options.capture ? result.stdout.trim() : "";
|
|
}
|
|
|
|
function ensureOnMain(projectRoot) {
|
|
const currentBranch = runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], projectRoot, {
|
|
capture: true,
|
|
});
|
|
if (currentBranch !== "main") {
|
|
throw new Error(`Release workflow must run from main. Current branch: ${currentBranch}`);
|
|
}
|
|
}
|
|
|
|
function ensureCleanWorkingTree(projectRoot, message) {
|
|
const status = runCommand("git", ["status", "--porcelain", "--untracked-files=no"], projectRoot, {
|
|
capture: true,
|
|
});
|
|
|
|
if (status) {
|
|
throw new Error(message || "Working tree has tracked changes. Commit or stash them first.");
|
|
}
|
|
}
|
|
|
|
function ensureTagMissing(projectRoot, tagName) {
|
|
const result = spawnSync("git", ["rev-parse", "--verify", tagName], {
|
|
cwd: projectRoot,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
if (result.status === 0) {
|
|
throw new Error(`Tag ${tagName} already exists.`);
|
|
}
|
|
}
|
|
|
|
function ensureTagExists(projectRoot, tagName) {
|
|
const result = spawnSync("git", ["rev-parse", "--verify", tagName], {
|
|
cwd: projectRoot,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
throw new Error(`Tag ${tagName} does not exist. Run release:prepare first.`);
|
|
}
|
|
}
|
|
|
|
function ensureGithubReleaseMissing(projectRoot, tagName) {
|
|
const result = spawnSync("gh", ["release", "view", tagName], {
|
|
cwd: projectRoot,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
if (result.status === 0) {
|
|
throw new Error(`GitHub release ${tagName} already exists.`);
|
|
}
|
|
}
|
|
|
|
function readPackageVersion(projectRoot) {
|
|
const packagePath = path.join(projectRoot, "package.json");
|
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
|
return packageJson.version;
|
|
}
|
|
|
|
function ensureChangelogSection(projectRoot, version) {
|
|
const changelogPath = path.join(projectRoot, "CHANGELOG.md");
|
|
const changelogContent = fs.readFileSync(changelogPath, "utf8");
|
|
return extractChangelogSection(changelogContent, version);
|
|
}
|
|
|
|
function writeReleaseNotes(projectRoot, version, sectionContent) {
|
|
const releaseNotesDir = path.join(projectRoot, ".tmp", "releases");
|
|
const notesPath = path.join(releaseNotesDir, `v${version}.md`);
|
|
fs.mkdirSync(releaseNotesDir, { recursive: true });
|
|
fs.writeFileSync(notesPath, sectionContent, "utf8");
|
|
return notesPath;
|
|
}
|
|
|
|
function runReleaseSuite(projectRoot) {
|
|
runCommand("npm", ["run", "validate"], projectRoot);
|
|
runCommand("npm", ["run", "validate:references"], projectRoot);
|
|
runCommand("npm", ["run", "sync:all"], projectRoot);
|
|
runCommand("npm", ["run", "test"], projectRoot);
|
|
runCommand("npm", ["run", "app:build"], projectRoot);
|
|
}
|
|
|
|
function runReleasePreflight(projectRoot) {
|
|
ensureOnMain(projectRoot);
|
|
ensureCleanWorkingTree(projectRoot, "release:preflight requires a clean tracked working tree.");
|
|
const version = readPackageVersion(projectRoot);
|
|
ensureChangelogSection(projectRoot, version);
|
|
runReleaseSuite(projectRoot);
|
|
ensureCleanWorkingTree(
|
|
projectRoot,
|
|
"release:preflight left tracked changes. Sync and commit them before releasing.",
|
|
);
|
|
console.log(`[release] Preflight passed for version ${version}.`);
|
|
}
|
|
|
|
function stageReleaseFiles(projectRoot, contract) {
|
|
const filesToStage = getManagedFiles(contract, {
|
|
includeMixed: true,
|
|
includeReleaseManaged: true,
|
|
});
|
|
runCommand("git", ["add", ...filesToStage], projectRoot);
|
|
}
|
|
|
|
function prepareRelease(projectRoot, version) {
|
|
if (!version) {
|
|
throw new Error("Usage: npm run release:prepare -- X.Y.Z");
|
|
}
|
|
|
|
ensureOnMain(projectRoot);
|
|
ensureCleanWorkingTree(projectRoot, "release:prepare requires a clean tracked working tree.");
|
|
ensureTagMissing(projectRoot, `v${version}`);
|
|
ensureChangelogSection(projectRoot, version);
|
|
|
|
const currentVersion = readPackageVersion(projectRoot);
|
|
if (currentVersion !== version) {
|
|
runCommand("npm", ["version", version, "--no-git-tag-version"], projectRoot);
|
|
} else {
|
|
console.log(`[release] package.json already set to ${version}; keeping current version.`);
|
|
}
|
|
|
|
runReleaseSuite(projectRoot);
|
|
runCommand(
|
|
"npm",
|
|
["run", "sync:metadata", "--", "--refresh-volatile"],
|
|
projectRoot,
|
|
);
|
|
|
|
const refreshedReleaseNotes = ensureChangelogSection(projectRoot, version);
|
|
const notesPath = writeReleaseNotes(projectRoot, version, refreshedReleaseNotes);
|
|
const contract = loadWorkflowContract(projectRoot);
|
|
stageReleaseFiles(projectRoot, contract);
|
|
|
|
const stagedFiles = runCommand("git", ["diff", "--cached", "--name-only"], projectRoot, {
|
|
capture: true,
|
|
});
|
|
if (!stagedFiles) {
|
|
throw new Error("release:prepare did not stage any files. Nothing to commit.");
|
|
}
|
|
|
|
runCommand("git", ["commit", "-m", `chore: release v${version}`], projectRoot);
|
|
runCommand("git", ["tag", `v${version}`], projectRoot);
|
|
|
|
console.log(`[release] Prepared v${version}.`);
|
|
console.log(`[release] Notes file: ${notesPath}`);
|
|
console.log(`[release] Next step: npm run release:publish -- ${version}`);
|
|
}
|
|
|
|
function publishRelease(projectRoot, version) {
|
|
if (!version) {
|
|
throw new Error("Usage: npm run release:publish -- X.Y.Z");
|
|
}
|
|
|
|
ensureOnMain(projectRoot);
|
|
ensureCleanWorkingTree(projectRoot, "release:publish requires a clean tracked working tree.");
|
|
|
|
const packageVersion = readPackageVersion(projectRoot);
|
|
if (packageVersion !== version) {
|
|
throw new Error(`package.json version ${packageVersion} does not match requested release ${version}.`);
|
|
}
|
|
|
|
const tagName = `v${version}`;
|
|
ensureTagExists(projectRoot, tagName);
|
|
ensureGithubReleaseMissing(projectRoot, tagName);
|
|
|
|
const tagCommit = runCommand("git", ["rev-list", "-n", "1", tagName], projectRoot, {
|
|
capture: true,
|
|
});
|
|
const headCommit = runCommand("git", ["rev-parse", "HEAD"], projectRoot, {
|
|
capture: true,
|
|
});
|
|
if (tagCommit !== headCommit) {
|
|
throw new Error(`${tagName} does not point at HEAD. Refusing to publish.`);
|
|
}
|
|
|
|
const notesPath = writeReleaseNotes(projectRoot, version, ensureChangelogSection(projectRoot, version));
|
|
|
|
runCommand("git", ["push", "origin", "main"], projectRoot);
|
|
runCommand("git", ["push", "origin", tagName], projectRoot);
|
|
runCommand("gh", ["release", "create", tagName, "--title", tagName, "--notes-file", notesPath], projectRoot);
|
|
|
|
console.log(`[release] Published ${tagName}.`);
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const projectRoot = findProjectRoot(__dirname);
|
|
|
|
if (args.command === "preflight") {
|
|
runReleasePreflight(projectRoot);
|
|
return;
|
|
}
|
|
|
|
if (args.command === "prepare") {
|
|
prepareRelease(projectRoot, args.version);
|
|
return;
|
|
}
|
|
|
|
if (args.command === "publish") {
|
|
publishRelease(projectRoot, args.version);
|
|
return;
|
|
}
|
|
|
|
throw new Error(
|
|
"Usage: node tools/scripts/release_workflow.js <preflight|prepare|publish> [X.Y.Z]",
|
|
);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(`[release] ${error.message}`);
|
|
process.exit(1);
|
|
}
|