Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 182 additions & 9 deletions tools/release.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
const { Builder } = require("./builder.cjs");
const cmdPrompt = require("prompt");
const colors = require("colors");
const { exec } = require("child_process");
const { exec, spawn } = require("child_process");
const loading = require("loading-indicator");
const fs = require("fs");
const DEBUG = false;
const vnu = require("vnu-jar");
const path = require("path");
Expand Down Expand Up @@ -329,10 +330,159 @@ const indicators = new Map([
],
]);

async function preflight() {
console.log(colors.cyan("\n Preflight checks\n"));
const errors = [];

// Java (needed for vnu HTML validator)
try {
await toExecPromise("java -version", { timeout: 10000, showOutput: false });
console.log(colors.green(" ✓ Java runtime"));
} catch {
errors.push(
"Java runtime not found (required by vnu HTML validator).\n" +
" Install: brew install java\n" +
" Then: sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk" +
" /Library/Java/JavaVirtualMachines/openjdk.jdk"
);
}

// Puppeteer Chrome (needed for respec2html)
try {
const chromePath = await toExecPromise(
"node -e 'import(\"puppeteer\").then(p => process.stdout.write(p.executablePath()))'",
{ timeout: 15000, showOutput: false }
);
if (!fs.existsSync(chromePath.trim())) {
throw new Error("Chrome binary missing");
}
console.log(colors.green(" ✓ Puppeteer Chrome"));
} catch {
errors.push(
"Puppeteer Chrome not found (required by respec2html).\n" +
" Install: npx puppeteer browsers install chrome"
);
}

// gh-pages branch must resolve unambiguously to origin
try {
const branches = await git("branch -r --list */gh-pages");
const remotes = branches
.trim()
.split("\n")
.filter(line => line.trim());
if (remotes.length > 1) {
const defaultRemote = await git("config checkout.defaultRemote").catch(
() => ""
);
if (!defaultRemote.trim()) {
errors.push(
`"gh-pages" exists on ${remotes.length} remotes:\n${remotes.map(r => ` ${r.trim()}`).join("\n")}\n Fix: git config checkout.defaultRemote origin`
);
} else {
console.log(colors.green(" ✓ gh-pages branch (via defaultRemote)"));
}
} else {
console.log(colors.green(" ✓ gh-pages branch"));
}
Comment thread
marcoscaceres marked this conversation as resolved.
} catch {
errors.push("Could not verify gh-pages branch status.");
}

if (errors.length) {
console.log(colors.red("\n ❌ Preflight failed:\n"));
errors.forEach((err, i) => {
console.log(colors.red(` ${i + 1}. ${err}\n`));
});
throw new Error("Fix the issues above and try again.");
}
console.log(colors.green("\n ✅ All preflight checks passed.\n"));
}

/**
* Runs a command interactively (stdio inherited), needed for npm publish OTP.
* @param {string} cmd
* @returns {Promise<void>}
*/
function toSpawnPromise(cmd) {
const [program, ...args] = cmd.split(" ");
console.log(colors.cyan(`Run: ${cmd}`));
if (DEBUG) return Promise.resolve();
return new Promise((resolve, reject) => {
const proc = spawn(program, args, { stdio: "inherit" });
proc.on("close", code => {
if (code !== 0) {
reject(new Error(`Command failed with exit code ${code}: ${cmd}`));
} else {
resolve();
}
});
proc.on("error", reject);
});
}

/**
* @param {string} version
* @param {string} mainHead
* @param {string} ghPagesHead
* @param {string} initialBranch
*/
async function rollback(version, mainHead, ghPagesHead, initialBranch) {
console.log(colors.yellow("\n ⏪ Rolling back local changes...\n"));
try {
const currentBranch = await getCurrentBranch();
if (currentBranch !== "main") {
await git("checkout main");
}
} catch {
// best effort
}
try {
await git(`tag -d "v${version}"`);
console.log(colors.yellow(` Deleted tag v${version}`));
} catch {
// tag may not exist yet
}
if (mainHead) {
try {
await git(`reset --hard ${mainHead}`);
console.log(colors.yellow(` Reset main to ${mainHead.slice(0, 8)}`));
} catch {
console.log(colors.red(" Failed to reset main — check manually."));
}
}
if (ghPagesHead) {
try {
await git("checkout gh-pages");
await git(`reset --hard ${ghPagesHead}`);
console.log(
colors.yellow(` Reset gh-pages to ${ghPagesHead.slice(0, 8)}`)
);
await git("checkout main");
} catch {
console.log(colors.red(" Failed to reset gh-pages — check manually."));
}
}
try {
const currentBranch = await getCurrentBranch();
if (initialBranch !== currentBranch) {
await git(`checkout ${initialBranch}`);
}
} catch {
// best effort
}
}

const run = async () => {
const initialBranch = await getCurrentBranch();
let version = "";
let mainHead = "";
let ghPagesHead = "";
let pushed = false;
try {
// 1. Confirm maintainer is on up-to-date and on the main branch ()
await preflight();

// 1. Confirm maintainer is on up-to-date and on the main branch
indicators.get("remote-update").show();
await git("remote update");
indicators.get("remote-update").hide();
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

preflight() runs before git remote update, but the gh-pages check relies on remote-tracking refs (git branch -r ...). If the local remote refs are stale, preflight can give incorrect results. Consider moving the remote update (or a fetch/prune) before the preflight phase, or have preflight itself refresh remotes before checking branches.

Suggested change
await preflight();
// 1. Confirm maintainer is on up-to-date and on the main branch
indicators.get("remote-update").show();
await git("remote update");
indicators.get("remote-update").hide();
indicators.get("remote-update").show();
await git("remote update");
indicators.get("remote-update").hide();
await preflight();
// 1. Confirm maintainer is on up-to-date and on the main branch

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied the suggestion — git remote update now runs before preflight() so the gh-pages branch check has current remote refs.

Expand All @@ -353,8 +503,12 @@ const run = async () => {
default:
throw new Error(`Your branch is not up-to-date. It ${branchState}.`);
}

// Save state for rollback (before any mutations)
mainHead = (await git("rev-parse HEAD")).trim();

// 2. Bump the version in `package.json`.
const version = await Prompts.askBumpVersion();
version = await Prompts.askBumpVersion();
await Prompts.askBuildAddCommitMergeTag();
await npm(`version ${version} -m "v${version}" --no-git-tag-version`);

Expand All @@ -380,27 +534,46 @@ const run = async () => {
await git(`commit -m "v${version}"`);
await git(`tag "v${version}"`);

// 5. Merge to gh-pages (git checkout gh-pages; git merge main)
await git("checkout gh-pages");
// 5. Merge to gh-pages
try {
ghPagesHead = (await git("rev-parse origin/gh-pages")).trim();
} catch {
// gh-pages may not exist locally yet
}
await git("checkout gh-pages").catch(() =>
git("checkout --track origin/gh-pages")
);
await git("pull origin gh-pages");
await git("merge main");
Comment thread
marcoscaceres marked this conversation as resolved.
await git("checkout main");

// 6. Push — point of no return
await Prompts.askPushAll();
indicators.get("push-to-server").show();
await git("push origin main");
await git("push origin gh-pages");
await git("push --tags");
indicators.get("push-to-server").hide();
pushed = true;
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushed is only set to true after all three git push commands succeed. If git push origin main succeeds but a later push fails (e.g., gh-pages or tags), the catch block will treat it as "not pushed" and perform a local rollback, leaving the remote partially updated and the local repo reset behind it. Consider marking the point-of-no-return after the first successful push (or tracking which refs were pushed) and adjusting the failure message/rollback behavior accordingly.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — pushed = true is now set immediately after git push origin main succeeds (the first push), so if gh-pages or tags push fails, the catch block won't attempt a local rollback against an already-updated remote.


// 7. Publish to npm (interactive for OTP auth)
console.log(colors.green(" Publishing to npm... 📡"));
await npm("publish", { showOutput: true });
await toSpawnPromise("npm publish");

if (initialBranch !== "main") {
await Prompts.askSwitchToBranch("main", initialBranch);
}
} catch (err) {
console.error(colors.red(`\n☠ ${err.stack}`));
const currentBranch = await getCurrentBranch();
if (initialBranch !== currentBranch) {
await git(`checkout ${initialBranch}`);
if (pushed) {
console.log(
colors.yellow(
"\n Git push succeeded. Only npm publish failed.\n" +
" Run `npm publish` manually to complete the release.\n"
)
);
} else {
await rollback(version, mainHead, ghPagesHead, initialBranch);
}
process.exit(1);
return;
Expand Down
Loading