Files
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

117 lines
5.4 KiB
Markdown

# CI on GitLab
[`.gitlab-ci.yml`](../.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/`](../.gitea/workflows), documented in
[ci-gitea.md](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](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](../skills/adding-a-stack/SKILL.md).