const { test } = require('node:test'); const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const SCRIPT = path.join(ROOT, 'scripts', 'publish-release.sh'); const TOKEN = 'release-token-that-must-not-enter-argv'; function runScenario({ viewStatus = 0, changelog = '## 2.0.0\n\n### Added\n\n- New release flow.\n' } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stoke-release-test-')); try { const runnerTemp = path.join(dir, 'runner-temp'); const log = path.join(dir, 'calls.jsonl'); const stub = path.join(dir, 'stoke-stub.js'); const deb = path.join(dir, 'stoke_2.0.0_all.deb'); fs.mkdirSync(runnerTemp); fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelog); fs.writeFileSync(deb, 'package'); fs.writeFileSync(stub, `#!/usr/bin/env node const fs = require('node:fs'); const args = process.argv.slice(2); const tokenIndex = args.indexOf('--token-file'); const configIndex = args.indexOf('--config'); const record = { args }; if (tokenIndex !== -1) { const tokenFile = args[tokenIndex + 1]; record.tokenFile = tokenFile; record.token = fs.readFileSync(tokenFile, 'utf8'); record.tokenMode = fs.statSync(tokenFile).mode & 0o777; } if (configIndex !== -1) record.config = args[configIndex + 1]; fs.appendFileSync(process.env.STOKE_CALL_LOG, JSON.stringify(record) + '\\n'); if (args.includes('release') && args.includes('view')) process.exit(Number(process.env.VIEW_STATUS)); `); fs.chmodSync(stub, 0o755); const result = spawnSync('bash', [SCRIPT, 'v2.0.0', '2.0.0', deb, 'heavy-duty', 'stoke'], { cwd: dir, encoding: 'utf8', env: { ...process.env, RELEASE_TOKEN: TOKEN, GITHUB_SERVER_URL: 'https://forge.example.test', RUNNER_TEMP: runnerTemp, STOKE: stub, STOKE_CALL_LOG: log, VIEW_STATUS: String(viewStatus), }, }); const calls = fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []; return { result, calls, runnerTemp }; } finally { fs.rmSync(dir, { recursive: true, force: true }); } } function command(call) { const index = call.args.indexOf('release'); return index === -1 ? '' : call.args[index + 1]; } test('existing release uploads the asset without creating another release', () => { const scenario = runScenario({ viewStatus: 0 }); assert.equal(scenario.result.status, 0, scenario.result.stderr); assert.deepEqual(scenario.calls.map(command).filter(Boolean), ['view', 'upload']); assert.equal(scenario.calls.some((call) => command(call) === 'create'), false); const upload = scenario.calls.find((call) => command(call) === 'upload'); assert.ok(upload.args.includes('--tag')); assert.ok(upload.args.includes('v2.0.0')); assert.ok(upload.args.includes('--asset')); assert.ok(upload.args.some((arg) => arg.endsWith('stoke_2.0.0_all.deb'))); }); test('missing release creates it with changelog notes and the asset', () => { const scenario = runScenario({ viewStatus: 1 }); assert.equal(scenario.result.status, 0, scenario.result.stderr); assert.deepEqual(scenario.calls.map(command).filter(Boolean), ['view', 'create']); const create = scenario.calls.find((call) => command(call) === 'create'); assert.ok(create.args.includes('--title')); assert.ok(create.args.includes('v2.0.0')); assert.ok(create.args.includes('--body-file')); assert.ok(create.args.includes('--asset')); }); test('authentication uses a 0600 token file and never puts the token in argv', () => { const scenario = runScenario(); assert.equal(scenario.result.status, 0, scenario.result.stderr); const auth = scenario.calls[0]; assert.ok(auth.args.includes('auth')); assert.ok(auth.args.includes('login')); assert.ok(auth.args.includes('--token-file')); assert.equal(auth.token, TOKEN); assert.equal(auth.tokenMode, 0o600); assert.equal(auth.args.includes('https://forge.example.test'), true); assert.equal(scenario.calls.every((call) => call.args.every((arg) => !arg.includes(TOKEN))), true); assert.equal(scenario.calls.every((call) => call.config === auth.config), true); assert.equal(fs.existsSync(auth.tokenFile), false, 'temporary credential file must be removed'); }); test('missing changelog section aborts before any release command', () => { const scenario = runScenario({ changelog: '## 1.0.0\n\n- Old release.\n' }); assert.equal(scenario.result.status, 1); assert.match(scenario.result.stderr, /no section for '2\.0\.0'/); assert.deepEqual(scenario.calls.map(command).filter(Boolean), []); });