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]>
168 lines
6.7 KiB
YAML
168 lines
6.7 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
|
|
- 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"
|