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-bullseye

runs 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:

  1. 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.
  2. 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: xyz syntax is supported.
  • jobs.<id>.environment is ignored entirely.
  • Expression support is limited. always() works, but I wouldn’t build gating logic on if:. 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/cache only 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.