Files
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

226 lines
12 KiB
Markdown

# CI on Gitea
Three workflows, in `.gitea/workflows/`:
| Workflow | Trigger | Needs host access |
| --- | --- | --- |
| [`lint.yml`](../.gitea/workflows/lint.yml) | every push and pull request | no |
| [`deploy.yml`](../.gitea/workflows/deploy.yml) | manual, or the dispatch API | yes |
| [`connectivity.yml`](../.gitea/workflows/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.
```bash
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](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 | **connectivity**`ping` | 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](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"`:
```yaml
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 **connectivity**`probe`, and read [connectivity.md](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` |