--- # 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///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"