#!/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?.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)!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.endedAt) || Date.parse(bundle?.phases?.startedAt)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?.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; } }