Skip to main content
Ansible · lesson 5 of 6
Ansible advanced Lesson 5 of 6

Gating a Pipeline on ansible-playbook's Exit Code, Not Its Output

A CI step doesn't parse PLAY RECAP text, it reads $?. See what --syntax-check, --check --diff, and a failing task really return, and gate on that.

By SkillByExample · Updated

ansible ci-cd check-mode exit-codes

A CI pipeline never reads the PLAY RECAP — it reads the process exit code. Three gates, each verified against what ansible-playbook and ansible-lint actually return, not what the docs say they should.

Gate 1: catch broken YAML before anything runs

# broken_playbook.yml
---
- name: Broken playbook
  hosts: local
  tasks:
    - name: Missing colon
      debug
        msg: "this is broken"
ansible-playbook -i inventory.ini broken_playbook.yml --syntax-check
echo "EXIT=$?"
ERROR! We were unable to read either as JSON nor YAML, these are the errors we got from each:
JSON: Expecting value: line 1 column 1 (char 0)

Syntax Error while loading YAML.
  could not find expected ':'. while scanning a simple key
  in "<unicode string>", line 6, column 7
could not find expected ':'
  in "<unicode string>", line 7, column 12

The error appears to be in '/root/lesson/broken_playbook.yml': line 7, column 12, but may
be elsewhere in the file depending on the exact syntax problem.

The offending line appears to be:

      debug
        msg: "this is broken"
           ^ here
EXIT=4

--syntax-check never contacts a host or runs a task — it parses the YAML and stops. Exit code 4 here means “parser error,” a distinct number from a task actually failing. This is the cheapest possible pipeline gate: a broken playbook never reaches the next stage.

Gate 2: prove the change with —check —diff, without applying it

# cicd_playbook.yml
---
- name: Deploy config
  hosts: local
  tasks:
    - name: Ensure a file exists
      copy:
        content: "hello from the CI pipeline\n"
        dest: /tmp/greeting.txt

The file on disk currently holds different content (hello again from ansible, left over from an earlier lesson):

ansible-playbook -i inventory.ini cicd_playbook.yml --check --diff
TASK [Ensure a file exists] ****************************************************
--- before: /tmp/greeting.txt
+++ after: /tmp/greeting.txt
@@ -1 +1 @@
-hello again from ansible
+hello from the CI pipeline

changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
cat /tmp/greeting.txt
hello again from ansible

--check --diff reported changed: [localhost] and printed the exact line-level diff it would write — but the file on disk is untouched. This is a genuine dry run, not a lower-confidence guess: a pipeline can show this diff to a human for approval before ever running the real apply step.

Gate 3: the exit code a failed task actually returns

# failing_playbook.yml
---
- name: Deploy config
  hosts: local
  tasks:
    - name: Ensure a file exists
      copy:
        content: "hello from the CI pipeline\n"
        dest: /tmp/greeting.txt
    - name: A task that fails
      command: /bin/false
ansible-playbook -i inventory.ini failing_playbook.yml
echo "EXIT=$?"
TASK [Ensure a file exists] ****************************************************
changed: [localhost]

TASK [A task that fails] *******************************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], "rc": 1, ...}

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
EXIT=2

Exit code 2 — a real, distinct value from the 4 a syntax error produced. A CI step written as ansible-playbook ... && deploy_succeeded or checked with if [ $? -eq 0 ] gates correctly on this number without parsing a single line of the human-readable recap above it.

A real lint failure, caught before deploy

ansible-lint cicd_playbook.yml
echo "EXIT=$?"
WARNING  Listing 2 violation(s) that are fatal
Read documentation for instructions on how to ignore specific rule violations.

# Rule Violation Summary

  1 risky-file-permissions profile:safety tags:unpredictability
  1 fqcn profile:safety tags:formatting

Failed: 2 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'moderate'. Rating: 2/5 star
risky-file-permissions: File permissions unset or incorrect.
cicd_playbook.yml:5 Task/Handler: Ensure a file exists

fqcn[action-core]: Use FQCN for builtin module actions (copy).
cicd_playbook.yml:6:7 Use `ansible.builtin.copy` or `ansible.legacy.copy` instead.
EXIT=2

cicd_playbook.yml is valid YAML — --syntax-check on it passes clean — but ansible-lint still exits 2 on two real style/safety violations: no explicit file mode: on the written file, and copy used without its fully-qualified collection name. A pipeline that only gates on --syntax-check would let both of these through; ansible-lint is a separate, stricter gate that catches things syntax validity doesn’t.

Chaining the gates as one pipeline step

ansible-playbook -i inventory.ini cicd_playbook.yml --syntax-check && \
ansible-playbook -i inventory.ini cicd_playbook.yml --check --diff && \
ansible-playbook -i inventory.ini cicd_playbook.yml
echo "PIPELINE EXIT=$?"
playbook: cicd_playbook.yml

TASK [Ensure a file exists] ****************************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

TASK [Ensure a file exists] ****************************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
PIPELINE_EXIT=0

