汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
167 行
17 KiB
JavaScript
167 行
17 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 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.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(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;
|
|
}
|
|
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') {
|
|
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 {console.error('[release-receipt] invalid_or_incomplete');process.exitCode=1;}
|
|
}
|