Records the wiring decision (AC-delegated auth, required celery-beat) and the two non-obvious gotchas: single-label hosts aren't URL-valid (reach services by IP) and abonnement callbacks must enforce auth. Documents the new notifications CI job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.7 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) |
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 upormake 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, sodocker compose -f infra/docker-compose.local.yml up -djust works.
Why not the obvious alternatives
- Bake config into an image (incl. an inline Dockerfile) —
docker compose upwould then work unaided, but it's a build; we wanted the upstream images as-is. - Compose
configs:with inlinecontent— 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
--waittreats a one-shot exiting0as a failure unless somethingdepends_onit withservice_completed_successfully.flowable-initdeploys the BPMN and exits with no dependant, so--waitfails the moment it does — last linecontainer infra-flowable-init-1 exited (0). - The containerized runner can't reach published host ports, so an external
curl localhost:8080/healthcan't work either.
Fix — infra/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
Why — upload-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.
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 ….
Applied — make 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-integration.sh, infra/verify-notifications.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.