name: CI on: push: branches: [main] pull_request: branches: [main] permissions: contents: read # Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's # concurrency slots aren't spent on commits nobody is waiting for (refs #127). concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # Self-hosted runner — see docs/runbooks/ci.md for the runner setup. # `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15). # Each job calls a `make` target — the same one developers run locally # (`make ci`). The Makefile is the single source of truth; see docs/runbooks/ci.md. jobs: lint: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' # Cache the NuGet package store so each .NET job restores from disk, not the network. There are # no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3 # avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort # — a miss just restores from the network. See issue #73. - uses: https://github.com/actions/cache@v3 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}- - run: make lint build: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - uses: https://github.com/actions/cache@v3 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}- - run: make build unit: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - uses: https://github.com/actions/cache@v3 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}- - run: make unit # Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote. - name: Unit test summary if: always() run: | [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY" # Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build. frontend: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/pnpm/action-setup@v4 with: version: 11 - uses: https://github.com/actions/setup-node@v4 with: node-version: '24' cache: 'pnpm' - run: make frontend # Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote. - name: Frontend test summary if: always() run: | [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY" mutation: runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' - uses: https://github.com/actions/cache@v3 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }} restore-keys: | nuget-${{ runner.os }}- - run: make mutation # Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea # 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because # `make mutation` stops at the first break, the summary also shows exactly where it stopped. # Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM. - name: Mutation score summary if: always() run: | [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 { echo "## 🧬 Mutation testing" echo for svc in acl event-subscriber domain bff; do echo "### $svc" echo report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1) if [ -n "$report" ]; then sed '1s/^\xef\xbb\xbf//' "$report" else echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._" fi echo done } >> "$GITHUB_STEP_SUMMARY" # Publish the Stryker HTML reports. `if: always()` uploads them even when the # ratchet fails — that is exactly when you want to inspect the survivors. # `continue-on-error` keeps the upload best-effort: the mutation *gate* is the # ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend # 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's # non-deterministic StrykerOutput// dir. Pinned @v3: @v4's bundled # @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook. - uses: https://github.com/actions/upload-artifact@v3 if: always() continue-on-error: true with: name: acl-mutation-report path: services/acl/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn - uses: https://github.com/actions/upload-artifact@v3 if: always() continue-on-error: true with: name: event-subscriber-mutation-report path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn - uses: https://github.com/actions/upload-artifact@v3 if: always() continue-on-error: true with: name: domain-mutation-report path: services/domain/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn - uses: https://github.com/actions/upload-artifact@v3 if: always() continue-on-error: true with: name: bff-mutation-report path: services/bff/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn # One stage for every check that needs the live stack. Booting OpenZaak once (instead # of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs # in a built image and everything reaches services by container IP. Needs Docker + egress # (base images, nuget, selectielijst.openzaak.nl). # # `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so # they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack # bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The # light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity. # # No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job # gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new # transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never # dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a # failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push # re-run exercises verify-stack, so we still get the signal. verify-stack: needs: [mutation] runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 # Bring the full stack up + wait for health — this also is the DoD "compose up # reaches green health" smoke (it replaces the old compose-smoke job). # Each check carries an `id` so the summary step below can report its per-check outcome (#136). # A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped. - name: Bring up the full stack & wait for health id: up run: make verify-up - name: Observability backplane (Grafana + Tempo + Prometheus datasources) id: obs run: OBS_TIMEOUT=180 make verify-observability - name: Objecttypen API up + token authenticates id: objecttypen run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen - name: Objecten API up + token authenticates + trusts Objecttypen id: objecten run: OBJECTEN_TIMEOUT=120 make verify-objecten - name: ACL ↔ OpenZaak integration tests id: acl run: make verify-acl - name: OpenZaak → NRC notification delivery id: nrc run: make verify-nrc - name: OpenZaak → NRC → Event Subscriber → projection-api id: projection run: make verify-projection - name: Domain → Flowable → ACL → OpenZaak id: domain run: make verify-domain - name: BFF → Keycloak + domain + projection id: bff run: make verify-bff - name: Distributed traces reach Tempo (one connected trace across services) id: tracing run: TRACING_TIMEOUT=120 make verify-tracing - name: Golden-signal metrics scraped by Prometheus (/metrics on every service) id: metrics run: METRICS_TIMEOUT=120 make verify-metrics - name: Self-service e2e (Playwright, login → submit → success) id: e2e run: make verify-e2e # Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows # which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the # job-level status-function `if` of #134) so it renders even after a check fails. - name: verify-stack check summary if: always() env: UP: ${{ steps.up.outcome }} OBS: ${{ steps.obs.outcome }} OBJECTTYPEN: ${{ steps.objecttypen.outcome }} OBJECTEN: ${{ steps.objecten.outcome }} ACL: ${{ steps.acl.outcome }} NRC: ${{ steps.nrc.outcome }} PROJECTION: ${{ steps.projection.outcome }} DOMAIN: ${{ steps.domain.outcome }} BFF: ${{ steps.bff.outcome }} TRACING: ${{ steps.tracing.outcome }} METRICS: ${{ steps.metrics.outcome }} E2E: ${{ steps.e2e.outcome }} run: | [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; } { echo "## 🔌 verify-stack checks" echo echo "| Check | Result |" echo "| ----- | :----: |" echo "| Bring up + health | $(icon "$UP") |" echo "| Observability backplane | $(icon "$OBS") |" echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |" echo "| Objecten API + token | $(icon "$OBJECTEN") |" echo "| ACL ↔ OpenZaak | $(icon "$ACL") |" echo "| OpenZaak → NRC | $(icon "$NRC") |" echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |" echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |" echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |" echo "| Distributed traces (Tempo) | $(icon "$TRACING") |" echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |" echo "| Self-service e2e (Playwright) | $(icon "$E2E") |" } >> "$GITHUB_STEP_SUMMARY" # Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied # out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive. - name: e2e spec summary if: always() run: | [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY" # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten tempo prometheus grafana 2>&1 || true - name: Tear down if: always() run: make down