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
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ on:
permissions:
contents: read

concurrency:
group: webforge-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -19,6 +24,12 @@ jobs:
with:
node-version: 22

- name: Environment
run: |
node --version
npm --version
google-chrome --version || google-chrome-stable --version || chromium --version || true

- name: Unit and contract tests
run: npm test

Expand All @@ -34,5 +45,8 @@ jobs:
- name: Renderer coverage
run: npm run renderer:coverage

- name: Release gate
- name: Package release gate
run: npm run release:gate

- name: Full environment release gate
run: npm run release:full
24 changes: 18 additions & 6 deletions src/core/deployment-executor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { visualReadinessChecks } from './visual-readiness.mjs';

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;

function gateChecks(projectDir){
const evidence=JSON.parse(fs.readFileSync(path.join(projectDir,'evidence.receipt.json'),'utf8'));
Expand All @@ -20,30 +21,41 @@ 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};
}

export function executeDeployment(projectDir,{mode='preview',provider='vercel',productionApproved=false}={}){
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.v3',status:'BLOCKED',mode,provider,eligibility,checks};
if(!eligible) return {schema:'webforge.deployment-execution.v4',status:'BLOCKED',mode,provider,sourceSha:sourceSha(),eligibility,checks};

const runtimeRoot=path.join(projectDir,'runtime');
if(provider==='vercel'){
if(!hasCommand('vercel')) return {schema:'webforge.deployment-execution.v3',status:'UNVERIFIED',mode,provider,detail:'vercel CLI unavailable',eligibility,checks};
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});
const output=tail((r.stdout||'')+(r.stderr||''));
const url=(output.match(/https:\/\/[^\s]+\.vercel\.app[^\s]*/)||[])[0]||null;
return {schema:'webforge.deployment-execution.v3',status:r.status===0?'PASS':'FAIL',mode,provider,url,detail:output,eligibility,checks};
return deploymentResult({mode,provider,eligibility,checks,commandStatus:r.status,output,url});
}

if(!hasCommand('wrangler')) return {schema:'webforge.deployment-execution.v3',status:'UNVERIFIED',mode,provider,detail:'wrangler CLI unavailable',eligibility,checks};
if(!hasCommand('wrangler')) return {schema:'webforge.deployment-execution.v4',status:'UNVERIFIED',mode,provider,sourceSha:sourceSha(),detail:'wrangler CLI unavailable',eligibility,checks};
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.v3',status:'FAIL',mode,provider,detail:'no static build output for Cloudflare Pages',eligibility,checks};
if(!buildDir) return {schema:'webforge.deployment-execution.v4',status:'FAIL',mode,provider,sourceSha:sourceSha(),detail:'no static build output for Cloudflare Pages',eligibility,checks};
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 {schema:'webforge.deployment-execution.v3',status:r.status===0?'PASS':'FAIL',mode,provider,url,detail:output,eligibility,checks};
return deploymentResult({mode,provider,eligibility,checks,commandStatus:r.status,output,url});
}
Loading