# 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 \ --labels ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest ``` A label is `[:[:]]`, where the schema is `docker://` (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..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` |