import {parseArgs} from "node:util" import {followUps, git, name, notes, repository, version} from "./context.mjs" import {ask, bold, dim, fail, green, red, yellow} from "./shell.mjs" export const release = async (command, steps) => { let args try { args = parseArgs({ options: { check: {type: "boolean", default: false}, yes: {type: "boolean", short: "y", default: false}, }, allowPositionals: true, }) } catch (error) { fail(`${error.message}\nUsage: ${command} [--check] [--yes] [step...]`) } const {values: options, positionals: chosen} = args const unknownStep = chosen.find(step => !steps.some(({name}) => name === step)) if (unknownStep) { fail(`Unknown step ${unknownStep}. Steps: ${steps.map(step => step.name).join(", ")}`) } const selected = chosen.length > 0 ? steps.filter(step => chosen.includes(step.name)) : steps const width = Math.max(...selected.map(step => step.name.length)) const problems = selected.map(step => ({step, missing: step.missing?.() ?? []})) const warnings = [] if (!notes) { problems.push({missing: [`CHANGELOG.md has no "# ${version}" section`]}) } if (git("rev-parse", `refs/tags/${version}`)) { const pushed = git("ls-remote", "--tags", "origin", `refs/tags/${version}`) if (pushed === undefined) { warnings.push("couldn't reach origin to check whether the tag is pushed") } else if (!pushed) { problems.push({missing: [`the ${version} tag is not on origin: git push origin ${version}`]}) } if (git("rev-parse", "HEAD") !== git("rev-parse", `refs/tags/${version}^{commit}`)) { warnings.push(`HEAD is not the ${version} tag, so the build won't match what you tagged`) } } else { problems.push({ missing: [`there is no ${version} tag: git tag ${version} && git push origin ${version}`], }) } if (git("status", "--porcelain")) { warnings.push("the working tree has uncommitted changes") } console.log(bold(`\n${name} ${version} -> ${repository.host}${repository.pathname}\n`)) for (const step of selected) { console.log(` ${step.name.padEnd(width)} ${step.title}`) } if (warnings.length > 0) { console.log("") for (const warning of warnings) { console.log(yellow(` ! ${warning}`)) } } const blocked = problems.filter(({missing}) => missing.length > 0) if (blocked.length > 0) { console.log("") for (const {step, missing} of blocked) { console.log(red(` x ${step ? `${step.name}: missing ${missing.join(", ")}` : missing[0]}`)) for (const line of step?.setup ?? []) { console.log(dim(` ${line}`)) } } fail("\nNothing ran.") } if (options.check) { console.log(green("\nReady to go.")) process.exit(0) } if (!options.yes) { if (!process.stdin.isTTY) { fail("Not a terminal; pass --yes to run unattended") } const answer = await ask(`\nRelease ${version}? [y/N] `) if (!["y", "yes"].includes(answer.trim().toLowerCase())) { fail("Aborted.") } } const done = [] for (const [index, step] of selected.entries()) { console.log(bold(`\n> ${step.title}`)) const started = Date.now() try { await step.run() } catch (error) { console.error(red(`\n${step.name} failed: ${error.message}`)) const remaining = selected.slice(index).map(remainingStep => remainingStep.name) fail(`Pick up where this left off with: ${command} ${remaining.join(" ")}`) } done.push(`${step.name.padEnd(width)} ${Math.round((Date.now() - started) / 1000)}s`) } console.log(bold(`\n${name} ${version}\n`)) for (const line of done) { console.log(` ${green("done")} ${line}`) } if (followUps.length > 0) { console.log(bold("\nLeft to do by hand")) for (const followUp of followUps) { console.log(` - ${followUp}`) } } }