--- # 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