diff --git a/src/core/deployment-executor.mjs b/src/core/deployment-executor.mjs index 3436d29..0a55a9b 100644 --- a/src/core/deployment-executor.mjs +++ b/src/core/deployment-executor.mjs @@ -1,12 +1,59 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; import { evaluateDeployment } from './deployment.mjs'; import { visualReadinessChecks } from './visual-readiness.mjs'; +const REPO_ROOT=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..'); +const RECEIPT_SCHEMA='webforge.deployment-execution.v5'; const tail=s=>String(s||'').slice(-6000); const hasCommand=cmd=>spawnSync('sh',['-lc',`command -v ${cmd}`],{encoding:'utf8'}).status===0; -const sourceSha=()=>process.env.VERCEL_GIT_COMMIT_SHA||process.env.GITHUB_SHA||process.env.CI_COMMIT_SHA||null; +const first=(...values)=>values.find(v=>typeof v==='string'&&v.trim())?.trim()||null; +const sourceSha=explicit=>first(explicit,process.env.WEBFORGE_SOURCE_SHA,process.env.VERCEL_GIT_COMMIT_SHA,process.env.GITHUB_SHA,process.env.CI_COMMIT_SHA); + +export function loadCanonicalProductionTarget(repoRoot=REPO_ROOT){ + return JSON.parse(fs.readFileSync(path.join(repoRoot,'config','production-target.json'),'utf8')); +} + +export function readCheckoutState(repoRoot=REPO_ROOT,{spawn=spawnSync}={}){ + const head=spawn('git',['-C',repoRoot,'rev-parse','HEAD'],{encoding:'utf8'}); + const status=spawn('git',['-C',repoRoot,'status','--porcelain'],{encoding:'utf8'}); + return { + sha:head.status===0?String(head.stdout||'').trim()||null:null, + clean:status.status===0?String(status.stdout||'').trim()==='':false, + verified:head.status===0&&status.status===0 + }; +} + +export function validateCanonicalVercelProductionBinding({targetRecord,provider,teamId,projectId,sourceSha:expectedSourceSha,checkoutSha,checkoutClean,productionApproved}){ + const blockers=[]; + if(targetRecord?.status!=='CANONICAL') blockers.push('canonical-target-status'); + if(targetRecord?.scope!=='production-hosting-target-identity') blockers.push('canonical-target-scope'); + if(targetRecord?.provider!=='vercel') blockers.push('canonical-provider'); + const canonicalTeamId=first(targetRecord?.target?.teamId); + const canonicalProjectId=first(targetRecord?.target?.projectId); + if(!canonicalTeamId) blockers.push('canonical-team-id-missing'); + if(!canonicalProjectId) blockers.push('canonical-project-id-missing'); + if(provider!=='vercel') blockers.push('provider-mismatch'); + if(!teamId) blockers.push('team-id-missing'); + else if(canonicalTeamId&&teamId!==canonicalTeamId) blockers.push('team-id-mismatch'); + if(!projectId) blockers.push('project-id-missing'); + else if(canonicalProjectId&&projectId!==canonicalProjectId) blockers.push('project-id-mismatch'); + if(!expectedSourceSha) blockers.push('source-sha-missing'); + if(!checkoutSha) blockers.push('checkout-sha-missing'); + else if(expectedSourceSha&&checkoutSha!==expectedSourceSha) blockers.push('checkout-sha-mismatch'); + if(checkoutClean!==true) blockers.push('checkout-not-clean'); + if(productionApproved!==true) blockers.push('production-approval-missing'); + return {eligible:blockers.length===0,status:blockers.length===0?'ELIGIBLE':'BLOCKED',blockers,canonicalTeamId,canonicalProjectId}; +} + +export function buildVercelInvocation({mode,teamId,projectId}){ + if(!teamId||!projectId) throw new Error('explicit Vercel teamId and projectId are required'); + const args=['deploy','--yes','--project',projectId,'-T',teamId]; + if(mode==='production') args.push('--prod'); + return {command:'vercel',args,env:{VERCEL_ORG_ID:teamId,VERCEL_PROJECT_ID:projectId}}; +} function gateChecks(projectDir){ const evidence=JSON.parse(fs.readFileSync(path.join(projectDir,'evidence.receipt.json'),'utf8')); @@ -21,41 +68,57 @@ function gateChecks(projectDir){ ]; } -function deploymentResult({mode,provider,eligibility,checks,commandStatus,output,url}){ - const sha=sourceSha(); - if(commandStatus!==0){ - return {schema:'webforge.deployment-execution.v4',status:'FAIL',mode,provider,url:null,sourceSha:sha,detail:output,eligibility,checks}; - } - if(!url){ - return {schema:'webforge.deployment-execution.v4',status:'UNVERIFIED',mode,provider,url:null,sourceSha:sha,detail:`Deployment command exited 0 but no deployment URL was captured.\n${output}`,eligibility,checks}; - } - return {schema:'webforge.deployment-execution.v4',status:'PASS',mode,provider,url,sourceSha:sha,detail:output,eligibility,checks}; +function receiptBase({repository,branch,sourceSha:sha,provider,teamId,projectId,mode,eligibility,checks}){ + return {schema:RECEIPT_SCHEMA,repository,branch,sourceSha:sha,provider,teamId,projectId,mode,eligibility,checks}; +} + +export function deploymentResult({repository=null,branch=null,sourceSha:sha=null,mode,provider,teamId=null,projectId=null,eligibility=null,checks=[],commandStatus,output,url}){ + const base=receiptBase({repository,branch,sourceSha:sha,provider,teamId,projectId,mode,eligibility,checks}); + if(commandStatus!==0) return {...base,status:'FAIL',verificationStatus:'FAIL',deploymentUrl:null,url:null,detail:output}; + if(!url) return {...base,status:'UNVERIFIED',verificationStatus:'UNVERIFIED',deploymentUrl:null,url:null,detail:`Deployment command exited 0 but no deployment URL was captured.\n${output}`}; + return {...base,status:'PASS',verificationStatus:'VERIFIED',deploymentUrl:url,url,detail:output}; } -export function executeDeployment(projectDir,{mode='preview',provider='vercel',productionApproved=false}={}){ +export function executeDeployment(projectDir,{mode='preview',provider='vercel',productionApproved=false,teamId=null,projectId=null,expectedSourceSha=null,repository=null,branch=null,repoRoot=REPO_ROOT}={}){ if(!['preview','production'].includes(mode)) throw new Error('mode must be preview or production'); if(!['vercel','cloudflare'].includes(provider)) throw new Error('provider must be vercel or cloudflare'); const checks=gateChecks(projectDir); const eligibility=evaluateDeployment({checks},{productionApproved}); const eligible=mode==='preview'?eligibility.previewEligible:eligibility.productionEligible; - if(!eligible) return {schema:'webforge.deployment-execution.v4',status:'BLOCKED',mode,provider,sourceSha:sourceSha(),eligibility,checks}; + const sha=sourceSha(expectedSourceSha); + const resolvedRepository=first(repository,process.env.WEBFORGE_REPOSITORY,process.env.GITHUB_REPOSITORY); + const resolvedBranch=first(branch,process.env.WEBFORGE_BRANCH,process.env.GITHUB_REF_NAME); + const resolvedTeamId=first(teamId,process.env.VERCEL_ORG_ID); + const resolvedProjectId=first(projectId,process.env.VERCEL_PROJECT_ID); + const base=receiptBase({repository:resolvedRepository,branch:resolvedBranch,sourceSha:sha,provider,teamId:resolvedTeamId,projectId:resolvedProjectId,mode,eligibility,checks}); + if(!eligible) return {...base,status:'BLOCKED',verificationStatus:'BLOCKED'}; const runtimeRoot=path.join(projectDir,'runtime'); if(provider==='vercel'){ - if(!hasCommand('vercel')) return {schema:'webforge.deployment-execution.v4',status:'UNVERIFIED',mode,provider,sourceSha:sourceSha(),detail:'vercel CLI unavailable',eligibility,checks}; - const args=['deploy','--yes']; if(mode==='production')args.push('--prod'); - const r=spawnSync('vercel',args,{cwd:runtimeRoot,encoding:'utf8',timeout:180000}); + if(mode==='production'){ + let targetRecord; + try{targetRecord=loadCanonicalProductionTarget(repoRoot);}catch(error){return {...base,status:'BLOCKED',verificationStatus:'BLOCKED',detail:`canonical target unreadable: ${error.message}`};} + const checkout=readCheckoutState(repoRoot); + const binding=validateCanonicalVercelProductionBinding({targetRecord,provider,teamId:resolvedTeamId,projectId:resolvedProjectId,sourceSha:sha,checkoutSha:checkout.sha,checkoutClean:checkout.verified&&checkout.clean,productionApproved}); + if(!binding.eligible) return {...base,status:'BLOCKED',verificationStatus:'BLOCKED',detail:`production provenance blocked: ${binding.blockers.join(', ')}`,provenance:binding}; + } + if(!hasCommand('vercel')) return {...base,status:'UNVERIFIED',verificationStatus:'UNVERIFIED',detail:'vercel CLI unavailable'}; + let invocation={command:'vercel',args:['deploy','--yes'],env:{}}; + if(mode==='production'){ + try{invocation=buildVercelInvocation({mode,teamId:resolvedTeamId,projectId:resolvedProjectId});}catch(error){return {...base,status:'BLOCKED',verificationStatus:'BLOCKED',detail:error.message};} + } + const r=spawnSync(invocation.command,invocation.args,{cwd:runtimeRoot,encoding:'utf8',timeout:180000,env:{...process.env,...invocation.env}}); const output=tail((r.stdout||'')+(r.stderr||'')); const url=(output.match(/https:\/\/[^\s]+\.vercel\.app[^\s]*/)||[])[0]||null; - return deploymentResult({mode,provider,eligibility,checks,commandStatus:r.status,output,url}); + return deploymentResult({...base,commandStatus:r.status,output,url}); } - if(!hasCommand('wrangler')) return {schema:'webforge.deployment-execution.v4',status:'UNVERIFIED',mode,provider,sourceSha:sourceSha(),detail:'wrangler CLI unavailable',eligibility,checks}; + if(!hasCommand('wrangler')) return {...base,status:'UNVERIFIED',verificationStatus:'UNVERIFIED',detail:'wrangler CLI unavailable'}; const buildDir=fs.existsSync(path.join(runtimeRoot,'dist'))?'dist':fs.existsSync(path.join(runtimeRoot,'.vercel','output','static'))?'.vercel/output/static':null; - if(!buildDir) return {schema:'webforge.deployment-execution.v4',status:'FAIL',mode,provider,sourceSha:sourceSha(),detail:'no static build output for Cloudflare Pages',eligibility,checks}; + if(!buildDir) return {...base,status:'FAIL',verificationStatus:'FAIL',detail:'no static build output for Cloudflare Pages'}; const args=['pages','deploy',buildDir]; if(mode==='production') args.push('--branch','main'); const r=spawnSync('wrangler',args,{cwd:runtimeRoot,encoding:'utf8',timeout:180000}); const output=tail((r.stdout||'')+(r.stderr||'')); const url=(output.match(/https:\/\/[^\s]+\.pages\.dev[^\s]*/)||[])[0]||null; - return deploymentResult({mode,provider,eligibility,checks,commandStatus:r.status,output,url}); + return deploymentResult({...base,commandStatus:r.status,output,url}); } diff --git a/tests/deployment-provenance.test.mjs b/tests/deployment-provenance.test.mjs new file mode 100644 index 0000000..ef5b4c5 --- /dev/null +++ b/tests/deployment-provenance.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildVercelInvocation, deploymentResult, validateCanonicalVercelProductionBinding } from '../src/core/deployment-executor.mjs'; + +const TEAM='team_P8cB47XVLz6mGykUGFdfh0ki'; +const PROJECT='prj_At8RZceUAND02Y5Bxsu4lV6066Y5'; +const SHA='825354b5572d07f126cd586b9199f8bd4658b1cb'; +const targetRecord={status:'CANONICAL',scope:'production-hosting-target-identity',provider:'vercel',target:{teamId:TEAM,projectId:PROJECT}}; +const valid={targetRecord,provider:'vercel',teamId:TEAM,projectId:PROJECT,sourceSha:SHA,checkoutSha:SHA,checkoutClean:true,productionApproved:true}; + +test('correct canonical project/team is eligible',()=>{ + const out=validateCanonicalVercelProductionBinding(valid); + assert.equal(out.status,'ELIGIBLE'); + assert.equal(out.eligible,true); + assert.deepEqual(out.blockers,[]); +}); + +test('wrong project is blocked',()=>{ + const out=validateCanonicalVercelProductionBinding({...valid,projectId:'prj_wrong'}); + assert.equal(out.status,'BLOCKED'); + assert.ok(out.blockers.includes('project-id-mismatch')); +}); + +test('wrong team is blocked',()=>{ + const out=validateCanonicalVercelProductionBinding({...valid,teamId:'team_wrong'}); + assert.equal(out.status,'BLOCKED'); + assert.ok(out.blockers.includes('team-id-mismatch')); +}); + +test('missing source SHA is blocked',()=>{ + const out=validateCanonicalVercelProductionBinding({...valid,sourceSha:null}); + assert.equal(out.status,'BLOCKED'); + assert.ok(out.blockers.includes('source-sha-missing')); +}); + +test('productionApproved false is blocked',()=>{ + const out=validateCanonicalVercelProductionBinding({...valid,productionApproved:false}); + assert.equal(out.status,'BLOCKED'); + assert.ok(out.blockers.includes('production-approval-missing')); +}); + +test('Vercel CLI invocation contains explicit project and team binding',()=>{ + const invocation=buildVercelInvocation({mode:'production',teamId:TEAM,projectId:PROJECT}); + assert.deepEqual(invocation.args,['deploy','--yes','--project',PROJECT,'-T',TEAM,'--prod']); + assert.equal(invocation.env.VERCEL_PROJECT_ID,PROJECT); + assert.equal(invocation.env.VERCEL_ORG_ID,TEAM); +}); + +test('command success without deployment URL is UNVERIFIED',()=>{ + const out=deploymentResult({repository:'eimyroot/WEBFORGE',branch:'main',sourceSha:SHA,mode:'production',provider:'vercel',teamId:TEAM,projectId:PROJECT,commandStatus:0,output:'done',url:null}); + assert.equal(out.status,'UNVERIFIED'); + assert.equal(out.verificationStatus,'UNVERIFIED'); + assert.equal(out.deploymentUrl,null); +}); + +test('command failure is FAIL',()=>{ + const out=deploymentResult({repository:'eimyroot/WEBFORGE',branch:'main',sourceSha:SHA,mode:'production',provider:'vercel',teamId:TEAM,projectId:PROJECT,commandStatus:1,output:'failed',url:null}); + assert.equal(out.status,'FAIL'); + assert.equal(out.verificationStatus,'FAIL'); +});