Files
config-public/docs/ci-gitea.md
T
Lucas WintherandClaude Opus 5 971034e630 Add the Gitea Actions configuration
The third port of the same two things. Gitea walks WORKFLOW_DIRS and stops
at the first directory that exists, so .gitea/workflows/ replaces
.github/workflows/ outright on a Gitea instance — which is the point, since
deploy.yml there is built on repository_dispatch and an environment: and
Gitea has neither.

Four differences shape the files:

- No environment:, so no scoped secret and no required reviewers. The file
  does not write the key at all rather than claim a protection that Gitea
  parses and ignores; what gates a deploy is write access to the repository.
- No repository_dispatch. External callers post to the workflow dispatch
  API, which fills in the same form — and can return a run id, so unlike
  repository_dispatch the caller may follow the deploy it asked for.
- runs-on takes a literal label only, so GitHub's vars.DEPLOY_RUNNER
  expression becomes one documented line to edit.
- A job is itself a container with no Docker socket, so the secret scan runs
  the pinned gitleaks binary instead of the upstream image.

Input validation carries over unchanged and matters more here: type: choice
constrains the dispatch form, not the API.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 03:58:18 +02:00

12 KiB

CI on Gitea

Three workflows, in .gitea/workflows/:

Workflow Trigger Needs host access
lint.yml every push and pull request no
deploy.yml manual, or the dispatch API yes
connectivity.yml manual yes (ping only)

Gitea Actions is close enough to GitHub Actions that the files look almost the same, and different enough in four specific places that copying the GitHub ones across produces a repository that lints fine and cannot deploy. Those four places are the whole content of this document.

Version floor. workflow_dispatch arrived in Gitea 1.23, dispatching it over the API in 1.24, and concurrency: in 1.26. On 1.23 the deploy form works and only external triggering is unavailable; below 1.23 nothing here runs manually at all.

.gitea/workflows/ shadows .github/workflows/

Gitea walks WORKFLOW_DIRS.gitea/workflows then .github/workflows by default — and stops at the first directory that exists. So adding this directory silently switches a Gitea instance over to these three files and leaves the GitHub ones unread.

That is the intent, not a side effect. .github/workflows/deploy.yml is built on repository_dispatch and an environment:, and Gitea has neither: on Gitea it would be a deploy workflow with one entry point missing and a protection that looks configured and is not.

If you are only using one platform, delete the others rather than letting them rot. Both ports are kept here because the translation between them is a large part of what this repository is demonstrating.

Setup

1. A runner

There are no hosted runners. Nothing in Actions runs until you register an act_runner yourself, so this step is not optional the way it is on GitHub.

act_runner register --no-interactive \
  --instance https://gitea.example.com \
  --token <registration token from Settings → Actions → Runners> \
  --labels ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest

A label is <name>[:<schema>[:<args>]], where the schema is docker://<image> (the job runs in a container from that image) or host (the job runs directly on the machine with the tools installed there). runs-on is matched against the label names, first match wins, and a job whose runs-on matches nothing falls back to docker.gitea.com/runner-images:ubuntu-latest.

Use the full image, not a -slim one. The slim images are node:20-slim with very little else. Every uses: step is a JavaScript action, so the image must have Node — that much the slim images give you — but the rest of these jobs want curl, git, tar and a working apt-get.

If the runner cannot reach your hosts, register a second one somewhere that can, give it a label of its own, and change runs-on: in deploy.yml and in connectivity.yml. It has to be a literal — Gitea supports runs-on: xyz and runs-on: [xyz] and nothing else, so GitHub's ${{ vars.DEPLOY_RUNNER || 'ubuntu-latest' }} has no equivalent here. Do not name a label before a runner carries it; the job then queues forever rather than failing.

A runner only makes outbound HTTPS connections to the instance, which is why this works where an inbound firewall exception would need a policy change.

2. The deploy key

Settings → Actions → Secrets → Add Secret, named SSH_PRIVATE_KEY_B64, holding the base64 of your deploy private key. secrets.md has how to encode it, and what not to use on Windows.

An organisation-level secret of the same name works too, and is the right choice when several config repositories share one deploy key. A repository secret wins over an organisation one of the same name.

The matching public key must be in the deploy user's authorized_keys on every host.

3. What replaces environments

Nothing does, and it is worth being clear-eyed about that before you point this at production.

jobs.<job_id>.environment is parsed and ignored by Gitea. There are no environment-scoped secrets and no required reviewers, so the deploy key is readable by any workflow run in this repository, and "press the button" stays a one-person operation. deploy.yml therefore does not write an environment: key at all — a protection that exists only in YAML is worse than no protection, because you stop checking.

What you actually have:

  • Dispatching a workflow requires write access to the repository. That is the real gate on who can deploy, so the repository's collaborator list is your deploy ACL. Keep it short.
  • Protected branches stop a deploy being dispatched from a ref nobody reviewed — the dispatch form takes a branch, and ansible/ is read from the ref you pick. Protect the default branch and restrict who may push to it.
  • An organisation secret with limited membership, if you want the key scoped more tightly than a repository full of contributors.

