接入发布候选回执强制校验(09-06-release-gate-integrity)
这个提交包含在:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户