Skip to main content

D99 — CI runs the dev compose stack — gates execute inside the app container (RESOLVED)

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The pipeline it describes is mapped for readers on Testing & the gates; the still-open deploy-time RLS gate is tracked in docs/tracking/devops-hardening.md §2.

Superseded in part by D100. The core decision here — run the real dev compose stack and execute the gates through the make targets, so CI can't drift from local dev — still stands. What D100 changes is the job layout: the single GitHub-hosted job below is split across runners — the suite (and its migrate → tenants:rls → verify-isolation hard gate) moves to a Blacksmith ARM runner (native arm64, matching prod), Pint + PHPStan move to a free GitHub-hosted setup-php job (no longer in-container — an explicit parity-for-speed tradeoff), and the SDK-package suites run in-container on a small Blacksmith ARM runner. So the single-job ordering (make lint-check → make stan → make migrate → make test-parallel in one box), the utila-sdk-stays-runner-hosted note, and the "Build & Test at ~21.5 min" timing below describe the original pipeline; see D100 for the current shape.

Context

The ci job was red on main for the wrong reasons and structurally unable to match production:

  • It pinned PHP 8.3 via runner-hosted shivammathur/setup-php, but composer.lock already resolved to packages requiring ≥ 8.4.1 — so every run died at composer install, never reaching a real gate. composer.json still declared php ^8.3.
  • Its Postgres service was postgres:16-alpine and its cache was redis:7-alpine, while dev and prod target Postgres 18 / Valkey 8 — a version-parity gap tracked as devops-hardening §4.
  • It set DB_* / REDIS_* env on the test step that phpunit.xml / tests/bootstrap.php force-override anyway — env-block theatre that looked like configuration but did nothing.
  • Gates ran on the runner against a hand-assembled toolchain (vendor/bin/phpstan, vendor/bin/pint --test, php artisan test --parallel), each an independent re-derivation of what the make targets already do locally — free to drift from the container the developer runs.

The result: a required check that was both broken and, even once green, unable to certify that the code runs on the deployed runtime.

Decision

Run the real dev compose stack in CI and execute every gate inside the app container through the make targets, so the pipeline is the local dev environment by construction and cannot drift.

  • Boot the actual stack. A docker-compose.ci.yml override, layered via COMPOSE_FILE=docker-compose.yml:docker-compose.ci.yml at the job level, replaces only the app service's command with sleep infinity (an inert, long-lived docker compose exec target) and names its image stables-app:ci. postgres:18-alpine + valkey/valkey:8-alpine and their healthchecks come straight from the base file and gate app startup via depends_on + --wait. Object storage (minio + the one-shot minio-init bucket creator) is booted too so the real-driver S3 integration tests (documents direct-transfer, payments exports, audit WORM) execute in CI rather than silently skipping when S3 is unreachable.
  • Build the image once, cache across runs. A docker/bake-action step builds the Dockerfile.dev (php:8.5-cli-alpine) target app with GitHub Actions layer cache (cache-from/to=type=gha, mode=max) and load: true into the local docker store, so docker compose up -d --no-build --wait app starts without rebuilding. The compose files double as valid bake files; the cache config lives only in the workflow, not duplicated in compose.
  • Gates via make targets, in order: a real APP_KEY is written into .env before the stack boots (compose snapshots .env into the container env via env_file, and Laravel's immutable Dotenv won't later override an already-set var — so a key:generate that only rewrites the file could not take effect; the key must exist first), then inside the container: make composer-install → the Vite manifest is built on the runner (node:24-alpine parity, npm ci + npm run buildpublic/build is gitignored + bind-mounted, and the suite renders @vite Blade views with no Vite::fake). The build runs after make composer-install because resources/css/app.css @imports vendor/livewire/flux/dist/flux.css, and vendor/ is only written into the bind mount once Composer install has run inside the container — the runner's node then reads it. Then → make lint-check (pint --test) → make stan (PHPStan, 1 GB, level 6) → make migrate (migrate → tenants:rls → tenants:verify-isolation, then the test DB) → make test-parallel (the full parallel suite, ~2 workers on the 2-vCPU runner, within Valkey's 16-logical-DB constraint).
  • tenants:verify-isolation becomes a hard CI gate (devops-hardening §2, CI half): it exits non-zero on policy drift, so a tenant-scoped table shipped without an ENABLED+FORCED RLS policy fails the build.
  • Align the platform constraint: composer.json php ^8.3php ^8.5 to match the runtime the lock already required.
  • Make the Makefile headless-safe: docker compose exec allocates a TTY by default and aborts with "the input device is not a TTY" with no controlling terminal, so EXEC now adds -T only when stdin is not a TTY ([ -t 0 ] || echo "-T") — interactive targets (shell/tinker/psql) keep their TTY when run by hand.
  • utila-sdk stays runner-hosted. That job is a standalone package with its own composer.lock; testing it directly via setup-php 8.5 (already the target runtime) is cheaper than building and booting the full app image. No parity gap — it was always on 8.5.

Consequences

  • CI certifies the code on the same PHP + Postgres + Valkey majors and the same make-target commands as local dev and production; the version-parity item (§4) and the PHPStan-binary item (§1) are resolved by construction.
  • Gate 4 (tenants:verify-isolation) now blocks PRs that add an unpolicied tenant-scoped table — the test-pipeline half of devops-hardening §2. The deploy-time half (the same fail-closed step in the deploy workflows + production entrypoint) is still open and tracked there; local runs remain mandatory after a tenant-scoped schema change.
  • The job's timeout was raised 15 → 45 min for headroom on the first, uncached image build (cold cache); the gha layer cache should make subsequent runs cheap.
  • Confirmed by the first live run: CI run 29170185619 on PR #73 (2026-07-12) went fully green — bake build + gha layer cache, compose boot (postgres/valkey/minio + minio-init), composer/npm ordering, the pre-boot APP_KEY write, Pint, PHPStan, the migrate + tenants:verify-isolation hard gate, and the full parallel suite (2114 passed), with Build & Test at ~21.5 min on a warm layer cache. Two live-run findings were fixed en route: the asset build was reordered to run after composer install (already reflected above), and UTILA_DEFAULT_VAULT was pinned in phpunit.xml because env()'s default argument only kicks in when a var is unset, not when it's set-but-empty, and .env.example sets it to empty — so the app-side default was silently never applying.

Alternatives rejected

  • Keep runner-hosted setup-php + a standalone Postgres service, just fix the versions. Would green the build but leave two toolchains — the CI step list and the make targets — free to drift, and would need a /etc/hosts alias hack (or duplicated DB_HOST/service-name plumbing) to make the app reach a runner-side Postgres the way it reaches the compose postgres service. Booting the real stack removes the whole class of drift instead of patching one instance of it.
  • A separate CI-only compose file describing the whole stack. Rejected in favour of a thin override on the existing docker-compose.yml (only the app command + image), so CI exercises the same base file developers run — nothing to keep in sync.
  • A committed .env.ci template (evaluated 2026-07-11). The only CI-divergent value is APP_KEYphpunit.xml + tests/bootstrap.php force every test-critical setting anyway, and .env.example already carries the compose service hostnames. A committed static APP_KEY invites secret-scanner findings (the Aikido gate) and is a bad look in a fintech repo, and a second env template is a new drift surface against this ADR's zero-drift thesis. So CI copies .env.example and writes a freshly generated key into .env before the stack boots (no key:generate involved); .env.ci remains trivially adoptable if CI ever needs genuinely divergent env values.

← Decision log index