256 lines
9.9 KiB
YAML
256 lines
9.9 KiB
YAML
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
|