接入发布候选回执强制校验(09-06-release-gate-integrity)

这个提交包含在:
2026-09-19 14:25:33 +08:00
父节点 1f8d5b658a
当前提交 16b9cad98c
共修改 15 个文件,包含 722 行新增和 91 行删除
+51 -4
查看文件
@@ -11,6 +11,7 @@ import { verifyLocalCandidateResults } from './verify-local-candidate-results.mj
import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages, validTime } from './qa-stage-record.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const DEPLOYMENT_ENTRIES = ['deploy/trialrun/production-identity-bootstrap.mjs','deploy/trialrun/deploy-existing.sh','deploy/trialrun/deploy.sh'];
const hash = value => createHash('sha256').update(value).digest('hex');
const read = file => JSON.parse(readFileSync(file,'utf8'));
const PHASES = ['static','full-local'];
@@ -37,7 +38,7 @@ export function captureReleaseSource(root=ROOT) {
return {...source,untrackedDigest:hash(JSON.stringify(files))};
}
export function currentPolicy(root=ROOT) {
return {digest:hash(JSON.stringify(POLICY_FILES.map(file=>[file,hash(readFileSync(path.join(root,file)))]))), environmentDigest:environmentPolicyDigest(), runnerDigest:hash(readFileSync(path.join(root,'scripts/qa/full-local-environment.mjs'))), probeDigest:hash(readFileSync(path.join(root,'scripts/qa/browser-environment-probe.mjs'))), toolchain:{node:process.version,playwright:read(path.join(root,'client/node_modules/@playwright/test/package.json')).version,browserRegistry:hash(readFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'))),browserVersions:Object.fromEntries(read(path.join(root,'client/node_modules/playwright-core/browsers.json')).browsers.filter(browser=>['chromium','webkit'].includes(browser.name)).map(browser=>[browser.name,browser.browserVersion])),platform:process.platform,arch:process.arch,ci:Boolean(process.env.CI),workers:process.env.CI?2:4}};
return {digest:hash(JSON.stringify([...POLICY_FILES,...DEPLOYMENT_ENTRIES].map(file=>[file,hash(readFileSync(path.join(root,file)))]))), environmentDigest:environmentPolicyDigest(), runnerDigest:hash(readFileSync(path.join(root,'scripts/qa/full-local-environment.mjs'))), probeDigest:hash(readFileSync(path.join(root,'scripts/qa/browser-environment-probe.mjs'))), toolchain:{node:process.version,playwright:read(path.join(root,'client/node_modules/@playwright/test/package.json')).version,browserRegistry:hash(readFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'))),browserVersions:Object.fromEntries(read(path.join(root,'client/node_modules/playwright-core/browsers.json')).browsers.filter(browser=>['chromium','webkit'].includes(browser.name)).map(browser=>[browser.name,browser.browserVersion])),platform:process.platform,arch:process.arch,ci:Boolean(process.env.CI),workers:process.env.CI?2:4}};
}
function saneSource(source) {
return /^[a-f0-9]{40}$/.test(source?.commit ?? '') && /^[a-f0-9]{40}$/.test(source?.tree ?? '') && /^[a-f0-9]{64}$/.test(source?.digest ?? '') && source?.repository?.trackedCoverageComplete === true && typeof source.repository.porcelainV1Z === 'string' && source.dirty === Boolean(source.repository.porcelainV1Z.length);
@@ -122,7 +123,7 @@ function readBundle(record,seal=false) {
}
function facts() {return {root:ROOT,source:captureReleaseSource(),policy:currentPolicy(),contract:read(path.join(ROOT,'scripts/qa/full-local-coverage-contract.json')),manifest:read(path.join(ROOT,'scripts/qa/route-manifest.json'))};}
function ownedDirectory(input,create=false) {
const relative=path.relative(ROOT,path.resolve(input)).split(path.sep).join('/');
const relative=path.relative(ROOT,path.resolve(ROOT,input)).split(path.sep).join('/');
if (!relative.startsWith('artifacts/release-gate/')) throw Error('release_report_outside_artifacts');
const dir=confinedPath(ROOT,relative);
if (create && existsSync(dir) && readdirSync(dir).length) throw Error('release_report_not_empty');
@@ -130,10 +131,52 @@ function ownedDirectory(input,create=false) {
if (realpathSync(dir)!==dir) throw Error('release_report_symlink');
return dir;
}
export class ReleaseGateError extends Error {
constructor(code) { super(code); this.code=code; }
}
export function deploymentReadOnlyMode(env=process.env, allowReadOnly=false) {
const flags=['PRODUCTION_IDENTITY_ONLY','PRODUCTION_READ_ONLY_PREFLIGHT'].map(key=>env[key]??'0');
if (flags.some(value=>!['0','1'].includes(value))) throw new ReleaseGateError('read_only_flag_invalid');
if (!allowReadOnly && flags.includes('1')) throw new ReleaseGateError('read_only_cannot_deploy');
return {identityOnly:flags[0]==='1',preflightOnly:flags[1]==='1'};
}
// Read-only assertion: never generates evidence or invokes a test/deploy runner.
// A returned commit is pinned by each caller across its subsequent preflights.
export function requireReleaseReceipt(input=process.env.RELEASE_GATE_RECEIPT_DIR, expectedCommit) {
try {
deploymentReadOnlyMode();
validateSkipRequest('deploy',process.env);
if (typeof input!=='string' || !input.trim()) throw new ReleaseGateError('receipt_required');
const dir=ownedDirectory(input);
const file=confinedPath(dir,'release-result.json');
if (!lstatSync(file).isFile()) throw new ReleaseGateError('receipt_invalid');
const record=read(file), current=facts();
for (const expected of [expectedCommit,process.env.RELEASE_GATE_EXPECTED_COMMIT]) {
if (expected!==undefined && (!/^[a-f0-9]{40}$/.test(expected) || expected!==current.source.commit)) {
throw new ReleaseGateError('candidate_commit_mismatch');
}
}
const result=verifyReleaseEvidence(record,readBundle(record),current);
if (!result.deploymentEligible) throw new ReleaseGateError('receipt_not_eligible');
return {commit:current.source.commit,directory:dir};
} catch (error) {
if (error instanceof ReleaseGateError) throw error;
throw new ReleaseGateError('receipt_invalid');
}
}
if (process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const [action,input,arg,rawExit]=process.argv.slice(2);
if (action==='skip') {
if (action==='require') {
console.log(requireReleaseReceipt(input,arg).commit);
} else if (action==='write-mode') {
deploymentReadOnlyMode();
validateSkipRequest('deploy',process.env);
} else if (action==='skip') {
const result=validateSkipRequest(arg,process.env);
if (result) { const dir=ownedDirectory(input,true);atomic(confinedPath(dir,'skip-receipt.json'),result);console.log('[release-gate] approved diagnostic skips: PARTIAL'); }
} else {
@@ -162,5 +205,9 @@ if (process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv
} else throw Error('action_invalid');
}
}
} catch {console.error('[release-receipt] invalid_or_incomplete');process.exitCode=1;}
} catch (error) {
const code=error instanceof ReleaseGateError?error.code:'invalid_or_incomplete';
console.error(`[release-receipt] ${code}; run verify-candidate on the final clean candidate before deployment`);
process.exitCode=1;
}
}