AnsibleXXV · Check Mode, Diff and Static ValidationCheck mode, diff and static validation
ansible-lint on an existing repository
What you'll learn
- Name the six profiles in order and state what each one is checking for
- Read a lint summary and identify which profile the repository currently passes
- Configure .ansible-lint so an inherited repository can be gated from day one
- Distinguish a rule that is skipped from a rule that is deferred with a record
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
--syntax-check establishes that a playbook compiles. ansible-lint
establishes rather more, and unlike the static gates it has an opinion:
it knows that shell: systemctl restart nginx should be a systemd
task, that package: state=latest makes a run non-reproducible, and that
a file written without an explicit mode inherits whatever the umask
happens to be.
It is a separate project with its own release cycle, and it declares
which ansible-core it is checking against:
$ ansible-lint --versionansible-lint 26.6.0 using ansible-core:2.21.3 ansible-compat:26.6.0 ruamel-yaml:0.19.1 ruamel-yaml-clib:NonePin it alongside ansible-core in the controller’s requirements file.
A lint version that floats will fail a build one morning on a rule that
did not exist the night before, and the first thing anybody does with a
linter that fails unpredictably is turn it off.
The profile ladder
Six profiles, each extending the previous one, designed — in the
project’s own framing — to gradually increase strictness as your content
matures. ansible-lint --list-profiles prints them with their rules;
what follows is the shape rather than the full listing.
| Profile | Extends | What it is checking for |
|---|---|---|
min | — | That Ansible can load the content at all: parser-error, load-failure, syntax-check, internal-error |
basic | min | Common coding mistakes and consistent style: name, yaml, no-free-form, command-instead-of-module, deprecated-module, jinja, no-jinja-when, key-order, var-naming |
moderate | basic | Readability and maintainability: name[template], name[imperative], name[casing] |
safety | moderate | Non-determinism and security: package-latest, latest, risky-file-permissions, risky-octal, risky-shell-pipe, avoid-implicit |
shared | safety | Fitness to be published and reused: galaxy, meta-*, no-changed-when, no-handler, ignore-errors, no-relative-paths |
production | shared | Requirements for certified content in Automation Platform: fqcn, sanity, avoid-dot-notation, meta-no-dependencies |
The boundary worth internalising is between moderate and safety.
Everything up to moderate is about the code being readable and
conventional. safety is the first profile whose rules are about the
behaviour of the run — package-latest because a play that installs
the newest available version does something different on Tuesday than it
did on Monday, risky-file-permissions because a file written with no
mode gets whatever the remote umask decides.
Reading the output
A deliberately careless playbook, checked at basic:
$ ansible-lint --profile basic deploy.yml# Rule Violation Summary
1 command-instead-of-module profile:basic tags:command-shell,idiom
1 command-instead-of-shell profile:basic tags:command-shell,idiom
1 jinja profile:basic tags:formatting
2 no-free-form profile:basic tags:syntax,risk
1 no-jinja-when profile:basic tags:deprecations
3 name profile:basic tags:idiom
1 yaml profile:basic tags:formatting,yaml
2 name profile:basic tags:idiom
Failed: 13 failure(s), 1 warning(s) in 1 files processed of 1 encountered. Profile 'basic' was required, but 'min' profile passed.
command-instead-of-module: systemctl used in place of systemd module
deploy.yml:4 Task/Handler: shell systemctl restart nginx
no-jinja-when: No Jinja2 in when.
deploy.yml:13 Task/Handler: debug msg={{ item }}
jinja[spacing]: Jinja2 spacing could be improved: {{ deploy_env == 'production' }} -> deploy_env == 'production' (warning)
deploy.yml:15:11 Task/Handler: debug msg={{ item }}Two things in that output do more work than the individual findings.
The summary line names the profile you actually pass. “Profile
‘basic’ was required, but ‘min’ profile passed” is the single most
useful sentence ansible-lint produces on an inherited repository,
because it converts “thousands of errors” into a position on a ladder and
a direction to walk.
Rule names are sub-scoped. name[missing], name[casing] and
name[play] are three distinct checks under the name rule, and they
can be configured independently. That granularity is what makes the
adoption strategy below possible.
Note also no-jinja-when catching when: "{{ deploy_env == 'production' }}"
— the same construct ansible-core 2.21.3 warns is deprecated for
removal in 2.23. The linter has been asking for that fix for years and
now has the release note behind it.
Adopting it on a repository that fails everything
The failure mode is predictable: run ansible-lint on four years of
inherited automation, get two thousand findings, conclude that fixing
them is a quarter’s work, and never turn it on. The repository then
accumulates a further year of violations.
The way through is to gate the trend rather than the total.
Step 1 — find out where you stand. Run at min. Almost everything
passes min, because its rules are about content Ansible cannot load at
all. If min fails, fix that first; the repository has files that do not
parse.
Step 2 — declare the target and record the debt. Put a
.ansible-lint at the repository root:
# .ansible-lint
profile: basic
exclude_paths:
- vendor/
- .cache/
# Rules we have not fixed yet. Each entry is a commitment with a date,
# not a decision. Review this list at every retrospective.
warn_list:
- name[casing] # ~200 occurrences, mechanical, scheduled Q4
- yaml[indentation] # will be fixed by the formatter pass
# Rules we have decided not to adopt, with the reason in the comment.
skip_list:
- no-free-form # legacy roles; removal tracked in INFRA-2231
warn_list reports the rule and does not fail the run. skip_list
suppresses it entirely.
$ ansible-lint deploy.yml; echo "exit=$?"Failed: 9 failure(s), 3 warning(s) in 1 files processed of 1 encountered. Profile 'basic' was required, but 'min' profile passed.
exit=2Step 3 — gate new and changed files only. The rule that makes adoption survivable is that the linter must never fail a build over code somebody did not touch. Lint the files in the diff:
CHANGED=$(git diff --name-only origin/main...HEAD -- '*.yml' '*.yaml')
if [ -n "$CHANGED" ]; then
ansible-lint $CHANGED
fi
New work is clean from day one, the debt shrinks whenever a file is edited for other reasons, and nobody is asked to stop and fix two thousand findings before shipping a change.
Step 4 — empty the warn_list, one entry at a time. Each entry is a
scheduled piece of work with a visible size. A repository that removes
one entry per month is a repository getting measurably better, which is
a much easier thing to sustain than a cleanup project.
Exceptions in the file, where they belong
Where a rule is right in general and wrong for one task, record the exception at the task rather than disabling the rule everywhere:
- name: Restart the vendor appliance agent, which ships no module
ansible.builtin.command: /opt/vendor/bin/agentctl restart # noqa: no-changed-when
$ ansible-lint --profile shared noqa.yml# Rule Violation Summary
1 no-changed-when profile:shared tags:command-shell,idempotency
Failed: 1 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Profile 'shared' was required, but 'safety' profile passed. Rating: 3/5 star
no-changed-when: Commands should not change things if nothing needs doing.
noqa.yml:4 Task/Handler: Query the vendor agent stateAn inline noqa is strictly better than a skip_list entry for the same
rule, on three counts: it is scoped to one task, it appears in the diff
when the task changes, and it sits next to the code whose oddity it
explains. Write the reason in a comment above it and a reviewer two years
later has everything they need.
Knowledge check
Knowledge check · 4 questions
Q1. Which profile is the first whose rules are about the behaviour of the run rather than the readability of the code?
Q2. An inherited repository produces two thousand findings. Which of these are part of a workable adoption path? Select all that apply.
Q3. ansible-lint works on the parsed structure of files with variables unresolved, so package-latest finds state: latest written literally but not a state whose value arrives from a variable.
Q4. A command task legitimately has no idempotent form and trips no-changed-when. What is the best way to record that?
Passing score: 75%. Answers are checked in this browser.