import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import path from 'node:path'; import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './artifact.mjs'; import { loadScenarioCatalog } from './catalog.mjs'; import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs'; import { loadActiveRegressionCorpus } from './regression-corpus.mjs'; import { createGateReport, writeGateReport } from './report.mjs'; import { assertSafeGateEnvironment, assertSafePortalBase } from './safety.mjs'; const ROOT = path.resolve(new URL('..', import.meta.url).pathname); const MODES = new Set(['deterministic', 'scenarios', 'browser', 'providers', 'upgrade', 'all']); async function git(...args) { const result = await runCommand('git', args, { cwd: ROOT, timeoutMs: 30_000 }); if (result.code !== 0) throw new Error(`git ${args.join(' ')} failed`); return result.stdout.trim(); } export function parseRunnerArgs(argv) { const options = { mode: 'all', artifact: null, portalBase: 'http://127.0.0.1:8081', reportRoot: path.join(ROOT, '.release-gate'), timeoutMs: 15 * 60 * 1000, suiteConcurrency: Number(process.env.RELEASE_GATE_SUITE_CONCURRENCY ?? 4), }; for (let index = 2; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--mode') options.mode = argv[++index] ?? ''; else if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? ''); else if (arg === '--portal-base') options.portalBase = argv[++index] ?? ''; else if (arg === '--report-root') options.reportRoot = path.resolve(argv[++index] ?? ''); else if (arg === '--timeout-ms') options.timeoutMs = Number(argv[++index]); else if (arg === '--suite-concurrency') options.suiteConcurrency = Number(argv[++index]); else if (arg === '-h' || arg === '--help') options.help = true; else throw new Error(`Unknown argument: ${arg}`); } if (!MODES.has(options.mode)) throw new Error(`Invalid release gate mode: ${options.mode}`); if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { throw new Error('timeout-ms must be a positive number'); } if ( !Number.isFinite(options.suiteConcurrency) || options.suiteConcurrency <= 0 || options.suiteConcurrency > 16 ) { throw new Error('suite-concurrency must be between 1 and 16'); } options.suiteConcurrency = Math.floor(options.suiteConcurrency); return options; } export async function runWithConcurrency(items, concurrency, worker) { const results = new Array(items.length); let nextIndex = 0; async function runWorker() { while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; results[index] = await worker(items[index], index); } } await Promise.all( Array.from( { length: Math.min(items.length, Math.max(1, concurrency)) }, () => runWorker(), ), ); return results; } // Some suites intentionally mutate the shared candidate artifact. Run those // suites before the parallel batch so they cannot race suites that boot the // same runtime while it is being rebuilt. export async function runSuitesWithConcurrency(items, concurrency, worker) { const results = new Array(items.length); const parallel = []; for (let index = 0; index < items.length; index += 1) { if (items[index].exclusive) { results[index] = await worker(items[index], index); } else { parallel.push({ item: items[index], index }); } } const parallelResults = await runWithConcurrency( parallel, concurrency, ({ item, index }) => worker(item, index), ); for (let index = 0; index < parallel.length; index += 1) { results[parallel[index].index] = parallelResults[index]; } return results; } export function filterGeneratedWorktreeStatus(status) { return status .split('\n') .filter((line) => { if (!line) return false; const path = line.slice(3).replace(/^"|"$/g, ''); return !path.startsWith('.release-gate/') && !path.startsWith('.runtime/portal/public/plaza-covers/'); }) .join('\n'); } export async function runCommand(command, args, { cwd = ROOT, timeoutMs = 15 * 60 * 1000, env = process.env, } = {}) { return new Promise((resolve) => { const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let timedOut = false; const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, timeoutMs); child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); child.on('error', (error) => { clearTimeout(timer); resolve({ code: 1, stdout, stderr: `${stderr}${error.message}`, timedOut }); }); child.on('close', (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr, timedOut }); }); }); } function scenarioResult(scenario) { return { id: scenario.id, name: scenario.name, status: 'unknown', cleanup_status: 'not_required', evidence: [], reason: 'automation_not_implemented', }; } async function runSuite(suite, outputDir, timeoutMs) { const [command, ...args] = suite.command; const result = await runCommand(command, args, { cwd: ROOT, timeoutMs }); const logDir = path.join(outputDir, 'logs'); await fs.mkdir(logDir, { recursive: true }); const logPath = path.join(logDir, `${suite.id}.log`); await fs.writeFile( logPath, [ `$ ${[command, ...args].join(' ')}`, `exit_code=${result.code}`, `timed_out=${result.timedOut}`, '', result.stdout, result.stderr, ].join('\n'), ); return { ...result, logPath: path.relative(outputDir, logPath) }; } async function applyRepositoryChecks(results, artifactPath) { const byId = new Map(results.map((result) => [result.id, result])); const branch = await git('branch', '--show-current'); const status = await git('status', '--porcelain=v1', '--untracked-files=all'); const relevantStatus = filterGeneratedWorktreeStatus(status); const originMain = await runCommand( 'git', ['rev-parse', '--verify', 'origin/main'], { cwd: ROOT, timeoutMs: 30_000 }, ); const remoteSha = originMain.code === 0 ? originMain.stdout.trim() : null; const headSha = await git('rev-parse', 'HEAD'); const rel01 = byId.get('REL-01'); rel01.status = branch === 'main' && relevantStatus === '' ? 'passed' : 'failed'; rel01.reason = rel01.status === 'passed' ? null : 'candidate_must_be_clean_main'; rel01.evidence.push( `branch=${branch || 'detached'}`, `worktree_clean=${relevantStatus === ''}`, `ignored_generated_paths=${status !== relevantStatus}`, ); const ciStatus = String(process.env.MEMIND_RELEASE_CI_STATUS ?? '').trim().toLowerCase(); const rel02 = byId.get('REL-02'); rel02.status = remoteSha === headSha && ciStatus === 'success' ? 'passed' : 'failed'; rel02.reason = rel02.status === 'passed' ? null : 'candidate_must_equal_origin_main_with_successful_ci'; rel02.evidence.push( `head=${headSha}`, `origin_main=${remoteSha ?? 'missing'}`, `ci_status=${ciStatus || 'missing'}`, ); if (!artifactPath) return null; const artifact = await hashArtifact(artifactPath); const rel03 = byId.get('REL-03'); rel03.reason = 'repeat_build_comparison_not_implemented'; rel03.evidence.push(`artifact_sha256=${artifact.sha256}`, `artifact_kind=${artifact.kind}`); const inspection = await inspectPortalRuntime(artifactPath); const rel04 = byId.get('REL-04'); rel04.status = inspection.passed ? 'passed' : 'failed'; rel04.reason = rel04.status === 'passed' ? null : 'artifact_incomplete_or_contains_forbidden_paths'; rel04.evidence.push( `missing=${inspection.missing.join(',') || 'none'}`, `forbidden=${inspection.forbidden.join(',') || 'none'}`, ); const rel11 = byId.get('REL-11'); rel11.status = inspection.missing.length === 0 ? 'unknown' : 'failed'; rel11.reason = rel11.status === 'failed' ? 'runtime_dependency_closure_incomplete' : 'runtime_container_start_smoke_not_implemented'; rel11.evidence.push(`missing=${inspection.missing.join(',') || 'none'}`); return artifact; } function suiteIsInMode(suite, mode) { return mode === 'all' || suite.mode === mode; } export async function executeReleaseGate(options) { const startedAt = new Date(); assertSafeGateEnvironment({ targets: [options.portalBase] }); assertSafePortalBase(options.portalBase); if (options.artifact) { options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT }); } const catalog = await loadScenarioCatalog({ root: ROOT }); validateAutomationSuites(catalog); const commitSha = await git('rev-parse', 'HEAD'); const branch = await git('branch', '--show-current'); const outputDir = options.mode === 'all' ? path.join(options.reportRoot, commitSha) : path.join(options.reportRoot, commitSha, 'partials', options.mode); const results = catalog.map(scenarioResult); const byId = new Map(results.map((result) => [result.id, result])); const artifact = await applyRepositoryChecks(results, options.artifact); const regressionCorpus = await loadActiveRegressionCorpus({ root: ROOT, catalog }); const comp09 = byId.get('COMP-09'); comp09.reason = regressionCorpus.status === 'ready' ? 'production_regression_replay_not_implemented' : `production_regression_corpus_${regressionCorpus.status}`; comp09.evidence.push( `manifest=${path.relative(ROOT, regressionCorpus.manifestPath)}`, `fixtures=${regressionCorpus.fixtures.length}`, ...regressionCorpus.errors.map((error) => `error=${error}`), ); const suites = AUTOMATION_SUITES.filter((candidate) => suiteIsInMode(candidate, options.mode)); const executions = await runSuitesWithConcurrency( suites, options.suiteConcurrency, (suite) => runSuite(suite, outputDir, options.timeoutMs), ); for (let index = 0; index < suites.length; index += 1) { const suite = suites[index]; const execution = executions[index]; for (const scenarioId of suite.scenarios) { const scenario = byId.get(scenarioId); scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed'; scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed'; scenario.evidence.push( `suite=${suite.id}`, `log=${execution.logPath}`, ...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`), ); } } const report = createGateReport({ commitSha, branch, artifactSha256: artifact?.sha256 ?? '0'.repeat(64), artifact: artifact ? { path: path.relative(ROOT, options.artifact), kind: artifact.kind, files: artifact.files, bytes: artifact.bytes } : { path: null, kind: 'missing' }, scenarios: results, mode: options.mode, startedAt, completedAt: new Date(), }); await writeGateReport(report, outputDir); return { report, outputDir }; }