CI
Repositories hosted on Anvil run CI with Actions: workflows in .gitea/workflows/ (or .github/workflows/), in the same syntax as GitHub Actions. Anvil records every workflow run and job and turns them into run history, failure diagnosis, flaky job detection and runner sizing advice. It doesn't use any runner time itself: reviews run in Anvil, not in Actions.
Runners today #
| Labels | ubuntu-latest, ubuntu-24.04 |
| Image | gitea/runner-images:ubuntu-24.04, close to GitHub's Ubuntu runner |
| Concurrency | five jobs at a time across the server |
| Size per job | 4 CPU, 8 GB memory |
| Timeout | 1 hour per job |
A workflow that runs on GitHub's ubuntu-latest usually runs here unchanged:
# .gitea/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test
CI for invited users' repositories is switched on per repository after the admin has looked at it, because jobs currently share the host's Docker. See Security and data.
Run history #
CI → Run history lists every job, newest first, with its status, repository, branch, commit, who triggered it, the event (push, pull request, schedule, manual), and how long it took compared with the slowest job in view.
Filter by status, event, user, repository, job and branch in the left column. Each filter shows counts, and the long lists are searchable. The time range at the top (1 hour to 30 days) applies to every page and is kept as you move around. Above the list, the run distribution chart shows how many runs succeeded and failed over time, with the average duration.
The overview page adds the headline numbers for the range: jobs, pass rate, median job duration and queue time, flaky jobs and runner minutes.
Failure diagnosis on the PR #
When a job fails on a commit that's the head of an open pull request, Anvil explains it in the PR. It waits 90 seconds so the other jobs of the run can finish, then for up to six failed jobs it reads:
- the end of the job's log (the last 250 lines, without colour codes and timestamps);
- how often that job failed in the last 30 runs in the repository;
- whether the same job passed on the same commit in another attempt;
- the code, in a fresh checkout of the PR.
It posts one comment per pull request, headed "CI failed on <commit>", and edits it on later failures. For each failed job you get the kind of failure, the file and line when it can tell, the cause, the fix, the relevant log lines, and a note when the job was seen passing on the same commit. If a re-run has passed by the time it looks, it says nothing.
Turn it off for a repository with ci.analyze_failures: false in .anvil.yaml. Each diagnosis is capped at $1.
Anvil also sees CI results when it reviews: the job results recorded so far for the head commit are part of its input.
Flaky jobs #
A job is flaky when it both failed and passed on the same commit, usually after a re-run. CI → Flaky jobs lists them with the repository, how many commits were affected, the failure rate and when it last happened. When a flaky job fails on a pull request, the failure diagnosis says so instead of sending you to look for a bug.
Detection works on whole jobs here. For individual tests, see Tests and flaky tests.
Job pages and logs #
Click any job in Run history to open its page. It shows the job's steps on a timeline, how long its last 30 runs took, the other jobs of the same run, failed tests when the run uploaded a test report, and the log.
Anvil fetches a job's log when the job finishes and keeps it for 30 days. Very long logs are cut in the middle to 5 MB (the start and the end, where failures are, stay). In the log:
- each step is a fold; failed steps open by default, and
##[group]blocks fold inside them; - colours from the tools are kept;
- click a line number to link to it (
#L120), shift-click a second one for a range; - Search this log finds every line containing the text, opens the steps it's in, and moves between hits with Enter and Shift+Enter;
- the download button saves the log as a text file.
Older jobs without a stored log are fetched when you open them, as long as the host still has the log.
Log search #
CI → Log search searches the stored logs of every job you can see, in the time range at the top. Every word must appear; put phrases in quotes. Narrow it with filters:
| Filter | Example |
|---|---|
repo: |
repo:bizzy or repo:davidwickerhf/bizzy |
branch: |
branch:main |
job: |
job:"unit tests" (part of the name is enough) |
status: |
status:failure, status:success |
For example, status:failure ECONNRESET or job:e2e "Timed out". Each result shows the first matching line; clicking it opens the job at that line with the search filled in. A search covers the newest 40 matching jobs.
Tests and flaky tests #
Anvil reads JUnit XML test reports from a run's artifacts when the run finishes. Most test runners can write JUnit: Vitest (--reporter=junit), Jest (jest-junit), pytest (--junitxml), Go (gotestsum --junitfile), Maven and Gradle. Upload the file as an artifact whose name starts with junit or test-results; name it junit-<job name> when a run has several test jobs, so results land on the right job:
- run: npx vitest run --reporter=default --reporter=junit --outputFile=junit.xml
- uses: actions/upload-artifact@v4
if: always() # upload the report when tests fail too
with:
name: junit-${{ github.job }}
path: junit.xml
CI → Tests lists:
- Flaky tests: a test that passed and failed on the same commit, or that keeps flipping between passing and failing (three or more changes across its recent runs while mostly passing). Each shows why, its last 20 results and its failure rate.
- Failing tests that aren't flaky: broken until someone fixes them.
- Slowest tests, by average time.
Click a test for its history: every result with the commit, branch, time and failure message. Test results are kept for 90 days. Failed tests also appear on the job's page.
Machine metrics #
For jobs on Anvil runners, the runner manager records the container's CPU and memory every 10 seconds. The job page charts both against the size's limits and gives a sizing hint from the peaks with 20% headroom, for example "Peaked at 1.4 CPU and 2.1 GB; anvil-2cpu would do.", or suggests a bigger size when the job ran out of CPU or came close to its memory limit. Jobs on the runners for hosted repositories don't have metrics yet. While a job runs, its chart updates every 10 seconds.
CI → Compute shows CPU and memory across all jobs: running jobs live, the runner pool's capacity, and compute history by repository and runner size. See Compute.
Monitors #
CI → Monitors sends you a notice when something goes wrong in a repository's CI. Set them up per repository:
- CI fails on the branch: any job on the watched branch (
mainby default) fails. One notice per commit. - A job gets slower: a successful job on the branch took more than the threshold (50% by default) longer than its median over its last 20 successful runs. It needs five earlier runs, and ignores jobs under a minute and slowdowns under 30 seconds.
- A test turns flaky: from test reports, as above. One notice per test.
Notices appear at the top of the Monitors page. With Email too, each one is also emailed to the addresses you list, or to you if the list is empty. The quiet period (30 minutes by default) stops a burst of failures from sending one email each: notices of the same kind within it are only shown in Anvil.
Runner sizing #
CI → Rightsize runners groups jobs by their runs-on label and shows, for each: the number of jobs, the duration at the median and the 95th percentile, the queue time at the same percentiles, and the runner minutes used. Each row gets a plain recommendation, for example "Jobs wait 3m 10s at p95. Add capacity to this pool." or "Sized well."
Below that, Runners shows each runner's job count and average duration and queue time, and Heaviest jobs lists the jobs that use the most runner minutes, with their label: the first candidates for a bigger or smaller runner.
Coming #
These are planned, not built:
- Runner pools by size:
wicker-2cpu,wicker-4cpuandwicker-8cpulabels with matching CPU and memory limits, next toubuntu-latest. Pick one withruns-on: wicker-8cpu. - Sticky volumes with your own keys and copy-on-write for pull requests (Anvil runners have one volume per repository today, see below).
- A shared container image cache and per-repository prebuilt runner images.
- A terminal into a failed job's container on an Anvil runner, for a few minutes, for the person who started it.
Anvil runners for GitHub Actions #
On GitHub repositories with the Anvil app installed, a job can run on Anvil's machines instead of GitHub's. Change runs-on:
jobs:
test:
runs-on: anvil-4cpu # or anvil-2cpu, anvil-8cpu (anvil = anvil-2cpu)
| Label | CPUs | Memory |
|---|---|---|
anvil-2cpu |
2 | 6 GB |
anvil-4cpu |
4 | 12 GB |
anvil-8cpu |
8 | 24 GB |
Each job gets a fresh container from GitHub's official runner image (ghcr.io/actions/actions-runner), registered for that one job with a just-in-time token, and removed when the job ends. It runs as a normal user with sudo inside the container, but has no access to the host's Docker. Docker images are built on your own BuildKit daemon instead (see below).
Limits: a repository must belong to an invited Anvil account, each owner has a daily budget of runner minutes, and jobs stop after 6 hours. GitHub bills self-hosted runner minutes at its platform rate ($0.002 per minute since March 2026); Anvil itself doesn't charge for them.
Jobs may wait when the server is busy #
CI shares the server with the websites and databases it runs, so all CI together gets a fixed share of the server. When that share is in use, or the server is short on memory, CPU or disk, new jobs stay queued and start as soon as there's room. The job shows why on CI → Rightsize runners and on its job page, for example "waiting: CI pool full", and the Host protection panel there shows how much of the share is in use. A job that asks for more than the whole share fails at once with the size that would fit. If memory runs out while jobs run, Anvil stops the newest job ("stopped to protect the server: memory low") so the server stays up; re-run it once things are quiet. Jobs also run at a lower CPU and disk priority than the sites, so they can be slower while the server is busy.
Caching on Anvil runners #
Anvil runners keep caches on the same NVMe disk as the jobs, so restoring them doesn't cross the internet. Most of it needs no change to your workflows.
Actions cache (actions/cache, setup-node, setup-python, …) #
Works unchanged. Every Anvil runner job sends cache requests to a cache server on Anvil's server instead of GitHub's, so a restore reads from local disk. The same workflow keeps working on GitHub-hosted runners, where it uses GitHub's cache.
jobs:
test:
runs-on: anvil-4cpu
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm # restored from Anvil's disk
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
The rules follow GitHub's:
- Entries belong to one repository. Other repositories, and other owners, never see them.
- A job reads entries saved on its own branch first, then on the default branch. A pull request gets its own scope: it can restore what
mainsaved, but what it saves never reachesmainor other pull requests. Pull requests from forks count as pull requests, whatever their branch is called. - An entry can't be overwritten. Saving an existing key logs a warning and moves on; change the key instead.
- Entries unused for 7 days are deleted, and when the disk budget is full (25 GB per owner, 100 GB in total) the least recently used go first.
The runners page shows, for each job, how many cache lookups hit and how much was restored locally.
Docker builds #
Each owner gets a BuildKit daemon of their own on Anvil's server that keeps its layer cache between jobs. The job itself has no Docker daemon, so Anvil points Docker's build tools at that BuildKit for you. The usual workflow from Docker's docs works unchanged:
jobs:
image:
runs-on: anvil-4cpu
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
docker/setup-buildx-actionwith its defaults would start a BuildKit container, which needs a Docker daemon. On Anvil runners it gets a builder on your BuildKit instead; its log says "anvil: this job has no Docker daemon; the builder uses your BuildKit …". Options that only apply to a BuildKit container (driver-opts,buildkitd-flags,buildkitd-config) are ignored. Settingdriver: remoteandendpoint: ${{ env.BUILDKIT_HOST }}yourself also works, and is what to use if you pin a Buildxversion:(a downloaded Buildx skips Anvil's setup).docker build .anddocker buildx buildin arun:step build on your BuildKit too (a builder namedanvilis preset). Add--pushto get the image out; see the limits below.cache-from/cache-to: type=ghawork and store the cache in Anvil's Actions cache, with the same rules asactions/cache(per repository, per branch, pull requests can't write tomain's scope).
What is cached where:
| Cache | Where | Shared by | Kept |
|---|---|---|---|
| BuildKit layer cache | Your BuildKit's volume on your server | All repositories of the owner | Up to 20 GB, oldest layers pruned first |
type=gha |
Anvil's Actions cache on the same disk | One repository, scoped by branch | 7 days unused, counts towards the 25 GB per owner |
The layer cache alone already makes unchanged steps CACHED on the next build, with no cache-from/cache-to. type=gha is worth adding when you also build on GitHub-hosted runners with the same workflow, or want a cache that survives BuildKit pruning its layers.
Limits:
- There's no Docker daemon in the job, so
load: true,docker runordocker compose upof the image you built,docker/setup-qemu-actionandservices:containers don't work. Push the image (push: true, or--push) and use it from the registry, or export it withoutputs: type=docker,dest=image.tar/type=local. - Multi-platform builds (
platforms: linux/amd64,linux/arm64) build for amd64 natively;RUNsteps for other platforms need QEMU on the server, which isn't installed. Cross-compiling in the Dockerfile (FROM --platform=$BUILDPLATFORM) works. type=ghain a plaindocker buildx buildin arun:step needs the runtime token that only actions get, as on GitHub; usedocker/build-push-actionordocker/bake-action.- BuildKit is shared by all repositories of one owner (never across owners), and every job of that owner can run code in it, pull requests included. Don't build with secrets you wouldn't hand to every repository of that owner. It stops after 30 idle minutes; its cache stays.
Git checkout #
Works unchanged. Anvil's server keeps a mirror of each repository that runs jobs, refreshed whenever a job starts. actions/checkout still fetches from GitHub, but only what the mirror doesn't already have, so a checkout of a large repository mostly reads local disk. If the mirror is behind, the missing commits come from GitHub as usual.
Sticky volume #
Add the label anvil-sticky to give a job a persistent directory at $ANVIL_STICKY_DIR (/anvil/sticky), one per repository, kept between jobs. Use it for things that are slow to rebuild and fine to reuse, such as a package store or a compiler cache:
runs-on: [anvil-4cpu, anvil-sticky]
steps:
- uses: actions/checkout@v4
- run: pnpm config set store-dir "$ANVIL_STICKY_DIR/pnpm-store"
- run: pnpm install --frozen-lockfile
Only pushes, scheduled and manual runs on the default branch can write to it; every other job (pull requests included) gets it read-only, and $ANVIL_STICKY_WRITABLE is 0. Point tools that need to write at a copy in that case. Jobs that run at the same time share the volume, so use tools that cope with that (package stores do).
On repositories hosted on Anvil #
Jobs already use the runner's built-in cache server on the same disk, so actions/cache and setup-* caching are local there too. The repositories live on the same server, so checkouts don't cross the internet either.