name: CI # Tests + static analysis + lint. Runs on every push to a release branch and on # PRs targeting main. The production image is built/pushed separately and ONLY on # a v* tag (see image.yml) — never here. on: push: branches: ['release/*'] pull_request: branches: [main] jobs: backend: runs-on: docker container: image: catthehacker/ubuntu:act-latest services: database: image: postgres:16-alpine env: POSTGRES_DB: buckets POSTGRES_USER: buckets POSTGRES_PASSWORD: buckets options: >- --health-cmd "pg_isready -U buckets -d buckets" --health-interval 5s --health-timeout 5s --health-retries 10 env: # Point the test suite at the CI postgres service (host = service name). DATABASE_URL: postgresql://buckets:buckets@database:5432/buckets?serverVersion=16&charset=utf8 steps: - uses: https://data.forgejo.org/actions/checkout@v4 - name: Set up PHP uses: https://github.com/shivammathur/setup-php@v2 with: php-version: '8.4' extensions: pdo_pgsql, mbstring, xml, dom, intl, zip coverage: pcov - name: Cache Composer dependencies uses: https://data.forgejo.org/actions/cache@v4 with: path: ~/.composer/cache key: composer-${{ hashFiles('composer.lock') }} restore-keys: composer- - name: Install Composer dependencies run: composer install --no-interaction --prefer-dist - name: Lint (php-cs-fixer) run: vendor/bin/php-cs-fixer check --diff env: PHP_CS_FIXER_IGNORE_ENV: '1' - name: Static analysis (PHPStan) run: vendor/bin/phpstan analyse --memory-limit=1G -c phpstan.dist.neon - name: Create test database run: php bin/console --env=test doctrine:database:create --if-not-exists - name: Run migrations run: php bin/console --env=test doctrine:migrations:migrate --no-interaction - name: Tests with coverage run: php bin/phpunit --coverage-clover coverage.xml # Backend sits at ~98.8% clover element coverage (a few framework-glue # elements are @codeCoverageIgnore'd in the text report but still counted # in the clover XML). Gate at a 95% floor: catches real backslide without # demanding an unreachable 100% off the raw clover metric (mirrors the # frontend's vite.config thresholds, which are floors, not 100). - name: Enforce coverage threshold run: | MIN=95 COVERAGE=$(php -r ' $xml = @simplexml_load_file("coverage.xml"); if ($xml === false || !isset($xml->project->metrics)) { echo "0"; exit; } $m = $xml->project->metrics; $el = (int) $m["elements"]; $cov = (int) $m["coveredelements"]; echo $el > 0 ? round(($cov / $el) * 100, 2) : 0; ') echo "Coverage: ${COVERAGE}% (minimum ${MIN}%)" php -r "exit((float)\$argv[1] >= (float)\$argv[2] ? 0 : 1);" "$COVERAGE" "$MIN" \ || { echo "::error::Coverage ${COVERAGE}% is below the ${MIN}% threshold"; exit 1; } frontend: runs-on: docker container: image: catthehacker/ubuntu:act-latest defaults: run: working-directory: frontend steps: - uses: https://data.forgejo.org/actions/checkout@v4 # Node is preinstalled in catthehacker/ubuntu:act-latest (no setup-node # needed). npm ci is lockfile-driven, so the image's npm version is fine; # the *production* frontend build pins NPM_VERSION inside the Dockerfile. - name: Cache npm dependencies uses: https://data.forgejo.org/actions/cache@v4 with: path: ~/.npm key: npm-${{ hashFiles('frontend/package-lock.json') }} restore-keys: npm- - name: Install dependencies run: npm ci - name: Lint (oxlint) run: npm run lint - name: Format check (Prettier) run: npm run format:check - name: Type check (tsc) run: npx tsc -b # Coverage thresholds are enforced inside vite.config.ts (test:coverage # fails on backslide) — no separate gate step needed. - name: Tests with coverage run: npm run test:coverage # End-to-end smoke tests. Unlike local (where compose stands up dev services), # CI builds the PRODUCTION image and runs Playwright against it — so e2e # exercises the real shipped artifact: the SPA served by Caddy with same-origin # /api/* routing (Phase A). The prod entrypoint waits for the DB and runs # migrations itself, so no separate migrate step is needed here. # # NOTE: this BUILDS the prod image to test against; it never PUSHES it. Image # publishing stays tag-gated (see image.yml). Building-to-test on release/* is # deliberate and does not cross the publish lock. e2e: runs-on: docker container: image: catthehacker/ubuntu:act-latest needs: [backend, frontend] steps: - uses: https://data.forgejo.org/actions/checkout@v4 - name: Set up Docker Buildx uses: https://data.forgejo.org/docker/setup-buildx-action@v3 - name: Build production image uses: https://data.forgejo.org/docker/build-push-action@v5 with: context: . target: frankenphp_prod build-args: | NPM_VERSION=11.17.0 push: false load: true tags: buckets-prod:e2e # Stand up postgres + the app as plain `docker run` containers on an # explicitly-created network. We do NOT use a `services:` postgres + the # `job.container.network` context here: that field is GitHub-specific and # support in Forgejo's act_runner is unreliable, so an empty value would # silently drop `app` onto the default bridge where it can't resolve the # DB by hostname. Creating the network ourselves keeps name resolution # under our control and portable across runner versions. # # Pre-clean first: if a prior run crashed mid-job on a shared Docker # daemon, the leftover containers/network would make `create`/`run --name` # fail on "name already in use". `|| true` keeps a clean first run quiet. - name: Create the e2e network run: | docker rm -f app database 2>/dev/null || true docker network rm e2e-net 2>/dev/null || true docker network create e2e-net # Playwright runs INSIDE this job container and targets the app by hostname # (E2E_BASE_URL=http://app:80), so the job container must share e2e-net to # resolve `app`. With a `services:` block the runner wires this up for us; # since we manage the network ourselves, connect the job container — its # $HOSTNAME is the container id act_runner assigned us (Docker's default # hostname). If a future runner overrides the hostname this step fails # LOUDLY (network connect errors) rather than silently — acceptable since # the whole e2e job is unverified on the real runner until the first # release/* push. - name: Join the job container to the e2e network run: docker network connect e2e-net "$HOSTNAME" - name: Start the database run: | docker run -d --name database --network e2e-net \ -e POSTGRES_DB=buckets \ -e POSTGRES_USER=buckets \ -e POSTGRES_PASSWORD=buckets \ --health-cmd "pg_isready -U buckets -d buckets" \ --health-interval 5s --health-timeout 5s --health-retries 10 \ postgres:16-alpine - name: Wait for the database to be healthy run: | for i in $(seq 1 30); do if [ "$(docker inspect -f '{{.State.Health.Status}}' database)" = "healthy" ]; then echo "Database is up."; exit 0 fi echo "Waiting for database... ($i)"; sleep 2 done echo "Database did not become healthy — dumping logs:"; docker logs database; exit 1 - name: Start the app run: | docker run -d --name app --network e2e-net \ -e SERVER_NAME=":80" \ -e APP_SECRET=ci-e2e-secret \ -e DATABASE_URL="postgresql://buckets:buckets@database:5432/buckets?serverVersion=16&charset=utf8" \ -e MERCURE_PUBLISHER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!" \ -e MERCURE_SUBSCRIBER_JWT_KEY="!ChangeThisMercureHubJWTSecretKey!" \ buckets-prod:e2e # Wait for a FULLY healthy app, not just a reachable one: check the body # for "db":"ok" (same precision as the compose healthcheck), so a 503 from # an app that booted but can't reach the DB fails HERE with a clear "did # not become healthy" message instead of confusingly mid-Playwright. - name: Wait for the app to be healthy run: | for i in $(seq 1 60); do if docker exec app php -r 'exit(str_contains((string) @file_get_contents("http://127.0.0.1:80/api/health"), "\"db\":\"ok\"") ? 0 : 1);' 2>/dev/null; then echo "App is up."; exit 0 fi echo "Waiting for app... ($i)"; sleep 2 done echo "App did not become healthy — dumping logs:"; docker logs app; exit 1 # Node is preinstalled in the runner image (see frontend job note), but the # Chromium binary is not — install it (with OS deps) before running. - name: Install Playwright dependencies working-directory: e2e run: npm ci - name: Install Playwright browser working-directory: e2e run: npx playwright install --with-deps chromium - name: Run Playwright tests working-directory: e2e env: # Target the app container by name on the shared job network. E2E_BASE_URL: http://app:80 run: npm test - name: Dump app logs on failure if: failure() run: docker logs app || true