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]>
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 | 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. 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 connectivity → probe, 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 |