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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1130f2f07e
commit
971034e630
@@ -0,0 +1,225 @@
|
||||
# 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` |
|
||||
Reference in New Issue
Block a user