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:
Lucas Winther
2026-09-16 03:58:18 +02:00
co-authored by Claude Opus 5
parent 1130f2f07e
commit 971034e630
4 changed files with 635 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
---
# Diagnostics, for when a deploy job hangs instead of failing.
#
# Two jobs, deliberately separate:
#
# ping uses the real deploy key and asks Ansible to talk to every host. This
# is the right first thing to run after setting the secret up, and the
# right first thing to run when a deploy misbehaves. It changes nothing.
# probe uses no credentials at all and answers a narrower question: where do
# the packets stop? Run it when ping times out.
#
# The distinction that matters, and the reason `probe` exists:
#
# Permission denied (publickey) the key is wrong — you reached sshd
# Connection refused you reached the host, nothing is listening
# Connection timed out packets are being dropped in transit
#
# Only the third is a network problem, and no amount of re-pasting the key will
# fix it. See docs/connectivity.md.
name: connectivity
on:
workflow_dispatch:
inputs:
hosts:
description: Space-separated hosts to probe (defaults to the inventory)
required: false
default: edge.example.com app.example.com
jobs:
ping:
name: ansible ping
# A literal label, for the reason spelled out in deploy.yml. Keep the two
# files in step: a diagnostic that runs somewhere other than the deploy is
# diagnosing the wrong machine.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install the pinned toolchain
run: |
set -eu
scripts/check.sh --print-specs | head -1 | xargs -d '\n' pip install --quiet
- name: Load the deploy key
env:
SSH_KEY_BACKEND: ci
SSH_PRIVATE_KEY_B64: ${{ secrets.SSH_PRIVATE_KEY_B64 }}
run: |
set -eu
SSH_KEY_PATH="$(scripts/load-ssh-key.sh)"
echo "SSH_KEY_PATH=$SSH_KEY_PATH" >> "$GITHUB_ENV"
# Success looks like `SUCCESS => {"ping": "pong"}` for every host. That
# proves the runner decoded the key, reached port 22, authenticated, and ran
# Python on the far side — which is everything a deploy needs except the
# playbook itself.
- name: Ping every host in the inventory
working-directory: ansible
run: ansible all -m ansible.builtin.ping
probe:
name: network probe
runs-on: ubuntu-latest
# A report, not a gate. Every section must run even when the thing it probes
# is unreachable, which is the normal case when you are running this. The
# steps also keep themselves green with `set +e`, so the report survives on an
# instance that ignores this key.
continue-on-error: true
steps:
- name: Probe
env:
HOSTS: ${{ github.event.inputs.hosts }}
run: |
set +e
# A bare TCP connect can be answered by a transparent proxy that then
# says nothing, which looks like success and is not. Real sshd greets
# first, so the banner is the only honest proof you reached the server.
ssh_banner() {
b=$(timeout 6 bash -c "exec 3<>/dev/tcp/$1/${2:-22}; head -c 12 <&3" 2>/dev/null || true)
case "$b" in
SSH-*) printf ' %-26s %-5s OK, sshd replied: %s\n' "$1" "${2:-22}" "$b" ;;
'') printf ' %-26s %-5s NO REPLY (dropped, or a silent proxy)\n' "$1" "${2:-22}" ;;
*) printf ' %-26s %-5s answered, but not sshd: %s\n' "$1" "${2:-22}" "$b" ;;
esac
}
# Distinguishes "refused" (allowed out, nothing listening) from
# "filtered" (dropped in transit). This is the whole point of the job:
# if every port but 443 is filtered, you are behind an egress allowlist
# and no alternative SSH port will help.
port_state() {
# `rc=0; out=$(...) || rc=$?` and not `out=$(...); rc=$?` — the latter
# is a failing simple command, which under set -e ends the step before
# rc is ever read, so a filtered port aborts the report instead of
# describing it.
rc=0
out=$(timeout 5 bash -c "cat < /dev/null > /dev/tcp/$1/$2" 2>&1) || rc=$?
if [ "$rc" -eq 0 ]; then s='open'
elif printf '%s' "$out" | grep -qi refused; then
s='refused -> port allowed out'
elif [ "$rc" -eq 124 ]; then
s='filtered -> blocked in transit'
else
s="unclear (rc=$rc) $out"
fi
printf ' %-26s %-5s %s\n' "$1" "$2" "$s"
}
printf '== 0. does this runner have a network at all? ==\n'
printf ' DNS: '; getent hosts github.com >/dev/null 2>&1 && echo resolves || echo 'CANNOT RESOLVE'
# Egress to github.com is worth knowing for a second reason here: unless
# your administrator set DEFAULT_ACTIONS_URL to `self`, it is also where
# every `uses:` step is downloaded from.
printf ' https external: '
curl -s -o /dev/null -w '%{http_code}\n' --max-time 10 \
https://github.com || echo 'NO EGRESS'
printf ' egress address: '; curl -s --max-time 10 https://ifconfig.me || echo unknown
printf '\n\n== 1. SSH to the target hosts ==\n'
for h in $HOSTS; do ssh_banner "$h"; done
printf '\n== 2. SSH elsewhere: is outbound 22 blocked wholesale? ==\n'
ssh_banner github.com
printf '\n== 3. Which ports are allowed out at all? ==\n'
for h in $HOSTS; do
port_state "$h" 22
port_state "$h" 443
done
+167
View File
@@ -0,0 +1,167 @@
---
# Deploys, on Gitea. One way in, and only one:
#
# - a human fills in the form under Actions > deploy > Run workflow, or the
# same form is filled in over the API by another repository's job
#
# Nothing deploys on a push. That is the single most important property of this
# file: a merge to the default branch changes what *would* be deployed, and a
# person still decides when.
#
# Gitea has no `repository_dispatch`, so the two entry points GitHub gets collapse
# into one: the workflow dispatch API posts to *this* form.
#
# POST /api/v1/repos/<owner>/<repo>/actions/workflows/deploy.yml/dispatches
#
# That is better than it sounds — the response can carry the run id, so a caller
# can follow the deploy it asked for, which `repository_dispatch` never allowed.
# See docs/triggering-deploys.md.
#
# The `options:` list has to be kept in step with ansible/playbooks/ by hand; a
# choice input cannot be populated dynamically on any platform. It is step 4 of
# skills/adding-a-stack/SKILL.md. Setup: docs/ci-gitea.md.
name: deploy
on:
workflow_dispatch:
inputs:
stack:
description: Which stack to act on
required: true
type: choice
options:
- site
- reverse-proxy
- banner
- metrics
- static-site
- webapp
- webapp-staging
mode:
description: check reports what would change; deploy does it
required: true
default: check
type: choice
options:
- check
- deploy
pull:
description: >-
Image pull policy. always fetches newer images and enables per-stack
update work (backups, migrations).
required: true
default: policy
type: choice
options:
- policy
- always
- missing
- never
# Two deploys of the same stack at once would race on the host. Concurrency
# syntax landed in Gitea 1.26; before that this block is ignored, and the only
# thing standing between you and a race is that both runs are started by hand.
concurrency:
group: deploy-${{ github.event.inputs.stack }}
cancel-in-progress: false
jobs:
ansible:
name: ${{ github.event.inputs.mode || 'check' }} ${{ github.event.inputs.stack }}
# Change this label if the runner that picks up `ubuntu-latest` cannot reach
# your hosts, and register a second runner carrying whatever you put here.
# It has to be a literal: Gitea supports `runs-on: xyz` and `runs-on: [xyz]`
# only, so GitHub's `${{ vars.DEPLOY_RUNNER || 'ubuntu-latest' }}` trick has
# no equivalent. Do not name a label no runner carries — the job then queues
# forever rather than failing.
runs-on: ubuntu-latest
# No `environment:` here. Gitea parses and ignores it, so writing one would
# claim a protection that does not exist: there are no required reviewers and
# no environment-scoped secrets. SSH_PRIVATE_KEY_B64 is an ordinary
# repository (or organisation) secret, and what actually gates a deploy is
# that dispatching a workflow needs write access to this repository. See
# "What replaces environments" in docs/ci-gitea.md.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# STACK, MODE and PULL reach a shell command, and over the dispatch API they
# arrive from another repository. They go through the environment, never
# through ${{ }} interpolation into a run: block — an expression is
# substituted into the script *before* the shell sees it, so a crafted value
# would be executed rather than compared. Validate, then use.
#
# `type: choice` is a UI constraint, not an API one. Do not read the
# dropdown above as validation.
- name: Validate the requested stack and pull policy
env:
# The `|| default` halves are not decoration. An input's declared
# `default:` is applied by the dispatch *form*; a caller posting to the
# API can leave the key out altogether, and a missing `mode` would then
# be refused below rather than falling back to the safe value. `stack`
# has no default on purpose — there is no safe guess for it.
STACK: ${{ github.event.inputs.stack }}
MODE: ${{ github.event.inputs.mode || 'check' }}
PULL: ${{ github.event.inputs.pull || 'policy' }}
run: |
set -eu
case "$STACK" in
''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1 ;;
esac
case "$MODE" in
check|deploy) ;;
*) echo "refusing MODE='$MODE'"; exit 1 ;;
esac
# An unchecked value can smuggle extra `-e` overrides onto the command
# line (stack_dest, ansible_host) and redirect the deploy somewhere else
# entirely.
case "$PULL" in
policy|always|missing|never) ;;
*) echo "refusing PULL='$PULL'"; exit 1 ;;
esac
test -f "ansible/playbooks/${STACK}.yml" \
|| { echo "no playbook for '$STACK'"; exit 1; }
{
echo "STACK=$STACK"
echo "MODE=$MODE"
echo "PULL=$PULL"
} >> "$GITHUB_ENV"
- name: Install the pinned toolchain
run: |
set -eu
scripts/check.sh --print-specs | head -1 | xargs -d '\n' pip install --quiet
ansible-galaxy install -r ansible/requirements.yml
# Two lines, not `export SSH_KEY_PATH="$(...)"`. export is a special builtin,
# so the compound command reports *its* exit status and swallows the
# script's — under `set -e` a loader failure goes unnoticed and the job dies
# much later with a per-host "Permission denied (publickey)" instead of the
# loader's own message saying which variable was missing.
- name: Load the deploy key
env:
SSH_KEY_BACKEND: ci
SSH_PRIVATE_KEY_B64: ${{ secrets.SSH_PRIVATE_KEY_B64 }}
run: |
set -eu
SSH_KEY_PATH="$(scripts/load-ssh-key.sh)"
echo "SSH_KEY_PATH=$SSH_KEY_PATH" >> "$GITHUB_ENV"
- name: Run the playbook
working-directory: ansible
env:
ANSIBLE_FORCE_COLOR: "1"
run: |
set -eu
# An `if`, not `[ ... ] && extra=...`: when the test fails the AND-list
# returns non-zero, and under `set -e` that ends the job right here with
# no error message and a green-looking cancel.
if [ "$MODE" = "check" ]; then
set -- --check --diff
else
set --
fi
ansible-playbook "playbooks/${STACK}.yml" "$@" -e "pull=$PULL"
+112
View File
@@ -0,0 +1,112 @@
---
# The static gate on Gitea. The same three checks as .github/workflows/lint.yml,
# with the adjustments a Gitea runner forces. Setup: docs/ci-gitea.md.
#
# Creating .gitea/workflows/ at all is a decision rather than a convenience.
# Gitea walks WORKFLOW_DIRS in order — `.gitea/workflows` then `.github/workflows`
# by default — and stops at the first directory that exists. So this directory
# replaces the GitHub workflows wholesale on a Gitea instance, which is what you
# want: deploy.yml there is built on `repository_dispatch` and an `environment:`,
# and Gitea has neither.
#
# Delete whichever platform you are not using rather than letting it rot.
name: lint
on:
push:
branches: ["**"]
pull_request:
# Concurrency syntax landed in Gitea 1.26. Older instances ignore this block
# rather than failing on it — you simply get both runs.
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
jobs:
ansible:
name: yamllint + ansible-lint + syntax
# A literal label, never `${{ vars.SOMETHING }}`: Gitea supports `runs-on: xyz`
# and `runs-on: [xyz]` and nothing more. There are also no hosted runners, so
# this label is only ever your own — see "Runners" in docs/ci-gitea.md.
runs-on: ubuntu-latest
steps:
# Resolved against DEFAULT_ACTIONS_URL, which is github.com unless your
# administrator set it to `self`. On an instance that cannot reach github.com
# this step is the first thing that fails, and it fails for every workflow —
# mirror actions/checkout into the instance, or set the setting back.
- uses: actions/checkout@v4
# Keep this even though the runner image ships a Python. Ubuntu 24.04 images
# are PEP 668 "externally managed", where a plain `pip install` refuses to
# touch the system interpreter; setup-python brings its own and sidesteps it.
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# scripts/check.sh is the single source of the version pins; it prints them
# so that this workflow installs exactly what a contributor runs locally.
# Nothing here can drift from `make check`.
- name: Install the pinned toolchain
run: |
set -eu
scripts/check.sh --print-specs | tee /dev/stderr | xargs -d '\n' pip install --quiet
- name: Run the static gate
run: RUNNER=installed scripts/check.sh
shell:
name: shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Jobs run as root inside the runner's container, where `sudo` is often not
# installed at all. Asking for it only when the uid is not 0 keeps this step
# working on a container runner and on a `:host` runner alike.
- name: Install shellcheck
run: |
set -eu
if ! command -v shellcheck >/dev/null 2>&1; then
sudo=""
[ "$(id -u)" -eq 0 ] || sudo=sudo
$sudo apt-get update -qq
$sudo apt-get install -y --no-install-recommends shellcheck
fi
- name: Check the shell scripts
run: shellcheck scripts/*.sh server/*/*/app/*.sh
secrets:
name: secret scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# detect scans history, not just the working tree. A secret that was
# committed and then removed is still a leaked secret.
fetch-depth: 0
# The GitHub workflow runs the upstream gitleaks *image*. That is not
# portable here: an act_runner job is itself a container, and it has no
# Docker socket unless the administrator mounted one into it, so
# `docker run` fails with "Cannot connect to the Docker daemon". The
# released binary needs nothing but curl and has the same behaviour.
#
# Pinned deliberately: a scanner that changes under you turns a green
# history red on a morning you changed nothing.
- name: Fetch the pinned gitleaks binary
env:
GITLEAKS_VERSION: 8.30.1
run: |
set -eu
base=https://github.com/gitleaks/gitleaks/releases/download
curl -sSfL -o /tmp/gitleaks.tar.gz \
"$base/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
# Left in /tmp and run from there. Installing into /usr/local/bin would
# need root, which a `:host` runner does not have and should not want.
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
chmod +x /tmp/gitleaks
- name: Scan the working tree and history for committed secrets
run: |
/tmp/gitleaks detect --source=. --config=.gitleaks.toml \
--redact --verbose --no-banner
+225
View File
@@ -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` |