文件
hyb-oa/scripts/qa/release-gate-result.mjs

214 行
19 KiB
JavaScript

#!/usr/bin/env node
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, lstatSync, readlinkSync, readFileSync, readdirSync, realpathSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sourceSnapshot, compareSnapshots } from './full-local-diagnostic.mjs';
import { environmentPolicyDigest, validateEnvironmentReceipt } from './full-local-environment.mjs';
import { coveragePlan, verifyLocalCoverage, localSpecArgs } from './full-local-coverage.mjs';
import { verifyLocalCandidateResults } from './verify-local-candidate-results.mjs';
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'];
const POLICY_FILES = ['scripts/qa/run-release-gate.sh','scripts/qa/release-gate-result.mjs','scripts/qa/qa-stage-record.mjs','scripts/qa/full-local-coverage.mjs','scripts/qa/full-local-coverage-contract.json','scripts/qa/verify-full-local-discovery.mjs','scripts/qa/verify-local-candidate-results.mjs','scripts/qa/route-manifest.json','client/playwright.config.ts','client/package.json','client/package-lock.json','server/package.json','server/package-lock.json'];
const EVIDENCE_FILES = {environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'};
function atomic(file, value) { writeFileSync(file+'.tmp',JSON.stringify(value,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(file+'.tmp',file); }
function compactSource(value) {
const {commit,tree,dirty,digest,repository}=value;
return {commit,tree,dirty,digest,repository:{trackedDigest:repository.trackedDigest,indexDigest:repository.indexDigest,porcelainV1Z:repository.porcelainV1Z,trackedCoverageComplete:repository.trackedCoverageComplete}};
}
export function captureReleaseSource(root=ROOT) {
const source = compactSource(sourceSnapshot(root));
// Include untracked task/docs/deployment inputs which the shared product
// snapshot does not hash. Ignored private/runtime files remain excluded.
const result = spawnSync('git', ['ls-files','--others','--exclude-standard','-z'], {cwd:root,encoding:'utf8',maxBuffer:16*1024*1024});
if (result.status !== 0) throw Error('untracked_inputs_unavailable');
const files = result.stdout.split('\0').filter(Boolean).sort().map(file => {
const target = path.join(root,file);
if (realpathSync(path.dirname(target)) !== path.dirname(target)) throw Error('input_parent_symlink');
const stat = lstatSync(target);
if (!stat.isFile() && !stat.isSymbolicLink()) throw Error('input_kind_invalid');
return [file,stat.mode & 0o777,stat.isSymbolicLink() ? 'symlink':'file',hash(stat.isSymbolicLink() ? readlinkSync(target):readFileSync(target))];
});
return {...source,untrackedDigest:hash(JSON.stringify(files))};
}
export function currentPolicy(root=ROOT) {
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);
}
function releaseMatches(a,b) { return matches(a,b) && /^[a-f0-9]{64}$/.test(a?.untrackedDigest ?? '') && a.untrackedDigest === b?.untrackedDigest; }
function matches(a,b) { return saneSource(a) && saneSource(b) && compareSnapshots(a,b).candidateIntegrityStatus === 'UNCHANGED'; }
export function verifyReleaseEvidence(record,bundle,facts) {
const issues=[];
if (record?.schemaVersion!==1 || record?.profile!=='release-predeploy' || record?.mode!=='verify-candidate' || !/^[a-f0-9]{24}$/.test(record?.runId??'')) issues.push('receipt_profile_invalid');
if (!validTime(record?.startedAt) || !validTime(record?.finishedAt) || Date.parse(record.finishedAt)<Date.parse(record.startedAt) || record?.executionExit!==0 || record?.interrupted) issues.push('run_incomplete_or_failed');
issues.push(...validateStages(record?.phases,PHASES,'release-predeploy-stages'));
if (JSON.stringify(record?.policy)!==JSON.stringify(facts.policy)) issues.push('policy_or_toolchain_changed');
if (!releaseMatches(record?.sourceBefore,record?.sourceAfter) || !releaseMatches(record?.sourceBefore,facts.source)) issues.push('source_changed_or_invalid');
if (Date.parse(record?.phases?.startedAt)<Date.parse(record?.startedAt) || Date.parse(record?.phases?.endedAt)>Date.parse(record?.finishedAt)) issues.push('release_time_binding_invalid');
const env=bundle?.environment;
if (env?.schemaVersion!==1 || env?.mode!=='full-local' || env?.profile!=='isolated-online-v1' || env?.policyDigest!==facts.policy.environmentDigest || env?.status!=='PASS' || env?.exitCode!==0 || env?.childExit!==0 || env?.childSignal || env?.interrupted || env?.error || !validTime(env?.startedAt) || !validTime(env?.endedAt) || Date.parse(env.endedAt)<Date.parse(env.startedAt)) issues.push('managed_run_invalid');
issues.push(...validateEnvironmentReceipt(env?.environment,facts.policy.environmentDigest,true));
if (env?.platform!==facts.policy.toolchain.platform || env?.environment?.platform!==facts.policy.toolchain.platform || env?.environment?.runnerDigest!==facts.policy.runnerDigest || env?.environment?.probeDigest!==facts.policy.probeDigest) issues.push('environment_runtime_changed');
if (['chromium','webkit'].some(name=>!facts.policy.toolchain.browserVersions?.[name] || env?.environment?.nativeOnlineProbe?.engines?.find(engine=>engine.name===name)?.version!==facts.policy.toolchain.browserVersions[name])) issues.push('browser_version_changed');
if (env?.cleanup?.containerOwnerVerified!==true || env?.cleanup?.containerRemoved!==true || env?.cleanup?.scratchRemoved!==true || env?.cleanup?.error) issues.push('cleanup_incomplete');
if (!matches(env?.sourceBefore,env?.sourceAfter) || !matches(env?.sourceBefore,facts.source) || env?.sourceUnchanged!==true || env?.repositoryUnchanged!==true || env?.candidateIntegrityStatus!=='UNCHANGED') issues.push('managed_source_mismatch');
if (bundle?.phases?.schemaVersion!==1 || bundle?.phases?.profile!=='full-local-stages') issues.push('full_stage_profile_invalid');
issues.push(...validateStages(bundle?.phases,FULL_LOCAL_STAGES,'full-local-stages'));
const outerFull=record?.phases?.stages?.find(s=>s.id==='full-local');
if (env && outerFull && (Date.parse(env.startedAt)<Date.parse(outerFull.startedAt) || Date.parse(env.endedAt)>Date.parse(outerFull.endedAt) || Date.parse(bundle?.phases?.startedAt)<Date.parse(env.startedAt) || Date.parse(bundle?.phases?.endedAt)>Date.parse(env.endedAt))) issues.push('stage_time_binding_invalid');
let coverage=null,witness=null;
try {
const plan=coveragePlan(facts.root,bundle.discovery,facts.contract,facts.source);
if (plan.status!=='PASS' || JSON.stringify(plan)!==JSON.stringify(bundle.plan)) issues.push('discovery_plan_invalid');
coverage=verifyLocalCoverage(plan,bundle.execution,facts.source);
issues.push(...coverage.issues);
witness=verifyLocalCandidateResults(facts.manifest,bundle.execution);
if (!witness.ok) issues.push(...witness.issues);
const specs=new Set(plan.localSpecs);
if (!facts.manifest.acceptanceSpecs?.length || facts.manifest.acceptanceSpecs.some(file=>!specs.has(file))) issues.push('acceptance_spec_missing');
const expectedSelection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(plan),additionalArgs:[],retries:0,forbidOnly:true,workers:facts.policy.toolchain.workers,mode:'local'};
if (JSON.stringify(bundle.selection)!==JSON.stringify(expectedSelection)) issues.push('recorded_selection_invalid');
const config=bundle.execution?.config, projects=config?.projects;
if (config?.forbidOnly!==true || !Array.isArray(projects) || projects.length!==1 || projects.some(project=>project.retries!==0 || project.repeatEach!==1 || project.name!=='' || project.timeout!==180000 || JSON.stringify(project.testIgnore)!==JSON.stringify(['**/manual/**'])) || config?.shard!==null || config?.maxFailures!==0 || config?.workers!==facts.policy.toolchain.workers || config?.version!==facts.policy.toolchain.playwright) issues.push('execution_selection_invalid');
const stats=bundle.execution?.stats;
if (!validTime(stats?.startTime) || !Number.isFinite(stats?.duration) || stats.duration<0 || stats.expected!==plan.localCases.length || stats.unexpected!==0 || stats.flaky!==0 || stats.skipped!==0) issues.push('execution_statistics_invalid');
const browser=bundle?.phases?.stages?.find(s=>s.id==='browser');
if (Date.parse(stats?.startTime)<Date.parse(browser?.startedAt) || Date.parse(stats?.startTime)+stats?.duration>Date.parse(browser?.endedAt)) issues.push('browser_time_binding_invalid');
} catch { issues.push('coverage_evidence_invalid'); }
const localChecksPassed=issues.length===0;
const clean=[record?.sourceBefore,record?.sourceAfter,facts.source,env?.sourceBefore,env?.sourceAfter].every(source=>saneSource(source) && source.dirty===false);
if (!clean) issues.push('dirty_candidate');
return {schemaVersion:1,profile:'release-predeploy',status:!record?.finishedAt?'INCOMPLETE':localChecksPassed ? (clean?'PASS':'PARTIAL'):'FAIL',localChecksPassed,deploymentEligible:localChecksPassed&&clean,deliveryStatus:'INCOMPLETE',issues:[...new Set(issues)],localCases:coverage?.cases??[],obligations:coverage?.obligations??[],witnessSummary:witness?{ok:witness.ok,expected:witness.expectedTagCount,passed:witness.passedTagCount}:null};
}
export function validateSkipRequest(mode,env) {
const affected=[];
for (const [name,stage] of [['PROJECT_WIDE_SKIP_BUSINESS_FLOW','project-wide-business-flow'],['PROJECT_WIDE_SKIP_VISUAL','project-wide-visual']]) {
const value=env[name]??'0';
if (!['0','1'].includes(value)) throw Error('skip_value_invalid');
if (value==='1') affected.push(stage);
}
if (!affected.length) return null;
if (mode!=='project-wide') throw Error('skip_forbidden_for_release');
const values=['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'].map(name=>env[name]?.trim());
if (values.some(value=>!value || value.length>500 || /[\r\n\0]/u.test(value))) throw Error('skip_approval_metadata_required');
return {profile:'project-wide',status:'PARTIAL',deploymentEligible:false,affectedStages:affected,reason:values[0],operator:values[1],approvalRef:values[2],approvalTime:null,observedAt:new Date().toISOString()};
}
export function confinedPath(root,relative) {
if (typeof relative!=='string' || !relative || path.isAbsolute(relative) || relative.split('/').some(part=>part==='..'||part===''||part==='.') || relative.includes('\\')) throw Error('evidence_path_invalid');
if (realpathSync(root)!==root) throw Error('evidence_root_symlink');
const resolved=path.join(root,relative);
let cursor=root;
for (const part of relative.split('/')) { cursor=path.join(cursor,part); try { if (lstatSync(cursor).isSymbolicLink()) throw Error('evidence_symlink'); } catch (error) { if (error.code!=='ENOENT') throw error; } }
return resolved;
}
function readBundle(record,seal=false) {
const bundle={};
if (!/^[a-f0-9]{24}$/.test(record.runId??'') || record.fullLocalDirectory!==`artifacts/qa-flow/release-predeploy/${record.runId}`) throw Error('managed_report_path_invalid');
const base=confinedPath(ROOT,record.fullLocalDirectory);
for (const [key,relative] of Object.entries(EVIDENCE_FILES)) {
const file=confinedPath(base,relative);if (!lstatSync(file).isFile()) throw Error('evidence_not_file');const raw=readFileSync(file);const digest=hash(raw);
if (seal) record.evidence[key]={relative,sha256:digest};
else if (record.evidence?.[key]?.relative!==relative || record.evidence[key].sha256!==digest) throw Error('evidence_digest_mismatch');
bundle[key]=JSON.parse(raw);
}
return bundle;
}
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(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');
if (create) mkdirSync(dir,{recursive:true,mode:0o700});
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==='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 {
const dir=ownedDirectory(input,action==='init');const file=confinedPath(dir,'release-result.json');
if (action==='init') {
validateSkipRequest('verify-candidate',process.env);
const runId=randomBytes(12).toString('hex');
const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId,startedAt:new Date().toISOString(),finishedAt:null,status:'INCOMPLETE',deploymentEligible:false,policy:currentPolicy(),sourceBefore:captureReleaseSource(),sourceAfter:null,phases:newStageRecord(PHASES,'release-predeploy-stages'),fullLocalDirectory:`artifacts/qa-flow/release-predeploy/${runId}`,evidence:{},executionExit:null};
atomic(file,record);
} else {
const record=read(file);
if (record.profile!=='release-predeploy'||record.mode!=='verify-candidate') throw Error('receipt_profile_invalid');
if (action==='full-path') console.log(confinedPath(ROOT,record.fullLocalDirectory));
else if (action==='stage-start'||action==='stage-end') {
if (!releaseMatches(record.sourceBefore,captureReleaseSource())) throw Error('stage_source_changed');
updateStageRecord(record.phases,action==='stage-start'?'start':'end',arg,Number(rawExit));atomic(file,record);
} else if (action==='finish') {
if (!/^\d{1,3}$/.test(arg??'')) throw Error('exit_invalid');
const exitCode=Number(arg);updateStageRecord(record.phases,'finish',null,exitCode);record.executionExit=exitCode;record.sourceAfter=captureReleaseSource();record.finishedAt=new Date().toISOString();
let result;
try {const bundle=readBundle(record,true);result=verifyReleaseEvidence(record,bundle,facts());}
catch {result={status:exitCode?'FAIL':'INCOMPLETE',localChecksPassed:false,deploymentEligible:false,deliveryStatus:'INCOMPLETE',issues:['required_evidence_unavailable']};}
Object.assign(record,{status:result.status,deploymentEligible:result.deploymentEligible,verification:result});atomic(file,record);console.log(JSON.stringify({status:result.status,localChecksPassed:result.localChecksPassed,deploymentEligible:result.deploymentEligible,issues:result.issues}));process.exitCode=result.deploymentEligible?0:result.status==='PARTIAL'?2:1;
} else if (action==='verify') {
const result=verifyReleaseEvidence(record,readBundle(record),facts());console.log(JSON.stringify(result));process.exitCode=result.deploymentEligible?0:result.status==='PARTIAL'?2:1;
} else throw Error('action_invalid');
}
}
} 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;
}
}