--- # Deploys. Two ways in, and only two: # # - a human fills in the form under Actions > deploy > Run workflow # - another repository asks for a stack by name, via repository_dispatch # # 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. # # GitHub has no per-job manual button, so GitLab's grid of check: and # deploy: buttons becomes one form with a `stack` dropdown. The list under # `options:` has to be kept in step with ansible/playbooks/ by hand — see # skills/adding-a-stack/SKILL.md. Setup: docs/ci-github-actions.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 repository_dispatch: types: [deploy] # Two deploys of the same stack at once would race on the host. concurrency: group: deploy-${{ github.event.inputs.stack || github.event.client_payload.stack }} cancel-in-progress: false permissions: contents: read jobs: ansible: name: >- ${{ github.event.inputs.mode || 'deploy' }} ${{ github.event.inputs.stack || github.event.client_payload.stack }} # Self-hosted when the hosts are not reachable from GitHub's network — set # the DEPLOY_RUNNER repository variable to the runner's label. See # "Runners" in docs/ci-github-actions.md. runs-on: ${{ vars.DEPLOY_RUNNER || 'ubuntu-latest' }} # The Environment is what replaces GitLab's Protected variables: the deploy # key is stored on it, so it exists only for jobs that name it, and its # protection rules (required reviewers, allowed branches) gate every run. environment: production steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.12" # STACK, MODE and PULL reach a shell command, and under repository_dispatch # they come 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. - name: Validate the requested stack and pull policy env: STACK: ${{ github.event.inputs.stack || github.event.client_payload.stack }} MODE: ${{ github.event.inputs.mode || github.event.client_payload.mode || 'deploy' }} PULL: ${{ github.event.inputs.pull || github.event.client_payload.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"