Both the --check --diff stage and the real apply report changed=0 — Gate 3’s run earlier in this lesson already converged /tmp/greeting.txt to this exact content before /bin/false failed the play, so there’s nothing left to change here. That’s the idempotent pipeline working as intended: re-running a deploy step against an already-deployed target is a safe no-op, not a repeat of the original write. && chains each stage so a nonzero exit from any gate — a syntax error, a failed dry run, a failed real apply — stops the pipeline immediately, exactly the behavior a CI system’s own step-failure handling depends on.

Practice

1. Run --syntax-check against a playbook that references a module that doesn't exist, and compare the exit code to the YAML-syntax-error case above.
# bad_module_playbook.yml
---
- name: Bad module
  hosts: local
  tasks:
    - name: Use a fake module
      totally_fake_module:
        foo: bar
ansible-playbook -i inventory.ini bad_module_playbook.yml --syntax-check
echo "EXIT=$?"
ERROR! couldn't resolve module/action 'totally_fake_module'. This often indicates a
misspelling, missing collection, or incorrect module path.

The error appears to be in '/root/lesson/bad_module_playbook.yml': line 5, column 7, but may
be elsewhere in the file depending on the exact syntax problem.
EXIT=4

Worth knowing: --syntax-check catches this too, with the same exit code 4 as the malformed YAML above — it resolves every module name while parsing, not just the YAML shape. This gate is stricter than “the file merely parses.”

2. Find something --syntax-check genuinely can't catch: an undefined Jinja variable.
# undefined_var_playbook.yml
---
- name: Undefined variable
  hosts: local
  tasks:
    - name: Use an undefined variable
      debug:
        msg: "{{ this_var_does_not_exist }}"
ansible-playbook -i inventory.ini undefined_var_playbook.yml --syntax-check
echo "EXIT=$?"
playbook: undefined_var_playbook.yml
EXIT=0

That passes clean — {{ this_var_does_not_exist }} is syntactically valid Jinja, so --syntax-check has no reason to object. Running it for real is a different story:

ansible-playbook -i inventory.ini undefined_var_playbook.yml
echo "EXIT=$?"
TASK [Use an undefined variable] ***********************************************
fatal: [localhost]: FAILED! => {"msg": "The task includes an option with an undefined
variable. The error was: 'this_var_does_not_exist' is undefined. ..."}

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0
EXIT=2

Same exit code (2) as every other task-level failure in this lesson — an undefined variable only surfaces when Jinja actually tries to render it at run time, which is precisely why --syntax-check is a cheap first gate, not a substitute for actually running the playbook.

3. Use --check without --diff and confirm it reports changed status without showing the actual content.
echo "different content" > /tmp/greeting.txt
ansible-playbook -i inventory.ini cicd_playbook.yml --check
TASK [Ensure a file exists] ****************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

--check alone reports changed: [localhost] — enough for a pipeline’s pass/fail gate — but without --diff it never prints what would actually change, which matters when the CI log is shared somewhere a secret value shouldn’t be echoed.

4. Fix the fqcn violation ansible-lint found, then re-run it to confirm one fewer failure.
# cicd_playbook.yml, module name fully qualified
    - name: Ensure a file exists
      ansible.builtin.copy:
        content: "hello from the CI pipeline\n"
        dest: /tmp/greeting.txt
        mode: "0644"
ansible-lint cicd_playbook.yml
echo "EXIT=$?"
Passed: 0 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'production'.
EXIT=0

Both violations are gone — ansible.builtin.copy resolves the fqcn warning, and an explicit mode: resolves risky-file-permissions. ansible-lint now exits 0, meaning a pipeline gate on it would let this playbook through.

Ansible topics continue with roles, variables, vault, and advanced playbook control flow in the lessons before this one — this pipeline pattern is the last mile: the same verified playbooks, gated the way a real CI system actually gates them.

Frequently Asked Questions

What exit code does ansible-playbook return when a task fails?
2 — a real fatal task failure returns exit code 2, distinct from 0 (clean success), 1 (a generic/unhandled error), and 4 (a parser/syntax error caught before any task ran). A CI pipeline gates on this number, never on scraping the human-readable PLAY RECAP text.
Does --syntax-check actually catch YAML errors before anything runs?
Yes — it parses the playbook and exits (4 on a real syntax error, 0 if valid) without contacting any host or running a single task. It's the cheapest possible pipeline gate: broken YAML never reaches the check-mode or apply stage at all.
Is --check --diff a real dry run, or does it still touch the filesystem?
It's a genuine dry run — Ansible evaluates what each task would do and reports changed or ok exactly as a real run would, but performs no actual writes. A file inspected with cat immediately after a --check --diff run that reported changed still shows its old, pre-change content untouched.
Why does the same playbook run twice in this lesson, once with --check and once for real?
The --check run proves what would happen without risking it; the real run then applies it. Running --check twice against an already-converged system should show changed=0 both times — a --check run that reports changed on a system a pipeline believes is already deployed is itself a signal something drifted.
What does ansible-lint check that ansible-playbook --syntax-check doesn't?
--syntax-check only verifies the YAML parses into a valid playbook structure; it says nothing about content quality. ansible-lint runs a rules profile against style and safety concerns — unpinned module names (fqcn), unset file permissions (risky-file-permissions), and dozens more — and exits non-zero when a violation is found, even though the playbook itself is perfectly valid YAML that would run fine.