If you need two-person deploys, the honest options are a Gitea instance running a merge-approval workflow in front of the branch the deploy runs from, or deploying from a repository only the deployers can write to.

Deploying

Actions → deploy → Run workflow. Three fields:

Field Values Meaning
stack the dropdown Which playbook to run. site is all of them.
mode check, deploy check runs --check --diff and touches nothing
pull policy, always, missing, never always fetches newer images and enables per-stack update work

Work up from harmless to real the first time:

Step What to run What it proves
1 connectivityping The runner can decode the key, reach every host over SSH, and run Python there. Changes nothing.
2 deploy with mode: check What that deploy would change.
3 deploy with mode: deploy The real thing.

static-site is the safest stack to prove this with: one container behind the proxy, no database and no persistent state, so redeploying it costs nothing if it goes wrong.

Another repository can ask this one to deploy after building an image — see triggering-deploys.md. On Gitea that is the same form, filled in over the API.

Differences from GitHub Actions that shape these files

Gitea GitHub Actions
Manual run workflow_dispatch form (1.23+) workflow_dispatch form
External trigger POST …/actions/workflows/deploy.yml/dispatches (1.24+) repository_dispatch
Caller waits for the result Can poll: the dispatch response carries the run id Not possible
Restricting who can deploy Write access + protected branches Environment protection rules
Runner selection A literal label in the file runs-on: with a vars. expression
Hosted runners None; you register act_runner Ubuntu, macOS, Windows
concurrency: 1.26+, ignored before Supported
Secret scan The pinned gitleaks binary The upstream gitleaks Docker image

Expressions are barely supported. Of GitHub's expression functions only always() is implemented, so success(), failure(), contains(), fromJSON() and friends are not available. The operators (||, &&, ==) and the contexts (github, secrets, vars, env) work, which is all these three files use. Keep it that way if you restructure them.

docker run does not work in a job. An act_runner job is itself a container and has no Docker socket unless the administrator mounted one in, so the GitHub lint workflow's docker run … gitleaks fails with "Cannot connect to the Docker daemon". The released binary is fetched and run directly instead — pinned, because a scanner that changes under you turns a green history red on a morning you changed nothing.

actions/setup-python stays, for a Gitea-specific reason. The runner images are Ubuntu 24.04, where the system Python is PEP 668 "externally managed" and a plain pip install refuses to touch it. setup-python brings its own interpreter and sidesteps that. If you drop it, you need pip install --break-system-packages and you are then installing into the image.

Every uses: is downloaded from github.com unless your administrator set DEFAULT_ACTIONS_URL = self, in which case actions/checkout and actions/setup-python must be mirrored into the instance as repositories of those names, or every workflow here fails on its first step. That setting takes only github or self; an arbitrary mirror URL has to be spelled out in full in the workflow.

Why untrusted input is validated

deploy.yml matches stack against [a-z0-9_-]+ and pull against a fixed list before running anything, and confirms the playbook exists. With the values coming from a dropdown this looks redundant twice over.

It is not, for two reasons specific to this platform. type: choice is a constraint on the form, not on the API: a dispatch call may send any string for stack, and the dispatch API is the only way another repository can trigger a deploy here, so every externally triggered deploy arrives through exactly the path the dropdown does not cover.

And the values never go into a run: block through ${{ }}. A workflow expression is substituted into the script text before the shell sees it, so a value containing shell syntax is executed rather than compared. Values go through env: and are read as "$STACK":

        env:
          STACK: ${{ github.event.inputs.stack }}
        run: |
          case "$STACK" in
            ''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;;
          esac

Keep that shape if you restructure the job. It is the difference between a validated argument and remote code execution on your deploy runner.

Troubleshooting

Job fails with Cause
Nothing runs at all; no run appears Actions is disabled for the repository (Settings → Repository → Enable Actions), or no runner is registered
The workflows in .github/ run instead .gitea/workflows/ is missing from the ref you dispatched, or WORKFLOW_DIRS was reordered instance-wide
No Run workflow button Gitea older than 1.23, or you do not have write access to the repository
exec: "node": executable file not found The runner's image has no Node, so no uses: step can run. Use a runner-images tag rather than a bare alpine/python image
Unable to resolve action actions/checkout The instance cannot reach github.com, or DEFAULT_ACTIONS_URL = self and the action is not mirrored
externally-managed-environment from pip The actions/setup-python step was removed
Cannot connect to the Docker daemon A step is trying to run Docker inside the job container
Job queued forever runs-on: names a label no runner carries
set SSH_PRIVATE_KEY_B64, or SSH_PRIVATE_KEY … The secret is not on this repository or its organisation
does not look like a private key The value was encoded with certutil or Get-Content, or re-wrapped across lines
The role 'compose_stack' was not found Ansible refused a world-writable working directory. Set ANSIBLE_CONFIG=$GITHUB_WORKSPACE/ansible/ansible.cfg on the job
Permission denied (publickey) The matching public key is not in the deploy user's authorized_keys
Connection timed out Network path, not credentials — run connectivityprobe, and read connectivity.md
no playbook for '…' The stack value does not match a file in ansible/playbooks/
Deploy succeeds, nothing changed Images are tagged latest and pull was policy