Files
memind/release-gate/runner.mjs
T
john f1f9a1cf8f
Memind CI / Test, build, and release guards (push) Successful in 1m59s
fix: ignore generated gate artifacts in cleanliness check
2026-07-26 15:20:14 +08:00

299 lines
11 KiB
JavaScript

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 };
}