ansible-lint is the highest-value-per-second check available for an Ansible repository, and the one most likely to be configured badly enough that a team switches it off.
The difference between the two outcomes is almost entirely about the profile you adopt and whether you baseline the existing findings. A team that starts at the strictest setting sees several hundred failures on its first run and concludes the tool is unusable; a team that starts where its repository already passes gets a green build and a ratchet it can tighten.
What it catches
Section titled “What it catches”Worth knowing, because it justifies the effort.
Deprecated and removed modules. Ansible announces deprecations several releases ahead and then removes them. Lint reports them long before the control node upgrade that turns them into hard failures, which converts a future outage into a pull request comment today. This alone is the strongest reason to run it.
shell and command where a module exists. Not idempotent, and reports changes incorrectly.
Missing changed_when on command tasks. Makes a run’s change report meaningless and fires handlers on every execution.
Unnamed tasks. Unreadable output when something fails.
Unsafe file permissions, and mode specified as a bare number rather than a string. This is a genuine trap: YAML parses a leading-zero number in a way that does not give you the octal permission you intended, so mode: 0644 and mode: "0644" produce different results on disk. Lint catching it is worth the whole tool.
Jinja2 spacing and syntax issues.
Variable naming that will collide.
Risky patterns — become where it is not needed, ignore_errors without justification, latest as a package state.
The official action
Section titled “The official action”name: Ansible lint
on: pull_request: push: branches: [main]
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- name: Run ansible-lint uses: ansible/ansible-lint@main with: args: "" setup_python: "true" python_version: "3.14" working_directory: "" requirements_file: requirements.ymlThe inputs, and what each is for:
args — extra command-line arguments, such as specific paths or --offline.
setup_python — whether the action installs Python. Leave true unless you have set it up yourself.
python_version — pin it, so a runner image change does not alter your results.
working_directory — for a repository where the Ansible content is in a subdirectory.
requirements_file — the important one. Without the collections installed, lint reports unknown-module errors for everything provided by a collection, which produces a wall of findings that are not real.
Or install it directly
Section titled “Or install it directly”The action is convenient. Installing the tool gives you exact control over the version.
- uses: actions/setup-python@v7 with: python-version: "3.14"
- name: Install env: # Pin both — check the current releases and set these deliberately. ANSIBLE_LINT_VERSION: "<pinned>" ANSIBLE_CORE_VERSION: "<pinned>" run: | python -m pip install --upgrade pip pip install \ "ansible-lint==${ANSIBLE_LINT_VERSION}" \ "ansible-core==${ANSIBLE_CORE_VERSION}" ansible-galaxy install -r requirements.yml
- name: Lint run: ansible-lintPin both ansible-lint and ansible-core. Lint results depend on both — a new ansible-core changes what modules exist, and a new lint version adds rules. An unpinned pair means findings change without a commit in your repository, and the failure arrives attached to an unrelated pull request.
This is the better choice for a repository where lint results matter, because you control exactly when the rules change. The action is the better choice for getting started.
Configuration
Section titled “Configuration”.ansible-lint, committed:
profile: moderate
exclude_paths: - .github/ - molecule/ - tests/fixtures/ - collections/ - roles/ # Downloaded — not roles/local/
skip_list: # Long lines are common in templates and our editor config handles it. - yaml[line-length]
warn_list: - experimental - fqcn[action-core]
enable_list: - args - no-log-passwordprofile is the main dial. From least to most strict: min, basic, moderate, safety, shared, production. Each includes the previous.
Adopt the level your repository passes today, then raise it deliberately as separate work. A repository set to production on day one reports hundreds of findings, and the response is switching lint off.
exclude_paths must cover downloaded content. Linting collections/ and installed roles/ produces findings about other people’s code that you cannot fix.
skip_list entries need a comment. An unexplained skip is a rule somebody disabled for a reason nobody remembers, and it will never be revisited.
warn_list is the migration path. A rule that warns rather than fails lets you see the findings, fix them over time, and then move it to enforcement.
Adopting it on an existing repository
Section titled “Adopting it on an existing repository”The sequence that avoids the pipeline being disabled.
-
Run it locally first, at several profiles, and count the findings. This tells you where to start.
-
Set the profile to the highest level that passes, or one below if the gap is small.
-
Add the workflow, not required yet. Let it run for a few days and confirm it is stable.
-
Make it required. A check that can be ignored is ignored.
-
Raise the profile one step, in a pull request that also fixes the new findings. One step, one pull request.
-
Repeat until you reach the level you want, or until the remaining findings are ones you have deliberately decided to skip with a comment.
Do not skip step 5’s pairing. Raising the profile in one pull request and fixing the findings in another means a red pipeline for everybody in between.
production is not automatically the goal. It includes rules about metadata and documentation completeness that matter for a published collection and are noise for a private playbook repository. Pick the level whose findings you would actually act on.
Scoping to changed files
Section titled “Scoping to changed files”At scale, linting everything on every pull request is still seconds — so usually, do not scope it.
The case for linting everything: a change to group_vars or requirements.yml can affect findings in files it did not touch. Scoping risks a missed finding for a saving measured in seconds.
Where scoping genuinely helps is a repository with thousands of files where the run has become a minute or more. Then:
- name: Changed files id: changed run: | git diff --name-only "origin/${{ github.base_ref }}...HEAD" \ | grep -E '\.(yml|yaml)$' | tr '\n' ' ' > /tmp/files echo "list=$(cat /tmp/files)" >> "$GITHUB_OUTPUT"
- name: Lint changed files if: steps.changed.outputs.list != '' run: ansible-lint ${{ steps.changed.outputs.list }}Still run the full lint on the default branch, so nothing accumulates unnoticed.
The honest recommendation: lint everything until it is slow enough to complain about. Most repositories never reach that point.
Making the output useful
Section titled “Making the output useful”Lint’s own output is good; the failure is burying it.
Print it in the job log, not into a variable that gets summarised. The rule name, file and line are the useful parts.
Consider SARIF output for repositories using code scanning — --sarif-file produces a report that GitHub can render as annotations on the pull request diff, which puts findings next to the lines they concern rather than in a log.
Annotations beat a log by a wide margin for whether a finding is read. A developer sees a comment on the line; they do not open a log.
Do not suppress the exit code. A lint step with continue-on-error: true and a summary comment is a lint step that never blocks anything.
Report the profile in the output so somebody reading a failure knows which ruleset produced it.
Handling dependencies
Section titled “Handling dependencies”The commonest source of spurious findings.
Install collections before linting. Without them, every module from a collection is unknown.
Use --offline if you have vendored everything and want to guarantee no network access during lint.
Pin collection versions in requirements.yml, or a collection update changes lint results with no commit in your repository.
Cache the installed collections keyed on requirements.yml, which makes the job faster and the cache correct.
Exclude the install directory from linting, or you lint other people’s code.
Running it locally
Section titled “Running it locally”A finding that appears first in CI is a finding that cost a round trip.
Install it in the same versions CI uses. A developer on a newer lint than CI sees findings that do not fail the build, and vice versa — both are confusing.
A pre-commit hook running lint on staged files gives the feedback before the push. The full run takes seconds; a staged-files run takes less.
ansible-lint --fix applies automatic fixes for the rules that have them — quoting, formatting, some fqcn rewrites. Worth running before opening a pull request, and worth not running in CI, for the same reason terraform fmt should not commit from a pipeline: a workflow that pushes to branches is a permission you do not want to grant.
ansible-lint --list-rules shows every rule and which profile includes it, which is how you find out what raising the profile would add before you raise it.
ansible-lint --profile production path/to/role on one role tells you the gap without failing everybody’s build.
Document the local setup in the README. “Run pip install -r requirements-dev.txt && ansible-lint” is two lines and removes the excuse.
When lint and reality disagree
Section titled “When lint and reality disagree”Occasionally a rule is wrong for your situation, and handling that well matters.
A rule that is wrong once gets an inline skip with a comment:
- name: Restart the legacy agent ansible.builtin.command: /opt/legacy/restart.sh changed_when: true # noqa: no-changed-when — the script is not idempotent and we cannot # detect its effect; restarting unconditionally is the documented usage.A rule that is wrong repeatedly goes in skip_list, with a comment saying why. If you find yourself adding the same inline skip five times, that is the signal.
A rule that is wrong because your code is unusual is worth questioning. Lint’s rules encode community experience, and “this rule does not fit us” is occasionally true and more often a sign that the code should change.
Never disable a rule to make a build green under time pressure without a follow-up. That is how a skip_list accumulates entries nobody can justify, and after a year the lint configuration is a record of deadlines rather than of decisions.
Review skip_list changes like any other policy change. A pull request disabling a rule changes what is enforced for everybody, and it should be reviewed as such rather than slipping through as part of a larger change.
Common mistakes
Section titled “Common mistakes”No requirements_file. Unknown-module findings for everything from a collection.
Adopting production immediately. Hundreds of findings, then lint is disabled.
Unpinned ansible-lint and ansible-core. Findings change with no commit of yours.
@main on the action, unpinned. Whatever is on that branch when your workflow runs.
Linting downloaded roles and collections. Findings you cannot fix.
Unexplained skip_list entries. Rules disabled for forgotten reasons.
continue-on-error: true. The check never blocks.
Not making it a required check. Advice rather than a gate.
Scoping to changed files prematurely. Risk for a saving of seconds.
Ignoring deprecation findings. They become hard failures at the next control node upgrade.
Beyond lint
Section titled “Beyond lint”Lint is one check. Knowing what it does not cover keeps expectations honest.
It does not run anything. A role that lints perfectly can fail on the first task against a real host. That is Molecule’s job.
It does not know your inventory. Undefined variables, missing groups and mismatched group_vars filenames are outside its scope.
It does not check your logic. A when condition that is subtly wrong is valid YAML and a valid task.
It does not find secrets. A password in a group_vars file is well-formed and lint has no opinion. Secret scanning and the vault checks in Ansible CI cover that.
It does not test idempotency. It flags patterns that risk it — a command with no changed_when — and cannot tell you whether a run actually changes things twice.
It does not validate templates render. A Jinja2 template with a variable that is never defined is syntactically fine.
The picture that emerges: lint covers form, Molecule covers behaviour, and inventory checks cover targeting. A repository running only lint is in decent shape and is not tested.
Keeping it useful over time
Section titled “Keeping it useful over time”Lint configuration rots like everything else.
Review the skip list annually. Rules skipped for reasons that no longer apply, and rules skipped for reasons nobody recorded. Both are worth resolving.
Raise the profile when the findings are near zero. A profile you comfortably pass is one you could move up from, and moving up finds new things.
Update the pinned versions deliberately, as their own pull request, with the findings fixed in the same change. A lint upgrade bundled into an unrelated change is a red build somebody will resolve by adding a skip.
Watch the deprecation findings especially. They have a deadline attached — the release that removes the module — and they are the findings most worth acting on early. Ignoring them converts a warning today into a broken control node upgrade later.
Check that new contributors are not surprised. If somebody’s first pull request fails lint on something the README does not mention, that is a documentation gap rather than their mistake.
Reviewing a lint configuration change
Section titled “Reviewing a lint configuration change”.ansible-lint decides what is enforced for everybody, which makes changing it a policy change.
A raised profile is good news and needs the findings fixed in the same pull request, or the build is red for everybody until somebody does it.
A lowered profile needs an explanation. It is occasionally right — a repository that adopted too strict a level and is backing off deliberately — and it is more often somebody unblocking themselves.
A new skip_list entry disables a rule repository-wide. Read what the rule does before approving, because the entry will outlive the situation that prompted it.
A widened exclude_paths stops linting a directory. Legitimate for downloaded content; a warning sign for anything you wrote.
A version bump to ansible-lint or ansible-core changes the rules. Expect new findings, and expect them fixed in the same change.
Put .ansible-lint under CODEOWNERS so somebody who cares about the standard is asked. This is a two-line change to a file most repositories already have, and it converts an easily-slipped change into a reviewed one.
The framing worth holding: the lint configuration is a written record of what your team has agreed automation should look like. Changes to it deserve the same attention as changes to any other shared standard.
Mental model
Section titled “Mental model”Lint encodes what the Ansible community has learned about writing automation that behaves predictably. The profile is how much of that you have adopted, and raising it is a piece of work rather than a setting.
Treated that way it stays useful. Treated as a switch to turn to maximum, it becomes a wall of findings and then a disabled check.
What you learned
Section titled “What you learned”- The official action is
ansible/ansible-lint, withargs,setup_python,python_version,working_directoryandrequirements_file requirements_fileis the input whose absence produces the most spurious findings- Pin the action to a SHA, and pin
ansible-lintandansible-coreif results matter - Profiles run
min→basic→moderate→safety→shared→production - Adopt the profile your repository passes, then raise it one step at a time with the fixes
exclude_pathsmust cover downloaded collections and roles- Skip entries need a comment;
warn_listis the migration path to enforcement - SARIF output produces annotations on the diff, which are read far more than logs
Exercise
Section titled “Exercise”Use a disposable Ansible repository.
-
Add the lint workflow with
requirements_fileset. Confirm it passes. -
Remove
requirements_fileand re-run. Predict: how many new findings, and are they real? -
Set the profile to
minand count findings. Thenmoderate. Thenproduction. Note the counts. -
Add a task using
shellwith nochanged_when. Predict: which profile first catches it? -
Add
mode: 0644as a bare number to a file task. Predict: does lint object, and do you know why it should? -
Add a
skip_listentry with no comment. Come back to it after reading something else and see whether you remember why. -
Add
--sarif-fileoutput and code scanning upload. Compare where findings appear. -
Set
continue-on-error: trueand introduce a failure. Predict: does the pull request show as passing? -
Delete the repository.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.