Gitea Actions runners are not GitHub Actions runners
The differences that actually bit me when I moved a workflow from GitHub Actions to a self-hosted act_runner on Gitea.
Gitea Actions is deliberately compatible with GitHub Actions, so it’s easy to assume you can drop a workflow in and it’ll run. Mostly you can. The gaps are small and in places undocumented, and they sit exactly where a migrated workflow is going to fail.
The label mapping decides your runtime
act_runner maps each runs-on label to a container image. A runner that was
registered with:
ubuntu-latest:docker://node:16-bullseyeruns every runs-on: ubuntu-latest job inside Node 16, whatever the workflow
itself expects. Node 16 is end-of-life and current Astro needs Node ≥ 22.12.0,
so with this mapping builds fail for reasons that have nothing to do with the
workflow file.
I saw two ways out:
- Pin the image in the workflow with
container: image: node:24-bookworm. That’s self-contained, needs no cluster change, and can’t affect anything beyond the one repository. - Fix the runner’s label mapping. That’s the right fix for everyone, but it’s a change to shared infrastructure, so it deserves its own change rather than getting bundled into whatever you were actually trying to ship.
Known differences from GitHub Actions
The one that cost me the most time: an inline run: block runs under
sh -e {0}, and on Debian images that’s dash. set -euo pipefail dies
immediately with Illegal option -o pipefail. Steps that call
bash scripts/foo.sh aren’t affected, which is how a workflow can be mostly
fine and fail in exactly one place. I now put shell: bash on any step that
uses bash features, and I syntax-check inline blocks with dash -n instead of
bash -n.
The rest are smaller, but I’ve run into each of them:
- Only the simple
runs-on: xyzsyntax is supported. jobs.<id>.environmentis ignored entirely.- Expression support is limited.
always()works, but I wouldn’t build gating logic onif:. Two workflow files with disjoint triggers are clearer anyway. - Unqualified
uses:references are fetched from github.com by default, so the runner needs outbound HTTPS to it. On a homelab cluster with DNS filtering in front, check that before you debug anything else. actions/cacheonly works if the runner’s cache server is reachable from inside the job container. With a Docker-in-Docker sidecar, the address it auto-detects often isn’t. I treat caching as an optimisation and never let a build depend on it.
Skip actions/setup-node
If the job already runs in a Node container, setup-node just downloads a
second Node at runtime and gains you nothing. Deleting the step removes a
whole class of failure, along with one more dependency on external egress.
Smoke-test before writing the real workflow
A single throwaway workflow tells you four things in one go: whether job-level
container: is honoured, whether the image has the tools you need, whether
the runner can reach github.com to fetch actions, and whether npm is
reachable.
name: smoke
on: [push]
jobs:
smoke:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
steps:
- run: node -v && git --version
- uses: actions/checkout@v4
- run: npm ping || echo "NPM REGISTRY UNREACHABLE"Finding a problem here takes a minute. Finding it inside a deploy pipeline takes a good deal longer.
Reading a run’s result from the API
If you watch runs from a script instead of the web UI, pay attention to where
Gitea puts the verdict. It goes in status, which ends up as success or
failure, and conclusion stays null. A watcher that waits for
status == "completed", the way it would on GitHub, will wait forever.
Also check which step failed before you conclude anything about the target. When a deploy’s post-deploy verification fails, the deploy has still happened: the sync step before it succeeded, and production is already updated. Running that verification script by hand against production, with the same environment and no edits, is also the quickest way to find a bug in the assertion itself, since it only ever runs once everything upstream of it works.