diff --git a/.github/actions/web-checks/action.yml b/.github/actions/web-checks/action.yml new file mode 100644 index 0000000..35956f4 --- /dev/null +++ b/.github/actions/web-checks/action.yml @@ -0,0 +1,55 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +name: Web checks +description: Format, lint, build without a database, and run the test suite. +runs: + using: composite + steps: + - name: Check infrastructure trust policies offline + shell: bash + run: | + bash -n infra/provision.sh + python3 -m unittest discover -s infra/tests -v + - name: Install pnpm + shell: bash + run: npm install -g pnpm@11.5.2 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - name: Install dependencies + shell: bash + working-directory: web + run: pnpm install --frozen-lockfile + - name: Format check + shell: bash + working-directory: web + run: pnpm format:check + - name: Lint + shell: bash + working-directory: web + run: pnpm lint + - name: Build without database credentials + shell: bash + working-directory: web + run: pnpm build + - name: Require Docker for the integration suite + shell: bash + run: docker info > /dev/null + - name: Install uv for migration tests + uses: spiraldb/actions/.github/actions/setup-uv@0.18.6 + with: + sync: false + - name: Test migration runner + shell: bash + run: uv run --no-project --with 'psycopg[binary]>=3.2' python -m unittest discover -s scripts/tests -v + - name: Test deployment verification + shell: bash + run: node --test scripts/tests/verify-web-deploy.test.mjs + - name: Test + shell: bash + working-directory: web + run: pnpm test diff --git a/.github/workflows/web-ci.yml b/.github/workflows/web-ci.yml index 5f954fb..77a40a6 100644 --- a/.github/workflows/web-ci.yml +++ b/.github/workflows/web-ci.yml @@ -22,68 +22,14 @@ concurrency: group: web-ci-${{ github.ref }} cancel-in-progress: true -env: - # Keep in lockstep with `packageManager` in web/package.json. - PNPM_VERSION: "11.5.2" - jobs: web: name: format, lint, build, test runs-on: ubuntu-latest timeout-minutes: 30 - defaults: - run: - working-directory: web steps: - name: Checkout uses: actions/checkout@v4 - - name: Check infrastructure trust policies offline - working-directory: . - run: | - bash -n infra/provision.sh - python3 -m unittest discover -s infra/tests -v - - # pnpm must exist before setup-node so its `cache: pnpm` can resolve the store. - - name: Install pnpm - run: npm install -g "pnpm@${PNPM_VERSION}" - - - name: Setup Node - uses: actions/setup-node@v4 - with: - # Keep in lockstep with @types/node in web/package.json (Node 24 API surface). - node-version: "24" - cache: pnpm - cache-dependency-path: web/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Format check - run: pnpm format:check - - - name: Lint - run: pnpm lint - - # `next build` must succeed without a reachable database (every page and route - # is request-rendered), so no BENCH_DB_* env vars are provided here on purpose. - - name: Build - run: pnpm build - - # testcontainers self-skips the Postgres integration suite when no Docker daemon - # is available (a local-dev convenience); in CI that would silently drop the only - # Postgres integration coverage, so fail loudly if the daemon is missing. - - name: Require Docker for the integration suite - run: docker info > /dev/null - - - name: Install uv for migration tests - uses: spiraldb/actions/.github/actions/setup-uv@0.18.6 - with: - sync: false - - - name: Test migration runner - working-directory: . - run: uv run --no-project --with 'psycopg[binary]>=3.2' python -m unittest discover -s scripts/tests -v - - - name: Test - run: pnpm test + - name: Run web checks + uses: ./.github/actions/web-checks diff --git a/.github/workflows/web-deploy.yml b/.github/workflows/web-deploy.yml index 810b8e2..cc5b8df 100644 --- a/.github/workflows/web-deploy.yml +++ b/.github/workflows/web-deploy.yml @@ -22,10 +22,13 @@ on: push: branches: [develop] pull_request: - branches: [develop] paths: - 'web/**' + - 'migrations/**' + - 'scripts/**' - '.github/workflows/web-deploy.yml' + - '.github/workflows/web-ci.yml' + - '.github/actions/web-checks/**' workflow_dispatch: inputs: environment: @@ -35,19 +38,13 @@ on: default: preview concurrency: - # Include the event name so a manual workflow_dispatch (e.g. a preview deploy) on - # `develop` does NOT land in the same group as — and thus cancel — an in-flight - # push-triggered production deploy on the same ref. - group: web-deploy-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true + # Manual and push production runs share one promotion/verification queue. + # Let the active production run finish; superseded previews can be cancelled. + group: ${{ (github.event_name == 'push' || inputs.environment == 'production') && 'web-deploy-production' || format('web-deploy-preview-{0}-{1}', github.event_name, github.ref) }} + cancel-in-progress: ${{ github.event_name != 'push' && inputs.environment != 'production' }} permissions: contents: read - pull-requests: write - # Create the GitHub Deployment + statuses that render the native "View - # deployment" button and the in-progress spinner on the PR (Vercel's git - # integration is off here, so the workflow posts these itself). - deployments: write env: VERCEL_ORG_ID: ${{ vars.VERCEL_ORG_ID }} @@ -57,7 +54,27 @@ env: PNPM_VERSION: "11.5.2" jobs: + checks: + name: Check deployment commit + runs-on: ubuntu-latest + timeout-minutes: 30 + if: >- + (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) && + (github.event_name != 'workflow_dispatch' || inputs.environment != 'production' || + github.ref == 'refs/heads/develop') + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + - uses: ./.github/actions/web-checks + deploy: + needs: checks + permissions: + contents: read + pull-requests: write + deployments: write name: Build & deploy to Vercel runs-on: ubuntu-latest timeout-minutes: 20 @@ -69,8 +86,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} - # Open a GitHub Deployment against the PR head commit BEFORE the build, so + # Open a GitHub Deployment against the tested merge commit before the build, so # the PR shows an in-progress (spinner) status immediately and the later # success/failure step flips it to the "View deployment" button. PR-only; # `transient_environment` lets GitHub retire superseded previews. A failure @@ -81,7 +100,7 @@ jobs: if: github.event_name == 'pull_request' env: GH_TOKEN: ${{ github.token }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_SHA: ${{ github.sha }} run: | set -Eeuo pipefail id="$(gh api "repos/${GITHUB_REPOSITORY}/deployments" --method POST --jq '.id' --input - <> "$GITHUB_OUTPUT" fi + - name: Require a public production verification URL + if: steps.target.outputs.env == 'production' + env: + PROD_URL: ${{ vars.BENCH_SITE_BASE_URL }} + run: | + [[ "$PROD_URL" == https://* ]] || { echo "::error::Set BENCH_SITE_BASE_URL to the public HTTPS production URL."; exit 1; } + - name: Pull Vercel project settings run: vercel pull --yes --environment="${{ steps.target.outputs.env }}" --token="${{ secrets.VERCEL_TOKEN }}" - name: Build + env: + BENCH_BUILD_SHA: ${{ github.sha }} run: | set -Eeuo pipefail if [ "${{ steps.target.outputs.env }}" = "production" ]; then @@ -166,6 +194,21 @@ jobs: echo "url=${url}" >> "$GITHUB_OUTPUT" printf 'Deployed: %s\n' "$url" + - name: Verify deployed build and read paths + id: verify + timeout-minutes: 4 + env: + DEPLOY_URL: ${{ steps.deploy.outputs.url }} + PROD_URL: ${{ vars.BENCH_SITE_BASE_URL }} + TARGET_ENV: ${{ steps.target.outputs.env }} + EXPECTED_SHA: ${{ github.sha }} + run: | + if [ "$TARGET_ENV" = production ]; then + node scripts/verify-web-deploy.mjs "$PROD_URL" "$EXPECTED_SHA" production + else + node scripts/verify-web-deploy.mjs "$DEPLOY_URL" "$EXPECTED_SHA" preview + fi + # On a pull_request, post the preview URL back to the PR as a single sticky # comment (edited in place on each push) so the preview is one click from the PR. - name: Comment preview URL on the PR @@ -197,7 +240,7 @@ jobs: "state": "success", "environment_url": "${DEPLOY_URL}", "log_url": "${RUN_URL}", - "description": "Preview ready" + "description": "${{ steps.verify.outputs.result == 'protected' && 'Deployed; verification blocked by protection' || 'Preview verified' }}" } EOF diff --git a/docs/architecture/deploy-and-infra.md b/docs/architecture/deploy-and-infra.md index 3dd03d1..46ea419 100644 --- a/docs/architecture/deploy-and-infra.md +++ b/docs/architecture/deploy-and-infra.md @@ -91,13 +91,20 @@ scripts/migrate-schema.py ──IAM token, verify-full TLS──▶ RDS as `mi ``` push to develop ──▶ production deploy -workflow_dispatch ──▶ preview or production (input) +workflow_dispatch ──▶ preview or production (develop only) +same-repo PR ──▶ preview (event merge commit) │ ▼ -runner: pnpm install + `vercel build [--prod]` (builds web/ ON THE RUNNER) +shared checks on exact SHA: format + lint + build + test │ ▼ -`vercel deploy --prebuilt [--prod]` (uploads the prebuilt output) +runner: `vercel build [--prod]` on the same SHA + │ + ▼ +`vercel deploy --prebuilt [--prod]` + │ + ▼ +verify build SHA + read paths + CDN HIT (bounded retries) ``` - The Vercel project is **independently owned** by this repo: keyed by @@ -109,10 +116,32 @@ runner: pnpm install + `vercel build [--prod]` (builds web/ ON THE RUNNER) prebuilt output is then uploaded, eliminating a build race. - `develop` **is** production — there is no staging gate. The per-PR CI (including a testcontainer migration test) is the gate; a merge to `develop` - ships straight to the v4 production domain. + ships to the v4 production domain after the deploy workflow checks the same commit. +- Push and manual production runs share one concurrency group without cancelling an active run. + This serializes promotion and verification across both triggers. Previews can cancel stale runs. +- Production verification uses `BENCH_SITE_BASE_URL`. A protected preview reports verification + as blocked. A production authentication error or failed probe fails the workflow. - A `web-keep-warm` scheduled workflow pings the production deployment so the RDS connection pool and Data Cache stay warm between visits. +### Required merge check + +After the first standalone Web CI run completes, configure the branch protection rule or ruleset +for `develop` to require `format, lint, build, test`, with **GitHub Actions** as its source +(app ID `15368`). Enable **Require branches to be up to date before merging**. These are operator +settings. The workflows do not change repository protection. + +From a checkout of that tested commit, verify the context and integration before saving the rule: + +```bash +checked_sha="$(git rev-parse HEAD)" +gh api "repos/vortex-data/benchmarks-website/commits/${checked_sha}/check-runs" \ + --jq '.check_runs[] | select(.name == "format, lint, build, test") | {name, conclusion, app_id: .app.id}' +``` + +The completed check must report `success` and app ID `15368`. The shared composite preserves +this standalone check name when its internal steps change. + ## Legacy v3 host deploy (`ops/`) — decommissioned 2026-07-08 The v3 Rust server ran on an EC2 host under systemd, deployed by a **polling** diff --git a/docs/runbooks/deploy-secrets-setup.md b/docs/runbooks/deploy-secrets-setup.md index e780559..e634774 100644 --- a/docs/runbooks/deploy-secrets-setup.md +++ b/docs/runbooks/deploy-secrets-setup.md @@ -218,7 +218,7 @@ variable. | `VERCEL_TOKEN` (secret) | `web-deploy.yml` | | `VERCEL_ORG_ID` | `web-deploy.yml` | | `VERCEL_PROJECT_ID` | `web-deploy.yml` | -| `BENCH_SITE_BASE_URL` | `web-keep-warm.yml` | +| `BENCH_SITE_BASE_URL` | `web-keep-warm.yml`, production verification in `web-deploy.yml` | | `GH_BENCH_SCHEMA_ROLE_ARN` | `schema-deploy.yml` | | `RDS_BENCH_REGION` | `schema-deploy.yml` | | `RDS_BENCH_INSTANCE_ENDPOINT` | `schema-deploy.yml` | @@ -349,6 +349,8 @@ export VERCEL_ORG_ID="" # the value you set as t export VERCEL_PROJECT_ID="" read -rs VERCEL_TOKEN && export VERCEL_TOKEN # paste at the prompt; never echoed, never recorded in shell history # Run from the REPO ROOT (not web/) to match the workflow's project-resolution path: +BENCH_BUILD_SHA="$(git rev-parse HEAD)" +export BENCH_BUILD_SHA vercel pull --yes --environment=preview --token="$VERCEL_TOKEN" && vercel build --token="$VERCEL_TOKEN" unset VERCEL_TOKEN # drop the secret from the environment when done ``` diff --git a/scripts/tests/verify-web-deploy.test.mjs b/scripts/tests/verify-web-deploy.test.mjs new file mode 100644 index 0000000..d1f52e1 --- /dev/null +++ b/scripts/tests/verify-web-deploy.test.mjs @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { verifyDeployment } from '../verify-web-deploy.mjs'; + +const SHA = 'a'.repeat(40); +const chart = { commits: [{ sha: 'b'.repeat(40) }], series: { vortex: [1] } }; +function fixture(overrides = {}) { + const hits = new Map(); + return async (url) => { + const path = url.pathname; + hits.set(path, (hits.get(path) ?? 0) + 1); + if (overrides[path]) return overrides[path](); + if (path === '/api/health') return Response.json({ status: 'ok', build_sha: SHA, schema_version: 1 }); + if (path === '/api/groups') return Response.json({ groups: [{ slug: 'group', charts: [{ slug: 'chart' }] }] }); + if (path === '/api/group/group') return Response.json({ charts: [chart] }); + if (path === '/api/chart/chart') return Response.json(chart, { headers: { 'x-vercel-cache': hits.get(path) > 1 ? 'HIT' : 'MISS' } }); + return new Response('benchmarks', { + headers: { 'content-type': 'text/html', 'x-vercel-cache': hits.get(path) > 1 ? 'HIT' : 'MISS' }, + }); + }; +} +const noSleep = async () => {}; + +test('verifies a matching build and populated read paths after CDN warmup', async () => { + assert.equal(await verifyDeployment('https://bench.test', SHA, 'production', fixture(), noSleep), 'verified'); +}); +test('rejects an older healthy deployment', async () => { + await assert.rejects(verifyDeployment('https://bench.test', 'c'.repeat(40), 'production', fixture(), noSleep), /different build/); +}); +test('reports protected previews without claiming verification; production fails', async () => { + const fetcher = fixture({ '/api/health': () => new Response('', { status: 401 }) }); + assert.equal(await verifyDeployment('https://bench.test', SHA, 'preview', fetcher, noSleep), 'protected'); + await assert.rejects(verifyDeployment('https://bench.test', SHA, 'production', fetcher, noSleep), /production route/); +}); +test('fails on empty group data and a CDN that never hits', async () => { + await assert.rejects(verifyDeployment('https://bench.test', SHA, 'production', fixture({ + '/api/groups': () => Response.json({ groups: [] }), + }), noSleep), /populated groups/); + await assert.rejects(verifyDeployment('https://bench.test', SHA, 'production', fixture({ + '/api/chart/chart': () => Response.json(chart, { headers: { 'x-vercel-cache': 'MISS' } }), + }), noSleep), /CDN did not report HIT/); +}); + +test('accepts a global commit window with no measurement for the selected chart', async () => { + assert.equal(await verifyDeployment('https://bench.test', SHA, 'production', fixture({ + '/api/group/group': () => Response.json({ charts: [{ commits: [{ sha: SHA }], series: { vortex: [null] } }] }), + }), noSleep), 'verified'); +}); + +test('rejects a successful HTTP response containing only an error shell', async () => { + await assert.rejects(verifyDeployment('https://bench.test', SHA, 'production', fixture({ + '/': () => new Response('Error', { headers: { 'content-type': 'text/html' } }), + }), noSleep), /rendered page content is missing/); +}); + +test('recognizes Vercel SSO redirects only as protected previews', async () => { + const redirect = (location) => fixture({ + '/api/health': () => new Response(null, { status: 302, headers: { location } }), + }); + const sso = redirect('https://vercel.com/sso-api?url=https%3A%2F%2Fbench.test'); + assert.equal(await verifyDeployment('https://bench.test', SHA, 'preview', sso, noSleep), 'protected'); + await assert.rejects(verifyDeployment('https://bench.test', SHA, 'production', sso, noSleep), /production route/); + await assert.rejects(verifyDeployment('https://bench.test', SHA, 'preview', redirect('https://example.com/login'), noSleep), /HTTP 302/); +}); diff --git a/scripts/verify-web-deploy.mjs b/scripts/verify-web-deploy.mjs new file mode 100644 index 0000000..3c1d16c --- /dev/null +++ b/scripts/verify-web-deploy.mjs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +import assert from 'node:assert/strict'; +import { appendFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { setTimeout } from 'node:timers/promises'; + +/** Verify the public read paths within a three-minute deadline. */ +export async function verifyDeployment(base, sha, environment, fetchImpl = fetch, sleep = setTimeout) { + assert.match(sha, /^[0-9a-f]{40}$/); + assert.ok(['preview', 'production'].includes(environment)); + const origin = new URL(base); + assert.equal(origin.protocol, 'https:'); + assert.equal(origin.username + origin.password + origin.search + origin.hash, ''); + const deadline = Date.now() + 180_000; + + async function request(path, validate) { + let lastError; + for (let attempt = 0; attempt < 4; attempt++) { + const remaining = deadline - Date.now(); + assert.ok(remaining > 0, 'Deployment verification deadline exceeded'); + try { + const response = await fetchImpl(new URL(path, origin), { + redirect: 'manual', + signal: AbortSignal.timeout(Math.min(20_000, remaining)), + }); + const location = response.headers.get('location'); + const redirect = location && [301, 302, 303, 307, 308].includes(response.status) + ? new URL(location, origin) : null; + const vercelSso = redirect?.origin === 'https://vercel.com' && redirect.pathname === '/sso-api'; + if ([401, 403].includes(response.status) || vercelSso) { + if (environment === 'preview') return 'protected'; + throw new Error(`Public production route ${path} returned ${response.status}`); + } + assert.equal(response.status, 200, `${path}: HTTP ${response.status}`); + return await validate(response); + } catch (error) { + lastError = error; + if (attempt < 3) await sleep(Math.min(2_000, Math.max(0, deadline - Date.now()))); + } + } + throw lastError; + } + + async function json(path, validate) { + return request(path, async (response) => { + assert.match(response.headers.get('content-type') ?? '', /application\/json/); + const body = await response.json(); + validate(body); + return body; + }); + } + + function chart(body) { + assert.ok(Array.isArray(body.commits), 'Chart commits must be an array'); + assert.ok(body.series && typeof body.series === 'object' && !Array.isArray(body.series), + 'Chart series must be an object'); + for (const values of Object.values(body.series)) { + assert.ok(Array.isArray(values) && values.length === body.commits.length && + values.every((value) => value === null || (typeof value === 'number' && Number.isFinite(value))), + 'Chart series must align with its commit window'); + } + } + + const health = await json('/api/health', (body) => { + assert.equal(body.status, 'ok'); + assert.equal(body.build_sha, sha, 'Health reports a different build'); + assert.ok(Number.isInteger(body.schema_version)); + }); + if (health === 'protected') return 'protected'; + const groups = await json('/api/groups', (body) => { + assert.ok(Array.isArray(body.groups) && body.groups.some((group) => group.charts?.length), + 'No populated groups'); + }); + if (groups === 'protected') return 'protected'; + const group = groups.groups.find((item) => item.charts?.length); + const groupPath = `/api/group/${encodeURIComponent(group.slug)}?n=1`; + const groupBody = await json(groupPath, (body) => { + assert.ok(Array.isArray(body.charts) && body.charts.length > 0); + chart(body.charts[0]); + }); + if (groupBody === 'protected') return 'protected'; + const chartSlug = encodeURIComponent(group.charts[0].slug); + const chartBody = await json(`/api/chart/${chartSlug}`, chart); + if (chartBody === 'protected') return 'protected'; + for (const path of ['/', `/chart/${chartSlug}`]) { + const result = await request(path, async (response) => { + assert.match(response.headers.get('content-type') ?? '', /text\/html/); + const html = await response.text(); + assert.ok(html.includes(path === '/' + ? 'data-role="global-filter-bar"' + : `data-chart-slug="${chartSlug}"`), `${path}: rendered page content is missing`); + }); + if (result === 'protected') return 'protected'; + } + // Repeat the same URLs, without a cache-busting query, to verify CDN reuse. + for (const path of ['/', `/api/chart/${chartSlug}`]) { + const result = await request(path, async (response) => { + await response.arrayBuffer(); + assert.equal(response.headers.get('x-vercel-cache'), 'HIT', `${path}: CDN did not report HIT`); + }); + if (result === 'protected') return 'protected'; + } + return 'verified'; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const result = await verifyDeployment(...process.argv.slice(2)); + const message = result === 'protected' + ? 'Deployment created; preview protection blocked verification. Health, build identity, and CDN caching remain unverified.' + : 'Verified build SHA, health, representative group/chart JSON, HTML pages, and CDN HIT.'; + console.log(`${result === 'protected' ? '::notice::' : ''}${message}`); + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `result=${result}\n`); + if (process.env.GITHUB_STEP_SUMMARY) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${message}\n`); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/web/README.md b/web/README.md index 20c32bd..7551fd9 100644 --- a/web/README.md +++ b/web/README.md @@ -54,13 +54,13 @@ warmth: on those routes: that header is consumed (and stripped) by Vercel's CDN alone at the highest precedence, so the CDN caches the rendered pages while browsers still revalidate every load. -Verify on a live deployment with `curl -sI | grep -i x-vercel-cache` (expect `MISS` then -`HIT` within the five-minute fresh window). The deploy workflow runs this probe automatically after -each deploy. -When deployment protection returns 401/403 on a deployment URL the probe skips with a notice; -production avoids that blind spot automatically once `BENCHMARKS_WEB_PROD_URL` is set (the probe -then targets the public domain, where a 401/403 fails the run instead), so the manual check is -only needed for protected previews and for production while the var is unset. One deliberate +The deploy workflow verifies the expected build SHA through `/api/health`, a representative group +and chart JSON response, the landing and chart pages, and a CDN `HIT` on repeated landing/chart-API +requests. The probe has a three-minute deadline. Production uses the public `BENCH_SITE_BASE_URL` +and fails verification on authentication errors or redirects. A protected preview reports +verification as blocked, with no claim that its health, build identity, or caching passed. + +One deliberate divergence from the API routes: the `vercel.json` header rules apply to every response status, so an unknown `/chart/:slug` 404 follows the HTML page rule: five minutes fresh and then eligible for Vercel's one-day stale-while-revalidate window. That is acceptable for opaque, never-linked @@ -70,25 +70,29 @@ were, the same HTML page rule would bound it). ## Deploys -`.github/workflows/web-deploy.yml` runs the check suite on every PR touching -`benchmarks-website/web/**`, `migrations/**` (the integration suite applies that DDL to its -testcontainer), or the deploy tooling itself (the workflow and its `verify-cdn-cache` composite -action), then deploys via the Vercel CLI (`vercel pull` / `vercel build` / -`vercel deploy --prebuilt`): a preview deployment per same-repo PR, and a -production deployment on each push to the deploy branch (`ct/bench-v4` during the migration; -flips to `develop` when the migration branch squash-merges). +`.github/workflows/web-ci.yml` and the deploy workflow run the same `web-checks` composite action: +format, lint, build without database credentials, and tests with Docker. The standalone required +check remains `format, lint, build, test`. A deployment waits for its own check job, then checks +out and builds that exact `github.sha`. PRs use the event's merge commit, including stacked PRs +whose base is another branch. Fork PRs run correctness CI but do not receive deployment credentials. + +Pushes to `develop` deploy production. Manual production runs are limited to `develop`. +Both triggers share one production queue and let an active deploy finish verification. +New preview runs can cancel older previews of the same event and ref. +Same-repository PRs touching the app, migrations, scripts, or check/deploy tooling produce previews. +The Vercel CLI builds on the runner and uploads the prebuilt output. `BENCH_BUILD_SHA` is embedded +in that output so the uncached health endpoint identifies the checked-out build. One-time operator setup: -1. Create the Vercel project: Framework Next.js, **Root Directory `benchmarks-website/web`**, +1. Create the Vercel project: Framework Next.js, **Root Directory `web/`**, and the GitHub integration **disabled** (deploys are CLI-driven from CI; the integration would double-deploy). 2. Set the GitHub repo secret `VERCEL_TOKEN` (a Vercel deploy token) and repo variables - `VERCEL_ORG_ID` + `VERCEL_PROJECT_ID` (from the Vercel project settings). Optionally set the - repo variable `BENCHMARKS_WEB_PROD_URL` to the public production URL, as a full - `https://` with no trailing slash: deployment protection never covers the public - domain, so the post-deploy CDN probe can verify caching through it even when deployment URLs - are protected (and a 401/403 from it fails the deploy run rather than skipping). + `VERCEL_ORG_ID` + `VERCEL_PROJECT_ID` (from the Vercel project settings). Set the + repo variable `BENCH_SITE_BASE_URL` to the public production URL as `https://`. + The existing keep-warm workflow uses the same variable. Production deployment requires it + before uploading and verifies the public domain after deployment. 3. Configure `BENCH_DB_*` on the Vercel project (Production and Preview environments). Two open wiring choices are deliberately left to this step, per environment: - **Endpoint**: the RDS Proxy (`vortex-bench-proxy.proxy-*.us-east-1.rds.amazonaws.com`) is diff --git a/web/next.config.js b/web/next.config.js index e40cc78..b08a72b 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -7,6 +7,11 @@ const nextConfig = { // (workspace root, the v2 benchmarks-website project, and this app), so // Next.js cannot infer the correct root on its own. outputFileTracingRoot: __dirname, + // Embed the checked-out commit in prebuilt output, independent of runtime settings. + env: { + VERCEL_GIT_COMMIT_SHA: + process.env.BENCH_BUILD_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? 'unknown', + }, }; module.exports = nextConfig;