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]>
226 lines
12 KiB
Markdown
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` |
|