Skip to content

chore: release main #27

chore: release main

chore: release main #27

Workflow file for this run

name: CI
# OvenClear core is an offline, deterministic TypeScript library (Node 18+,
# vitest). This pipeline mirrors what a judge runs locally: typecheck + tests,
# then the offline PROOF stage (deterministic seed re-hash, end-to-end self-test,
# ledger tamper-rejection, benchmark), then a security gate. There is no UI
# framework, so the Lighthouse / bundle stages of the standard template are N/A.
#
# Stage 4 deploys the storefront to Vercel production β€” push to main only, and
# only once stages 1–3 are green. See that job for the staged-rollout mechanics
# that keep https://ovenclear.edycu.dev up even when a deploy is bad.
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# Superseded PR runs are noise and get cancelled. A main-branch run is never
# cancelled: it carries the production deploy, and killing that halfway is how
# a live site ends up half-promoted.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
# ── Stage 1: Quality (typecheck + 156 vitest tests) ──────────────────────
quality:
name: "Stage 1 Β· Quality"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: ${{ github.event_name == 'push' && fromJSON('[20, 22]') || fromJSON('[20]') }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Type check (tsc --noEmit, strict)
run: npm run typecheck
- name: Unit + golden tests (vitest run)
run: npm test
# ── Stage 2: Proof (offline determinism + provenance integrity) ──────────
proof:
name: "Stage 2 Β· Offline Proof"
needs: [quality]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Deterministic fixture re-hash (seed --check)
run: npm run seed:check
- name: End-to-end self-test (interview β†’ verdict β†’ label + QA β†’ re-issue)
run: npm run self-test
- name: Ledger tamper-rejection (verify_ledger)
run: npm run verify-ledger
- name: Verdict benchmark + zero golden-flip gate
run: npm run bench
# ── Stage 3: Security (secret scan + dependency audit) ───────────────────
security:
name: "Stage 3 Β· Security"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for the secret scanner
- name: TruffleHog secret scan
uses: trufflesecurity/trufflehog@main
with:
extra_args: --only-verified
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: npm audit (high + critical)
run: npm audit --audit-level=high
continue-on-error: true # advisory: never block on a transitive upstream advisory
# ── Stage 4: Deploy (Vercel production, staged rollout) ──────────────────
#
# https://ovenclear.edycu.dev is a judged artifact. The ordering below exists
# so that a broken build can never take it down:
#
# 1. Build api/index.js locally. Vercel has no build command configured for
# this project, so the bundle esbuild produces from api/_app.ts is what
# actually gets uploaded and run. Skip this step and the function is
# simply absent (api/index.js is gitignored β€” it is an artifact, not
# source). .vercelignore deliberately does NOT exclude it.
# 2. Deploy to the production target with --skip-domain. That creates a real
# production deployment but leaves every domain, including the custom one,
# pointing at the deployment that is already known to work.
# 3. Smoke the new deployment on its own URL. If it fails, the job stops here
# and the live site never saw it.
# 4. Only then promote, force the custom-domain alias (promote does not
# reliably move an aliased custom domain), and smoke the live domain.
#
# Runtime configuration (NODE_ENV, DATA_DIR, PRICE_USD, LEDGER_KEY_NAMESPACE,
# STRIPE_*, BASE_URL) lives in the Vercel project's Production environment, not
# here β€” server/config.ts refuses to boot in production without it, so a
# missing variable fails step 3 rather than the live site.
deploy:
name: "Stage 4 Β· Deploy (Vercel production)"
needs: [quality, proof]
if: github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment:
name: Production
url: https://ovenclear.edycu.dev
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ vars.VERCEL_PROJECT_ID }}
PROD_DOMAIN: ovenclear.edycu.dev
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build the serverless bundle (api/_app.ts β†’ api/index.js)
run: npm run build:vercel
- name: Refuse to deploy without the function bundle
run: |
test -s api/index.js || { echo "::error::api/index.js is missing or empty β€” the deployed function would 404."; exit 1; }
echo "api/index.js: $(wc -c < api/index.js) bytes"
- name: Install Vercel CLI
run: npm install --global vercel@53
- name: Link and pull the production environment
run: vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
- name: Deploy to production (custom domain NOT moved yet)
id: deploy
run: |
set -euo pipefail
# `--skip-domain` makes this the current production deployment while
# leaving ovenclear.edycu.dev on the last known-good one, which is what
# buys us the smoke gate below. The CLI prints a JSON envelope on newer
# versions and a bare URL on older ones; accept either.
vercel deploy --prod --skip-domain --archive=tgz --token="$VERCEL_TOKEN" | tee deploy.out
url=$(node -e '
const fs = require("fs");
const t = fs.readFileSync("deploy.out", "utf8").trim();
try { const j = JSON.parse(t); if (j?.deployment?.url) { console.log(j.deployment.url); process.exit(0); } } catch {}
const m = t.match(/https:\/\/[A-Za-z0-9._-]+\.vercel\.app/);
if (!m) { console.error("could not parse a deployment URL out of the CLI output"); process.exit(1); }
console.log(m[0]);
')
rm -f deploy.out
echo "Deployed: $url"
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "host=${url#https://}" >> "$GITHUB_OUTPUT"
- name: Smoke the new deployment BEFORE it takes traffic
run: node scripts/smoke.mjs "${{ steps.deploy.outputs.url }}"
- name: Move the custom domain onto the verified deployment
run: |
set -uo pipefail
new_host="${{ steps.deploy.outputs.host }}"
served=$(vercel inspect "https://${PROD_DOMAIN}" --token="$VERCEL_TOKEN" 2>&1 || true)
if printf '%s' "$served" | grep -qF "$new_host"; then
echo "${PROD_DOMAIN} already resolves to ${new_host}."
else
# Known Vercel behaviour, and the reason this step exists at all: a
# production deployment does not reliably pull an aliased custom
# domain across with it, so the alias is set explicitly.
echo "${PROD_DOMAIN} is not on ${new_host} yet; setting the alias explicitly."
vercel alias set "${{ steps.deploy.outputs.url }}" "$PROD_DOMAIN" --token="$VERCEL_TOKEN"
served=$(vercel inspect "https://${PROD_DOMAIN}" --token="$VERCEL_TOKEN" 2>&1 || true)
printf '%s' "$served" | grep -qF "$new_host" \
|| { echo "::error::${PROD_DOMAIN} still does not point at ${new_host}."; exit 1; }
fi
- name: Smoke the LIVE site (hard gate)
run: node scripts/smoke.mjs "https://${PROD_DOMAIN}"
- name: Summarise
if: always()
run: |
{
echo "### Production deploy"
echo ""
echo "- deployment: ${{ steps.deploy.outputs.url }}"
echo "- live: https://${PROD_DOMAIN}"
} >> "$GITHUB_STEP_SUMMARY"