# Novel Systems — the CI workflows, verbatim. # # This file is generated by scripts/build-pipeline-report.mjs. It is the # concatenation of every file under .github/workflows, unedited, including their # comments. It is not a summary or an illustration: these are the bytes GitHub # Actions executes. # # Do not edit this file. Edit the workflow and run `npm run pipeline:generate`. # `npm run check:pipeline` fails the build if the two disagree. # # .github/workflows/ci.yml 4 jobs, 37 steps # .github/workflows/deployed-api.yml 1 job, 3 steps # # WHY THIS IS PUBLISHED AS A FILE RATHER THAN AS A BADGE. # # The repository is private, so a workflow status badge renders "not found" and # an Actions run log 404s for anyone without a seat. Publishing the workflow is # what remains: it shows what runs, on what trigger, against what services, and # with what thresholds — all of which are checkable statements — rather than # asserting a conclusion nobody can open. # # A run history is deliberately not published here. Run conclusions live behind # an authenticated API, and a file in this repository claiming a particular run # passed would be a hand-entered assertion about a system you cannot check. What # is published instead is one real, verifiable execution: /coverage.json records # the test command's achieved figures, when it ran, and a SHA-256 of the backend # source tree it ran against, so a stale number fails the build rather than # ageing quietly. # # Run logs are available in the security package on request. # # Rendered view of this file: /engineering#ci # The test run these jobs produce: /coverage.json # The schema the migration jobs apply: /schema.sql # The contract the daily job checks: /openapi.json # ============================================================================= # .github/workflows/ci.yml # The gate every change passes before it reaches a deployment. Runs on every push to main and on every pull request. # ============================================================================= # .github/workflows/ci.yml # # The gate every change passes before it reaches a deployment. # # Deployment itself is Vercel's git integration, not a job in this file. Vercel # builds both projects on every push to main and produces a preview on every # pull request; a deploy step here would duplicate that and the two would race. # What this workflow adds is the checking Vercel does not do — the tests, the # policy guards, and the database-level isolation proof — and the branch # protection rule that requires it turns "the build succeeded" into "the build # succeeded and the invariants hold". # # The jobs are split by workspace rather than by kind. The frontend and the API # have separate dependency trees and separate Vercel projects, and a single job # installing both would make a backend test failure look like a frontend # problem in the checks list. name: CI on: push: branches: [main] pull_request: # Manual runs, for confirming the pipeline itself works without inventing a # commit to trigger it. workflow_dispatch: # A second push to the same branch cancels the first. Two runs of the same # branch tell you nothing the later one does not, and the Postgres service # container in the isolation job is not free. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true permissions: contents: read env: # Pinned rather than "lts". A floating major is a build that breaks on a day # nobody changed anything, which is the most expensive kind of failure to # diagnose because the diff is empty. NODE_VERSION: "22.x" jobs: frontend: name: Frontend — typecheck, guards, build runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: package-lock.json - name: Install run: npm ci - name: Typecheck run: npm run typecheck # verify-pricing.mjs holds the locked reference derivation — 96.00"W × # 120.00"D must return 642.10 wholesale and 1284.20 retail — plus the # pricing table and sitemap audits. It is the check that notices a rate # card edit that moved a published price. - name: Pricing and sitemap audit run: npm run verify:pricing # The two engines cannot import from each other, so this is the only thing # that notices when their margin floors drift apart. See D-001 in # DECISIONS.md. - name: Margin policy run: npm run check:margin # Same argument as the margin check, applied to the four cut-size # allowances. A margin disagreement produces a wrong price and someone # eventually spots it on an invoice; a cut-size disagreement produces a # wrong piece of aluminium and nobody spots it until it is on a van. See # D-002 in DECISIONS.md. - name: Cut-list parity run: npm run check:cutlist # The step above compares the two engines' constants. This one compares # their answers, and it is the check that would have caught the bug the # constant check could not: the four allowances were identical, and the # engines still cut a 30.1" opening to two different tubes, because the # browser snapped the dimension to the quarter inch before subtracting and # the server did not. # # Both engines are held against fixtures/cut-list-parity.json — the # browser one here, the server one by the unit tests in the API job below. # A change that satisfies one side and not the other fails whichever job # it did not satisfy. See D-010 in DECISIONS.md. - name: CPQ parity against the shared fixture run: npm run check:cpq-parity # A Supabase client built in a module body is built during prerender, on # this runner, where no project is configured — and takes the whole build # down with "supabaseUrl is required". This guard catches the pattern # before the build step does, with an error that names the file and line. - name: Client construction run: npm run check:client # /api/health once answered `{"status":"ok","dependencies":{...all # "configured"}}` while all three intake paths were failing and # support_tickets held zero rows — two uptime monitors recorded 100% # availability throughout. The endpoint now states which question it # answered, and answers the stronger one on ?probe=deep. This guard holds # that shape: both arms labelled, no dependency reported without a probe, # every probe imported, exported and awaited. A fifth dependency added # without a probe is a type-checking, compiling, silent return of the same # bug, so a compiler cannot be the thing that catches it. - name: Health endpoint measures what it reports run: npm run check:health-honesty - name: Proof copy run: npm run check:proof - name: Host configuration run: npm run check:hosts # Holds /developers and the homepage sandbox proxy against # public/openapi.json, which is generated from the API's own Zod schemas. # The published reference is hand-written prose with no compiler over it, # and it had drifted badly: a per-plan rate limit table an order of # magnitude above what the server enforces, an idempotency header the # dispatcher has never sent, and webhook events no code path fires. All of # it type-checked. See D-011 in DECISIONS.md. - name: API reference against the generated spec run: npm run check:api-reference - name: Internal links run: npm run check:links # Runs the guards a second time through `prebuild`, which is deliberate — # it proves the build gate is actually wired up, not just that the scripts # pass when invoked by hand. - name: Build run: npm run build backend: name: API — typecheck, schema, tests, coverage runs-on: ubuntu-latest defaults: run: working-directory: backend steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: backend/package-lock.json - name: Install run: npm ci # `prisma validate` resolves env() at parse time, so both URLs have to be # set even though nothing here connects to a database. The values are # deliberately unreachable placeholders: the step's job is to prove the # schema parses and its relations resolve, and a real connection string # would make a syntax check depend on a live server. - name: Validate the Prisma schema env: DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci_schema_validation DIRECT_DATABASE_URL: postgresql://ci:ci@127.0.0.1:5432/ci_schema_validation run: npm run prisma:validate - name: Typecheck run: npm run typecheck # public/openapi.json is generated from the Zod schemas in src/schemas, and # this step regenerates it and fails if the committed file differs. It is # the half of the contract check that lives on this side: the frontend job # asserts the published reference agrees with the spec, and this asserts # the spec agrees with the server. Neither alone is enough — a spec that # matches the docs and not the code is exactly the state this repository # was in. See D-011 in DECISIONS.md. - name: OpenAPI spec matches the schemas run: npm run openapi:check # Unit and integration in one command, with coverage thresholds attached. # # The integration half boots the real Express app on an ephemeral port and # drives it with fetch, against an in-memory stand-in for Prisma. That # covers what unit tests structurally cannot: helmet, CORS, the body-size # limit, the rate limiters, Express's own routing and 404 handling, and # every middleware in the chain in the order it actually runs. Two of the # defects it was written to catch — a malformed query string answering 500 # instead of 422, and a malformed JSON body doing the same — were live in # `main` and invisible to a controller-level test, because neither fault # is inside a controller. # # It deliberately does not test row-level security. RLS is enforced by # Postgres, so a fake client would report it as passing with every policy # dropped; that proof belongs to the isolation job below and nowhere else. # # The thresholds are a floor for the whole `src` tree, not a target. They # sit just under the current figures (98.8 line / 90.8 branch / 93.3 # function), so this step fails on a meaningful regression rather than on # ordinary drift. Raising them is a deliberate act; letting them slip is # not something a pull request should be able to do quietly. - name: Tests and coverage run: npm run test:coverage - name: Build run: npm run build isolation: name: API — tenant isolation against Postgres runs-on: ubuntu-latest defaults: run: working-directory: backend # The claim this platform makes loudest is that one contractor cannot see # another's quotes. A real Postgres is the only thing that can check it: # row-level security is enforced by the database, and a mocked client would # pass with every policy dropped. services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: ci-postgres POSTGRES_DB: novel_systems_test ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: # The scratch credential for a container that exists for the length of one # job and is never reachable from outside the runner. It is written here # rather than kept in a secret on purpose: a value in a secret implies it # matters somewhere, and this one does not. RLS_ADMIN_URL: postgresql://postgres:ci-postgres@localhost:5432/novel_systems_test steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: backend/package-lock.json - name: Install run: npm ci # Applies every migration file to an empty database over a raw connection # and then attacks the policies. Proving the migrations apply to nothing # is half the value: a schema that only works in the order somebody # happened to run things is a schema that fails on a restore from backup. # # The file list is read from the migrations directory rather than written # out here or in the test. A hardcoded list inside a test whose entire job # is catching omissions is the one omission it cannot catch. - name: Row-level security policies run: npm run test:rls # test/tenant-isolation.test.ts existed in this repository for weeks # without ever running anywhere. It was on no npm script that CI invoked, # so it sat in the tree looking like coverage and providing none — which # is the exact failure its own header warns about, one directory over. # # It is a different test from the one above and both are worth the # minutes. test:rls attacks the policies directly over a raw connection: # it proves the database refuses a cross-tenant read. This one goes # through Prisma and withTenant(), the path the API actually takes, and # proves the application does not hand the database a query that the # policies would happily allow because the tenant id was never set. # Passing the first and failing the second is a real and quiet outcome. # # The database is built here rather than reused from test:rls, which # creates a scratch database and drops it on the way out. The novel_app # password is set at this point for the same reason 0002 does not carry # one: a password written into a migration is in the history forever. # # JWT_SECRET is here because the test imports src/db/prisma.ts, which # imports config/env.ts, which validates the whole environment at import # and exits the process on a missing value. This test authenticates # nobody and signs nothing; the variable is required by the module graph, # not by the assertions. It is written inline for the same reason # RLS_ADMIN_URL is: a value in a secret implies it matters somewhere. # # Its absence is why this job failed on every run from the one that added # it until 5 August 2026 — the process exited at import, before a single # assertion, and the isolation proof this repository points at was not # being made. Which is, once again, the failure the test's own header # warns about: a file that looks like coverage and provides none. - name: Tenant isolation through the application path env: OWNER_URL: postgresql://postgres:ci-postgres@localhost:5432/isolation_test JWT_SECRET: ci-isolation-job-signs-nothing-0000000000 run: | set -euo pipefail docker run --rm --network host -e PGPASSWORD=ci-postgres postgres:16 \ psql "$RLS_ADMIN_URL" -v ON_ERROR_STOP=1 \ -c 'create database isolation_test' DATABASE_URL="$OWNER_URL" DIRECT_DATABASE_URL="$OWNER_URL" \ npx prisma migrate deploy docker run --rm --network host -e PGPASSWORD=ci-postgres postgres:16 \ psql "$OWNER_URL" -v ON_ERROR_STOP=1 \ -c "alter role novel_app with login password 'ci-app'" DIRECT_DATABASE_URL="$OWNER_URL" \ DATABASE_URL="postgresql://novel_app:ci-app@localhost:5432/isolation_test" \ npm run test:isolation migrations: name: API — migrations apply forward and converge runs-on: ubuntu-latest defaults: run: working-directory: backend # `prisma migrate deploy` passing on a developer's laptop proves the # migrations run against that laptop. This job proves the two cases that # actually happen in production. # # Fresh — every migration against an empty database. This is a restore # from backup, a new region, and a reviewer checking out the # branch. It is also the only way to catch a migration that # silently depends on data an earlier one happened to leave. # # Upgrade — the migrations already deployed, then the new one on top. # This is the path the live database will actually take, and # it is the one that finds `if not exists` clauses that skip # work an `alter` needed done, and `alter column` statements # that behave differently on a populated table than they read. # # Then both schemas are dumped and diffed. Two paths that both "succeed" # while producing different schemas is the failure mode worth the runner # minutes: it means production is running something no test ever built. services: postgres: image: postgres:16 env: POSTGRES_PASSWORD: ci-postgres POSTGRES_DB: postgres ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 env: ADMIN_URL: postgresql://postgres:ci-postgres@localhost:5432/postgres FRESH_URL: postgresql://postgres:ci-postgres@localhost:5432/migrate_fresh UPGRADE_URL: postgresql://postgres:ci-postgres@localhost:5432/migrate_upgrade steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: backend/package-lock.json - name: Install run: npm ci # pg_dump is run out of the same image as the server rather than out of # whatever the runner image ships. A client one major behind the server # refuses to dump at all, and a client one ahead emits syntax the diff # would report as a schema difference that is really a formatting one. - name: Create the two databases run: | docker run --rm --network host -e PGPASSWORD=ci-postgres postgres:16 \ psql "$ADMIN_URL" -v ON_ERROR_STOP=1 \ -c 'create database migrate_fresh' \ -c 'create database migrate_upgrade' # Path 1. Everything, against nothing. - name: Fresh — apply every migration to an empty database env: DATABASE_URL: ${{ env.FRESH_URL }} DIRECT_DATABASE_URL: ${{ env.FRESH_URL }} run: npx prisma migrate deploy # Path 2, first half. "What production is running right now" is not # something CI can look up, so it is approximated as every migration # except the newest — which is exactly true on the pull request that # introduces one, and that is the pull request this check exists for. # # The newest directory is moved out of the tree rather than filtered by # name, because `migrate deploy` reads the directory, not an argument. - name: Upgrade — apply the previously deployed migrations env: DATABASE_URL: ${{ env.UPGRADE_URL }} DIRECT_DATABASE_URL: ${{ env.UPGRADE_URL }} run: | set -euo pipefail newest="$(ls -1 prisma/migrations | grep -E '^[0-9]' | sort | tail -1)" echo "Holding back the newest migration: $newest" mkdir -p /tmp/held-back mv "prisma/migrations/$newest" "/tmp/held-back/$newest" echo "HELD_BACK=$newest" >> "$GITHUB_ENV" npx prisma migrate deploy # Path 2, second half. The live upgrade, in isolation. - name: Upgrade — apply the new migration on top env: DATABASE_URL: ${{ env.UPGRADE_URL }} DIRECT_DATABASE_URL: ${{ env.UPGRADE_URL }} run: | set -euo pipefail mv "/tmp/held-back/$HELD_BACK" "prisma/migrations/$HELD_BACK" npx prisma migrate deploy # `--schema-only` means the `_prisma_migrations` rows — which differ # between the two paths by construction, on timestamps and on how many # transactions wrote them — never enter the comparison, while its table # definition still does. Grants and row-level security policies are left # in deliberately: a policy present on a fresh database and missing on an # upgraded one is precisely the silent failure this job is here to find, # and stripping privileges to make the diff tidy would hide it. - name: Compare the two schemas run: | set -euo pipefail # The `\restrict` / `\unrestrict` pair pg_dump wraps its output in # carries a fresh random nonce on every invocation. It is a psql # safety fence, not schema, and leaving it in would make this step # fail on every run including the ones where nothing is wrong. dump () { docker run --rm --network host -e PGPASSWORD=ci-postgres postgres:16 \ pg_dump --schema-only "$1" \ | sed -E '/^\\(un)?restrict /d' } dump "$FRESH_URL" > /tmp/fresh.sql dump "$UPGRADE_URL" > /tmp/upgrade.sql if ! diff -u /tmp/fresh.sql /tmp/upgrade.sql; then echo "::error::A fresh database and an upgraded database do not agree." echo "The migrations apply without erroring but do not converge, which" echo "means production would run a schema no fresh build produces." exit 1 fi echo "Fresh and upgraded schemas are identical." # Applied-and-nothing-pending, asked of the database rather than inferred # from the fact that the previous steps exited zero. # # This deliberately does not run `prisma migrate diff` against the # datamodel. Several objects in these migrations — the composite # tenant-scoped foreign keys, the GiST exclusion constraint on rate card # windows — have no expression in Prisma's schema language, so a diff # would report the safety features as drift and fail forever. See D-009. - name: Confirm the migration history is fully applied env: DATABASE_URL: ${{ env.FRESH_URL }} DIRECT_DATABASE_URL: ${{ env.FRESH_URL }} run: npx prisma migrate status --- # ============================================================================= # .github/workflows/deployed-api.yml # A daily check that the running API still serves every path the published OpenAPI contract declares. Scheduled, not triggered by a push, because the API deploys from a different repository. # ============================================================================= # .github/workflows/deployed-api.yml # # Does the running API still serve every path we publish a contract for? # # This is a separate workflow from ci.yml, and it is scheduled rather than # triggered by a push, for two reasons. # # It cannot run on push. The API deploys from a different repository, published # by `push.command` immediately after `git push origin main`. A job that ran on # push would start before — or racing with — the publish it is checking, and # would fail on every legitimate backend change. That is a check that trains # people to ignore it. # # It cannot live in ci.yml. Everything there is a gate on a change; this is an # observation about a deployment, and it fails for reasons that have nothing to # do with the commit under test. Mixing the two makes a red X ambiguous, and an # ambiguous red X is the one nobody reads. # # Drift of this kind is slow — it accumulated over fourteen commits before # anyone noticed — so a daily answer is a timely answer. name: Deployed API contract on: schedule: # 08:00 UTC, which is early morning in Toronto. A failure is waiting at the # start of the day rather than arriving in the middle of one. - cron: "0 8 * * *" workflow_dispatch: permissions: contents: read jobs: routed: name: Every declared operation is routed runs-on: ubuntu-latest # The API can be slow to cold-start a function; the script's own per-request # timeout is 15s and there are fourteen operations. timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "22.x" # No `npm ci`. The script reads public/openapi.json and calls fetch — # both are in the standard library, and installing a dependency tree to # run one file is a slower job with more ways to fail for reasons that # have nothing to do with the thing being checked. - name: Check every declared operation against the live host run: node scripts/check-deployed-api.mjs