chore: release main #27
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |