fix: harden release gate and page delivery
Memind CI / Test, build, and release guards (push) Failing after 12m3s
Memind CI / Test, build, and release guards (push) Failing after 12m3s
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 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' && status === '' ? 'passed' : 'failed';
|
||||
rel01.reason = rel01.status === 'passed' ? null : 'candidate_must_be_clean_main';
|
||||
rel01.evidence.push(`branch=${branch || 'detached'}`, `worktree_clean=${status === ''}`);
|
||||
|
||||
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 runWithConcurrency(
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user