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