diff --git a/.github/workflows/release-safety.yml b/.github/workflows/release-safety.yml new file mode 100644 index 0000000..482e1e1 --- /dev/null +++ b/.github/workflows/release-safety.yml @@ -0,0 +1,35 @@ +name: Release command safety + +on: + pull_request: + branches: [main] + paths: + - .github/workflows/release-safety.yml + - scripts/release.mjs + - scripts/release-arguments.mjs + - test/release-arguments.test.mjs + - test/fixtures/release-command-stubs.mjs + - package.json + +permissions: + contents: read + +concurrency: + group: release-safety-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + release-safety: + name: Release safety (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Test release commands with intercepted subprocesses + run: node --test test/release-arguments.test.mjs diff --git a/package.json b/package.json index 3acf803..0399f6c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "standalone": "npm run build && npm install --prefix manager && npm run standalone --prefix manager", "release": "node scripts/release.mjs", + "test:release": "node --test test/release-arguments.test.mjs", "dockerbuild": "gh workflow run brain-release.yml -R flujo-app/brain --ref main" }, "keywords": [ diff --git a/scripts/release-arguments.mjs b/scripts/release-arguments.mjs new file mode 100644 index 0000000..09b99bf --- /dev/null +++ b/scripts/release-arguments.mjs @@ -0,0 +1,32 @@ +export const releaseUsage = `Usage: node scripts/release.mjs [patch|minor|major|x.y.z] [--dry-run] + node scripts/release.mjs --help + +Defaults to a patch release. --dry-run performs preflight checks only. +The npm_config_dry_run environment setting is also honored.`; + +export function parseReleaseArguments(argv, env = {}) { + const configuredDryRun = String(env.npm_config_dry_run ?? env.NPM_CONFIG_DRY_RUN ?? '').trim().toLowerCase(); + if (!['', 'false', '0', 'true', '1'].includes(configuredDryRun)) { + throw new Error('Invalid npm_config_dry_run setting; use true, false, 1, or 0.'); + } + + let dryRun = configuredDryRun === 'true' || configuredDryRun === '1'; + let help = false; + let bump; + for (const argument of argv) { + if (argument === '--dry-run') { + dryRun = true; + } else if (argument === '--help' || argument === '-h') { + help = true; + } else if (argument.startsWith('-')) { + throw new Error(`Unknown option '${argument}'. Use --dry-run or --help.`); + } else { + if (bump !== undefined) throw new Error('Specify only one release version or bump.'); + if (!/^(patch|minor|major|\d+\.\d+\.\d+)$/.test(argument)) { + throw new Error(`Unknown bump '${argument}' - use patch, minor, major, or an exact x.y.z version.`); + } + bump = argument; + } + } + return { bump: bump ?? 'patch', dryRun, help }; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 0e5de2c..fb19943 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -7,7 +7,7 @@ // npm run release minor 0.1.0 -> 0.2.0 // npm run release major 0.1.0 -> 1.0.0 // npm run release 1.2.3 exactly 1.2.3 -// npm run release -- --dry-run preflight checks only, changes nothing +// node scripts/release.mjs --dry-run preflight only (refreshes git refs) // // Steps: preflight (on main, clean tree, in sync with origin) -> // `npm version` (bumps package.json + lockfile, commits, tags v) -> @@ -15,6 +15,7 @@ import { execSync, spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +import { parseReleaseArguments, releaseUsage } from './release-arguments.mjs'; const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); const show = (cmd) => { console.log(`\n> ${cmd}`); execSync(cmd, { stdio: 'inherit' }); }; @@ -41,12 +42,17 @@ async function waitForWorkflow(workflow, branch, label) { } } -const args = process.argv.slice(2); -const dryRun = args.includes('--dry-run'); -const bump = args.find((a) => !a.startsWith('--')) ?? 'patch'; -if (!/^(patch|minor|major|\d+\.\d+\.\d+)$/.test(bump)) { - fail(`Unknown bump '${bump}' - use patch, minor, major, or an exact x.y.z version.`); +let releaseOptions; +try { + releaseOptions = parseReleaseArguments(process.argv.slice(2), process.env); +} catch (error) { + fail(error.message); +} +if (releaseOptions.help) { + console.log(releaseUsage); + process.exit(0); } +const { dryRun, bump } = releaseOptions; // --- preflight --------------------------------------------------------------- const branch = run('git rev-parse --abbrev-ref HEAD'); diff --git a/test/fixtures/release-command-stubs.mjs b/test/fixtures/release-command-stubs.mjs new file mode 100644 index 0000000..6932a76 --- /dev/null +++ b/test/fixtures/release-command-stubs.mjs @@ -0,0 +1,28 @@ +// Loaded only by release tests: every child-process call is intercepted, so a +// regression cannot invoke a real git/npm/gh executable or publish anything. +import childProcess from 'node:child_process'; +import { appendFileSync } from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; + +const record = (command) => appendFileSync(process.env.RELEASE_TEST_COMMAND_LOG, `${JSON.stringify(command)}\n`); +for (const method of ['exec', 'execFile', 'execFileSync', 'fork', 'spawn']) { + childProcess[method] = () => { + record(`blocked child_process.${method}`); + throw new Error(`Unexpected child_process.${method} blocked by release test`); + }; +} +childProcess.execSync = (command) => { + record(command); + if (command === 'git rev-parse --abbrev-ref HEAD') return 'main\n'; + if (command === 'git status --porcelain') return ''; + if (command === 'git fetch origin main "+refs/tags/*:refs/tags/*"') return ''; + if (command === 'git rev-parse main' || command === 'git rev-parse origin/main') return 'synthetic-release-head\n'; + if (command === 'gh auth status') return ''; + throw new Error(`Unexpected release command blocked by test: ${command}`); +}; +childProcess.spawnSync = (command, args) => { + record([command, ...args].join(' ')); + if (command === 'gh' && args.length === 1 && args[0] === '--version') return { status: 0 }; + throw new Error(`Unexpected release subprocess blocked by test: ${command}`); +}; +syncBuiltinESMExports(); diff --git a/test/release-arguments.test.mjs b/test/release-arguments.test.mjs new file mode 100644 index 0000000..10eebfb --- /dev/null +++ b/test/release-arguments.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { parseReleaseArguments } from '../scripts/release-arguments.mjs'; + +const entrypoint = fileURLToPath(new URL('../scripts/release.mjs', import.meta.url)); +const stubs = new URL('./fixtures/release-command-stubs.mjs', import.meta.url).href; + +function runRelease(t, args, overrides = {}) { + const tempRoot = path.resolve(tmpdir()); + const directory = mkdtempSync(path.join(tempRoot, 'brain-release-test-')); + t.after(() => { + const resolved = path.resolve(directory); + assert.equal(path.dirname(resolved), tempRoot); + assert.ok(path.basename(resolved).startsWith('brain-release-test-')); + rmSync(resolved, { recursive: true, force: true }); + }); + const commandLog = path.join(directory, 'commands.jsonl'); + writeFileSync(commandLog, ''); + writeFileSync(path.join(directory, 'package.json'), JSON.stringify({ version: '0.0.1' })); + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (name.toLowerCase() === 'npm_config_dry_run' || name === 'NODE_OPTIONS') delete env[name]; + } + const result = spawnSync(process.execPath, ['--import', stubs, entrypoint, ...args], { + cwd: directory, + env: { ...env, ...overrides, RELEASE_TEST_COMMAND_LOG: commandLog }, + encoding: 'utf8', + timeout: 10_000, + }); + assert.equal(result.error, undefined); + const commands = readFileSync(commandLog, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line)); + return { ...result, commands }; +} + +for (const setting of ['true', 'TRUE', '1']) { + test(`npm environment dry-run ${setting} cannot version or push`, (t) => { + const result = runRelease(t, [], { npm_config_dry_run: setting }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Dry run - preflight passed/); + assert.ok(result.commands.includes('git fetch origin main "+refs/tags/*:refs/tags/*"')); + assert.ok(result.commands.every((command) => !/\b(version|push|publish)\b/.test(command.replace('gh --version', '')))); + }); +} + +test('explicit dry-run remains safe when npm environment is false', (t) => { + const result = runRelease(t, ['minor', '--dry-run'], { npm_config_dry_run: 'false' }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Would run: npm version minor/); + assert.ok(result.commands.every((command) => !command.startsWith('npm ') && !command.startsWith('git push'))); +}); + +for (const args of [['--dryrun'], ['--unknown'], ['--dry-run=true'], ['patch', 'minor'], ['patch', '1.2.3'], ['nope']]) { + test(`invalid arguments ${args.join(' ')} invoke no commands`, (t) => { + const result = runRelease(t, args); + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown option|Specify only one|Unknown bump/); + assert.deepEqual(result.commands, []); + }); +} + +for (const argument of ['--help', '-h']) { + test(`${argument} invokes no commands`, (t) => { + const result = runRelease(t, [argument]); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage:/); + assert.deepEqual(result.commands, []); + }); +} + +test('invalid npm dry-run setting fails before any command', (t) => { + const result = runRelease(t, [], { npm_config_dry_run: 'tru' }); + assert.equal(result.status, 1); + assert.deepEqual(result.commands, []); + assert.match(result.stderr, /Invalid npm_config_dry_run setting/); +}); + +test('default and explicit releases retain version selection', () => { + assert.deepEqual(parseReleaseArguments([], {}), { bump: 'patch', dryRun: false, help: false }); + for (const bump of ['patch', 'minor', 'major', '1.2.3']) { + assert.equal(parseReleaseArguments([bump], {}).bump, bump); + } + for (const setting of ['', 'false', 'FALSE', '0']) { + assert.equal(parseReleaseArguments([], { npm_config_dry_run: setting }).dryRun, false); + } + assert.equal(parseReleaseArguments([], { NPM_CONFIG_DRY_RUN: 'true' }).dryRun, true); +});