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,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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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