# 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 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. **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: 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 _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.