Files
register-referentie/docs/runbooks/gitea-actions-gotchas.md
T
not d6b3f9764f
CI / build (push) Successful in 1m9s
CI / lint (push) Successful in 1m27s
CI / unit (push) Successful in 1m32s
CI / frontend (push) Successful in 3m19s
CI / mutation (push) Successful in 6m24s
CI / verify-stack (push) Successful in 9m33s
fix(e2e): bound the Playwright run and make a failed login say why (closes #161) (#165)
## What & why

#161 is really two defects, and the second one is why the first was undiagnosable.

**A wedged suite consumed the job, and took the post-mortem with it.** Nothing bounded the
Playwright run, so CI stopped the job mid-suite — and `if: always()` does not survive that. Run
739's job metadata shows every step after the e2e as a **0-second failure** stamped at the kill:

```
14 failure  09:48:17 -> 10:14:54  Self-service e2e (Playwright …)
15 failure  10:14:54 -> 10:14:54  verify-stack check summary       ← if: always()
16 failure  10:14:54 -> 10:14:54  e2e spec summary                 ← if: always()
17 failure  10:14:54 -> 10:14:54  Dump container logs on failure   ← if: failure()
18 failure  10:14:54 -> 10:14:54  Tear down                        ← if: always()
```

So the per-spec summary, the container-log dump and the teardown never ran, and the log lost
whatever the killed process had buffered — leaving the single `✘` line the issue was filed from.
`globalTimeout` now makes Playwright stop and *report*: the JSON report is written and those steps
still get their turn. (A `timeout-minutes` on the job would have reproduced the same failure, so
there isn't one.) The "~24-minute gap" is that kill, not necessarily a hang — note run 739 shows
`run_attempt: 2`, and `concurrency.cancel-in-progress` kills an in-flight run on any re-run or push.

**A login that never got its form ate the 90-second test timeout.** Playwright actions auto-wait
until the *test* timeout, not `expect.timeout` — so a portal that serves its page but never
bootstraps (its `config.json` fetch or the OIDC discovery behind `authorize()` failed; `main.ts`
only `console.error`s) spent 90s to report `locator.fill: Test timeout of 90000ms exceeded`: the
symptom, not the cause. That is catalogus.spec's 1.8 minutes. Both Keycloak forms are now asserted
visible first, with a 20s budget and a message naming the step that never happened.

Verified against a real blank-bootstrap portal — the beheer image served with a `config.json` that
is not JSON — which fails in **20.2s** with *"the Keycloak login form never appeared — the portal
did not reach Keycloak (check its config.json fetch and the OIDC discovery …)"*.

**And the summary now says why.** The per-spec table (#136) rendered a verdict icon and nothing
else, so even a surviving summary cost a log dive. Failing specs now carry their first error,
flattened for a table cell (ANSI stripped, newlines collapsed, `|` escaped, clipped) — shape
verified against a real @playwright/test 1.61 failing report, with a stdlib assert self-check on
`make unit`.

Closes #161

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass; refactor commit follows (login helper dedup).
- [x] Conventional Commits referencing the issue (`refs #161`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (untouched).
- [x] Docs updated — `docs/runbooks/gitea-actions-gotchas.md` §9.
- [x] ADR — not needed: no boundary, dependency or coupling rule touched (test/CI infra only).
- [x] Demo note — not applicable: nothing user-visible.

## Notes for reviewers

**What this does not do: identify why the beheerder login failed that once.** The evidence to do
that was destroyed by defect 2, which is what this PR fixes. The suite ran green here five times
today (catalogus.spec 1.1–5.3s each) — but a local box is not the loaded CI runner, so that is weak
evidence and I am not claiming the flake is gone. What changes is that the next occurrence is
bounded and self-describing: it fails in 20s naming the failing step, the JSON report survives, and
the summary prints the error. Please keep #161 in mind rather than treating this as proof.

**Two follow-ups I did not pull into this PR:**
- *All four portals show a permanently blank page if their startup fetch fails* — `main.ts` does
  `fetch('config.json').then(bootstrap).catch(console.error)`, one shot, no UI and no recovery. That
  is a real product gap (the deliberately-broken portal above is exactly what a user would see) and
  wants its own slice, not a test-infra PR.
- `retries: 1` is untouched. CLAUDE.md §15 says flaky tests are fixed rather than retried, but
  removing retries while a real flake is unexplained would trade a rare red for a frequent one.
  Worth revisiting once #161 recurs (or doesn't) with the new diagnostics.

The login-helper rename (`medewerker-login.ts` → `keycloak-login.ts`, citizen logins routed through
`loginBurger`) is its own no-behaviour-change commit: the three citizen specs each duplicated the
same three-line login, so guarding the login path once meant routing them through it first.Reviewed-on: #165
2026-09-04 10:53:35 +00:00

15 KiB

Gitea Actions gotchas

How our CI (Gitea Actions on the hosted ubuntu-latest runner) differs from a local run, and the workarounds in this repo. Referenced by CLAUDE.md §8.7/§15.

One root cause sits under most of this: the runner executes the job inside a container, so when a step runs docker compose up, Compose starts the stack as sibling containers on the host's daemon. Anything that assumes the job and those containers share a filesystem — or a localhost — breaks.

Gotcha Fix Lives in
Bind-mounted config arrives empty docker cp config into external volumes infra/seed-config.sh
docker compose up --wait is unsupported / flaky poll health with docker inspect infra/wait-healthy.sh
pg_isready passes before PostGIS is ready add a PostGIS_Version() probe the db healthchecks
upload-artifact@v4 fails ("not supported on GHES") pin @v3 .gitea/workflows/ci.yaml (mutation job)
upload-artifact@v3 fails with "Artifact service responded with 500" mark the upload continue-on-error: true (server-side; issue #62) .gitea/workflows/ci.yaml (mutation job)

1. Bind mounts don't reach the containers

Symptom — green locally, but compose-smoke fails with:

oz-init-1 | CommandError: Yaml file `/app/setup_configuration/data.yaml` does not exist.

Migrations run fine; only the step that reads a mounted file fails. The same trap hits nrc-init, flowable-init, and keycloak.

Why — a relative bind mount like ./openzaak/setup_configuration:/app/... is resolved by Compose to a path inside the job container (/workspace/.../setup_configuration). The daemon then looks for that path on its own host, doesn't find it, and mounts an empty directory. (It works on a runner that executes jobs on the host — which is why moving to ubuntu-latest exposed it.)

Fix — use the upstream images verbatim (no build) and stream config into external named volumes with docker cp, which copies over the Docker API and so works wherever the daemon runs. infra/seed-config.sh creates each volume, mounts it in a throwaway helper, and copies the files in:

Asset Volume Mounted at
OpenZaak data.yaml rr-oz-config oz-init:/app/setup_configuration
Keycloak realms rr-kc-realms keycloak:/opt/keycloak/data/import
registratie.bpmn rr-fl-bpmn flowable-init:/work

The volumes are external: true with fixed names, so they resolve identically under docker compose and podman-compose. make seeds before every up; make down removes them. (Open Notificaties needs nothing — nrc-init migrates only.)

Consequence — bare docker compose up can't self-seed external volumes:

  • CI / Linux / macOS: make up or make smoke (seed, then start).
  • No-make / Windows: infra/docker-compose.local.yml — a twin stack that bind-mounts the config instead. Bind mounts are fine locally because a local daemon can see your working directory, so docker compose -f infra/docker-compose.local.yml up -d just works.

Why not the obvious alternatives

  • Bake config into an image (incl. an inline Dockerfile) — docker compose up would then work unaided, but it's a build; we wanted the upstream images as-is.
  • Compose configs: with inline content — Compose writes a client-side temp file and bind-mounts it, hitting the exact same problem.
  • A host-executing runner — bind mounts would work with zero seeding, but it reintroduces a self-hosted runner and undoes the move to ubuntu-latest.

2. Readiness: poll health, don't use --wait

docker compose up --wait looks ideal but fails us three ways:

  • podman-compose doesn't implement it (unrecognized arguments: --wait) — so it would break local dev.
  • A project-wide --wait treats a one-shot exiting 0 as a failure unless something depends_on it with service_completed_successfully. flowable-init deploys the BPMN and exits with no dependant, so --wait fails the moment it does — last line container infra-flowable-init-1 exited (0).
  • The containerized runner can't reach published host ports, so an external curl localhost:8080/health can't work either.

Fixinfra/wait-healthy.sh polls each durable service (openzaak nrc-web acl bff, listed as WAIT_SVCS in the Makefile) with docker ps + docker inspect '{{.State.Health.Status}}' until it reports healthy. It uses only primitives both runtimes support, reads the in-container healthcheck (no host port needed), and ignores the one-shots (they only need to have run). WAIT_TIMEOUT defaults to 420 s — enough for the cold OpenZaak migrate (~90 s) plus app start.


3. pg_isready passes before PostGIS is ready

pg_isready succeeds as soon as the TCP port is open — before the postgis/postgis image has finished running CREATE EXTENSION postgis. An init container that starts migrating in that window can fail on a missing PostGIS. So the db healthchecks add a SELECT PostGIS_Version() probe, making dependents wait for the extension, not just the port.


4. actions/upload-artifact@v4 refuses to run on Gitea

Symptom — the mutation job's make mutation step passes (95% score), but the upload step right after it fails the job:

::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
❌  Failure - Main https://github.com/actions/upload-artifact@v4

Whyupload-artifact@v4 bundles @actions/artifact v2, which inspects the server URL and hard-aborts on anything that isn't github.com, treating Gitea as an unsupported GitHub Enterprise Server. The check fires regardless of whether the Gitea server can actually store artifacts (1.24+ can). It is the action, not the server, that refuses.

Fix — pin actions/upload-artifact@v3 (and download-artifact@v3 if ever needed). v3 uses the older artifact protocol that Gitea implements, and has no GHES guard. Inputs are the same (name, path, if-no-files-found), so it is a drop-in swap. Do not bump to @v4 until act_runner advertises github.com-compatible artifact support.

Second failure mode — the server's artifact backend returns 500. Even on the correctly-pinned @v3, uploads can fail with:

Create Artifact Container - Attempt 5 of 5 failed with error: Artifact service responded with 500
::error::Create Artifact Container failed: Artifact service responded with 500

This is the Gitea server's artifact storage failing (not the action's GHES guard), so it is outside the repo's control. Because the mutation job's upload steps run with if: always(), that 500 would fail the job even though the ratchet passed. Fix: mark the uploads continue-on-error: true (issue #62). The mutation gate is the Stryker ratchet — make mutation's exit code fails the job on a real regression — so the report upload is best-effort: when the server's artifact storage is restored, reports publish again with no workflow change.


5. A runner process can't reach a service container's published port

Symptom — green locally, but a CI step that runs on the runner and talks to a compose service over localhost fails. The ACL integration test's seed died with:

OpenZaak ready (000)
urllib.error.URLError: <urlopen error [Errno 111] Connection refused>
make: *** [Makefile:114: integration] Error 1

OpenZaak was demonstrably up — uwsgi had been serving for ~2 minutes — yet curl/urllib to localhost:8000 from the runner were refused the whole time.

Why — the same sibling-container split as §1. Compose starts the stack via the host daemon, so ports: ["8000:8000"] publishes to the daemon host, not to the job container. From the runner, localhost:8000 has nothing listening. (make smoke sidesteps this by polling readiness via docker inspect (§2), never a service port.)

Fix — don't talk to service ports from the runner. Either check state via docker inspect (health), or run the client inside the compose network so it reaches the service by name (http://openzaak:8000). For a test/seed that needs the repo's own code, deliver it via a built image (not a bind mount — §1), then docker run --network <stack>_cg ….

Appliedmake integration (ADR-0006) and make verify-notifications (ADR-0007) do exactly this: they run the seed/test/driver as containers on the stack network and reach services by container IP (see §6).


6. OpenZaak / NRC reject single-label hosts in URLs

Symptom — talking to OpenZaak or NRC by compose service name fails where a URL is validated: catalogus/zaaktype filters, the zaak zaaktype URL, and abonnement callbackUrl come back 400 "Voer een geldige URL in." — even though the host resolves and is reachable.

Why — these apps validate URLs with Django's URLValidator, which rejects a single-label host like openzaak or nrc-web (no dot, and not localhost). localhost passes (so it's invisible in host-port-based local runs); in-network the reality is a service name or an IPv4 literal — and only the IP passes.

Fix — in-network tooling reaches OpenZaak/NRC by container IP (docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'), not service name; the notif verify harness also registers the sink callback by IP. (infra/run-acl-integration.sh, infra/run-notification-check.sh.)

Related — abonnement callbacks must enforce auth. NRC probes a callback when an abonnement is registered and refuses it (no-auth-on-callback-url) unless it returns 401 without the configured Authorization. The verify sink (infra/notification-sink.py) enforces a bearer token for exactly this reason.


7. A job with if: ${{ !cancelled() }} (or always()) + needs sticks in "waiting"

Symptom — after upgrading to Gitea 1.27 + act_runner 2.0.0, one job never starts: the run sits in state waiting forever, the job has no logs (never dispatched to a runner), and the other jobs finish normally. main stays pending/red. Seen on the verify-stack job (#134).

Why — Gitea 1.27 reworked cancellation/aggregation: a job gated by a status-function if (always() / cancelled() / !cancelled()) on top of needs now routes through a new transitional Cancelling job state plus a server↔runner capability negotiation ("Requires Gitea Runner 2.0.0"). On the 1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never offered to a runner and never leaves waiting. Jobs with no if/needs are unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)

Fix — don't gate a needs job with a status-function if. Use the default if: success() (i.e. omit the if). If you need "run even when an upstream job fails", prefer serialising with a concurrency group over needs + always().

Also — a run already stuck this way will not clear itself; force-cancel it from the Actions UI (plain cancel can also stall on this version, #35782). Push the workflow fix to produce a fresh run.


8. Job summaries ($GITHUB_STEP_SUMMARY) need Gitea ≥1.27 + runner ≥2.0

Markdown a step appends to the $GITHUB_STEP_SUMMARY file renders on the run page (no artifact download). We use it for per-run reports (#136): mutation scores (Stryker markdown reporter), per-service unit results (infra/trx-summary.py over TRX), per-frontend results (infra/vitest-summary.py over each app's vitest JSON), the verify-stack check table, and per-spec e2e results (infra/playwright-summary.py).

Requirements / conventions:

  • Requires Gitea ≥ 1.27 (stores/renders summaries) and act_runner ≥ 2.0.0 (uploads them). Older pairings silently skip the upload.
  • Guard every write: [ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0 — on a runner without support the var is unset and >> "$GITHUB_STEP_SUMMARY" would be an ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
  • Use if: always() (step-level) on summary steps so they render even when the thing they report on failed. Step-level always() is fine on 2.0.0 — unlike the job-level status-function if of §7.
  • Getting a report out of the e2e container: Playwright writes playwright-report.json inside the container; infra/run-e2e-check.sh docker cps it back to the host (capturing the test exit code first) so the summary step can read it.

9. if: always() does not survive the job being killed — bound the work itself

if: always() makes a step run when an earlier step failed. It does not help when the job as a whole is stopped: the run's remaining steps are simply never dispatched.

That is how #161 lost its diagnosis. verify-stack entered make verify-e2e at 09:48:17 and the job ended at 10:14:54 — 26½ minutes later, mid-suite. Every step after the e2e shows a 0-second failure stamped at that same instant:

14 failure  09:48:17 -> 10:14:54  Self-service e2e (Playwright, login → submit → success)
15 failure  10:14:54 -> 10:14:54  verify-stack check summary       ← if: always()
16 failure  10:14:54 -> 10:14:54  e2e spec summary                 ← if: always()
17 failure  10:14:54 -> 10:14:54  Dump container logs on failure   ← if: failure()
18 failure  10:14:54 -> 10:14:54  Tear down                        ← if: always()

So the per-spec summary, the container-log dump and the teardown never ran, and the job log — which also loses whatever the killed process had buffered — ended at a single line. A job that dies takes its own post-mortem with it.

Read the step timings, not just the log. GET /api/v1/repos/{owner}/{repo}/actions/jobs/{id} returns every step with started_at/completed_at; a row of identical zero-length steps at the end means killed, not silent. (Job ids come from …/actions/runs/{run}/jobs, and that route returns only the latest attempt — a re-run hides the failed one, so keep the failing job id from the original report. Logs: …/actions/jobs/{id}/logs, see also gitea-ci-logs.)

Conventions that follow:

  • Bound long-running work inside the tool, where it can still report. Playwright's globalTimeout (tests/e2e/playwright.config.ts) ends the run, writes the JSON report and exits, so the summary and log-dump steps still get their turn. A timeout-minutes on the job would reproduce the very failure above.
  • Never let an auto-waiting action be the timeout. Playwright actions (fill, click) inherit the test timeout, not expect.timeout, so a missing element costs the full 90 s and reports locator.fill: Test timeout … — the symptom. Assert the element visible first with its own budget and a message (tests/e2e/keycloak-login.ts).
  • Remember concurrency.cancel-in-progress: true in ci.yaml: a new push to the same ref, or a re-run, kills the in-flight run the same way. Check run_attempt before concluding a job hung.