CI Pipeline #1886
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 Pipeline | |
| on: | |
| workflow_dispatch: {} | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| branches: | |
| - main | |
| env: | |
| NODE_VERSION: '20' | |
| jobs: | |
| # Quality checks - runs on all branches and PRs | |
| # Self-host deploy: .github/workflows/deploy-selfhost.yml on push to main | |
| quality: | |
| name: Code Quality Checks | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| # SSOT: lint + umlauts + typecheck + test + build are bundled in the | |
| # `verify` npm script (package.json). CI calls it verbatim so the gating | |
| # chain can't drift from what runs locally. Do not re-inline these checks | |
| # here — the umlaut gate (Swiss ä/ö/ü convention) lives inside `verify` | |
| # too. Unit tests are ALSO run by the standalone `test` job below, which | |
| # the post-main verdict needs by name; that duplication is deliberate and | |
| # parallel (no wall-clock cost), not an oversight. | |
| - name: Verify (lint + umlauts + typecheck + test + build) | |
| run: npm run verify | |
| env: | |
| # Build-time placeholders so strict env validation doesn't fail during CI compile. | |
| AUTH_SECRET: ci-build-placeholder-secret-32chars | |
| DB_HOST: localhost | |
| DB_NAME: revampit_ci | |
| DB_USER: ci | |
| DB_PASSWORD: ci | |
| # Auth smoke gate: verifies prod login when E2E secrets are present. | |
| # Prefers dual-persona admin credentials; falls back to legacy AUTH_TEST_*. | |
| auth-smoke: | |
| name: Auth Smoke Test | |
| runs-on: ubuntu-latest | |
| # Deliberately NO `needs:`. This job tests the ALREADY-DEPLOYED production | |
| # site (PLAYWRIGHT_BASE_URL defaults to https://revampit.orangecat.ch); it | |
| # never builds this branch and reads nothing `quality` produced. Waiting on | |
| # `quality` (429s) was ordering, not dependency — and after #303 unblocked | |
| # the e2e job, this pair became the new critical path: quality 429s → | |
| # inventory-smoke 181s = 610s of a 634s run. | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Install Playwright browser | |
| run: npx playwright install --with-deps chromium | |
| - name: Run auth smoke (if credentials configured) | |
| env: | |
| AUTH_TEST_EMAIL: ${{ secrets.AUTH_TEST_EMAIL }} | |
| AUTH_TEST_PASSWORD: ${{ secrets.AUTH_TEST_PASSWORD }} | |
| AUTH_TEST_ADMIN_EMAIL: ${{ secrets.AUTH_TEST_ADMIN_EMAIL }} | |
| AUTH_TEST_ADMIN_PASSWORD: ${{ secrets.AUTH_TEST_ADMIN_PASSWORD }} | |
| run: | | |
| EMAIL="${AUTH_TEST_EMAIL:-${AUTH_TEST_ADMIN_EMAIL:-}}" | |
| PASSWORD="${AUTH_TEST_PASSWORD:-${AUTH_TEST_ADMIN_PASSWORD:-}}" | |
| if [ -z "$EMAIL" ] || [ -z "$PASSWORD" ]; then | |
| echo "No AUTH_TEST_* or AUTH_TEST_ADMIN_* secrets — skipping auth smoke." | |
| exit 0 | |
| fi | |
| export AUTH_TEST_EMAIL="$EMAIL" | |
| export AUTH_TEST_PASSWORD="$PASSWORD" | |
| PLAYWRIGHT_BASE_URL="${PLAYWRIGHT_BASE_URL:-https://revampit.orangecat.ch}" \ | |
| npx playwright test tests/e2e/auth-smoke.spec.ts --project=chromium --reporter=line | |
| # Prod health gate: full dual-persona route inventory (186 routes) when passwords set. | |
| # Primary gate is post-deploy in deploy-selfhost.yml; this catches prod regressions on PRs too. | |
| inventory-smoke: | |
| name: Dual-Persona Inventory Smoke | |
| runs-on: ubuntu-latest | |
| # Deliberately NO `needs:` — same reason as auth-smoke above. This walks 186 | |
| # routes on the LIVE site via scripts/e2e-inventory-prod.sh; nothing about | |
| # that verdict depends on whether this branch lints. Removing the wait moves | |
| # 181s off the critical path without changing what is checked. | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Install Playwright browser | |
| run: npx playwright install --with-deps chromium | |
| - name: Run inventory smoke (if dual-persona secrets configured) | |
| env: | |
| AUTH_TEST_USER_PASSWORD: ${{ secrets.AUTH_TEST_USER_PASSWORD }} | |
| AUTH_TEST_ADMIN_PASSWORD: ${{ secrets.AUTH_TEST_ADMIN_PASSWORD }} | |
| AUTH_TEST_USER_EMAIL: ${{ secrets.AUTH_TEST_USER_EMAIL }} | |
| AUTH_TEST_ADMIN_EMAIL: ${{ secrets.AUTH_TEST_ADMIN_EMAIL }} | |
| run: bash scripts/e2e-inventory-prod.sh | |
| # Migration drift check — applies every SQL migration in scripts/db/migrations/ | |
| # to a throwaway Postgres container. Uses pgvector image so 005-hirn-ai-rag.sql | |
| # can CREATE EXTENSION vector. Runs on PRs and main pushes. | |
| migrations: | |
| name: Migration Drift Check | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' | |
| services: | |
| postgres: | |
| image: pgvector/pgvector:pg17 | |
| env: | |
| POSTGRES_PASSWORD: ci | |
| POSTGRES_DB: revampit_drift | |
| options: >- | |
| --health-cmd "pg_isready -U postgres" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| ports: | |
| - 5432:5432 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Apply migrations in order | |
| env: | |
| PGHOST: localhost | |
| PGPORT: 5432 | |
| PGUSER: postgres | |
| PGPASSWORD: ci | |
| PGDATABASE: revampit_drift | |
| run: bash scripts/db/apply-migrations-ci.sh | |
| e2e-local: | |
| name: Local E2E Journeys | |
| runs-on: ubuntu-latest | |
| # Deliberately NO `needs:`. This job is self-contained — it brings its own | |
| # postgres service, applies its own migrations, seeds its own data, builds | |
| # the app and starts its own server. It consumed no output from `quality` | |
| # or `migrations`; both were gates expressed as dependencies, and they cost | |
| # the full 373s of `quality` on the critical path of the second-longest job. | |
| # | |
| # Measured before: quality 373s → e2e 336s = 709s wall-clock. | |
| # `needs:` is for "I use what that job produced", not "I prefer to run | |
| # after it". Every job here remains a required check regardless of order. | |
| # | |
| # Trade-off, stated: a PR that fails lint now also spends the e2e minutes | |
| # rather than being cut off early. | |
| services: | |
| postgres: | |
| image: pgvector/pgvector:pg17 | |
| env: | |
| POSTGRES_PASSWORD: ci | |
| POSTGRES_DB: revampit_e2e | |
| options: >- | |
| --health-cmd "pg_isready -U postgres" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| ports: | |
| - 5432:5432 | |
| env: | |
| AUTH_SECRET: ci-e2e-placeholder-secret-32chars | |
| NEXTAUTH_URL: http://localhost:3001 | |
| AUTH_URL: http://localhost:3001 | |
| PLAYWRIGHT_BASE_URL: http://localhost:3001 | |
| DB_HOST: localhost | |
| DB_PORT: 5432 | |
| DB_NAME: revampit_e2e | |
| DB_USER: postgres | |
| DB_PASSWORD: ci | |
| DB_SSL: false | |
| PGHOST: localhost | |
| PGPORT: 5432 | |
| PGUSER: postgres | |
| PGPASSWORD: ci | |
| PGDATABASE: revampit_e2e | |
| AUTH_TEST_EMAIL: e2e-admin@revampit.test | |
| AUTH_TEST_PASSWORD: E2EAdmin123! | |
| AUTH_TEST_ADMIN_EMAIL: e2e-admin@revampit.test | |
| AUTH_TEST_ADMIN_PASSWORD: E2EAdmin123! | |
| AUTH_TEST_SECOND_ADMIN_EMAIL: e2e-admin2@revampit.test | |
| AUTH_TEST_SECOND_ADMIN_PASSWORD: E2EAdmin123! | |
| AUTH_TEST_USER_EMAIL: e2e-user@revampit.test | |
| AUTH_TEST_USER_PASSWORD: E2EUser123! | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Install Playwright browser | |
| run: npx playwright install --with-deps chromium | |
| - name: Apply migrations | |
| run: bash scripts/db/apply-migrations-ci.sh | |
| - name: Seed E2E data | |
| run: npm run e2e:seed | |
| - name: Build app | |
| run: npm run build | |
| - name: Start app | |
| run: | | |
| npm run start -- -p 3001 > revampit-e2e.log 2>&1 & | |
| for i in {1..90}; do | |
| if curl -fsS http://localhost:3001/api/health >/dev/null; then | |
| exit 0 | |
| fi | |
| sleep 2 | |
| done | |
| cat revampit-e2e.log | |
| exit 1 | |
| - name: Run Playwright E2E journeys | |
| run: npm run test:e2e:journeys -- --project=chromium --reporter=line | |
| # Specs that are not named *journey and therefore fell outside the glob | |
| # above — so nothing ran them, anywhere, ever. Run by name, not by | |
| # pattern, so a file cannot silently drop out of coverage again. | |
| # security 41 assertions: no admin page renders and no admin/ | |
| # money API answers 2xx to a signed-out request | |
| # notification-hrefs 21 assertions | |
| # user-admin-flows 16 assertions | |
| - name: Run Playwright E2E guards | |
| run: npm run test:e2e:guards -- --project=chromium --reporter=line | |
| - name: Upload E2E artifacts | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: e2e-local-artifacts | |
| path: | | |
| playwright-report/ | |
| test-results/ | |
| revampit-e2e.log | |
| if-no-files-found: ignore | |
| # Unit tests. This job ran with `continue-on-error: true` from the day it was | |
| # added ("non-blocking while suite matures"), and the suite matured to 7,769 | |
| # tests without anyone flipping it back. The cost was not hypothetical: the | |
| # 2026-07-28 primary-* -> success-* token sweep left 25 assertions failing in | |
| # 15 suites, and every PR since reported this job green. A gate whose result | |
| # is discarded is not a gate — it is a 6-minute no-op that buys confidence it | |
| # has not earned. | |
| test: | |
| name: Run Tests | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 20 | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Run tests | |
| run: npm run test | |
| # Dependency advisories. Blocks a PR that INTRODUCES a critical; high and | |
| # below are reported but do not fail, because a third party publishing an | |
| # advisory must not be able to jam the merge train (auto-merge refuses a red | |
| # base). Advisories that appear with no code change are caught by the | |
| # scheduled security-audit.yml instead — this job only sees what a run touches. | |
| security: | |
| name: Dependency Security Audit | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Audit runtime dependencies | |
| env: | |
| FAIL_ON: critical | |
| run: bash scripts/ci/dependency-audit.sh | |
| # Red-main alarm for the DISPATCHED path. | |
| # | |
| # Every merge here is an auto-merge made with GITHUB_TOKEN, which fires no | |
| # push event — so the sweep re-arms this workflow by dispatch, and a | |
| # dispatched run emits no `workflow_run` event for main-red-alert.yml to | |
| # catch. That workflow is therefore silent on almost every main CI run this | |
| # repo produces (observed 2026-08-06: main went red on a66baa55 and no issue | |
| # was filed). A dispatched run does the handoff itself; the push path keeps | |
| # using main-red-alert.yml. One trigger per path, one shared policy script. | |
| post-main: | |
| name: Main Red Alert (dispatched) | |
| # MUST list every other job in this file. The alarm's verdict has to mean | |
| # the same thing as the merge gate's, and the gate reads the RUN's | |
| # conclusion — which every job contributes to. Enforced by | |
| # src/__tests__/ci/main-red-verdict.test.ts, because this list has already | |
| # drifted once (see the commit that added this comment). | |
| needs: [quality, auth-smoke, inventory-smoke, migrations, e2e-local, test, security] | |
| if: always() && github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| issues: write | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v7 | |
| - name: Resolve this run's verdict | |
| id: verdict | |
| env: | |
| R_QUALITY: ${{ needs.quality.result }} | |
| R_AUTH: ${{ needs.auth-smoke.result }} | |
| R_INVENTORY: ${{ needs.inventory-smoke.result }} | |
| R_MIGRATIONS: ${{ needs.migrations.result }} | |
| R_E2E: ${{ needs.e2e-local.result }} | |
| R_TEST: ${{ needs.test.result }} | |
| R_SECURITY: ${{ needs.security.result }} | |
| run: | | |
| set -euo pipefail | |
| # The question this answers is NOT "did the code break?" but "is main | |
| # blocking the merge queue?" — because that is what the auto-merge | |
| # green-base guard asks, and the two must agree. | |
| # | |
| # So anything that is not success-or-skipped counts as red, including | |
| # `cancelled`. A cancelled job on main blocks every open PR exactly as | |
| # hard as a failing one; treating it as a non-event is what let main | |
| # sit red and SILENT for ~14h on 2026-08-07 while 11 PRs waited. | |
| # (`skipped` stays green: jobs here are conditional on event type.) | |
| results="$R_QUALITY $R_AUTH $R_INVENTORY $R_MIGRATIONS $R_E2E $R_TEST $R_SECURITY" | |
| conclusion=success | |
| for result in $results; do | |
| case "$result" in | |
| success|skipped) ;; | |
| *) conclusion=failure ;; | |
| esac | |
| done | |
| echo "conclusion=$conclusion" >> "$GITHUB_OUTPUT" | |
| echo "verdict: $conclusion (from: $results)" | |
| - name: File or resolve the main-red issue | |
| env: | |
| CONCLUSION: ${{ steps.verdict.outputs.conclusion }} | |
| RUN_SHA: ${{ github.sha }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| run: bash scripts/ci/main-red-alert.sh |