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]>
225 lines
7.9 KiB
YAML
225 lines
7.9 KiB
YAML
---
|
|
# 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
|