From 1f660188fc00017a36e769601abe9064d58fa6fa Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:13:10 +0530 Subject: [PATCH 1/5] Release scripts: checked, reproducible source packaging and verification --- scripts/release/check-ci.mjs | 19 +++ scripts/release/github.mjs | 32 ++++ scripts/release/homebrew.mjs | 48 ++++++ scripts/release/lib.mjs | 190 ++++++++++++++++++++++++ scripts/release/package.mjs | 41 ++++++ scripts/release/prepare.mjs | 132 +++++++++++++++++ scripts/release/publish.mjs | 78 ++++++++++ scripts/release/verify-artifact.mjs | 81 ++++++++++ tests/releaseWorkflow.test.ts | 221 ++++++++++++++++++++++++++++ 9 files changed, 842 insertions(+) create mode 100644 scripts/release/check-ci.mjs create mode 100644 scripts/release/github.mjs create mode 100644 scripts/release/homebrew.mjs create mode 100644 scripts/release/lib.mjs create mode 100644 scripts/release/package.mjs create mode 100644 scripts/release/prepare.mjs create mode 100644 scripts/release/publish.mjs create mode 100644 scripts/release/verify-artifact.mjs create mode 100644 tests/releaseWorkflow.test.ts diff --git a/scripts/release/check-ci.mjs b/scripts/release/check-ci.mjs new file mode 100644 index 00000000..36419e18 --- /dev/null +++ b/scripts/release/check-ci.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// Publish gate: the exact commit has successful Verify (22.x) / Verify (26.x) checks and a successful dispatched CI run. +// Usage: node scripts/release/check-ci.mjs --commit SHA [--repo owner/name] (reads GitHub with GH_TOKEN) +import {parseArgs} from 'node:util'; +import {pathToFileURL} from 'node:url'; +import {evaluateCiGate} from './lib.mjs'; +import {gh} from './github.mjs'; + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const {values} = parseArgs({options: {commit: {type: 'string'}, repo: {type: 'string', default: 'raiseCatError/notMyShell'}}}); + if (!/^[0-9a-f]{40}$/u.test(values.commit ?? '')) throw new Error('--commit must be a 40-character SHA'); + const checkRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/commits/${values.commit}/check-runs?per_page=100`]).flatMap(page => page.check_runs); + const dispatchRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/actions/workflows/ci.yml/runs?head_sha=${values.commit}&event=workflow_dispatch&per_page=100`]).flatMap(page => page.workflow_runs); + const blockers = evaluateCiGate({checkRuns, dispatchRuns}); + if (blockers.length) { for (const item of blockers) console.error(`- [${item.code}] ${item.message}`); process.exit(1); } + console.log(`CI is green on ${values.commit}`); + } catch (error) { console.error(error.message); process.exit(2); } +} diff --git a/scripts/release/github.mjs b/scripts/release/github.mjs new file mode 100644 index 00000000..a866bf43 --- /dev/null +++ b/scripts/release/github.mjs @@ -0,0 +1,32 @@ +// Thin wrappers over the gh CLI. Tokens come from the environment (GH_TOKEN); nothing is ever printed from it. +import {execFileSync} from 'node:child_process'; + +export function gh(args, {input, json = true} = {}) { + const out = execFileSync('gh', args, {encoding: 'utf8', input, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024}); + return json ? (out.trim() === '' ? undefined : JSON.parse(out)) : out; +} + +function notFound(error) { + return /HTTP 404|Not Found/iu.test(`${error.stderr ?? ''}${error.message ?? ''}`); +} + +export function getMasterHead(repo) { + return gh(['api', `repos/${repo}/git/ref/heads/master`]).object.sha; +} + +// {tag: {exists, commit} , release: {id, draft, tag_name, assets} | undefined}. Reads only. +export function getRemoteState(repo, tag) { + let tagState = {exists: false}; + try { + let object = gh(['api', `repos/${repo}/git/ref/tags/${tag}`]).object; + while (object.type === 'tag') object = gh(['api', `repos/${repo}/git/tags/${object.sha}`]).object; + tagState = {exists: true, commit: object.sha}; + } catch (error) { if (!notFound(error)) throw error; } + const releases = gh(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]).flat(); + const release = releases.find(item => item.tag_name === tag); + return {tag: tagState, release}; +} + +export function ghBuffer(args) { + return execFileSync('gh', args, {stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 512 * 1024 * 1024}); +} diff --git a/scripts/release/homebrew.mjs b/scripts/release/homebrew.mjs new file mode 100644 index 00000000..63c4ee58 --- /dev/null +++ b/scripts/release/homebrew.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Produces the tap formula for a release: only `url` and `sha256` change. Never merges and never builds bottles. +// Usage: node scripts/release/homebrew.mjs --version X.Y.Z --sha256 HEX --formula current.rb --out new.rb +// [--open-pr --tap raiseCatError/homebrew-tap] (needs GH_TOKEN with write access to the tap) +import {readFile, writeFile} from 'node:fs/promises'; +import {resolve} from 'node:path'; +import {parseArgs} from 'node:util'; +import {pathToFileURL} from 'node:url'; +import {compareSemver, parseSemver} from './lib.mjs'; + +const URL_LINE = /^(\s*url\s+")(https:\/\/github\.com\/raiseCatError\/notMyShell\/archive\/refs\/tags\/v)(\d+\.\d+\.\d+)(\.tar\.gz")\s*$/mu; +const SHA_LINE = /^(\s*sha256\s+")([0-9a-f]{64})(")\s*$/mu; + +export function updateFormula(text, version, sha) { + if (!parseSemver(version)) throw new Error(`"${version}" is not a plain X.Y.Z version.`); + if (!/^[0-9a-f]{64}$/u.test(sha)) throw new Error('The sha256 must be 64 lowercase hex characters.'); + const url = URL_LINE.exec(text), digest = SHA_LINE.exec(text); + if (!url || !digest) throw new Error('The formula has no recognizable source `url` and `sha256` lines.'); + if ((text.match(/^\s*url\s/gmu) ?? []).length !== 1 || (text.match(/^\s*sha256\s/gmu) ?? []).length !== 1) throw new Error('The formula has more than one url or sha256 line (bottles or resources?); update it by hand.'); + if (compareSemver(version, url[3]) < 0) throw new Error(`The formula is already at ${url[3]}, newer than ${version}.`); + return text.replace(URL_LINE, `$1$2${version}$4`).replace(SHA_LINE, `$1${sha}$3`); +} + +async function openPr({tap, version, content}) { + const {gh} = await import('./github.mjs'); + const branch = `nmsh-${version}`, title = `nmsh ${version}`; + const base = gh(['api', `repos/${tap}`]).default_branch; + const open = gh(['api', `repos/${tap}/pulls?state=open&head=${tap.split('/')[0]}:${branch}`]); + if (open.length) { console.log(`Tap PR already open: ${open[0].html_url}`); return open[0].html_url; } + const baseSha = gh(['api', `repos/${tap}/git/ref/heads/${base}`]).object.sha; + gh(['api', '-X', 'POST', `repos/${tap}/git/refs`, '-f', `ref=refs/heads/${branch}`, '-f', `sha=${baseSha}`]); + const file = gh(['api', `repos/${tap}/contents/Formula/nmsh.rb?ref=${base}`]); + gh(['api', '-X', 'PUT', `repos/${tap}/contents/Formula/nmsh.rb`, '-f', `message=${title}`, '-f', `branch=${branch}`, '-f', `sha=${file.sha}`, '-f', `content=${Buffer.from(content).toString('base64')}`]); + const pr = gh(['api', '-X', 'POST', `repos/${tap}/pulls`, '-f', `title=${title}`, '-f', `head=${branch}`, '-f', `base=${base}`, '-f', `body=Source formula update for the nmsh v${version} release: url and sha256 only.`]); + console.log(`Opened ${pr.html_url}`); + return pr.html_url; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const {values} = parseArgs({options: {version: {type: 'string'}, sha256: {type: 'string'}, formula: {type: 'string'}, out: {type: 'string'}, 'open-pr': {type: 'boolean', default: false}, tap: {type: 'string', default: 'raiseCatError/homebrew-tap'}}}); + if (!values.version || !values.sha256 || !values.formula || !values.out) throw new Error('Usage: homebrew.mjs --version X.Y.Z --sha256 HEX --formula FILE --out FILE [--open-pr]'); + const content = updateFormula(await readFile(resolve(values.formula), 'utf8'), values.version, values.sha256); + await writeFile(resolve(values.out), content); + console.log(`Wrote ${values.out}`); + if (values['open-pr']) await openPr({tap: values.tap, version: values.version, content}); + } catch (error) { console.error(error.message); process.exit(1); } +} diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs new file mode 100644 index 00000000..d4b54ebf --- /dev/null +++ b/scripts/release/lib.mjs @@ -0,0 +1,190 @@ +// Pure release rules shared by the release scripts and tested in tests/releaseWorkflow.test.ts. +// Nothing here touches the network or the repository; callers pass in what they read. +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import {basename, join} from 'node:path'; + +export const MODES = ['dry-run', 'candidate', 'publish']; +export const REQUIRED_CI_CHECKS = ['Verify (22.x)', 'Verify (26.x)']; +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; + +// A blocker is {code, message, soft?}. Soft blockers (a candidate's "Unreleased" heading, pending QA rows) are +// reported in every mode, but only stop a publish. +const blocker = (code, message, soft = false) => ({code, message, ...(soft ? {soft: true} : {})}); + +export function parseSemver(text) { + const match = SEMVER.exec(String(text)); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; +} + +export function compareSemver(a, b) { + const left = parseSemver(a), right = parseSemver(b); + if (!left || !right) throw new Error(`Not a plain X.Y.Z version: ${!left ? a : b}`); + for (let index = 0; index < 3; index += 1) if (left[index] !== right[index]) return left[index] < right[index] ? -1 : 1; + return 0; +} + +export const tagFor = version => `v${version}`; + +export function checkVersionConsistency({version, packageJson, lock, changelog, mode}) { + const blockers = []; + if (!parseSemver(version)) return [blocker('version-invalid', `"${version}" is not a plain X.Y.Z version.`)]; + if (packageJson?.version !== version) blockers.push(blocker('version-mismatch', `package.json is ${packageJson?.version ?? 'missing'}, not ${version}.`)); + if (lock?.version !== version) blockers.push(blocker('version-mismatch', `package-lock.json root is ${lock?.version ?? 'missing'}, not ${version}.`)); + if (lock?.packages?.['']?.version !== version) blockers.push(blocker('version-mismatch', `package-lock.json packages[""] is ${lock?.packages?.['']?.version ?? 'missing'}, not ${version}.`)); + const section = findChangelogHeading(changelog, version); + if (!section) blockers.push(blocker('changelog-missing', `CHANGELOG.md has no "## [${version}]" section.`)); + else if (/unreleased|candidate/iu.test(section.title)) blockers.push(blocker('changelog-unreleased', `The ${version} changelog heading is still "${section.title.trim()}"; give it the publication date.`, mode !== 'publish')); + return blockers; +} + +function findChangelogHeading(changelog, version) { + const lines = String(changelog ?? '').split('\n'); + const prefix = `## [${version}]`; + const index = lines.findIndex(line => line.startsWith(prefix)); + return index < 0 ? undefined : {index, lines, title: lines[index].slice(prefix.length)}; +} + +export function extractChangelogSection(changelog, version) { + const found = findChangelogHeading(changelog, version); + if (!found) return undefined; + let end = found.lines.length; + for (let index = found.index + 1; index < found.lines.length; index += 1) { + if (found.lines[index].startsWith('## ') || /^\[[^\]]+\]:\s/u.test(found.lines[index])) { end = index; break; } + } + return found.lines.slice(found.index + 1, end).join('\n').trim(); +} + +export function validateRefs({version, tag, commit, mode, masterHead, headCommit}) { + const blockers = []; + if (!MODES.includes(mode)) blockers.push(blocker('mode-invalid', `Unknown mode "${mode}".`)); + if (parseSemver(version) && tag !== tagFor(version)) blockers.push(blocker('tag-invalid', `The tag must be ${tagFor(version)}, not "${tag}".`)); + if (!parseSemver(version)) blockers.push(blocker('tag-invalid', `No valid version for a tag (got "${version}").`)); + if (!/^[0-9a-f]{40}$/u.test(String(commit))) blockers.push(blocker('commit-invalid', `The commit must be a full 40-character hex SHA, not "${commit}".`)); + else { + if (headCommit && headCommit !== commit) blockers.push(blocker('commit-not-checked-out', `The checkout is ${headCommit}, not ${commit}.`)); + if (mode === 'publish') { + if (!/^[0-9a-f]{40}$/u.test(String(masterHead))) blockers.push(blocker('master-unknown', 'master\'s head could not be determined; publishing needs it.')); + else if (masterHead !== commit) blockers.push(blocker('commit-not-master', `Publishing is only allowed from master's head (${masterHead}), not ${commit}.`)); + } + } + return blockers; +} + +export function checkApproval({mode, confirmation, version}) { + if (mode !== 'publish') return []; + const expected = `publish ${tagFor(version)}`; + return confirmation === expected ? [] : [blocker('approval-missing', `Publishing needs the confirmation "${expected}".`)]; +} + +export function evaluateQaGate(qa, version) { + if (!qa) return [blocker('qa-missing', `No physical QA record (docs/releases/${tagFor(version)}-qa.json); a release needs one.`)]; + const blockers = []; + if (qa.version !== version) blockers.push(blocker('qa-version', `The QA record is for ${qa.version ?? 'no version'}, not ${version}.`)); + if (!Array.isArray(qa.rows) || qa.rows.length === 0) return [...blockers, blocker('qa-empty', 'The QA record has no rows.')]; + for (const row of qa.rows) { + const id = row?.id ?? '(no id)'; + if (row?.status === 'pass') continue; + if (row?.status === 'waived') { + if (typeof row.reason !== 'string' || row.reason.trim() === '') blockers.push(blocker('qa-waiver-reason', `QA row ${id} is waived without a reason.`)); + } else blockers.push(blocker('qa-row-open', `QA row ${id} is "${row?.status ?? 'unset'}"; it must pass or be waived with a reason.`, row?.status === 'pending')); + } + return blockers; +} + +export function qaSummary(qa) { + if (!qa || !Array.isArray(qa.rows)) return 'No physical QA record exists for this release.'; + const count = status => qa.rows.filter(row => row?.status === status).length; + const waived = qa.rows.filter(row => row?.status === 'waived'); + const lines = [`Physical QA: ${count('pass')} of ${qa.rows.length} rows passed, ${waived.length} waived, ${qa.rows.length - count('pass') - waived.length} open.`]; + for (const row of waived) lines.push(`- Waived: ${row.id}${row.reason ? ` - ${row.reason}` : ''}`); + return lines.join('\n'); +} + +export function sha256(data) { + return createHash('sha256').update(data).digest('hex'); +} + +export function formatSums(entries) { + return entries.map(({name, sha256: digest}) => `${digest} ${name}\n`).join(''); +} + +export function parseSums(text) { + const entries = []; + for (const line of String(text).split('\n')) { + if (line === '') continue; + const match = /^([0-9a-f]{64}) [ *]([^/\\\0]+)$/u.exec(line); + if (!match) throw new Error(`Malformed SHA256SUMS line: ${line}`); + entries.push({name: match[2], sha256: match[1]}); + } + if (entries.length === 0) throw new Error('SHA256SUMS lists no files.'); + return entries; +} + +// Returns problems (empty when every listed file exists and matches). `required` names must be listed. +export async function verifySums(dir, {required = []} = {}) { + const problems = []; + let entries; + try { entries = parseSums(await readFile(join(dir, 'SHA256SUMS'), 'utf8')); } catch (error) { + return [`SHA256SUMS: ${error.code === 'ENOENT' ? 'missing' : error.message}`]; + } + for (const name of required) if (!entries.some(entry => entry.name === name)) problems.push(`${name}: not listed in SHA256SUMS`); + for (const {name, sha256: expected} of entries) { + let data; + try { data = await readFile(join(dir, basename(name))); } catch { problems.push(`${name}: missing artifact`); continue; } + const actual = sha256(data); + if (actual !== expected) problems.push(`${name}: checksum mismatch (expected ${expected}, got ${actual})`); + } + return problems; +} + +// remote = {tag: {exists, commit} | undefined, release: {draft} | undefined}. Never moves a pushed tag. +export function classifyExisting({commit, tag, release}) { + const tagCommit = tag?.exists ? tag.commit : undefined; + if (release && !release.draft) return {action: 'refuse', blocker: blocker('release-exists', `A published GitHub release for this tag already exists.`)}; + if (tag?.exists && tagCommit !== commit) return {action: 'refuse', blocker: blocker('tag-exists', `The tag exists at ${tagCommit}, not ${commit}. A pushed tag is never moved.`)}; + if (release) return {action: 'resume', reason: 'The tag is at this commit and only a draft release exists; resuming it.'}; + if (tag?.exists) return {action: 'resume', reason: 'The tag exists at this commit without a release; creating the draft release.'}; + return {action: 'create', reason: 'Neither the tag nor a release exists.'}; +} + +// checkRuns: [{name, conclusion}], dispatchRuns: [{conclusion, event}] for the exact commit. +export function evaluateCiGate({checkRuns, dispatchRuns}) { + const blockers = []; + for (const name of REQUIRED_CI_CHECKS) { + const runs = (checkRuns ?? []).filter(run => run.name === name); + if (!runs.some(run => run.conclusion === 'success')) blockers.push(blocker('ci-check', `No successful "${name}" check run on this commit.`)); + else if (runs.some(run => run.conclusion && run.conclusion !== 'success' && run.conclusion !== 'skipped' && run.conclusion !== 'cancelled')) blockers.push(blocker('ci-check', `"${name}" also has a failing run on this commit.`)); + } + if (!(dispatchRuns ?? []).some(run => run.event === 'workflow_dispatch' && run.conclusion === 'success')) blockers.push(blocker('ci-dispatch', 'No successful workflow_dispatch CI run on this commit (Fedora and timing smokes run only on dispatch).')); + return blockers; +} + +export function buildReleaseNotes({version, changelog, qa, commit}) { + const section = extractChangelogSection(changelog, version); + const tag = tagFor(version); + return `${section ?? `(No changelog section for ${version}.)`} + +## Install and upgrade + +- Homebrew: \`brew update && brew upgrade raiseCatError/tap/nmsh\` (once the tap has ${version}). +- From source: download the \`${tag}\` source archive, or in an existing source checkout use \`/update\`. NMSh is a Node.js application; \`npm ci && npm run build\` builds it. +- The attached \`nmsh-${version}-source.tar.gz\` is the exact tree of commit \`${commit}\`; verify it with \`SHA256SUMS\`. + +## Limitations + +- This is a source release. There are no standalone or portable binaries: node-pty is a native addon built on your machine. +- A build from a source archive reports "build unknown" (no Git history). + +## Physical QA + +${qaSummary(qa)} + +The full changelog is in [CHANGELOG.md](https://github.com/raiseCatError/notMyShell/blob/${tag}/CHANGELOG.md). +`; +} + +export function summarizeBlockers(blockers, mode) { + const failing = mode === 'publish' ? blockers : blockers.filter(item => !item.soft); + return {failing, soft: blockers.filter(item => !failing.includes(item))}; +} diff --git a/scripts/release/package.mjs b/scripts/release/package.mjs new file mode 100644 index 00000000..a872b757 --- /dev/null +++ b/scripts/release/package.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +// Packages the exact commit: git archive -> nmsh-X.Y.Z-source.tar.gz, then SHA256SUMS over the release files. +// Usage: node scripts/release/package.mjs --version X.Y.Z --out DIR [--commit SHA] +import {execFileSync} from 'node:child_process'; +import {readFile, stat, writeFile} from 'node:fs/promises'; +import {existsSync} from 'node:fs'; +import {join, resolve} from 'node:path'; +import {parseArgs} from 'node:util'; +import {pathToFileURL} from 'node:url'; +import {formatSums, parseSemver, sha256} from './lib.mjs'; + +export const artifactName = version => `nmsh-${version}-source.tar.gz`; + +export async function packageRelease(argv) { + const {values} = parseArgs({args: argv, options: {version: {type: 'string'}, commit: {type: 'string'}, out: {type: 'string'}}}); + if (!values.version || !values.out || !parseSemver(values.version)) throw new Error('Usage: package.mjs --version X.Y.Z --out DIR [--commit SHA]'); + const out = resolve(values.out); + const commit = execFileSync('git', ['rev-parse', '--verify', `${values.commit ?? 'HEAD'}^{commit}`], {encoding: 'utf8'}).trim(); + const tarball = join(out, artifactName(values.version)); + // git archive stamps entries with the commit time and gzips with -n, so the same commit gives the same bytes. + execFileSync('git', ['archive', '--format=tar.gz', `--prefix=notMyShell-${values.version}/`, '-o', tarball, commit]); + const data = await readFile(tarball); + const provenancePath = join(out, 'provenance.json'); + if (existsSync(provenancePath)) { + const provenance = JSON.parse(await readFile(provenancePath, 'utf8')); + if (provenance.commit !== commit) throw new Error(`provenance.json is for ${provenance.commit}, not ${commit}; run prepare.mjs for this commit first.`); + provenance.artifact = {name: artifactName(values.version), sha256: sha256(data), bytes: (await stat(tarball)).size}; + await writeFile(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`); + } + const names = [artifactName(values.version), 'provenance.json'].filter(name => existsSync(join(out, name))); + const entries = []; + for (const name of names) entries.push({name, sha256: sha256(await readFile(join(out, name)))}); + await writeFile(join(out, 'SHA256SUMS'), formatSums(entries)); + console.log(`Packaged ${commit} as ${tarball}`); + for (const entry of entries) console.log(`${entry.sha256} ${entry.name}`); + return {tarball, commit, entries}; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + packageRelease(process.argv.slice(2)).catch(error => { console.error(error.message); process.exit(1); }); +} diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs new file mode 100644 index 00000000..ee7f9a40 --- /dev/null +++ b/scripts/release/prepare.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +// Prepares a release: checks the rules, runs the checks, records provenance and writes the release notes. +// Usage: node scripts/release/prepare.mjs --version X.Y.Z --out DIR [--commit SHA] [--mode dry-run|candidate|publish] +// [--repo owner/name] [--master-head SHA] [--confirmation TEXT] [--skip-checks] [--offline] +import {execFileSync, spawnSync} from 'node:child_process'; +import {mkdir, readFile, writeFile} from 'node:fs/promises'; +import {existsSync} from 'node:fs'; +import {join, resolve} from 'node:path'; +import {parseArgs} from 'node:util'; +import {pathToFileURL} from 'node:url'; +import { + MODES, buildReleaseNotes, checkApproval, checkVersionConsistency, classifyExisting, evaluateQaGate, + sha256, summarizeBlockers, tagFor, validateRefs, +} from './lib.mjs'; +import {getRemoteState} from './github.mjs'; + +const git = (...args) => execFileSync('git', args, {encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).trim(); +const readJson = async path => JSON.parse(await readFile(path, 'utf8')); + +function runCheck(name, command, args, logDir) { + const started = Date.now(); + const result = spawnSync(command, args, {encoding: 'utf8', maxBuffer: 256 * 1024 * 1024}); + const log = join(logDir, `${name}.log`); + return writeFile(log, `$ ${command} ${args.join(' ')}\n${result.stdout ?? ''}${result.stderr ?? ''}`).then(() => ({ + name, command: `${command} ${args.join(' ')}`, exitCode: result.status ?? 1, durationMs: Date.now() - started, log, + stdout: result.stdout ?? '', + })); +} + +function auditSummary(stdout) { + try { + const counts = JSON.parse(stdout).metadata?.vulnerabilities; + return counts ? {...counts} : undefined; + } catch { return undefined; } +} + +export async function prepare(argv) { + const {values} = parseArgs({args: argv, options: { + version: {type: 'string'}, commit: {type: 'string'}, out: {type: 'string'}, mode: {type: 'string', default: 'dry-run'}, + repo: {type: 'string', default: 'raiseCatError/notMyShell'}, 'master-head': {type: 'string'}, confirmation: {type: 'string', default: ''}, + 'skip-checks': {type: 'boolean', default: false}, offline: {type: 'boolean', default: false}, + }}); + if (!values.version || !values.out) throw new Error('Usage: prepare.mjs --version X.Y.Z --out DIR [--commit SHA] [--mode dry-run|candidate|publish]'); + const {version, mode} = values; + if (!MODES.includes(mode)) throw new Error(`--mode must be one of ${MODES.join(', ')}`); + if (mode === 'publish' && (values['skip-checks'] || values.offline)) throw new Error('Publish mode never skips checks or the remote lookup.'); + const out = resolve(values.out); + const logDir = join(out, 'logs'); + await mkdir(logDir, {recursive: true}); + + const blockers = []; + const notes = []; + const head = git('rev-parse', 'HEAD'); + const commit = values.commit ?? head; + const tag = tagFor(version); + let masterHead = values['master-head']; + if (!masterHead && mode === 'publish') { try { masterHead = git('rev-parse', 'refs/remotes/origin/master'); } catch { /* reported as a blocker */ } } + blockers.push(...validateRefs({version, tag, commit, mode, masterHead, headCommit: head})); + blockers.push(...checkApproval({mode, confirmation: values.confirmation, version})); + const dirty = git('status', '--porcelain'); + if (dirty) blockers.push({code: 'git-dirty', message: `The working tree is not clean:\n${dirty.split('\n').slice(0, 10).join('\n')}`}); + + const packageJson = await readJson('package.json'); + const lock = await readJson('package-lock.json'); + const changelog = await readFile('CHANGELOG.md', 'utf8'); + blockers.push(...checkVersionConsistency({version, packageJson, lock, changelog, mode})); + + const qaPath = join('docs', 'releases', `${tag}-qa.json`); + const qa = existsSync(qaPath) ? await readJson(qaPath) : undefined; + blockers.push(...evaluateQaGate(qa, version)); + + let remote; + if (values.offline) notes.push('Remote state not checked (--offline).'); + else { + try { + remote = getRemoteState(values.repo, tag); + const verdict = classifyExisting({commit, ...remote}); + if (verdict.action === 'refuse') blockers.push(verdict.blocker); + else notes.push(`Remote: ${verdict.reason}`); + } catch (error) { + blockers.push({code: 'remote-unreadable', message: `Could not read the tag and releases of ${values.repo}: ${String(error.stderr || error.message).trim().split('\n')[0]}`}); + } + } + + const checks = []; + if (values['skip-checks']) notes.push('Build, typecheck, tests and audit skipped (--skip-checks).'); + else if (!dirty) { + const steps = [ + ['build', 'npm', ['run', 'build']], + ['typecheck', 'npm', ['run', 'typecheck']], + ['test-fast', 'npm', ['run', 'test:fast']], + ['audit', 'npm', ['audit', '--omit=dev', '--json']], + ]; + for (const [name, command, args] of steps) { + const result = await runCheck(name, command, args, logDir); + checks.push(result); + if (name === 'audit') { + // Audit findings are reported in provenance and the notes, never hidden; they do not by themselves block. + result.audit = auditSummary(result.stdout); + if (result.exitCode !== 0) notes.push(`npm audit --omit=dev reported findings or failed (exit ${result.exitCode}): ${JSON.stringify(result.audit ?? 'see log')}`); + } else if (result.exitCode !== 0) blockers.push({code: `check-${name}`, message: `\`${result.command}\` failed (exit ${result.exitCode}); see ${result.log}`}); + } + } else notes.push('Checks not run because the tree is dirty.'); + + const lockText = await readFile('package-lock.json'); + const dependencies = Object.fromEntries(Object.keys(packageJson.dependencies ?? {}).sort().map(name => [name, lock.packages?.[`node_modules/${name}`]?.version ?? 'unknown'])); + let tree; try { tree = git('rev-parse', `${commit}^{tree}`); } catch { tree = undefined; } + const provenance = { + version, tag, mode, commit, tree, repository: values.repo, + node: process.version, npm: execFileSync('npm', ['--version'], {encoding: 'utf8'}).trim(), + lockfileSha256: sha256(lockText), dependencies, + checks: checks.map(({stdout, ...rest}) => rest), + qa: qa ? {rows: qa.rows?.length ?? 0} : null, + createdAt: new Date().toISOString(), + }; + await writeFile(join(out, 'provenance.json'), `${JSON.stringify(provenance, null, 2)}\n`); + await writeFile(join(out, 'release-notes.md'), buildReleaseNotes({version, changelog, qa, commit})); + + const {failing, soft} = summarizeBlockers(blockers, mode); + console.log(`Release ${tag} at ${commit} (${mode})`); + for (const note of notes) console.log(`note: ${note}`); + for (const item of soft) console.log(`warning: [${item.code}] ${item.message}`); + console.log(failing.length === 0 ? 'BLOCKERS: none' : `BLOCKERS (${failing.length}):`); + for (const item of failing) console.log(`- [${item.code}] ${item.message}`); + console.log(`Wrote ${join(out, 'provenance.json')} and ${join(out, 'release-notes.md')}`); + // Dry-run reports and continues; candidate and publish fail on blockers (publish counts soft ones too). + return {blockers: failing, exitCode: mode === 'dry-run' || failing.length === 0 ? 0 : 1}; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + prepare(process.argv.slice(2)).then(({exitCode}) => process.exit(exitCode), error => { console.error(error.message); process.exit(2); }); +} diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs new file mode 100644 index 00000000..30ca2b22 --- /dev/null +++ b/scripts/release/publish.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +// Publishes a verified release: annotated tag at the commit -> DRAFT release -> assets -> re-download and verify -> publish. +// Idempotent: a tag at the same commit with only a draft release is resumed; anything else already published is refused. +// Needs GH_TOKEN (contents: write). Usage: node scripts/release/publish.mjs --version X.Y.Z --commit SHA --dir DIR --confirmation TEXT +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {basename, join, resolve} from 'node:path'; +import {parseArgs} from 'node:util'; +import {pathToFileURL} from 'node:url'; +import {checkApproval, classifyExisting, parseSums, tagFor, validateRefs, verifySums} from './lib.mjs'; +import {gh, ghBuffer, getMasterHead, getRemoteState} from './github.mjs'; + +function fail(message) { throw new Error(message); } + +export async function publish(argv) { + const {values} = parseArgs({args: argv, options: { + version: {type: 'string'}, commit: {type: 'string'}, dir: {type: 'string'}, confirmation: {type: 'string', default: ''}, + repo: {type: 'string', default: process.env.GITHUB_REPOSITORY ?? 'raiseCatError/notMyShell'}, + }}); + const {version, commit, repo} = values; + if (!version || !commit || !values.dir) fail('Usage: publish.mjs --version X.Y.Z --commit SHA --dir DIR --confirmation TEXT'); + const tag = tagFor(version), dir = resolve(values.dir); + const name = `nmsh-${version}-source.tar.gz`; + + const early = [...checkApproval({mode: 'publish', confirmation: values.confirmation, version}), + ...validateRefs({version, tag, commit, mode: 'publish', masterHead: getMasterHead(repo)})]; + if (early.length) fail(early.map(item => `[${item.code}] ${item.message}`).join('\n')); + const problems = await verifySums(dir, {required: [name, 'provenance.json']}); + if (problems.length) fail(`Artifacts do not verify:\n${problems.join('\n')}`); + const provenance = JSON.parse(await readFile(join(dir, 'provenance.json'), 'utf8')); + if (provenance.commit !== commit || provenance.version !== version || provenance.mode !== 'publish') fail('provenance.json is not a publish-mode record for this version and commit.'); + + const verdict = classifyExisting({commit, ...getRemoteState(repo, tag)}); + if (verdict.action === 'refuse') fail(`[${verdict.blocker.code}] ${verdict.blocker.message}`); + console.log(verdict.reason); + + if (verdict.action === 'create') { + const object = gh(['api', '-X', 'POST', `repos/${repo}/git/tags`, '-f', `tag=${tag}`, '-f', `message=${tag}`, '-f', `object=${commit}`, '-f', 'type=commit']); + gh(['api', '-X', 'POST', `repos/${repo}/git/refs`, '-f', `ref=refs/tags/${tag}`, '-f', `sha=${object.sha}`]); + console.log(`Created annotated tag ${tag} at ${commit}`); + } + + const notes = await readFile(join(dir, 'release-notes.md'), 'utf8'); + const heading = (await readFile('CHANGELOG.md', 'utf8')).split('\n').find(line => line.startsWith(`## [${version}]`)) ?? ''; + const title = /—\s*(.+)$/u.exec(heading)?.[1]?.trim(); + const releaseName = title ? `${tag} — ${title}` : tag; + const state = getRemoteState(repo, tag); + let release = state.release; + const body = ['-f', `name=${releaseName}`, '-f', `body=${notes}`]; + if (!release) release = gh(['api', '-X', 'POST', `repos/${repo}/releases`, '-f', `tag_name=${tag}`, '-F', 'draft=true', ...body]); + else release = gh(['api', '-X', 'PATCH', `repos/${repo}/releases/${release.id}`, ...body]); + console.log(`Draft release ${release.id} ready`); + + const files = parseSums(await readFile(join(dir, 'SHA256SUMS'), 'utf8')).map(entry => entry.name).filter(file => file !== 'release-notes.md'); + gh(['release', 'upload', tag, '--repo', repo, '--clobber', join(dir, 'SHA256SUMS'), ...files.map(file => join(dir, file))], {json: false}); + + const check = await mkdtemp(join(tmpdir(), 'nmsh-redownload-')); + try { + const assets = gh(['api', `repos/${repo}/releases/${release.id}`]).assets; + for (const file of [...files, 'SHA256SUMS']) { + const asset = assets.find(item => item.name === basename(file)); + if (!asset) fail(`Asset ${file} is not on the draft release.`); + await mkdir(check, {recursive: true}); + await writeFile(join(check, file), ghBuffer(['api', '-H', 'Accept: application/octet-stream', `repos/${repo}/releases/assets/${asset.id}`])); + } + // Same SHA256SUMS content, and the downloaded bytes match it. + if ((await readFile(join(check, 'SHA256SUMS'), 'utf8')) !== (await readFile(join(dir, 'SHA256SUMS'), 'utf8'))) fail('Uploaded SHA256SUMS differs from the local one.'); + const bad = await verifySums(check, {required: [name]}); + if (bad.length) fail(`Re-downloaded assets do not verify; the release stays a draft:\n${bad.join('\n')}`); + } finally { await rm(check, {recursive: true, force: true}); } + + gh(['api', '-X', 'PATCH', `repos/${repo}/releases/${release.id}`, '-F', 'draft=false', '-f', 'make_latest=true']); + console.log(`Published ${tag}: https://github.com/${repo}/releases/tag/${tag}`); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + publish(process.argv.slice(2)).catch(error => { console.error(error.message); process.exit(1); }); +} diff --git a/scripts/release/verify-artifact.mjs b/scripts/release/verify-artifact.mjs new file mode 100644 index 00000000..0093b86f --- /dev/null +++ b/scripts/release/verify-artifact.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Verifies a packaged release the way Homebrew would install it: checksums, extract, npm ci, build, prune, run. +// Usage: node scripts/release/verify-artifact.mjs --version X.Y.Z --dir DIR [--work DIR] [--keep] +import {spawnSync} from 'node:child_process'; +import {cp, mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join, resolve} from 'node:path'; +import {parseArgs} from 'node:util'; +import {pathToFileURL} from 'node:url'; +import {parseSemver, verifySums} from './lib.mjs'; + +const PTY_SMOKE = ` +const assert = require('node:assert/strict'); +const pty = require(process.argv[2]); +const term = pty.spawn('/bin/echo', ['nmsh-pty-ok'], {cols: 80, rows: 24}); +let output = ''; +const timeout = setTimeout(() => { term.kill(); process.exit(1); }, 5000); +term.onData(data => { output += data; }); +term.onExit(({exitCode}) => { clearTimeout(timeout); assert.equal(exitCode, 0); assert.match(output, /nmsh-pty-ok/); console.log('nmsh-pty-ok'); }); +`; + +function run(label, command, args, options) { + console.log(`$ ${label}`); + const result = spawnSync(command, args, {encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...options}); + if (result.status !== 0) { + const tail = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim().split('\n').slice(-25).join('\n'); + throw new Error(`${label} failed (exit ${result.status ?? result.signal}):\n${tail}`); + } + return result.stdout ?? ''; +} + +export async function verifyArtifact(argv) { + const {values} = parseArgs({args: argv, options: {version: {type: 'string'}, dir: {type: 'string'}, work: {type: 'string'}, keep: {type: 'boolean', default: false}}}); + if (!values.version || !values.dir || !parseSemver(values.version)) throw new Error('Usage: verify-artifact.mjs --version X.Y.Z --dir DIR [--work DIR] [--keep]'); + const dir = resolve(values.dir); + const name = `nmsh-${values.version}-source.tar.gz`; + const problems = await verifySums(dir, {required: [name]}); + if (problems.length) throw new Error(`Artifact verification failed:\n${problems.map(item => `- ${item}`).join('\n')}`); + console.log(`Checksums verified for ${dir}`); + + const work = values.work ? resolve(values.work) : await mkdtemp(join(tmpdir(), 'nmsh-verify-')); + await mkdir(work, {recursive: true}); + try { + const home = join(work, 'home'); + await mkdir(home, {recursive: true}); + // Private HOME/XDG/npm cache: nothing here may touch the real home directory. + const env = { + ...process.env, HOME: home, XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local/share'), + XDG_STATE_HOME: join(home, '.local/state'), XDG_CACHE_HOME: join(home, '.cache'), npm_config_cache: join(work, 'npm-cache'), + npm_config_update_notifier: 'false', npm_config_fund: 'false', npm_config_audit: 'false', + }; + const src = join(work, 'src'); + await mkdir(src, {recursive: true}); + run('tar -xzf', 'tar', ['-xzf', join(dir, name), '-C', src], {env}); + const root = join(src, `notMyShell-${values.version}`); + const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')); + if (pkg.version !== values.version) throw new Error(`The archive's package.json is ${pkg.version}, not ${values.version}.`); + run('npm ci', 'npm', ['ci'], {cwd: root, env}); + run('npm run build', 'npm', ['run', 'build'], {cwd: root, env}); + run('npm prune --omit=dev', 'npm', ['prune', '--omit=dev', '--ignore-scripts'], {cwd: root, env}); + const libexec = join(work, 'prefix', 'libexec'); + await mkdir(libexec, {recursive: true}); + for (const item of ['bin', 'dist', 'node_modules', 'package.json', 'LICENSE', 'assets']) await cp(join(root, item), join(libexec, item), {recursive: true}); + await mkdir(join(libexec, 'scripts'), {recursive: true}); + for (const item of ['read-command-history.cjs', 'ensure-node-pty-helper.mjs']) await cp(join(root, 'scripts', item), join(libexec, 'scripts', item)); + const version = run('nmsh --version', process.execPath, [join(libexec, 'bin/nmsh'), '--version'], {env, cwd: work}); + if (!version.includes(`notMyShell ${values.version}`)) throw new Error(`nmsh --version printed "${version.trim()}", expected notMyShell ${values.version}`); + const smoke = join(work, 'pty-smoke.cjs'); + await writeFile(smoke, PTY_SMOKE); + const pty = run('node-pty spawn smoke', process.execPath, [smoke, join(libexec, 'node_modules/node-pty')], {env, cwd: work}); + if (!pty.includes('nmsh-pty-ok')) throw new Error('The node-pty smoke did not print nmsh-pty-ok.'); + console.log(`Verified ${name}: ${version.trim().replace(/\n/gu, ' / ')}; node-pty ok`); + } finally { + if (!values.keep) await rm(work, {recursive: true, force: true}); + else console.log(`Kept ${work}`); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + verifyArtifact(process.argv.slice(2)).catch(error => { console.error(error.message); process.exit(1); }); +} diff --git a/tests/releaseWorkflow.test.ts b/tests/releaseWorkflow.test.ts new file mode 100644 index 00000000..a7625b72 --- /dev/null +++ b/tests/releaseWorkflow.test.ts @@ -0,0 +1,221 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync, spawnSync} from 'node:child_process'; +import {mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +// @ts-expect-error plain ESM scripts without declarations +import * as lib from '../scripts/release/lib.mjs'; +// @ts-expect-error plain ESM scripts without declarations +import {updateFormula} from '../scripts/release/homebrew.mjs'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const tmp = (): string => mkdtempSync(join(process.env.NMSH_TEST_TMPDIR ?? process.env.TMPDIR ?? '.', 'release-')); +const SHA = 'a'.repeat(40), OTHER = 'b'.repeat(40); +const codes = (items: Array<{code: string}>): string[] => items.map(item => item.code); + +const changelog = (heading: string): string => `# Changelog\n\n## [Unreleased]\n\n- later\n\n## [1.2.3] - ${heading}\n\n### Added\n\n- a thing\n\n## [1.2.2] - 2026-01-01\n\n- old\n\n[Unreleased]: https://example.test/compare\n`; +const manifest = (version: string): {version: string; packages: {'': {version: string}}} => ({version, packages: {'': {version}}}); + +test('semver parse and compare', () => { + assert.deepEqual(lib.parseSemver('1.20.3'), [1, 20, 3]); + for (const bad of ['1.2', 'v1.2.3', '01.2.3', '1.2.3-rc1', '']) assert.equal(lib.parseSemver(bad), undefined, bad); + assert.equal(lib.compareSemver('1.10.0', '1.9.9'), 1); + assert.equal(lib.compareSemver('0.9.0', '0.10.0'), -1); + assert.equal(lib.compareSemver('2.0.0', '2.0.0'), 0); + assert.throws(() => lib.compareSemver('x', '1.0.0')); +}); + +test('version consistency reports every mismatch and an unreleased heading only blocks publishing', () => { + const good = {version: '1.2.3', packageJson: {version: '1.2.3'}, lock: manifest('1.2.3'), changelog: changelog('2026-10-30'), mode: 'publish'}; + assert.deepEqual(lib.checkVersionConsistency(good), []); + const bad = lib.checkVersionConsistency({...good, packageJson: {version: '1.2.2'}, lock: {version: '1.2.3', packages: {'': {version: '1.2.1'}}}}); + assert.equal(bad.length, 2); + assert.match(bad[0].message, /package\.json is 1\.2\.2/u); + assert.match(bad[1].message, /packages\[""\] is 1\.2\.1/u); + assert.deepEqual(codes(lib.checkVersionConsistency({...good, changelog: '# Changelog\n## [Unreleased]\n'})), ['changelog-missing']); + const candidate = {...good, mode: 'candidate', changelog: changelog('Unreleased (release candidate)')}; + const unreleased = lib.checkVersionConsistency(candidate); + assert.deepEqual(codes(unreleased), ['changelog-unreleased']); + assert.equal(lib.summarizeBlockers(unreleased, 'candidate').failing.length, 0); + assert.equal(lib.summarizeBlockers(unreleased, 'publish').failing.length, 1); + assert.deepEqual(codes(lib.checkVersionConsistency({...candidate, mode: 'publish'})), ['changelog-unreleased']); + assert.equal(lib.summarizeBlockers(lib.checkVersionConsistency({...candidate, mode: 'publish'}), 'publish').failing.length, 1); + assert.deepEqual(codes(lib.checkVersionConsistency({...good, version: '1.2'})), ['version-invalid']); +}); + +test('refs: tag shape, full commit, and publish only from the head of master', () => { + const ok = {version: '1.2.3', tag: 'v1.2.3', commit: SHA, mode: 'publish', masterHead: SHA, headCommit: SHA}; + assert.deepEqual(lib.validateRefs(ok), []); + assert.deepEqual(codes(lib.validateRefs({...ok, tag: '1.2.3'})), ['tag-invalid']); + assert.deepEqual(codes(lib.validateRefs({...ok, tag: 'v1.2.3-rc1'})), ['tag-invalid']); + assert.deepEqual(codes(lib.validateRefs({...ok, commit: 'abc1234'})), ['commit-invalid']); + assert.deepEqual(codes(lib.validateRefs({...ok, commit: SHA.toUpperCase()})), ['commit-invalid']); + assert.deepEqual(codes(lib.validateRefs({...ok, masterHead: OTHER})), ['commit-not-master']); + assert.deepEqual(codes(lib.validateRefs({...ok, masterHead: undefined})), ['master-unknown']); + assert.deepEqual(codes(lib.validateRefs({...ok, headCommit: OTHER})), ['commit-not-checked-out']); + assert.deepEqual(lib.validateRefs({...ok, mode: 'dry-run', masterHead: OTHER}), [], 'a dry run may use any commit'); + assert.deepEqual(codes(lib.validateRefs({...ok, mode: 'ship'})), ['mode-invalid']); +}); + +test('approval needs the exact confirmation text, in publish mode only', () => { + assert.deepEqual(lib.checkApproval({mode: 'publish', confirmation: 'publish v1.2.3', version: '1.2.3'}), []); + for (const confirmation of ['', 'publish 1.2.3', 'Publish v1.2.3', 'publish v1.2.3 ', 'publish v1.2.4']) { + assert.deepEqual(codes(lib.checkApproval({mode: 'publish', confirmation, version: '1.2.3'})), ['approval-missing'], confirmation); + } + assert.deepEqual(lib.checkApproval({mode: 'dry-run', confirmation: '', version: '1.2.3'}), []); +}); + +test('QA gate: missing file, open rows and unexplained waivers block; pending is soft', () => { + assert.deepEqual(codes(lib.evaluateQaGate(undefined, '1.2.3')), ['qa-missing']); + assert.deepEqual(codes(lib.evaluateQaGate({version: '1.2.3', rows: []}, '1.2.3')), ['qa-empty']); + assert.deepEqual(codes(lib.evaluateQaGate({version: '1.2.2', rows: [{id: 'a', status: 'pass'}]}, '1.2.3')), ['qa-version']); + const rows = [{id: 'a', status: 'pass'}, {id: 'b', status: 'waived', reason: 'no hardware'}, {id: 'c', status: 'waived'}, {id: 'd', status: 'fail'}, {id: 'e', status: 'pending'}]; + const blockers = lib.evaluateQaGate({version: '1.2.3', rows}, '1.2.3'); + assert.deepEqual(codes(blockers), ['qa-waiver-reason', 'qa-row-open', 'qa-row-open']); + assert.deepEqual(blockers.map((item: {soft?: boolean}) => Boolean(item.soft)), [false, false, true]); + assert.deepEqual(lib.evaluateQaGate({version: '1.2.3', rows: rows.slice(0, 2)}, '1.2.3'), []); + assert.match(lib.qaSummary({version: '1.2.3', rows}), /1 of 5 rows passed, 2 waived, 2 open[\s\S]*Waived: b - no hardware/u); +}); + +test('changelog section becomes release notes with install, limitations and QA status', () => { + assert.equal(lib.extractChangelogSection(changelog('2026-10-30'), '1.2.3'), '### Added\n\n- a thing'); + assert.equal(lib.extractChangelogSection(changelog('2026-10-30'), '9.9.9'), undefined); + const notes = lib.buildReleaseNotes({version: '1.2.3', changelog: changelog('2026-10-30'), qa: undefined, commit: SHA}); + assert.match(notes, /^### Added/u); + assert.match(notes, /## Install and upgrade[\s\S]*## Limitations[\s\S]*no standalone or portable binaries[\s\S]*## Physical QA\n\nNo physical QA record/u); + assert.match(notes, new RegExp(SHA, 'u')); +}); + +test('checksums: write, parse, verify; missing artifacts and mismatches fail clearly', async () => { + const dir = tmp(); + writeFileSync(join(dir, 'a.tgz'), 'alpha'); + writeFileSync(join(dir, 'provenance.json'), '{}'); + const sums = lib.formatSums([{name: 'a.tgz', sha256: lib.sha256('alpha')}, {name: 'provenance.json', sha256: lib.sha256('{}')}]); + assert.match(sums, /^[0-9a-f]{64} {2}a\.tgz\n/u); + assert.deepEqual(lib.parseSums(sums).map((entry: {name: string}) => entry.name), ['a.tgz', 'provenance.json']); + assert.throws(() => lib.parseSums('nonsense\n'), /Malformed/u); + assert.throws(() => lib.parseSums(`${lib.sha256('x')} ../escape\n`), /Malformed/u); + assert.deepEqual(await lib.verifySums(dir), ['SHA256SUMS: missing']); + writeFileSync(join(dir, 'SHA256SUMS'), sums); + assert.deepEqual(await lib.verifySums(dir, {required: ['a.tgz']}), []); + assert.deepEqual(await lib.verifySums(dir, {required: ['b.tgz']}), ['b.tgz: not listed in SHA256SUMS']); + writeFileSync(join(dir, 'a.tgz'), 'tampered'); + const mismatch = await lib.verifySums(dir); + assert.equal(mismatch.length, 1); + assert.match(mismatch[0], /^a\.tgz: checksum mismatch/u); + const missing = tmp(); + writeFileSync(join(missing, 'SHA256SUMS'), sums); + assert.deepEqual(await lib.verifySums(missing), ['a.tgz: missing artifact', 'provenance.json: missing artifact']); +}); + +test('existing tag and release: refuse, resume a draft at the same commit, never move a tag', () => { + const draft = {draft: true}, published = {draft: false}; + assert.equal(lib.classifyExisting({commit: SHA, tag: {exists: false}, release: undefined}).action, 'create'); + assert.equal(lib.classifyExisting({commit: SHA, tag: {exists: true, commit: SHA}, release: draft}).action, 'resume'); + assert.equal(lib.classifyExisting({commit: SHA, tag: {exists: true, commit: SHA}, release: undefined}).action, 'resume'); + const published1 = lib.classifyExisting({commit: SHA, tag: {exists: true, commit: SHA}, release: published}); + assert.deepEqual([published1.action, published1.blocker.code], ['refuse', 'release-exists']); + const moved = lib.classifyExisting({commit: SHA, tag: {exists: true, commit: OTHER}, release: draft}); + assert.deepEqual([moved.action, moved.blocker.code], ['refuse', 'tag-exists']); + assert.equal(lib.classifyExisting({commit: SHA, tag: {exists: false}, release: published}).action, 'refuse'); +}); + +test('CI gate needs both Verify checks and a successful dispatched run on the commit', () => { + const checks = [{name: 'Verify (22.x)', conclusion: 'success'}, {name: 'Verify (26.x)', conclusion: 'success'}]; + const dispatch = [{event: 'workflow_dispatch', conclusion: 'success'}]; + assert.deepEqual(lib.evaluateCiGate({checkRuns: checks, dispatchRuns: dispatch}), []); + assert.deepEqual(codes(lib.evaluateCiGate({checkRuns: checks.slice(0, 1), dispatchRuns: dispatch})), ['ci-check']); + assert.deepEqual(codes(lib.evaluateCiGate({checkRuns: [...checks, {name: 'Verify (26.x)', conclusion: 'failure'}], dispatchRuns: dispatch})), ['ci-check']); + assert.deepEqual(codes(lib.evaluateCiGate({checkRuns: checks, dispatchRuns: [{event: 'workflow_dispatch', conclusion: 'failure'}]})), ['ci-dispatch']); + assert.deepEqual(codes(lib.evaluateCiGate({checkRuns: [], dispatchRuns: []})), ['ci-check', 'ci-check', 'ci-dispatch']); +}); + +const FORMULA = `class Nmsh < Formula + homepage "https://github.com/raiseCatError/notMyShell" + url "https://github.com/raiseCatError/notMyShell/archive/refs/tags/v0.23.0.tar.gz" + sha256 "${'1'.repeat(64)}" + license "GPL-3.0-only" +end +`; + +test('homebrew formula update changes url and sha256 only, and refuses odd input', () => { + const next = updateFormula(FORMULA, '0.24.0', 'c'.repeat(64)); + assert.equal(next, FORMULA.replace('v0.23.0', 'v0.24.0').replace('1'.repeat(64), 'c'.repeat(64))); + assert.throws(() => updateFormula(FORMULA, '0.22.0', 'c'.repeat(64)), /newer/u); + assert.throws(() => updateFormula(FORMULA, '0.24.0', 'xyz'), /sha256/u); + assert.throws(() => updateFormula(FORMULA, '0.24', 'c'.repeat(64)), /X\.Y\.Z/u); + assert.throws(() => updateFormula('class X\nend\n', '0.24.0', 'c'.repeat(64)), /recognizable/u); + assert.throws(() => updateFormula(`${FORMULA} resource "x" do\n url "https://example.test/x.tgz"\n end\n`, '0.24.0', 'c'.repeat(64)), /more than one/u); +}); + +test('prepare dry-run reports a version mismatch as a blocker and publish refuses it', () => { + const dir = tmp(); + const base = ['scripts/release/prepare.mjs', '--version', '9.9.9', '--out', join(dir, 'out'), '--offline', '--skip-checks']; + const env = {...process.env, HOME: dir}; + const dry = spawnSync(process.execPath, [...base, '--mode', 'dry-run'], {cwd: root, encoding: 'utf8', env}); + assert.match(dry.stdout, /BLOCKERS \(\d+\):[\s\S]*version-mismatch|\[version-mismatch\]/u); + assert.match(dry.stdout, /\[qa-missing\]/u); + assert.equal(dry.status, 0, 'a dry run reports and continues'); + const live = spawnSync(process.execPath, [...base, '--mode', 'publish'], {cwd: root, encoding: 'utf8', env}); + assert.notEqual(live.status, 0); + assert.match(live.stderr, /never skips/u); +}); + +test('verify-artifact fails on a missing artifact and on a checksum mismatch', () => { + const dir = tmp(); + const run = (): {status: number | null; stderr: string} => spawnSync(process.execPath, ['scripts/release/verify-artifact.mjs', '--version', '1.2.3', '--dir', dir, '--work', join(dir, 'work')], {cwd: root, encoding: 'utf8', env: {...process.env, HOME: dir}}); + let result = run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /SHA256SUMS: missing/u); + writeFileSync(join(dir, 'SHA256SUMS'), lib.formatSums([{name: 'nmsh-1.2.3-source.tar.gz', sha256: lib.sha256('x')}])); + result = run(); + assert.match(result.stderr, /nmsh-1\.2\.3-source\.tar\.gz: missing artifact/u); + writeFileSync(join(dir, 'nmsh-1.2.3-source.tar.gz'), 'y'); + result = run(); + assert.match(result.stderr, /checksum mismatch/u); +}); + +test('package.mjs archives the exact commit reproducibly with a checksum file', () => { + const dir = tmp(); + const repo = join(dir, 'repo'); + mkdirSync(repo); + const git = (...args: string[]): string => execFileSync('git', args, {cwd: repo, encoding: 'utf8', env: {...process.env, HOME: dir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@example.test', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@example.test'}}).trim(); + git('init', '-q'); + writeFileSync(join(repo, 'package.json'), '{"version":"1.2.3"}'); + git('add', '.'); + git('commit', '-q', '-m', 'one'); + const commit = git('rev-parse', 'HEAD'); + writeFileSync(join(repo, 'later.txt'), 'not in the release'); + git('add', '.'); + git('commit', '-q', '-m', 'two'); + const pack = (out: string): void => { execFileSync(process.execPath, [join(root, 'scripts/release/package.mjs'), '--version', '1.2.3', '--commit', commit, '--out', out], {cwd: repo, env: {...process.env, HOME: dir}}); }; + mkdirSync(join(dir, 'o1')); mkdirSync(join(dir, 'o2')); + pack(join(dir, 'o1')); pack(join(dir, 'o2')); + assert.equal(readFileSync(join(dir, 'o1/SHA256SUMS'), 'utf8'), readFileSync(join(dir, 'o2/SHA256SUMS'), 'utf8'), 'the same commit packages to the same bytes'); + const listing = execFileSync('tar', ['-tzf', join(dir, 'o1/nmsh-1.2.3-source.tar.gz')], {encoding: 'utf8'}); + assert.match(listing, /notMyShell-1\.2\.3\/package\.json/u); + assert.doesNotMatch(listing, /later\.txt/u); +}); + +test('release.yml: manual or release-script-PR triggers only, pinned actions, gated publish, declared permissions', () => { + const text = readFileSync(join(root, '.github/workflows/release.yml'), 'utf8'); + const on = /^on:\n([\s\S]*?)\n\npermissions:/mu.exec(text)?.[1] ?? ''; + assert.match(on, /^ {2}workflow_dispatch:/mu); + assert.match(on, /^ {2}pull_request:\n {4}paths:\n {6}- scripts\/release\/\*\*\n {6}- \.github\/workflows\/release\.yml\n {6}- tests\/release\*\.test\.ts/mu); + assert.doesNotMatch(on, /^ {2}(push|schedule|release|workflow_run|pull_request_target):/mu); + assert.match(text, /^permissions: \{\}$/mu); + assert.match(text, /concurrency:\n {2}group: nmsh-release\n {2}cancel-in-progress: false/u); + for (const match of text.matchAll(/^\s*- uses: (\S+)/gmu)) assert.match(match[1], /^actions\/[\w-]+@[0-9a-f]{40}$/u, match[1]); + const jobs = text.split(/^jobs:\n/mu)[1].split(/^ {2}(?=[a-z]+:\n)/mu).filter(Boolean); + assert.equal(jobs.length, 3); + for (const job of jobs) assert.match(job, /^ {4}permissions:\n {6}\w+: (read|write)/mu, job.split('\n')[0]); + const publish = jobs.find(job => job.startsWith('publish:')) ?? ''; + assert.match(publish, /if: github\.event_name == 'workflow_dispatch' && inputs\.mode == 'publish' && github\.ref == 'refs\/heads\/master'/u); + assert.match(publish, /environment:\n {6}name: nmsh-production/u); + assert.match(publish, /contents: write/u); + for (const job of jobs.filter(item => !item.startsWith('publish:'))) assert.doesNotMatch(job, /write/u, job.split('\n')[0]); + assert.match(jobs.find(job => job.startsWith('homebrew:')) ?? '', /environment:\n {6}name: nmsh-production/u); + const runBodies = [...text.matchAll(/run: [|]\n((?: {10}.*\n?)+)/gu)].map(match => match[1]).join('\n'); + assert.doesNotMatch(runBodies, /\$\{\{ *inputs\./u, 'inputs reach shell only through env'); +}); From e2946b999fab35554cc7d5b4d41810079224352b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:13:10 +0530 Subject: [PATCH 2/5] Release workflow: gated dry-run and publish jobs with documented recovery --- .github/workflows/release.yml | 182 +++++++++++++++++++++++++++ docs/development/release-workflow.md | 107 ++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 docs/development/release-workflow.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..b70297d1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,182 @@ +name: Release + +# Source release of NMSh: annotated tag + GitHub Release with the source archive and checksums, then a Homebrew +# formula PR. Procedure and failure recovery: docs/development/release-workflow.md. The website sync PR and the manual +# "Publish NMSh website" workflow are separate and are not triggered from here. + +on: + workflow_dispatch: + inputs: + version: + description: Version to release (X.Y.Z, without v) + type: string + required: true + commit: + description: Full commit SHA (empty = the dispatched ref's head). Publishing requires master's head. + type: string + required: false + mode: + description: dry-run checks and packages only; publish tags and releases + type: choice + options: [dry-run, publish] + default: dry-run + confirmation: + description: 'Publishing only: type "publish vX.Y.Z"' + type: string + required: false + pull_request: + paths: + - scripts/release/** + - .github/workflows/release.yml + - tests/release*.test.ts + +permissions: {} + +concurrency: + group: nmsh-release + cancel-in-progress: false + +jobs: + prepare: + if: github.repository == 'raiseCatError/notMyShell' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: read + actions: read + checks: read + outputs: + version: ${{ steps.target.outputs.version }} + commit: ${{ steps.target.outputs.commit }} + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_COMMIT: ${{ inputs.commit }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_CONFIRMATION: ${{ inputs.confirmation }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ inputs.commit || github.sha }} + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.0 + cache: npm + - run: npm ci + - name: Resolve the target + id: target + run: | + # Pull requests (and an empty input) only ever dry-run; the version then comes from package.json. + VERSION="${INPUT_VERSION:-$(node -p "require('./package.json').version")}" + MODE="${INPUT_MODE:-dry-run}" + [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || MODE=dry-run + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + - name: Require CI on the exact commit and master's head (publish only) + if: steps.target.outputs.mode == 'publish' + run: node scripts/release/check-ci.mjs --commit "${{ steps.target.outputs.commit }}" --repo "$GITHUB_REPOSITORY" + - name: Prepare (checks, provenance, release notes) + env: + VERSION: ${{ steps.target.outputs.version }} + COMMIT: ${{ steps.target.outputs.commit }} + MODE: ${{ steps.target.outputs.mode }} + run: | + MASTER_HEAD="" + if [ "$MODE" = publish ]; then MASTER_HEAD="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/master" --jq .object.sha)"; fi + node scripts/release/prepare.mjs --version "$VERSION" --commit "$COMMIT" --mode "$MODE" --repo "$GITHUB_REPOSITORY" \ + --master-head "$MASTER_HEAD" --confirmation "$INPUT_CONFIRMATION" --out "$RUNNER_TEMP/release" | tee "$RUNNER_TEMP/prepare.txt" + { echo '### Release preparation'; echo '```'; cat "$RUNNER_TEMP/prepare.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + - name: Package the source archive + env: + VERSION: ${{ steps.target.outputs.version }} + COMMIT: ${{ steps.target.outputs.commit }} + run: node scripts/release/package.mjs --version "$VERSION" --commit "$COMMIT" --out "$RUNNER_TEMP/release" + - name: Verify the archive installs like the Homebrew formula + env: + VERSION: ${{ steps.target.outputs.version }} + run: node scripts/release/verify-artifact.mjs --version "$VERSION" --dir "$RUNNER_TEMP/release" --work "$RUNNER_TEMP/verify" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: nmsh-release + path: ${{ runner.temp }}/release + if-no-files-found: error + retention-days: 14 + + publish: + needs: prepare + if: github.event_name == 'workflow_dispatch' && inputs.mode == 'publish' && github.ref == 'refs/heads/master' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + environment: + name: nmsh-production + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + COMMIT: ${{ needs.prepare.outputs.commit }} + INPUT_CONFIRMATION: ${{ inputs.confirmation }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.0 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 + with: + name: nmsh-release + path: ${{ runner.temp }}/release + - name: Tag, draft release, upload, verify, publish + run: node scripts/release/publish.mjs --version "$VERSION" --commit "$COMMIT" --dir "$RUNNER_TEMP/release" --confirmation "$INPUT_CONFIRMATION" + + homebrew: + needs: [prepare, publish] + if: needs.publish.result == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: + name: nmsh-production + permissions: + contents: read + env: + VERSION: ${{ needs.prepare.outputs.version }} + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 22.22.0 + - name: Build the formula from GitHub's tag archive + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p "$RUNNER_TEMP/brew" + curl -fsSL --retry 5 --retry-all-errors -o "$RUNNER_TEMP/brew/tag.tar.gz" "https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/v$VERSION.tar.gz" + SHA="$(sha256sum "$RUNNER_TEMP/brew/tag.tar.gz" | cut -d' ' -f1)" + gh api -H 'Accept: application/vnd.github.raw' repos/raiseCatError/homebrew-tap/contents/Formula/nmsh.rb > "$RUNNER_TEMP/brew/current.rb" + node scripts/release/homebrew.mjs --version "$VERSION" --sha256 "$SHA" --formula "$RUNNER_TEMP/brew/current.rb" --out "$RUNNER_TEMP/brew/nmsh.rb" + echo "TAG_SHA=$SHA" >> "$GITHUB_ENV" + - name: Open the tap pull request (only when HOMEBREW_TAP_TOKEN exists) + run: | + if [ -n "$HOMEBREW_TAP_TOKEN" ]; then + GH_TOKEN="$HOMEBREW_TAP_TOKEN" node scripts/release/homebrew.mjs --version "$VERSION" --sha256 "$TAG_SHA" \ + --formula "$RUNNER_TEMP/brew/current.rb" --out "$RUNNER_TEMP/brew/nmsh.rb" --open-pr | tee -a "$GITHUB_STEP_SUMMARY" + else + { + echo "### Homebrew tap not updated" + echo "No HOMEBREW_TAP_TOKEN secret exists. Download the \`nmsh-formula\` artifact, put it at Formula/nmsh.rb on branch \`nmsh-$VERSION\` of raiseCatError/homebrew-tap and open a PR titled \`nmsh $VERSION\`. url sha256: \`$TAG_SHA\`." + } >> "$GITHUB_STEP_SUMMARY" + fi + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: nmsh-formula + path: ${{ runner.temp }}/brew/nmsh.rb + if-no-files-found: error + retention-days: 30 diff --git a/docs/development/release-workflow.md b/docs/development/release-workflow.md new file mode 100644 index 00000000..fae0324b --- /dev/null +++ b/docs/development/release-workflow.md @@ -0,0 +1,107 @@ +# Release workflow + +How a release is checked, packaged and published by `.github/workflows/release.yml` and `scripts/release/`. The +merge, tag and tap steps keep the shape of the [v0.22 runbook](v022-release-runbook.md); this workflow makes the +checkable parts repeatable. **Publishing needs the maintainer's go-ahead** and an approval of the `nmsh-production` +environment. + +NMSh is released as source: an annotated tag `vX.Y.Z` on the `master` merge commit and a GitHub Release carrying the +source archive and checksums. `package.json` is `private` (no npm publish). The Homebrew tap formula builds from the +tag archive with `npm ci` and the node-pty native addon, so **no standalone or portable binaries are offered**. + +## Pieces + +| Script | What it does | +|---|---| +| `prepare.mjs` | Rules and checks: version consistency, refs, QA gate, duplicates, build, typecheck, `test:fast`, `npm audit --omit=dev`. Writes `provenance.json` and `release-notes.md`, prints a BLOCKERS list. | +| `package.mjs` | `git archive` of the exact commit into `nmsh-X.Y.Z-source.tar.gz` (prefix `notMyShell-X.Y.Z/`), then `SHA256SUMS`. The same commit gives the same bytes. | +| `verify-artifact.mjs` | Verifies `SHA256SUMS`, extracts into an isolated directory, mimics the formula (`npm ci`, `npm run build`, `npm prune --omit=dev`), runs `nmsh --version` and a node-pty spawn, all with a private `HOME`/`XDG_*`. | +| `check-ci.mjs` | Publish gate: `Verify (22.x)` and `Verify (26.x)` succeeded on the exact commit, plus a successful `workflow_dispatch` CI run (Fedora and the timing smokes run only on dispatch). | +| `publish.mjs` | Annotated tag at the commit, DRAFT release, assets, re-download and verify, then publish. | +| `homebrew.mjs` | Rewrites `url` and `sha256` only in the tap formula; optionally opens the tap PR. | +| `lib.mjs` | The pure rules; tested in `tests/releaseWorkflow.test.ts`. | + +The physical QA gate is a machine-readable file, `docs/releases/vX.Y.Z-qa.json`: + +```json +{"version": "0.24.0", "rows": [ + {"id": "ghostty-first-run", "status": "pass"}, + {"id": "kitty-graphics", "status": "waived", "reason": "no Kitty on the QA machine; maintainer accepted"} +]} +``` + +Every row must be `pass`, or `waived` with a reason. `fail`, `pending` or a missing file blocks. A `pending` row is only a warning for +`candidate`, not for `publish`. The release notes state the QA result and any waivers. The prose record stays in +`docs/testing/`. + +## Modes + +- **dry-run** (default; also what a pull request touching the release files runs): reports every blocker and + continues. Exit code 0 unless the script itself breaks. Nothing is created. +- **candidate**: like a dry run, but exits non-zero on hard blockers. An `Unreleased`/candidate changelog heading + and `pending` QA rows are warnings. +- **publish**: any blocker stops it. Needs the commit to be `master`'s head, the confirmation `publish vX.Y.Z`, a dated + changelog section, a complete QA file, passing checks and the CI gate. + +## Run it + +Locally (scratch on the T7; this only reads GitHub): + +```sh +t7run node scripts/release/prepare.mjs --version X.Y.Z --mode dry-run --out "$T7_RUN_DIR/rel" +t7run node scripts/release/package.mjs --version X.Y.Z --out "$T7_RUN_DIR/rel" +t7run node scripts/release/verify-artifact.mjs --version X.Y.Z --dir "$T7_RUN_DIR/rel" +``` + +`prepare` needs a clean tree with `HEAD` at the commit. `--skip-checks` and `--offline` exist for dry runs only. + +In GitHub: **Actions → Release → Run workflow** from `master`, with `version`, `commit` (empty = the ref's head; +publishing needs `master`'s head), `mode` and, to publish, `confirmation` = `publish vX.Y.Z`. The `prepare` job +checks, packages and verifies; the `publish` job (only for `mode=publish` on `master`) waits for the `nmsh-production` +approval, then re-checks duplicates, tags, drafts, uploads, re-downloads, verifies and publishes. The `homebrew` job +builds the formula from GitHub's tag archive and opens the tap PR `nmsh X.Y.Z` only if the `HOMEBREW_TAP_TOKEN` +secret exists; otherwise it uploads the `nmsh-formula` artifact and says what to do. It never merges and never +publishes bottles. The tap's own `tests.yml` runs on that PR, and the maintainer dispatches the tap's `publish.yml`. + +Order for a release: finish the release PR into `master` (see the runbook), dispatch CI on its merge commit, run this +workflow as a dry run, fix blockers, then publish. The website sync PR and the manual **Publish NMSh website** run stay +separate and are not triggered here. + +## Failure recovery + +**CI fails or is missing.** `check-ci` names the missing check. Fix on the release branch, merge, dispatch CI on the +new `master` head and rerun. Nothing was created. + +**Packaging or `prepare` fails.** Nothing public exists. Read `logs/` in the uploaded `nmsh-release` artifact (or the +local `--out` directory), fix, rerun. A `provenance.json` for another commit is refused by `package.mjs`; rerun +`prepare` for this commit. + +**An asset cannot be verified.** If `verify-artifact` fails, it is before publishing: fix and rerun. If `publish` +reports the re-downloaded assets do not verify, the release is still a **draft**: nothing is public. Rerun the +workflow; it resumes the draft and uploads with `--clobber`. If it keeps failing, delete the draft in the GitHub UI +and rerun; keep the tag. + +**The GitHub Release fails partway.** The tag may exist and a draft may be left. A tag that was pushed is **never +moved or deleted** (Homebrew, GitHub's archive and users may have it). Rerunning with the same version and commit +resumes: a tag at the same commit with no release or only a draft is reused. If the tag is at a different commit, +the workflow refuses; fix forward with the next patch version. Only when the tag points at the wrong commit and nothing +(release, tap, website) has consumed it may the maintainer delete it by hand, as in the runbook's rollback section. + +**The Homebrew PR fails.** The release is already public. Rerun only the `homebrew` job, or use the `nmsh-formula` +artifact by hand. If branch `nmsh-X.Y.Z` already exists without a PR, open the PR from it (or delete the branch and +rerun). If the tap's `tests.yml` fails, fix the formula in the tap PR; `homebrew.mjs` only changes `url` and `sha256`. + +**The release already exists.** `prepare` and `publish` refuse with `release-exists`. This is correct: never overwrite a +published release. To fix its text, `gh release edit`; to fix its contents, publish the next patch version. + +**A local install fails after switching.** Source install: `npm ci && npm run build` in the checkout, then check +`nmsh --version`. Homebrew: `brew update && brew upgrade raiseCatError/tap/nmsh`, then `brew reinstall` if the +native addon is stale. To go back, use the runbook's rollback (`gh release edit vPREVIOUS --latest`, prerelease flag on +the bad one, revert the tap PR). + +## Known limits + +- A tarball build reports `build unknown`; it has no Git history. The tag archive Homebrew uses is GitHub's own and + is not byte-identical to the attached archive; its checksum is computed after tagging. +- `npm audit` findings are reported in `provenance.json` and the logs but do not block by themselves. +- The workflow's `uses:` are pinned to commit SHAs, matching `website-deploy.yml`. From 79ac11decb22b6b8381de4072ec5e578d0d48b6b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:14:29 +0530 Subject: [PATCH 3/5] Release packaging creates its output directory --- scripts/release/package.mjs | 3 ++- tests/releaseWorkflow.test.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/release/package.mjs b/scripts/release/package.mjs index a872b757..401a5c2b 100644 --- a/scripts/release/package.mjs +++ b/scripts/release/package.mjs @@ -2,7 +2,7 @@ // Packages the exact commit: git archive -> nmsh-X.Y.Z-source.tar.gz, then SHA256SUMS over the release files. // Usage: node scripts/release/package.mjs --version X.Y.Z --out DIR [--commit SHA] import {execFileSync} from 'node:child_process'; -import {readFile, stat, writeFile} from 'node:fs/promises'; +import {mkdir, readFile, stat, writeFile} from 'node:fs/promises'; import {existsSync} from 'node:fs'; import {join, resolve} from 'node:path'; import {parseArgs} from 'node:util'; @@ -16,6 +16,7 @@ export async function packageRelease(argv) { if (!values.version || !values.out || !parseSemver(values.version)) throw new Error('Usage: package.mjs --version X.Y.Z --out DIR [--commit SHA]'); const out = resolve(values.out); const commit = execFileSync('git', ['rev-parse', '--verify', `${values.commit ?? 'HEAD'}^{commit}`], {encoding: 'utf8'}).trim(); + await mkdir(out, {recursive: true}); const tarball = join(out, artifactName(values.version)); // git archive stamps entries with the commit time and gzips with -n, so the same commit gives the same bytes. execFileSync('git', ['archive', '--format=tar.gz', `--prefix=notMyShell-${values.version}/`, '-o', tarball, commit]); diff --git a/tests/releaseWorkflow.test.ts b/tests/releaseWorkflow.test.ts index a7625b72..be5da904 100644 --- a/tests/releaseWorkflow.test.ts +++ b/tests/releaseWorkflow.test.ts @@ -190,7 +190,6 @@ test('package.mjs archives the exact commit reproducibly with a checksum file', git('add', '.'); git('commit', '-q', '-m', 'two'); const pack = (out: string): void => { execFileSync(process.execPath, [join(root, 'scripts/release/package.mjs'), '--version', '1.2.3', '--commit', commit, '--out', out], {cwd: repo, env: {...process.env, HOME: dir}}); }; - mkdirSync(join(dir, 'o1')); mkdirSync(join(dir, 'o2')); pack(join(dir, 'o1')); pack(join(dir, 'o2')); assert.equal(readFileSync(join(dir, 'o1/SHA256SUMS'), 'utf8'), readFileSync(join(dir, 'o2/SHA256SUMS'), 'utf8'), 'the same commit packages to the same bytes'); const listing = execFileSync('tar', ['-tzf', join(dir, 'o1/nmsh-1.2.3-source.tar.gz')], {encoding: 'utf8'}); From 5faad807b4809c41a0ed5be90f0d252dfdf0a83a Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:15:33 +0530 Subject: [PATCH 4/5] Release workflow: a failing gate can't be hidden by a pipe GitHub's default bash runs without pipefail, so 'prepare.mjs | tee' would report success even when prepare failed. The workflow now declares 'shell: bash' (-eo pipefail), and prepare records its blockers in provenance.json, which publish refuses unless the list is empty. --- .github/workflows/release.yml | 5 +++++ scripts/release/lib.mjs | 8 ++++++++ scripts/release/prepare.mjs | 2 ++ scripts/release/publish.mjs | 5 +++-- tests/releaseWorkflow.test.ts | 13 +++++++++++++ 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b70297d1..cb4aeb63 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,11 @@ on: permissions: {} +# An explicit bash shell runs with -eo pipefail: a failing gate piped into tee must still fail its step. +defaults: + run: + shell: bash + concurrency: group: nmsh-release cancel-in-progress: false diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs index d4b54ebf..b23bf6d8 100644 --- a/scripts/release/lib.mjs +++ b/scripts/release/lib.mjs @@ -188,3 +188,11 @@ export function summarizeBlockers(blockers, mode) { const failing = mode === 'publish' ? blockers : blockers.filter(item => !item.soft); return {failing, soft: blockers.filter(item => !failing.includes(item))}; } + +/** Why a prepared provenance record may not be published (undefined when it may). */ +export function provenanceRefusal(provenance, {version, commit}) { + if (!provenance || provenance.commit !== commit || provenance.version !== version || provenance.mode !== 'publish') return 'provenance.json is not a publish-mode record for this version and commit.'; + if (!Array.isArray(provenance.blockers)) return 'provenance.json does not record its release blockers.'; + if (provenance.blockers.length) return `provenance.json records release blockers: ${provenance.blockers.join(', ')}.`; + return undefined; +} diff --git a/scripts/release/prepare.mjs b/scripts/release/prepare.mjs index ee7f9a40..6394f19b 100644 --- a/scripts/release/prepare.mjs +++ b/scripts/release/prepare.mjs @@ -111,6 +111,8 @@ export async function prepare(argv) { lockfileSha256: sha256(lockText), dependencies, checks: checks.map(({stdout, ...rest}) => rest), qa: qa ? {rows: qa.rows?.length ?? 0} : null, + // Publish refuses a record that lists any blocker, so a lost exit status can never publish. + blockers: blockers.map(item => item.code), createdAt: new Date().toISOString(), }; await writeFile(join(out, 'provenance.json'), `${JSON.stringify(provenance, null, 2)}\n`); diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs index 30ca2b22..9be6ca6d 100644 --- a/scripts/release/publish.mjs +++ b/scripts/release/publish.mjs @@ -7,7 +7,7 @@ import {tmpdir} from 'node:os'; import {basename, join, resolve} from 'node:path'; import {parseArgs} from 'node:util'; import {pathToFileURL} from 'node:url'; -import {checkApproval, classifyExisting, parseSums, tagFor, validateRefs, verifySums} from './lib.mjs'; +import {checkApproval, classifyExisting, parseSums, provenanceRefusal, tagFor, validateRefs, verifySums} from './lib.mjs'; import {gh, ghBuffer, getMasterHead, getRemoteState} from './github.mjs'; function fail(message) { throw new Error(message); } @@ -28,7 +28,8 @@ export async function publish(argv) { const problems = await verifySums(dir, {required: [name, 'provenance.json']}); if (problems.length) fail(`Artifacts do not verify:\n${problems.join('\n')}`); const provenance = JSON.parse(await readFile(join(dir, 'provenance.json'), 'utf8')); - if (provenance.commit !== commit || provenance.version !== version || provenance.mode !== 'publish') fail('provenance.json is not a publish-mode record for this version and commit.'); + const refusal = provenanceRefusal(provenance, {version, commit}); + if (refusal) fail(refusal); const verdict = classifyExisting({commit, ...getRemoteState(repo, tag)}); if (verdict.action === 'refuse') fail(`[${verdict.blocker.code}] ${verdict.blocker.message}`); diff --git a/tests/releaseWorkflow.test.ts b/tests/releaseWorkflow.test.ts index be5da904..05977892 100644 --- a/tests/releaseWorkflow.test.ts +++ b/tests/releaseWorkflow.test.ts @@ -218,3 +218,16 @@ test('release.yml: manual or release-script-PR triggers only, pinned actions, ga const runBodies = [...text.matchAll(/run: [|]\n((?: {10}.*\n?)+)/gu)].map(match => match[1]).join('\n'); assert.doesNotMatch(runBodies, /\$\{\{ *inputs\./u, 'inputs reach shell only through env'); }); + +test('a lost gate exit status cannot publish: pipefail shell, and publish refuses provenance that lists blockers', async () => { + const text = readFileSync(join(root, '.github/workflows/release.yml'), 'utf8'); + assert.match(text, /^defaults:\n {2}run:\n {4}shell: bash$/mu, 'every run step uses bash -eo pipefail'); + const {provenanceRefusal} = await import('../scripts/release/lib.mjs'); + const commit = 'a'.repeat(40); + const record = {version: '1.2.3', commit, mode: 'publish', blockers: [] as string[]}; + assert.equal(provenanceRefusal(record, {version: '1.2.3', commit}), undefined); + assert.match(provenanceRefusal({...record, blockers: ['qa-missing']}, {version: '1.2.3', commit})!, /qa-missing/); + assert.match(provenanceRefusal({...record, blockers: undefined}, {version: '1.2.3', commit})!, /does not record/); + assert.match(provenanceRefusal({...record, mode: 'dry-run'}, {version: '1.2.3', commit})!, /publish-mode/); + assert.match(provenanceRefusal(record, {version: '1.2.4', commit})!, /publish-mode/); +}); From a3541137d022cfeb3f87f0c4f94c61f54ce0270f Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Thu, 29 Oct 2026 01:22:29 +0530 Subject: [PATCH 5/5] Release workflow: harden publication after an independent safety review - An untagged draft release is not resumable: create the annotated tag first and pin target_commitish, so GitHub never makes a lightweight tag. - The archive digest reaches publish as a prepare-job output, outside the artifact; release-notes.md is covered by SHA256SUMS and uploaded. - A resumed draft must carry exactly the verified assets. - Pull-request dry runs use their own concurrency group. - HOMEBREW_TAP_TOKEN is scoped to the one step that opens the tap PR. - The dispatched version is validated before it reaches step outputs. - The CI gate counts only GitHub Actions' own check runs. --- .github/workflows/release.yml | 16 ++++++++++++---- scripts/release/check-ci.mjs | 4 +++- scripts/release/lib.mjs | 2 ++ scripts/release/package.mjs | 2 +- scripts/release/publish.mjs | 16 ++++++++++++---- tests/releaseWorkflow.test.ts | 18 ++++++++++++++++-- 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb4aeb63..2c812615 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,8 @@ defaults: shell: bash concurrency: - group: nmsh-release + # Pull-request dry runs get their own group so they never displace or wait behind a release dispatch. + group: ${{ github.event_name == 'pull_request' && format('nmsh-release-pr-{0}', github.ref) || 'nmsh-release' }} cancel-in-progress: false jobs: @@ -53,12 +54,14 @@ jobs: outputs: version: ${{ steps.target.outputs.version }} commit: ${{ steps.target.outputs.commit }} + archive_sha256: ${{ steps.package.outputs.archive_sha256 }} env: GH_TOKEN: ${{ github.token }} INPUT_VERSION: ${{ inputs.version }} INPUT_COMMIT: ${{ inputs.commit }} INPUT_MODE: ${{ inputs.mode }} INPUT_CONFIRMATION: ${{ inputs.confirmation }} + ARCHIVE_SHA256: ${{ needs.prepare.outputs.archive_sha256 }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: @@ -75,6 +78,7 @@ jobs: # Pull requests (and an empty input) only ever dry-run; the version then comes from package.json. VERSION="${INPUT_VERSION:-$(node -p "require('./package.json').version")}" MODE="${INPUT_MODE:-dry-run}" + [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version must be X.Y.Z"; exit 1; } [ "$GITHUB_EVENT_NAME" = workflow_dispatch ] || MODE=dry-run echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" @@ -94,10 +98,13 @@ jobs: --master-head "$MASTER_HEAD" --confirmation "$INPUT_CONFIRMATION" --out "$RUNNER_TEMP/release" | tee "$RUNNER_TEMP/prepare.txt" { echo '### Release preparation'; echo '```'; cat "$RUNNER_TEMP/prepare.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - name: Package the source archive + id: package env: VERSION: ${{ steps.target.outputs.version }} COMMIT: ${{ steps.target.outputs.commit }} - run: node scripts/release/package.mjs --version "$VERSION" --commit "$COMMIT" --out "$RUNNER_TEMP/release" + run: | + node scripts/release/package.mjs --version "$VERSION" --commit "$COMMIT" --out "$RUNNER_TEMP/release" + echo "archive_sha256=$(sha256sum "$RUNNER_TEMP/release/nmsh-$VERSION-source.tar.gz" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Verify the archive installs like the Homebrew formula env: VERSION: ${{ steps.target.outputs.version }} @@ -136,7 +143,7 @@ jobs: name: nmsh-release path: ${{ runner.temp }}/release - name: Tag, draft release, upload, verify, publish - run: node scripts/release/publish.mjs --version "$VERSION" --commit "$COMMIT" --dir "$RUNNER_TEMP/release" --confirmation "$INPUT_CONFIRMATION" + run: node scripts/release/publish.mjs --version "$VERSION" --commit "$COMMIT" --dir "$RUNNER_TEMP/release" --confirmation "$INPUT_CONFIRMATION" --expected-sha256 "$ARCHIVE_SHA256" homebrew: needs: [prepare, publish] @@ -149,7 +156,6 @@ jobs: contents: read env: VERSION: ${{ needs.prepare.outputs.version }} - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: @@ -169,6 +175,8 @@ jobs: node scripts/release/homebrew.mjs --version "$VERSION" --sha256 "$SHA" --formula "$RUNNER_TEMP/brew/current.rb" --out "$RUNNER_TEMP/brew/nmsh.rb" echo "TAG_SHA=$SHA" >> "$GITHUB_ENV" - name: Open the tap pull request (only when HOMEBREW_TAP_TOKEN exists) + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | if [ -n "$HOMEBREW_TAP_TOKEN" ]; then GH_TOKEN="$HOMEBREW_TAP_TOKEN" node scripts/release/homebrew.mjs --version "$VERSION" --sha256 "$TAG_SHA" \ diff --git a/scripts/release/check-ci.mjs b/scripts/release/check-ci.mjs index 36419e18..bdb0f2a7 100644 --- a/scripts/release/check-ci.mjs +++ b/scripts/release/check-ci.mjs @@ -10,7 +10,9 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { try { const {values} = parseArgs({options: {commit: {type: 'string'}, repo: {type: 'string', default: 'raiseCatError/notMyShell'}}}); if (!/^[0-9a-f]{40}$/u.test(values.commit ?? '')) throw new Error('--commit must be a 40-character SHA'); - const checkRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/commits/${values.commit}/check-runs?per_page=100`]).flatMap(page => page.check_runs); + const checkRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/commits/${values.commit}/check-runs?per_page=100`]).flatMap(page => page.check_runs) + // Only GitHub Actions' own check runs count: another app with checks:write could post a look-alike name. + .filter(run => run.app?.slug === 'github-actions'); const dispatchRuns = gh(['api', '--paginate', '--slurp', `repos/${values.repo}/actions/workflows/ci.yml/runs?head_sha=${values.commit}&event=workflow_dispatch&per_page=100`]).flatMap(page => page.workflow_runs); const blockers = evaluateCiGate({checkRuns, dispatchRuns}); if (blockers.length) { for (const item of blockers) console.error(`- [${item.code}] ${item.message}`); process.exit(1); } diff --git a/scripts/release/lib.mjs b/scripts/release/lib.mjs index b23bf6d8..24a2c838 100644 --- a/scripts/release/lib.mjs +++ b/scripts/release/lib.mjs @@ -143,6 +143,8 @@ export function classifyExisting({commit, tag, release}) { const tagCommit = tag?.exists ? tag.commit : undefined; if (release && !release.draft) return {action: 'refuse', blocker: blocker('release-exists', `A published GitHub release for this tag already exists.`)}; if (tag?.exists && tagCommit !== commit) return {action: 'refuse', blocker: blocker('tag-exists', `The tag exists at ${tagCommit}, not ${commit}. A pushed tag is never moved.`)}; + // Without the tag, a draft would make GitHub create a lightweight tag at the default branch on publish: create ours first. + if (!tag?.exists) return {action: 'create', reason: release ? 'Only an untagged draft release exists; creating the annotated tag, then reusing the draft.' : 'Neither the tag nor a release exists.'}; if (release) return {action: 'resume', reason: 'The tag is at this commit and only a draft release exists; resuming it.'}; if (tag?.exists) return {action: 'resume', reason: 'The tag exists at this commit without a release; creating the draft release.'}; return {action: 'create', reason: 'Neither the tag nor a release exists.'}; diff --git a/scripts/release/package.mjs b/scripts/release/package.mjs index 401a5c2b..d50a4d7f 100644 --- a/scripts/release/package.mjs +++ b/scripts/release/package.mjs @@ -28,7 +28,7 @@ export async function packageRelease(argv) { provenance.artifact = {name: artifactName(values.version), sha256: sha256(data), bytes: (await stat(tarball)).size}; await writeFile(provenancePath, `${JSON.stringify(provenance, null, 2)}\n`); } - const names = [artifactName(values.version), 'provenance.json'].filter(name => existsSync(join(out, name))); + const names = [artifactName(values.version), 'provenance.json', 'release-notes.md'].filter(name => existsSync(join(out, name))); const entries = []; for (const name of names) entries.push({name, sha256: sha256(await readFile(join(out, name)))}); await writeFile(join(out, 'SHA256SUMS'), formatSums(entries)); diff --git a/scripts/release/publish.mjs b/scripts/release/publish.mjs index 9be6ca6d..718d2415 100644 --- a/scripts/release/publish.mjs +++ b/scripts/release/publish.mjs @@ -2,6 +2,7 @@ // Publishes a verified release: annotated tag at the commit -> DRAFT release -> assets -> re-download and verify -> publish. // Idempotent: a tag at the same commit with only a draft release is resumed; anything else already published is refused. // Needs GH_TOKEN (contents: write). Usage: node scripts/release/publish.mjs --version X.Y.Z --commit SHA --dir DIR --confirmation TEXT +import {createHash} from 'node:crypto'; import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {basename, join, resolve} from 'node:path'; @@ -14,7 +15,7 @@ function fail(message) { throw new Error(message); } export async function publish(argv) { const {values} = parseArgs({args: argv, options: { - version: {type: 'string'}, commit: {type: 'string'}, dir: {type: 'string'}, confirmation: {type: 'string', default: ''}, + version: {type: 'string'}, commit: {type: 'string'}, dir: {type: 'string'}, 'expected-sha256': {type: 'string'}, confirmation: {type: 'string', default: ''}, repo: {type: 'string', default: process.env.GITHUB_REPOSITORY ?? 'raiseCatError/notMyShell'}, }}); const {version, commit, repo} = values; @@ -25,8 +26,11 @@ export async function publish(argv) { const early = [...checkApproval({mode: 'publish', confirmation: values.confirmation, version}), ...validateRefs({version, tag, commit, mode: 'publish', masterHead: getMasterHead(repo)})]; if (early.length) fail(early.map(item => `[${item.code}] ${item.message}`).join('\n')); - const problems = await verifySums(dir, {required: [name, 'provenance.json']}); + const problems = await verifySums(dir, {required: [name, 'provenance.json', 'release-notes.md']}); if (problems.length) fail(`Artifacts do not verify:\n${problems.join('\n')}`); + // The archive digest also travels as a prepare-job output, outside the artifact, so a replaced artifact can't pass. + const archiveSha = createHash('sha256').update(await readFile(join(dir, name))).digest('hex'); + if (!values['expected-sha256'] || archiveSha !== values['expected-sha256']) fail(`The archive digest ${archiveSha} does not match the prepare job's ${values['expected-sha256'] || '(missing)'}.`); const provenance = JSON.parse(await readFile(join(dir, 'provenance.json'), 'utf8')); const refusal = provenanceRefusal(provenance, {version, commit}); if (refusal) fail(refusal); @@ -47,17 +51,21 @@ export async function publish(argv) { const releaseName = title ? `${tag} — ${title}` : tag; const state = getRemoteState(repo, tag); let release = state.release; - const body = ['-f', `name=${releaseName}`, '-f', `body=${notes}`]; + const body = ['-f', `name=${releaseName}`, '-f', `body=${notes}`, '-f', `target_commitish=${commit}`]; if (!release) release = gh(['api', '-X', 'POST', `repos/${repo}/releases`, '-f', `tag_name=${tag}`, '-F', 'draft=true', ...body]); else release = gh(['api', '-X', 'PATCH', `repos/${repo}/releases/${release.id}`, ...body]); console.log(`Draft release ${release.id} ready`); - const files = parseSums(await readFile(join(dir, 'SHA256SUMS'), 'utf8')).map(entry => entry.name).filter(file => file !== 'release-notes.md'); + const files = parseSums(await readFile(join(dir, 'SHA256SUMS'), 'utf8')).map(entry => entry.name); gh(['release', 'upload', tag, '--repo', repo, '--clobber', join(dir, 'SHA256SUMS'), ...files.map(file => join(dir, file))], {json: false}); const check = await mkdtemp(join(tmpdir(), 'nmsh-redownload-')); try { const assets = gh(['api', `repos/${repo}/releases/${release.id}`]).assets; + // A resumed draft must carry exactly these files: nothing left over from an earlier or manual run. + const expected = [...files, 'SHA256SUMS'].map(file => basename(file)).sort(); + const actual = assets.map(item => item.name).sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) fail(`The draft's assets are ${actual.join(', ')}; expected exactly ${expected.join(', ')}. The release stays a draft.`); for (const file of [...files, 'SHA256SUMS']) { const asset = assets.find(item => item.name === basename(file)); if (!asset) fail(`Asset ${file} is not on the draft release.`); diff --git a/tests/releaseWorkflow.test.ts b/tests/releaseWorkflow.test.ts index 05977892..fc187136 100644 --- a/tests/releaseWorkflow.test.ts +++ b/tests/releaseWorkflow.test.ts @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import {execFileSync, spawnSync} from 'node:child_process'; import {mkdirSync, mkdtempSync, readFileSync, writeFileSync} from 'node:fs'; import {join} from 'node:path'; +import {tmpdir} from 'node:os'; import {fileURLToPath} from 'node:url'; // @ts-expect-error plain ESM scripts without declarations import * as lib from '../scripts/release/lib.mjs'; @@ -10,7 +11,7 @@ import * as lib from '../scripts/release/lib.mjs'; import {updateFormula} from '../scripts/release/homebrew.mjs'; const root = fileURLToPath(new URL('..', import.meta.url)); -const tmp = (): string => mkdtempSync(join(process.env.NMSH_TEST_TMPDIR ?? process.env.TMPDIR ?? '.', 'release-')); +const tmp = (): string => mkdtempSync(join(process.env.NMSH_TEST_TMPDIR ?? process.env.TMPDIR ?? tmpdir(), 'release-')); const SHA = 'a'.repeat(40), OTHER = 'b'.repeat(40); const codes = (items: Array<{code: string}>): string[] => items.map(item => item.code); @@ -204,7 +205,7 @@ test('release.yml: manual or release-script-PR triggers only, pinned actions, ga assert.match(on, /^ {2}pull_request:\n {4}paths:\n {6}- scripts\/release\/\*\*\n {6}- \.github\/workflows\/release\.yml\n {6}- tests\/release\*\.test\.ts/mu); assert.doesNotMatch(on, /^ {2}(push|schedule|release|workflow_run|pull_request_target):/mu); assert.match(text, /^permissions: \{\}$/mu); - assert.match(text, /concurrency:\n {2}group: nmsh-release\n {2}cancel-in-progress: false/u); + assert.match(text, /concurrency:\n(?: {2}#.*\n)? {2}group: \$\{\{ github\.event_name == 'pull_request' && format\('nmsh-release-pr-\{0\}', github\.ref\) \|\| 'nmsh-release' \}\}\n {2}cancel-in-progress: false/u); for (const match of text.matchAll(/^\s*- uses: (\S+)/gmu)) assert.match(match[1], /^actions\/[\w-]+@[0-9a-f]{40}$/u, match[1]); const jobs = text.split(/^jobs:\n/mu)[1].split(/^ {2}(?=[a-z]+:\n)/mu).filter(Boolean); assert.equal(jobs.length, 3); @@ -231,3 +232,16 @@ test('a lost gate exit status cannot publish: pipefail shell, and publish refuse assert.match(provenanceRefusal({...record, mode: 'dry-run'}, {version: '1.2.3', commit})!, /publish-mode/); assert.match(provenanceRefusal(record, {version: '1.2.4', commit})!, /publish-mode/); }); + +test('safety: an untagged draft gets an annotated tag first; publish pins digests, assets and the tap token', () => { + assert.equal(lib.classifyExisting({commit: SHA, tag: {exists: false}, release: {draft: true}}).action, 'create'); + assert.equal(lib.classifyExisting({commit: SHA, tag: {exists: true, commit: SHA}, release: {draft: true}}).action, 'resume'); + const text = readFileSync(join(root, '.github/workflows/release.yml'), 'utf8'); + assert.match(text, /--expected-sha256 "\$ARCHIVE_SHA256"/u, 'the archive digest reaches publish outside the artifact'); + assert.doesNotMatch(text.split(/^ {2}homebrew:/mu)[1]!.split('steps:')[0]!, /HOMEBREW_TAP_TOKEN/u, 'the tap token is step-scoped, not job-wide'); + assert.match(text, /\[\[ "\$VERSION" =~ \^\[0-9\]\+/u, 'the version is validated before it is written to outputs'); + const publish = readFileSync(join(root, 'scripts/release/publish.mjs'), 'utf8'); + assert.match(publish, /target_commitish=\$\{commit\}/u); + assert.match(publish, /expected exactly/u, 'a resumed draft must carry exactly the verified assets'); + assert.match(readFileSync(join(root, 'scripts/release/check-ci.mjs'), 'utf8'), /app\?\.slug === 'github-actions'/u); +});