Skip to main content
RunBook Academy

AnsibleXXXVIII · Git Workflow and CI for AnsibleAutomation as production code

Check mode as a merge gate

Advanced⏱ ~20 minansible-core

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

Not yet marked complete on this device.

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 outcomeExit code
Success0
Task failure only2
Unreachable host only4
Task failure and unreachable host4

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:

Configuration changecheck run, variable missing in this environment
$ ansible-playbook -i inventories/staging playbooks/webservers.yml --check --diff
fatal: [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=0

Exit 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:

Configuration changecheck run against hosts that are down
$ ansible-playbook -i inventories/staging playbooks/gate.yml --check --diff
fatal: [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=0

Illustrative 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 --limit in 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

  1. 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?

  2. Q2. A check gate produced an empty diff and exited 0. Which situations are consistent with that? Select all that apply.

  3. Q3. A check run that prints a complete, correct diff for a template proves that the real run will write that file successfully.

  4. 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.