Skip to content

CI Pipeline

CI Pipeline #1886

Workflow file for this run

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