# CI runbook — Gitea Actions > **Status: active.** The workflow `.gitea/workflows/ci.yaml` runs on Gitea's > hosted `ubuntu-latest` runner — no self-hosted runner required. > **`make ci` is still the local gate** — it runs the exact same checks > (the workflow calls the same `make` targets). ## The pipeline `.gitea/workflows/ci.yaml` runs on every push and pull request to `main`. Each job calls a `make` target — the **single source of truth** for the checks, so local and CI cannot drift: | Job | Target | Needs | |---|---|---| | `lint` | `make lint` → `dotnet format … --verify-no-changes` | .NET 10 SDK | | `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK | | `unit` | `make unit` → `dotnet test … -c Release` | .NET 10 SDK | | `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL) | .NET 10 SDK | | `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`, `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea Actions resolves them from GitHub. > **`compose-smoke` runs on a containerized runner.** Workspace bind mounts do > **not** reach the sibling containers Compose starts, so config/assets are > streamed into external named volumes via `docker cp` (`infra/seed-config.sh`), > and the upstream images are used verbatim (no build). If you add a service that > needs a repo file at runtime, seed it the same way — don't bind-mount it. Note: > bare `docker compose up` no longer self-seeds; use `make up`. See > [gitea-actions-gotchas.md](gitea-actions-gotchas.md). ## Mutation testing (the ratchet) The `mutation` job enforces test *strength*, not just coverage (CLAUDE.md §5). [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) is pinned as a local dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in CI: ```bash make mutation # dotnet tool restore + dotnet stryker on the ACL ``` Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json). It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped. **Baseline (the ratchet):** the ACL is the first service with branching logic, so it sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%** (one-mutant headroom over the ~20-mutant surface). Stryker exits non-zero — failing the job — when the score drops below `break`. Per §5 the baseline only moves **up**, and only as a slice's stated outcome; never lower it. New services add their own mutation run as they gain logic. The HTML report is written to `services/acl/StrykerOutput//reports/` (git-ignored); open it to see survived vs. killed mutants. ## Running the stack locally without `make` (Windows / Docker Desktop) `make` and the bash helpers assume a Unix shell. To bring the whole stack up on a machine without them (e.g. Windows + Docker Desktop), use the **local compose file**, which bind-mounts the config instead of seeding volumes — so it needs no `make`, no seed step, and no bash: ```bash docker compose -f infra/docker-compose.local.yml up -d --build # any engine docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop (Compose v2) docker compose -f infra/docker-compose.local.yml down --volumes ``` On Linux/macOS the same thing is wrapped as `make local` / `make local-down`. `infra/docker-compose.local.yml` mirrors the canonical `infra/docker-compose.yml` but swaps the external config volumes for bind mounts — valid locally because a local daemon can see the working directory (the seed/volume dance only exists for the containerized CI runner). Keep the two files in sync. ## Running CI locally (`make ci`) Until the runner exists, run the full pipeline yourself before pushing: ```bash make ci # lint + build + unit + mutation + smoke — what the pipeline runs make lint # or a single stage make mutation # Stryker.NET ratchet on the ACL make smoke # compose up --wait, curl /health, tear down ``` **Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`. On a **rootless Podman** box (the default dev setup here), the `smoke` target needs the Podman API socket and a Compose provider: ```bash systemctl --user enable --now podman.socket # start the API socket ln -sf "$(command -v podman)" ~/.local/bin/docker # docker -> podman shim # install Docker Compose v2 into ~/.local/bin as `docker-compose` (the provider) ``` The Makefile auto-points `DOCKER_HOST` at `/run/user/$(id -u)/podman/podman.sock` when that socket exists and `DOCKER_HOST` is unset, so `make smoke` "just works" locally while leaving real Docker hosts / CI runners untouched. ## Runner: `ubuntu-latest` All jobs run on Gitea's hosted **`ubuntu-latest`** runner — no self-hosted runner setup is required. The hosted runner ships with Docker and Docker Compose v2, so `make smoke` (`docker compose … up --wait`) works without extra configuration. If Gitea's hosted runners are unavailable and a self-hosted fallback is needed, register an `act_runner` with the `ubuntu-latest` label: ```bash VER=0.2.11 curl -fsSL -o /usr/local/bin/act_runner \ "https://dl.gitea.com/act_runner/${VER}/act_runner-${VER}-linux-amd64" chmod +x /usr/local/bin/act_runner act_runner register --no-interactive \ --instance https://git.labs.respellion.tech \ --token \ --name respellion-ci-1 \ --labels "ubuntu-latest:docker://node:20-bookworm" act_runner daemon ```