33const { Builder } = require ( "./builder.cjs" ) ;
44const cmdPrompt = require ( "prompt" ) ;
55const colors = require ( "colors" ) ;
6- const { exec } = require ( "child_process" ) ;
6+ const { exec, spawn } = require ( "child_process" ) ;
77const loading = require ( "loading-indicator" ) ;
8+ const fs = require ( "fs" ) ;
89const DEBUG = false ;
910const vnu = require ( "vnu-jar" ) ;
1011const 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+
332498const 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