Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/release-safety.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
32 changes: 32 additions & 0 deletions scripts/release-arguments.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
18 changes: 12 additions & 6 deletions scripts/release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
// 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<version>) ->
// push with the tag -> watch the CI build and print the release URL.

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' }); };
Expand All @@ -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');
Expand Down
28 changes: 28 additions & 0 deletions test/fixtures/release-command-stubs.mjs
Original file line number Diff line number Diff line change
@@ -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();
91 changes: 91 additions & 0 deletions test/release-arguments.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading