Ansible · 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
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.