Files
config-public/docs/ci-github-actions.md
T
Lucas WintherandClaude Opus 5 a0e56c96b7 Add the GitHub Actions workflows
A lint gate on every push, a manual deploy form, and a connectivity
workflow for when a deploy hangs instead of failing. Nothing deploys on a
push: merging changes what would be deployed, a person still decides when.
The stack name and pull policy reach a shell command, so they are validated
through env: rather than interpolated into a run: block.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 03:58:09 +02:00

6.5 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.

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.

Deploying

Actions → deploy → Run workflow. Three fields:

Field Values Meaning
stack the dropdown Which playbook to run. site is all of them.
mode check, deploy check runs --check --diff and touches nothing
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 connectivityping 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 connectivityprobe, 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