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