122 lines
4.4 KiB
JavaScript
122 lines
4.4 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
import { assertPortalRuntimePath, hashArtifact } from '../release-gate/artifact.mjs';
|
|
import { loadScenarioCatalog } from '../release-gate/catalog.mjs';
|
|
import { selectImpactScenarios } from '../release-gate/impact.mjs';
|
|
import { validateGateReport } from '../release-gate/report.mjs';
|
|
import { listChangedPathsBetween, runCommand } from '../release-gate/runner.mjs';
|
|
|
|
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
artifact: path.join(ROOT, '.runtime', 'portal'),
|
|
report: null,
|
|
};
|
|
for (let index = 2; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === '--artifact') options.artifact = path.resolve(argv[++index] ?? '');
|
|
else if (arg === '--report') options.report = path.resolve(argv[++index] ?? '');
|
|
else throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function comparableSelection(selection) {
|
|
return {
|
|
policy_version: selection.policy_version,
|
|
strategy: selection.strategy,
|
|
catalog_total: selection.catalog_total,
|
|
core_ids: selection.core_ids,
|
|
impact_groups: selection.impact_groups,
|
|
changed_paths: selection.changed_paths,
|
|
unmapped_paths: selection.unmapped_paths,
|
|
full_gate_reasons: selection.full_gate_reasons,
|
|
selected_ids: selection.selected_ids,
|
|
selected_total: selection.selected_total,
|
|
base_commit: selection.base_commit,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const options = parseArgs(process.argv);
|
|
options.artifact = assertPortalRuntimePath(options.artifact, { repoRoot: ROOT });
|
|
const commitSha = await git('rev-parse', 'HEAD');
|
|
const reportPath = options.report ?? path.join(ROOT, '.release-gate', commitSha, 'report.json');
|
|
const [raw, artifact] = await Promise.all([
|
|
fs.readFile(reportPath, 'utf8'),
|
|
hashArtifact(options.artifact),
|
|
]);
|
|
const report = JSON.parse(raw);
|
|
const validation = validateGateReport(report, {
|
|
expectedCommit: commitSha,
|
|
expectedArtifactSha256: artifact.sha256,
|
|
expectedBranch: 'main',
|
|
});
|
|
if (report.mode === 'impact' && /^[0-9a-f]{40}$/i.test(report?.selection?.base_commit ?? '')) {
|
|
try {
|
|
const [catalog, changedPaths, ancestry] = await Promise.all([
|
|
loadScenarioCatalog({ root: ROOT }),
|
|
listChangedPathsBetween(report.selection.base_commit, commitSha),
|
|
runCommand(
|
|
'git',
|
|
['merge-base', '--is-ancestor', report.selection.base_commit, commitSha],
|
|
{ cwd: ROOT, timeoutMs: 30_000 },
|
|
),
|
|
]);
|
|
const expectedSelection = {
|
|
...selectImpactScenarios({
|
|
catalog,
|
|
changedPaths,
|
|
forceFullReasons: ancestry.code === 0
|
|
? []
|
|
: [`deployed_commit_not_ancestor:${report.selection.base_commit}`],
|
|
}),
|
|
base_commit: report.selection.base_commit,
|
|
};
|
|
if (
|
|
JSON.stringify(comparableSelection(report.selection))
|
|
!== JSON.stringify(comparableSelection(expectedSelection))
|
|
) {
|
|
validation.errors.push('impact selection does not match the candidate Git diff');
|
|
validation.valid = false;
|
|
}
|
|
} catch (error) {
|
|
validation.errors.push(`impact selection cannot be reproduced: ${error.message}`);
|
|
validation.valid = false;
|
|
}
|
|
}
|
|
if (!validation.valid) {
|
|
const blocking = report.scenarios.filter(
|
|
(scenario) => !['passed', 'not_applicable'].includes(scenario.status),
|
|
);
|
|
const preview = blocking
|
|
.slice(0, 20)
|
|
.map((scenario) => `${scenario.id}:${scenario.status}`)
|
|
.join(', ');
|
|
const structural = validation.errors.filter(
|
|
(message) => !/^[A-Z]+-\d{2} is (failed|skipped|blocked|unknown)$/.test(message),
|
|
);
|
|
throw new Error([
|
|
'Gate report rejected.',
|
|
`blocking_scenarios=${blocking.length}`,
|
|
`preview=${preview}${blocking.length > 20 ? ', ...' : ''}`,
|
|
...structural.map((message) => `- ${message}`),
|
|
].join('\n'));
|
|
}
|
|
console.log(`Gate report verified: ${reportPath}`);
|
|
console.log(`commit=${commitSha}`);
|
|
console.log(`artifact_sha256=${artifact.sha256}`);
|
|
} catch (error) {
|
|
console.error(error.message);
|
|
process.exit(1);
|
|
}
|