Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 82 additions & 19 deletions src/core/deployment-executor.mjs
Original file line number Diff line number Diff line change
@@ -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'));
Expand All @@ -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});
}
60 changes: 60 additions & 0 deletions tests/deployment-provenance.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
Loading