From 1130f2f07ef1f9c8671b0693257e915443ea29a8 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 16 Sep 2026 03:58:09 +0200 Subject: [PATCH] Add the GitLab CI configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitlab-ci.yml | 224 ++++++++++++++++++++++++++++++++++++++++++++++ docs/ci-gitlab.md | 116 ++++++++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100644 docs/ci-gitlab.md diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..d198596 --- /dev/null +++ b/.gitlab-ci.yml @@ -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: --check --diff, reports what would change, touches nothing +# deploy: 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 diff --git a/docs/ci-gitlab.md b/docs/ci-gitlab.md new file mode 100644 index 0000000..53bac43 --- /dev/null +++ b/docs/ci-gitlab.md @@ -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:` — runs `--check --diff`, reports what would change, touches + nothing +- `deploy:` — 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 `. +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).