Skip to content

Commit 593d96e

Browse files
fix(tools): add preflight checks and rollback to release script (#5235)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent f4dff0d commit 593d96e

1 file changed

Lines changed: 215 additions & 9 deletions

File tree

tools/release.cjs

Lines changed: 215 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
const { Builder } = require("./builder.cjs");
44
const cmdPrompt = require("prompt");
55
const colors = require("colors");
6-
const { exec } = require("child_process");
6+
const { exec, spawn } = require("child_process");
77
const loading = require("loading-indicator");
8+
const fs = require("fs");
89
const DEBUG = false;
910
const vnu = require("vnu-jar");
1011
const path = require("path");
@@ -329,13 +330,186 @@ const indicators = new Map([
329330
],
330331
]);
331332

333+
async function preflight() {
334+
console.log(colors.cyan("\n Preflight checks\n"));
335+
const errors = [];
336+
337+
// Java (needed for vnu HTML validator)
338+
try {
339+
await toExecPromise("java -version", { timeout: 10000, showOutput: false });
340+
console.log(colors.green(" ✓ Java runtime"));
341+
} catch {
342+
errors.push(
343+
"Java runtime not found (required by vnu HTML validator).\n" +
344+
" Install: brew install java\n" +
345+
" Then: sudo ln -sfn /opt/homebrew/opt/openjdk/libexec/openjdk.jdk" +
346+
" /Library/Java/JavaVirtualMachines/openjdk.jdk"
347+
);
348+
}
349+
350+
// Puppeteer Chrome (needed for respec2html)
351+
try {
352+
const chromePath = await toExecPromise(
353+
"node -e 'import(\"puppeteer\").then(p => process.stdout.write(p.executablePath()))'",
354+
{ timeout: 15000, showOutput: false }
355+
);
356+
if (!fs.existsSync(chromePath.trim())) {
357+
throw new Error("Chrome binary missing");
358+
}
359+
console.log(colors.green(" ✓ Puppeteer Chrome"));
360+
} catch {
361+
errors.push(
362+
"Puppeteer Chrome not found (required by respec2html).\n" +
363+
" Install: npx puppeteer browsers install chrome"
364+
);
365+
}
366+
367+
// GitHub CLI (needed for creating GitHub Releases that trigger W3C CDN sync)
368+
try {
369+
await toExecPromise("gh auth status", {
370+
timeout: 10000,
371+
showOutput: false,
372+
});
373+
console.log(colors.green(" ✓ GitHub CLI (gh)"));
374+
} catch {
375+
errors.push(
376+
"GitHub CLI not found or not authenticated (required for creating releases).\n" +
377+
" Install: brew install gh\n" +
378+
" Then: gh auth login"
379+
);
380+
}
381+
382+
// origin/gh-pages must exist and be unambiguous
383+
try {
384+
const branches = await git("branch -r --list */gh-pages");
385+
const remotes = branches
386+
.trim()
387+
.split("\n")
388+
.filter(line => line.trim());
389+
const hasOrigin = remotes.some(r => r.trim() === "origin/gh-pages");
390+
if (!hasOrigin) {
391+
errors.push(
392+
"origin/gh-pages not found. The release requires it.\n" +
393+
" Fix: git fetch origin gh-pages"
394+
);
395+
} else if (remotes.length > 1) {
396+
const defaultRemote = await git("config checkout.defaultRemote").catch(
397+
() => ""
398+
);
399+
if (!defaultRemote.trim()) {
400+
errors.push(
401+
`"gh-pages" exists on ${remotes.length} remotes:\n${remotes.map(r => ` ${r.trim()}`).join("\n")}\n Fix: git config checkout.defaultRemote origin`
402+
);
403+
} else {
404+
console.log(colors.green(" ✓ gh-pages branch (via defaultRemote)"));
405+
}
406+
} else {
407+
console.log(colors.green(" ✓ gh-pages branch"));
408+
}
409+
} catch {
410+
errors.push("Could not verify gh-pages branch status.");
411+
}
412+
413+
if (errors.length) {
414+
console.log(colors.red("\n ❌ Preflight failed:\n"));
415+
errors.forEach((err, i) => {
416+
console.log(colors.red(` ${i + 1}. ${err}\n`));
417+
});
418+
throw new Error("Fix the issues above and try again.");
419+
}
420+
console.log(colors.green("\n ✅ All preflight checks passed.\n"));
421+
}
422+
423+
/**
424+
* Runs a command interactively (stdio inherited), needed for npm publish OTP.
425+
* @param {string} cmd
426+
* @returns {Promise<void>}
427+
*/
428+
function toSpawnPromise(cmd) {
429+
const [program, ...args] = cmd.split(" ");
430+
console.log(colors.cyan(`Run: ${cmd}`));
431+
if (DEBUG) return Promise.resolve();
432+
return new Promise((resolve, reject) => {
433+
const proc = spawn(program, args, { stdio: "inherit" });
434+
proc.on("close", code => {
435+
if (code !== 0) {
436+
reject(new Error(`Command failed with exit code ${code}: ${cmd}`));
437+
} else {
438+
resolve();
439+
}
440+
});
441+
proc.on("error", reject);
442+
});
443+
}
444+
445+
/**
446+
* @param {string} version
447+
* @param {string} mainHead
448+
* @param {string} ghPagesHead
449+
* @param {string} initialBranch
450+
*/
451+
async function rollback(version, mainHead, ghPagesHead, initialBranch) {
452+
console.log(colors.yellow("\n ⏪ Rolling back local changes...\n"));
453+
try {
454+
const currentBranch = await getCurrentBranch();
455+
if (currentBranch !== "main") {
456+
await git("switch main");
457+
}
458+
} catch {
459+
// best effort
460+
}
461+
try {
462+
await git(`tag -d "v${version}"`);
463+
console.log(colors.yellow(` Deleted tag v${version}`));
464+
} catch {
465+
// tag may not exist yet
466+
}
467+
if (mainHead) {
468+
try {
469+
await git(`reset --hard ${mainHead}`);
470+
console.log(colors.yellow(` Reset main to ${mainHead.slice(0, 8)}`));
471+
} catch {
472+
console.log(colors.red(" Failed to reset main — check manually."));
473+
}
474+
}
475+
if (ghPagesHead) {
476+
try {
477+
await git("switch gh-pages");
478+
await git(`reset --hard ${ghPagesHead}`);
479+
console.log(
480+
colors.yellow(` Reset gh-pages to ${ghPagesHead.slice(0, 8)}`)
481+
);
482+
} catch {
483+
console.log(colors.red(" Failed to reset gh-pages — check manually."));
484+
} finally {
485+
await git("switch main");
486+
}
487+
}
488+
try {
489+
const currentBranch = await getCurrentBranch();
490+
if (initialBranch !== currentBranch) {
491+
await git(`switch ${initialBranch}`);
492+
}
493+
} catch {
494+
// best effort
495+
}
496+
}
497+
332498
const run = async () => {
333499
const initialBranch = await getCurrentBranch();
500+
let version = "";
501+
let mainHead = "";
502+
let ghPagesHead = "";
503+
let pushed = false;
334504
try {
335-
// 1. Confirm maintainer is on up-to-date and on the main branch ()
505+
// Refresh remote refs before preflight (gh-pages check needs current data)
336506
indicators.get("remote-update").show();
337507
await git("remote update");
338508
indicators.get("remote-update").hide();
509+
510+
await preflight();
511+
512+
// 1. Confirm maintainer is on up-to-date and on the main branch
339513
if (initialBranch !== "main") {
340514
await Prompts.askSwitchToBranch(initialBranch, "main");
341515
}
@@ -353,8 +527,12 @@ const run = async () => {
353527
default:
354528
throw new Error(`Your branch is not up-to-date. It ${branchState}.`);
355529
}
530+
531+
// Save state for rollback (before any mutations)
532+
mainHead = (await git("rev-parse HEAD")).trim();
533+
356534
// 2. Bump the version in `package.json`.
357-
const version = await Prompts.askBumpVersion();
535+
version = await Prompts.askBumpVersion();
358536
await Prompts.askBuildAddCommitMergeTag();
359537
await npm(`version ${version} -m "v${version}" --no-git-tag-version`);
360538

@@ -380,27 +558,55 @@ const run = async () => {
380558
await git(`commit -m "v${version}"`);
381559
await git(`tag "v${version}"`);
382560

383-
// 5. Merge to gh-pages (git checkout gh-pages; git merge main)
384-
await git("checkout gh-pages");
561+
// 5. Merge to gh-pages
562+
try {
563+
ghPagesHead = (await git("rev-parse origin/gh-pages")).trim();
564+
} catch {
565+
// gh-pages may not exist locally yet
566+
}
567+
await git("checkout gh-pages").catch(() =>
568+
git("checkout --track origin/gh-pages")
569+
);
385570
await git("pull origin gh-pages");
386571
await git("merge main");
387572
await git("checkout main");
573+
574+
// 6. Push — point of no return after first successful push
388575
await Prompts.askPushAll();
389576
indicators.get("push-to-server").show();
390577
await git("push origin main");
391578
await git("push origin gh-pages");
392579
await git("push --tags");
580+
pushed = true;
393581
indicators.get("push-to-server").hide();
582+
583+
// 7. Publish to npm (interactive for OTP auth)
394584
console.log(colors.green(" Publishing to npm... 📡"));
395-
await npm("publish", { showOutput: true });
585+
await toSpawnPromise("npm publish");
586+
587+
// 8. Create GitHub Release (triggers W3C CDN sync)
588+
console.log(colors.green(" Creating GitHub Release... 📡"));
589+
await toExecPromise(`gh release create v${version} --generate-notes`, {
590+
timeout: 30000,
591+
showOutput: true,
592+
});
593+
396594
if (initialBranch !== "main") {
397595
await Prompts.askSwitchToBranch("main", initialBranch);
398596
}
399597
} catch (err) {
400598
console.error(colors.red(`\n☠ ${err.stack}`));
401-
const currentBranch = await getCurrentBranch();
402-
if (initialBranch !== currentBranch) {
403-
await git(`checkout ${initialBranch}`);
599+
if (pushed) {
600+
console.log(
601+
colors.yellow(
602+
`\n Git push succeeded but a later step failed.\n` +
603+
` You may need to run manually:\n` +
604+
` npm publish\n` +
605+
` gh release create v${version} --generate-notes\n`
606+
)
607+
);
608+
} else {
609+
await rollback(version, mainHead, ghPagesHead, initialBranch);
404610
}
405611
process.exit(1);
406612
return;

0 commit comments

Comments
 (0)