
Your own CI on Forgejo: the runner traps, one image for everything, and tests that cannot lie
Running your own forge means running your own CI: every pull request tested on your hardware, no minutes to buy, nothing leaving the house. Forgejo Actions reads the same workflow files as GitHub Actions, almost. The "almost" is where the evenings go.
How these figures were obtained. Measured on our own machines (see the protocol), usually a single run, with speeds as reported by the server itself; we do not publish raw logs. Treat them as orders of magnitude on this hardware, not as guarantees on yours. Figures from outside are labelled with their source.
What you get. The three traps that block a first workflow, a runner image that holds every tool so jobs stop reinstalling them (one job spent 1 min 24 of its 1 min 30 reinstalling tools), and four habits that keep a green CI honest across about forty repositories here.
A bit of history
Forgejo descends from Gogs, a self-hosted Git service written in Go (2014), forked as Gitea in 2016 by its community. When Gitea's governance moved to a company in 2022, the non-profit Codeberg started Forgejo as a community fork, and made it fully independent in 2024. Its CI, Forgejo Actions, deliberately reads the workflow format popularised by GitHub Actions (2018), so most existing workflows run with small changes. The runner is a separate program: you decide which machine runs your jobs, and what tools it carries.
1. Three traps of a first workflow
- The runner only answers its labels. Ours announces
ubuntu-latest. Any otherruns-on:leaves the job waiting forever, with no error: nobody claims it. - No slim image as job container. JavaScript actions such as
checkoutrun withnodeinside the job's container. Apython:slimimage has no node: the job dies with exit code 127 before its first step. - Write actions with their full URL:
https://data.forgejo.org/actions/checkout@v4. Short names are not resolved to GitHub.
2. One runner image for every repository
Each workflow used to install pip, ruff, pytest, uv, node and friends at every run: 1 min 24 of a 1 min 30 job. We built one image that carries them all, and pointed the runner's label at it.
- Based on a full Python image, with node copied from the official image (actions need it).
- Tool versions pinned in one place, and checked by the last build step: a wrong pin fails the build, it never ships.
- A manifest of every tool and version is written inside the image, as proof.
- No registry needed: the Dockerfile is the source, and the image is rebuilt in a few minutes inside the runner's own Docker daemon.
3. Four habits that keep green honest
- Read the forge's log before reproducing anything. A local reproduction of a failing job once came out green on a genuinely broken commit; the log named the four failing assertions at once. Logs are stored compressed, one file per job, in the forge's data folder.
- Verify locally before pushing. A typecheck or a test run on the laptop takes a minute; each round trip through CI costs ten, and a pull request to close.
- A test that locks a bug is worse than no test. After a repository was renamed, three tests still asserted the old name: the CI stayed green while every deployment failed. Test what the system must do, not the value it happens to have today.
- A ratchet for consistency. Our CI counts text that is not in the project's language and fails if the count rises; when it falls, the ceiling must fall in the same pull request. Legitimate exceptions are listed in a file, each with its reason.
Traps
A commit message written with a here-document on the same line as other commands takes those commands as its text. Keep git commit -F - <<'MSG' alone in its command, read the subject back with git log -1 --format=%s, and only then push.
Some API endpoints for runs return empty lists or 404 on certain versions, which looks like "no runner". Trust the job log and the runner's own status, not an empty list.
Sources
Found this useful? Tell us what to measure next.