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:
co-authored by
Claude Opus 5
parent
a0e56c96b7
commit
1130f2f07e
+224
@@ -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
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user