Files
config-public/docs/ci-github-actions.md
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 <[email protected]>
2026-09-30 15:16:58 +02:00

7.3 KiB

CI on GitHub Actions

Three workflows. There is a Gitea port of all three in .gitea/workflows/ — see ci-gitea.md, and note that Gitea reads that directory instead of this one.

Workflow Trigger Needs host access
lint.yml every push and pull request no
deploy.yml manual, or repository_dispatch yes
connectivity.yml manual yes (ping only)

Setup

1. Create the production environment

Settings → Environments → New environment → production.

This is where the deploy key lives, and it is what replaces GitLab's "Protected variable" concept. A secret on an environment is invisible to any job that does not name that environment, and the environment's own rules gate the job:

  • Required reviewers — a deploy waits for a named person to approve it. Worth turning on. It makes "press the button" a two-person operation without any change to this repository.
  • Deployment branches — restrict to your default branch so a deploy cannot be run from an arbitrary fork branch.

2. Add the deploy key

On that environment, add a secret SSH_PRIVATE_KEY_B64 holding the base64 of your deploy private key. See secrets.md for how to encode it, and what not to use on Windows.

The matching public key must be in the deploy user's authorized_keys on every host.

If any stack sets stack_secrets, add VAULT_TOKEN beside it, holding the read-only stacks-read token — see step 5 of Bootstrapping the vault in secrets.md.

3. Point at a runner

Deploys need to reach your hosts over SSH. GitHub-hosted runners have general internet egress, so if your hosts accept SSH from the internet, nothing to do.

If they do not — a private network, an IP allowlist, or a corporate egress filter — register a self-hosted runner somewhere that can reach them, and set a repository variable (not a secret) DEPLOY_RUNNER to its label:

Settings → Secrets and variables → Actions → Variables
DEPLOY_RUNNER = my-deploy-runner

Both host-touching workflows read it:

runs-on: ${{ vars.DEPLOY_RUNNER || 'ubuntu-latest' }}

A runner only makes outbound HTTPS connections to GitHub, which is why this works where an inbound exception would need a policy change. Do not set the variable before a runner carries that label — a label no runner has leaves jobs queued forever rather than failing.

A self-hosted runner needs Python's venv module and, for deploys, either rsync or apt-get with sudo: the toolchain action installs rsync when it is missing, because synchronize runs it on the runner as well as on the host.

The toolchain itself comes from ci/toolchain/, a composite action shared with the Gitea workflows. It caches a venv and the Galaxy collections with actions/cache, keyed on the pins in scripts/check.sh and ansible/requirements.yml and on the month, so most runs install nothing.

Deploying

Actions → deploy → Run workflow. Three fields:

Field Values Meaning
stack the dropdown Which playbook to run. site is all of them.
mode check, sync, deploy check runs --check --diff and touches nothing; sync copies the files for real but starts no container (-e start=false)
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.

Differences from GitLab that shape this file

There is no per-job manual button. GitLab renders a play button for every when: manual job, which is how the .gitlab-ci.yml here ends up with a check: and deploy: pair per stack. GitHub has one "Run workflow" form per workflow, so the stack becomes a choice input instead.

The practical cost is that the options: list in deploy.yml has to be kept in step with ansible/playbooks/ by hand. There is no way to populate a choice dynamically. Adding a stack means editing that list — it is step 4 of adding a stack, and forgetting it is how a service ends up deployable only from a laptop.

repository_dispatch does not wait. GitLab's strategy: depend makes an upstream pipeline mirror the downstream result. A repository_dispatch returns as soon as the event is accepted, so the caller learns nothing about whether the deploy worked. If you need the caller to block, see triggering-deploys.md.

Environments replace protected variables. GitLab hides a Protected variable from pipelines on unprotected refs, which produces the confusing failure of a variable that exists but is empty. GitHub's equivalent is scoping the secret to an environment and restricting which branches may deploy to it — the job is blocked outright rather than running with an empty key.

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. Under workflow_dispatch the values come from a dropdown and this looks redundant. Under repository_dispatch they come from another repository's payload, and they reach a shell command.

More importantly, they 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":

        env:
          STACK: ${{ github.event.inputs.stack || github.event.client_payload.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
set SSH_PRIVATE_KEY_B64, or SSH_PRIVATE_KEY ... The secret is not on the production environment, or the job does not name that environment
does not look like a private key The value was encoded with certutil or Get-Content, or re-wrapped across lines
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
no playbook for '...' The stack value does not match a file in ansible/playbooks/
Job queued forever DEPLOY_RUNNER names a label no runner carries
Deploy succeeds, nothing changed Images are tagged latest and pull was policy