Skip to main content
RunBook Academy

AnsibleXXV · Check Mode, Diff and Static ValidationCheck mode, diff and static validation

ansible-lint on an existing repository

Intermediate⏱ ~24 minansible-lint

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

Not yet marked complete on this device.

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

Read-only / Safethe versions in play
$ ansible-lint --version
ansible-lint 26.6.0 using ansible-core:2.21.3 ansible-compat:26.6.0 ruamel-yaml:0.19.1 ruamel-yaml-clib:None

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

ProfileExtendsWhat it is checking for
minThat Ansible can load the content at all: parser-error, load-failure, syntax-check, internal-error
basicminCommon coding mistakes and consistent style: name, yaml, no-free-form, command-instead-of-module, deprecated-module, jinja, no-jinja-when, key-order, var-naming
moderatebasicReadability and maintainability: name[template], name[imperative], name[casing]
safetymoderateNon-determinism and security: package-latest, latest, risky-file-permissions, risky-octal, risky-shell-pipe, avoid-implicit
sharedsafetyFitness to be published and reused: galaxy, meta-*, no-changed-when, no-handler, ignore-errors, no-relative-paths
productionsharedRequirements 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 runpackage-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:

Read-only / Safethirteen findings in a fourteen-line play
$ 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.

Read-only / Safethe same playbook with the configuration above
$ 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=2

Step 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
Read-only / Safeone violation, one exception
$ 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 state

An 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

  1. Q1. Which profile is the first whose rules are about the behaviour of the run rather than the readability of the code?

  2. Q2. An inherited repository produces two thousand findings. Which of these are part of a workable adoption path? Select all that apply.

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

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