Files
config-public/.gitea/workflows/deploy.yml
T
Lucas WintherandClaude Opus 5.5 be0b90b879 Cache the CI toolchain in a shared action
ci/toolchain installs the pinned tools, collections and rsync from cache for
both GitHub and Gitea; GitLab caches pip downloads. shellcheck now finds
scripts rather than globbing, which broke when a glob matched nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:16:58 +02:00

183 lines
7.5 KiB
YAML

---
# 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
- openbao
- static-site
- webapp
- webapp-staging
mode:
description: >-
check reports what would change; sync copies the files but starts
nothing; deploy does both
required: true
default: check
type: choice
options:
- check
- sync
- 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
# 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 ;;
# Has a playbook, so the test -f below would pass it, but it must
# run as an administrator — see docs/bootstrap.md. The dropdown
# leaves it out; the API does not.
bootstrap) echo "refusing STACK='$STACK': run it by hand"; exit 1 ;;
esac
case "$MODE" in
check|sync|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"
# rsync too: ansible.posix.synchronize shells out to it on the machine
# Ansible runs on, not only on the target.
- name: Install the pinned toolchain
uses: ./ci/toolchain
with:
collections: "true"
rsync: "true"
# 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"
# Read by the compose_stack role through lookup('env', ...) for stacks
# that set stack_secrets. It goes through the environment and never
# through ${{ }} into the run: body, for the reason the Validate step
# above spells out. See docs/secrets.md.
VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
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
elif [ "$MODE" = "sync" ]; then
# A real run that stops before Compose: the payload lands, the
# directories and networks are created, no container is touched.
set -- -e start=false
else
set --
fi
ansible-playbook "playbooks/${STACK}.yml" "$@" -e "pull=$PULL"