The same two things as the GitHub workflows, in GitLab's shape: one lint job, and a per-stack check/deploy button pair. Branch pipelines only, since a bare `when: manual` would otherwise make GitLab build a merge request pipeline that drops every job without rules — which looks exactly like the deploy buttons having vanished. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
5.4 KiB
CI on GitLab
.gitlab-ci.yml does the same two things as the GitHub
workflows: a lint gate on every push, and manual per-stack deploys.
If you are only using one platform, delete the others — there is also a Gitea
port in .gitea/workflows/, documented in
ci-gitea.md. All three are kept here because the translation
between them is a large part of what this repository is demonstrating.
Setup
1. CI/CD variables
Settings → CI/CD → Variables. Mark every one Protected.
| Variable | Value | Notes |
|---|---|---|
SSH_KEY_BACKEND |
ci |
Already set as a job default; override only to use vault |
SSH_PRIVATE_KEY_B64 |
the deploy key, base64, one line | Masked |
SSH_PRIVATE_KEY |
the raw key | Alternative: type File, cannot be masked |
VAULT_ADDR / VAULT_TOKEN |
a vault address and token | Only for the vault backend |
Protected matters more than it looks. A protected variable exists only on protected branches and tags. A pipeline on an ordinary feature branch gets an empty value and the job fails with the loader's message — the variable is not missing, it is invisible to that pipeline. This is the single most common first-run failure.
See secrets.md for how to encode the key.
2. A runner that can reach the hosts
Every job that touches a host is tagged deploy-runner through the .ansible
template. Either tag your own runner accordingly, or change the tag, or remove
the tags: block if shared runners can reach your hosts.
Do not add a tag before a runner carries it — a tag no runner has leaves jobs pending forever rather than failing.
The lint job is deliberately untagged. It needs no host access, so it keeps
using shared runners.
Deploying
Every stack has two buttons at the bottom of the pipeline graph:
check:<stack>— runs--check --diff, reports what would change, touches nothingdeploy:<stack>— does it
To update rather than deploy, run the pipeline with PULL=always (Build →
Pipelines → Run pipeline, add a variable) and press the same deploy button. That
also enables the work gated behind an update run — webapp's backup and
migration.
ansible:ping is the right first thing to run after setting the variables up. It
should print SUCCESS => {"ping": "pong"} for every host, and changes nothing.
Testing it on a branch
Two things have to be true before any deploy job can work:
- GitLab needs the branch. A pipeline only exists for a ref it has.
git push -u origin <your-branch>. - The branch must be protected, or the deploy credentials are not there.
Add it under Settings → Repository → Protected branches, or a
feature/*wildcard while testing.
Notes on the file
Branch pipelines only. The workflow: block at the top refuses merge request
pipelines, and the reason is not obvious. Any job carrying rules: - when: manual with no if: matches every pipeline source, including
merge_request_event — and that alone makes GitLab build a merge request
pipeline whenever an MR is open. An MR pipeline includes only jobs whose rules
match merge_request_event, so it drops every job that has no rules: at all:
the lint gate and all the per-stack buttons.
The result looks exactly like the deploy buttons having vanished, while the branch pipeline next to it has them all. Nothing here needs an MR pipeline, so the cleanest fix is to not build one.
STACK_FIXED, not STACK. The per-stack buttons pin the stack with
STACK_FIXED; the generic and triggered jobs read STACK. These cannot share a
name: a pipeline variable outranks a job's YAML variables: in GitLab, so with
both called STACK, pressing deploy:webapp inside a pipeline that was
triggered with STACK=static-site would deploy static-site — and with
PULL=always could run webapp's backup and migration against the wrong stack.
Named jobs, not parallel:matrix. A matrix collapses into a single
expandable node in the pipeline graph, which buries every play button one click
deeper. Written-out jobs each get their own button. It is more lines and a better
interface.
allow_failure: false on .ansible-manual. A bare when: manual defaults
allow_failure to true, unlike when: manual inside rules:. Without the
explicit override a failed deploy leaves the pipeline green, and an upstream
using strategy: depend never learns the deploy broke.
Differences from the GitHub workflows
| GitLab | GitHub Actions | |
|---|---|---|
| Per-stack manual run | A play button per job, generated from extends |
One workflow_dispatch form with a stack dropdown |
| Restricting who can deploy | Protected branches + protected variables | Environment protection rules (required reviewers) |
| Runner selection | tags: |
runs-on: with a label |
| Cross-project trigger | trigger: keyword or trigger token |
repository_dispatch |
| Caller waits for the result | strategy: depend |
Not supported by repository_dispatch |
| Secret scanning | Secret Detection template | gitleaks image in lint.yml |
The GitHub version needs its stack list maintained by hand because a choice
input cannot be populated dynamically; the GitLab version needs its job pairs
maintained by hand because there is no way to generate a button per playbook
either. Both are step 4 of
adding a stack.