AnsibleXXXVIII · Git Workflow and CI for AnsibleAutomation as production code
Check mode as a merge gate
What you'll learn
- Build a merge gate that produces reviewable evidence rather than a pass or fail
- Distinguish the four ansible-playbook exit codes and gate on them correctly
- Detect a check run that was clean because nothing executed
- State the limits a reviewer must know before treating a check diff as proof
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Every gate so far in this part answers yes or no. Lint passes or it does not. The secret scanner finds something or it does not. Those are useful and they are all statements about the text of the change.
This gate is different in kind. Its job is not to decide whether the
change is acceptable — it is to produce the artefact that lets a human
decide, quickly and conclusively. A --check --diff run against a staging
inventory, with its output attached to the pull request, converts review
from “can I imagine what this does” into “does this output match the
description”.
That is the highest-value thing a pull request in this repository can carry, and it is also the gate most likely to be believed beyond what it proves.
What the gate runs
ansible-playbook -i inventories/staging playbooks/site.yml \
--check --diff --vault-id stage@"$VAULT_STAGE"
--check puts every module that supports it into a mode where it reports
what it would do without doing it. --diff makes the file-managing
modules print the literal before-and-after of the content they would
write.
The severity of that command is worth being precise about, because it is
tempting to file it as read-only and it is not. Check mode is a request
that modules honour; a module with no check-mode support behaves according
to its own contract, and a task carrying check_mode: false executes for
real during a --check run. Part XXV’s mechanics lesson has the full
picture. Treat the command as CONFIGURATION against whatever inventory
you point it at, and point it at staging.
Exit codes, and the gate that gets them wrong
ansible-playbook distinguishes four outcomes, and this table was
established by execution on 2.21.3 rather than from documentation —
upstream does not publish it:
| Run outcome | Exit code |
|---|---|
| Success | 0 |
| Task failure only | 2 |
| Unreachable host only | 4 |
| Task failure and unreachable host | 4 |
Unreachable takes precedence. That last row is the one that catches
people: a run containing both real failures and unreachable hosts reports
4, so a CI job written as “if the exit code is 2, the run failed”
classifies it as something else entirely.
The correct gate is the simple one: anything other than 0 is a failure. Then distinguish the causes for the human reading the result, rather than for the branching logic.
Both codes are easy to reproduce in check mode. An undefined variable — the commonest way a change passes in one environment and fails in another — surfaces as a task failure:
$ ansible-playbook -i inventories/staging playbooks/webservers.yml --check --difffatal: [web-stg-01.example.com]: FAILED! => {"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'webserver_worker_processes' is undefined"}
PLAY RECAP *********************************************************************
web-stg-01.example.com : ok=0 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0Exit status 2. That failure is a genuine finding and it is one the gate
exists to produce: the change assumed a variable that only one environment
provides. Fix it in the repository, not by adding a default().
And when the staging hosts cannot be reached at all:
$ ansible-playbook -i inventories/staging playbooks/gate.yml --check --difffatal: [web-stg-01.example.com]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh", "unreachable": true}
PLAY RECAP *********************************************************************
web-stg-01.example.com : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0Illustrative output
Exit status 4.
The clean run that ran nothing
Here is the failure mode that makes this gate dangerous rather than merely limited, and it is why the gate needs an assertion of its own.
A check run that produces no diff looks the same whether it produced no
diff because the change is a no-op, or because no host was in the
play. And a pattern that matches no hosts is not an error: Ansible
warns, prints hosts (0):, and exits 0. The job is green, the diff is
empty, and the pull request appears to have been evaluated.
Three ways to arrive there, all of which have happened:
- The play targets a group that exists in production and not in staging.
- The staging inventory was emptied or renamed and nobody noticed, because nothing runs against staging except this gate.
- A
--limitin the CI job refers to a group that was renamed.
The defence is to make the run assert on its own scope. A preflight play that fails when the host count is wrong turns a silently empty gate into a red build:
- name: Gate preflight
hosts: all
gather_facts: false
run_once: true
tasks:
- name: Refuse to proceed against an unexpected number of hosts
ansible.builtin.assert:
that:
- ansible_play_hosts_all | length >= expected_min_hosts
fail_msg: >-
Expected at least {{ expected_min_hosts }} hosts, found
{{ ansible_play_hosts_all | length }}. The gate proved nothing.
success_msg: "{{ ansible_play_hosts_all | length }} hosts in scope."
Set expected_min_hosts per environment in group_vars, and the number
is the same one the README records. Part XXIV covers assertion-based
guardrails in general; this is the CI-specific application, and it is the
one that converts “the gate was green” into “the gate ran”.
Knowledge check
Knowledge check · 4 questions
Q1. A CI job runs the check gate and branches on the exit code, treating 2 as failure and anything else as success. What does it get wrong?
Q2. A check gate produced an empty diff and exited 0. Which situations are consistent with that? Select all that apply.
Q3. A check run that prints a complete, correct diff for a template proves that the real run will write that file successfully.
Q4. The central task of a change is a command module invocation that check mode skips. What is the best thing to attach to the pull request?
Passing score: 75%. Answers are checked in this browser.