Files
config-public/docs/ci-gitlab.md
T
Lucas WintherandClaude Opus 5 1130f2f07e Add the GitLab CI configuration
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]>
2026-09-16 03:58:09 +02:00

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 nothing
  • deploy:<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:

  1. GitLab needs the branch. A pipeline only exists for a ref it has. git push -u origin <your-branch>.
  2. 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.