Capture the plugin manifest version bumps that release:prepare generated for 9.0.0 and update the release staging step so Claude and Codex plugin manifests are included automatically in future release commits.
270 lines
8.4 KiB
JavaScript
270 lines
8.4 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:references"], projectRoot);
|
|
runCommand("npm", ["run", "sync:release-state"], projectRoot);
|
|
runCommand("npm", ["run", "test"], projectRoot);
|
|
runCommand("npm", ["run", "app:install"], projectRoot);
|
|
runCommand("npm", ["run", "app:build"], projectRoot);
|
|
runCommand("npm", ["pack", "--dry-run", "--json"], 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,
|
|
});
|
|
|
|
const claudePluginFiles = [
|
|
".claude-plugin/plugin.json",
|
|
".claude-plugin/marketplace.json",
|
|
].filter((filePath) => fs.existsSync(path.join(projectRoot, filePath)));
|
|
|
|
const pluginsDir = path.join(projectRoot, "plugins");
|
|
const codexPluginFiles = fs.existsSync(pluginsDir)
|
|
? fs
|
|
.readdirSync(pluginsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join("plugins", entry.name, ".codex-plugin", "plugin.json"))
|
|
.filter((filePath) => fs.existsSync(path.join(projectRoot, filePath)))
|
|
: [];
|
|
|
|
filesToStage.push(...claudePluginFiles, ...codexPluginFiles);
|
|
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);
|
|
}
|