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]>
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 | 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 |