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]>
This commit is contained in:
Lucas Winther
2026-09-16 03:58:09 +02:00
co-authored by Claude Opus 5
parent a0e56c96b7
commit 1130f2f07e
2 changed files with 340 additions and 0 deletions
+224
View File
@@ -0,0 +1,224 @@
---
# The GitLab port of .github/workflows/. Both are maintained; see
# docs/ci-gitlab.md for what differs and why.
#
# Two ways in, and only two:
# - a human presses play on a per-stack button (all manual, at the bottom)
# - another project's pipeline asks for a stack by name (deploy:triggered)
#
# Nothing deploys on a plain push.
# Branch pipelines only. Any job carrying `rules: - when: manual` with no `if:`
# matches every pipeline source -- and that alone is enough to make GitLab build
# a *merge request* pipeline whenever an MR is open. A merge request pipeline
# includes only jobs whose rules match merge_request_event, so it drops every job
# that has no `rules:` at all. The result looks exactly like the deploy buttons
# having vanished, while the branch pipeline beside it has them all.
#
# Nothing here needs a merge request pipeline: the lint gate and the manual
# buttons both belong to a branch. Skipping them also stops one commit building
# two pipelines. With no MR pipeline the MR page shows the branch pipeline for
# its head commit, so the widget is not empty.
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: never
- when: always
stages:
- test
- deploy
variables:
# A runner's build directory is world-writable, and Ansible refuses to read an
# ansible.cfg out of a world-writable directory -- it says so and carries on
# with no roles_path and no inventory, which surfaces much later as "the role
# 'compose_stack' was not found". Naming the file explicitly opts out of that
# discovery rule. Paths inside it then resolve relative to the file, so this
# works from any working directory.
ANSIBLE_CONFIG: $CI_PROJECT_DIR/ansible/ansible.cfg
lint:
stage: test
image: python:3.12-slim
# No `changes:` filter on purpose. Watching ansible/ and the lint configs means
# a commit touching only this file skips the job entirely -- including the
# commits fixing the lint job itself, so it silently proves nothing. The job
# takes under a minute.
script:
- apt-get update -qq && apt-get install -qq -y --no-install-recommends git
# scripts/check.sh is the single source of the version pins, and runs the
# same three checks a contributor runs locally with `make check`.
- ./scripts/check.sh --print-specs | xargs -d '\n' pip install --quiet
- RUNNER=installed ./scripts/check.sh
# Shared setup: toolchain and the deploy key. Carries no rules of its own, so it
# can never schedule itself.
.ansible:
stage: deploy
# Every job that touches a host inherits this through `extends`. Remove it, or
# change it to your own runner's tag, if your hosts are reachable from shared
# runners. Do not add a tag before a runner carries it -- a tag no runner has
# leaves jobs pending forever rather than failing.
tags:
- deploy-runner
image: python:3.12-slim
variables:
SSH_KEY_BACKEND: ci
ANSIBLE_FORCE_COLOR: '1'
before_script:
- apt-get update -qq && apt-get install -qq -y --no-install-recommends openssh-client curl rsync
- ./scripts/check.sh --print-specs | head -1 | xargs -d '\n' pip install --quiet
- ansible-galaxy install -r ansible/requirements.yml
# Two lines, not `export SSH_KEY_PATH="$(...)"`. export is a special builtin,
# so the compound command reports *its* status and swallows the script's --
# under `set -e` a loader failure (an unprotected ref, so no key variable)
# goes unnoticed and the job dies much later with a per-host "Permission
# denied (publickey)" instead of the loader's own message.
- SSH_KEY_PATH="$(./scripts/load-ssh-key.sh)"
- export SSH_KEY_PATH
# Can the runner reach the hosts with the configured key? Changes nothing, so it
# is the right first thing to run after setting up the CI/CD variables.
ansible:ping:
extends: .ansible
rules:
- when: manual
script:
- cd ansible && ansible all -m ansible.builtin.ping
# Runs one stack's playbook. STACK reaches a shell command and, for triggered
# pipelines, arrives from outside this project, so it is validated first.
.ansible-stack:
extends: .ansible
script:
# A per-stack button pins STACK_FIXED; the generic and triggered jobs take
# STACK as a pipeline variable. These cannot share one name: a pipeline
# variable outranks a job's YAML `variables:` in GitLab, so with both called
# STACK, pressing deploy:webapp inside a pipeline triggered with
# STACK=static-site would deploy static-site -- and with PULL=always could
# run webapp's backup and migration against the wrong stack.
- STACK="${STACK_FIXED:-${STACK:-}}"
- case "$STACK" in ''|*[!a-z0-9_-]*) echo "refusing STACK='$STACK'"; exit 1;; esac
# PULL reaches the same command line, unquoted, from the same untrusted place
# as STACK, so it gets the same treatment: an unchecked value can smuggle in
# extra `-e` overrides (stack_dest, ansible_host) and redirect a deploy.
- case "$PULL" in ''|always|missing|never|policy) ;; *) echo "refusing PULL='$PULL'"; exit 1;; esac
- test -f "ansible/playbooks/${STACK}.yml" || { echo "no playbook for '$STACK'"; exit 1; }
- cd ansible && ansible-playbook "playbooks/${STACK}.yml" ${ANSIBLE_EXTRA_ARGS} ${PULL:+-e pull=$PULL}
# Run a pipeline with STACK set (Build > Pipelines > Run pipeline, add a
# variable) and these two appear.
check:stack:
extends: .ansible-stack
variables:
ANSIBLE_EXTRA_ARGS: --check --diff
rules:
- if: '$STACK'
when: manual
deploy:stack:
extends: .ansible-stack
rules:
- if: '$STACK'
when: manual
# Deploy on request from another project's pipeline. This job exists only in
# pipelines that were triggered, so it can never fire on a push here.
# See docs/triggering-deploys.md.
deploy:triggered:
extends: .ansible-stack
rules:
# trigger token or API call
- if: '$CI_PIPELINE_SOURCE == "trigger" && $STACK'
# `trigger:` keyword in an upstream project (multi-project pipeline)
- if: '$CI_PIPELINE_SOURCE == "pipeline" && $STACK'
.ansible-manual:
extends: .ansible-stack
when: manual
# A bare `when: manual` defaults allow_failure to true, unlike `when: manual`
# inside `rules:`. Without this a failed deploy leaves the pipeline green and
# an upstream using `strategy: depend` never learns the deploy broke.
allow_failure: false
# One visible button per stack. Written out rather than generated with
# parallel:matrix: a matrix collapses into a single expandable node in the
# pipeline graph, which hides every play button one click deeper. Named jobs each
# get their own button.
#
# check:<stack> --check --diff, reports what would change, touches nothing
# deploy:<stack> does it
#
# To update rather than deploy, run the pipeline with PULL=always and press the
# same deploy button. That also enables the work gated behind an update run --
# webapp's backup and migration.
#
# Order mirrors ansible/playbooks/site.yml. Add a pair when a playbook lands.
.check:
extends: .ansible-manual
variables:
ANSIBLE_EXTRA_ARGS: --check --diff
.deploy:
extends: .ansible-manual
check:reverse-proxy:
extends: .check
variables:
STACK_FIXED: reverse-proxy
deploy:reverse-proxy:
extends: .deploy
variables:
STACK_FIXED: reverse-proxy
check:banner:
extends: .check
variables:
STACK_FIXED: banner
deploy:banner:
extends: .deploy
variables:
STACK_FIXED: banner
check:metrics:
extends: .check
variables:
STACK_FIXED: metrics
deploy:metrics:
extends: .deploy
variables:
STACK_FIXED: metrics
check:static-site:
extends: .check
variables:
STACK_FIXED: static-site
deploy:static-site:
extends: .deploy
variables:
STACK_FIXED: static-site
check:webapp:
extends: .check
variables:
STACK_FIXED: webapp
deploy:webapp:
extends: .deploy
variables:
STACK_FIXED: webapp
check:webapp-staging:
extends: .check
variables:
STACK_FIXED: webapp-staging
deploy:webapp-staging:
extends: .deploy
variables:
STACK_FIXED: webapp-staging
+116
View File
@@ -0,0 +1,116 @@
# 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).