DEV Community

Amaresh Pelleti
Amaresh Pelleti

Posted on Originally published at devtoolhub.com

Ansible Lint GitHub Actions: Fail PRs on Bad Playbooks

Originally published on DevToolHub.

I ran ansible-lint against a four-task playbook that looked fine at a glance. It reported 14 violations and exited with code 2. Setting up that same check as an Ansible Lint GitHub Actions workflow takes about ten lines of YAML, so the broken playbook never reaches main. This guide covers the workflow, the profile you should enforce, and a trap with ignore files that I hit while testing.

Everything below was run with ansible-lint 26.10.1 and ansible-core 2.21.5 on Python 3.14.

Why Add an Ansible Lint GitHub Actions Check?

An Ansible Lint GitHub Actions check turns style and safety rules into a required check on every pull request. A reviewer no longer has to spot a missing mode: on a copy task or a shell call that runs on every play. The check fails, the PR stays red, and the author fixes it before anyone reads the diff.

It also catches the quiet problems. For example, tasks that report "changed" on every run and state: latest installs that upgrade packages unpredictably. Both break idempotency, which is the property that makes a playbook safe to re-run.

What Ansible Lint Catches in a Real Playbook

First, here is the playbook I tested. It installs nginx, copies a config, restarts the service, and unpacks a tarball:

- name: Configure web servers
  hosts: web
  become: true
  tasks:
    - name: install nginx
      apt:
        name: nginx
        state: latest

    - name: Copy site config
      copy:
        src: nginx.conf
        dest: /etc/nginx/nginx.conf

    - shell: systemctl restart nginx

    - name: Fetch release tarball
      shell: curl -s https://example.com/app.tgz | tar xz -C /opt
Enter fullscreen mode Exit fullscreen mode

Running ansible-lint site.yml printed this summary line and exited with code 2:

Failed: 14 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Last profile that met the validation criteria was 'min'.
Enter fullscreen mode Exit fullscreen mode

The 14 violations came from nine rule IDs. Four are fqcn hits, one per short module name. The rest are the ones that matter in production:

  • Unpinned installs trip package-latest: "Package installs should not use latest."
  • The copy task has no mode, which trips risky-file-permissions.
  • Calling systemctl and curl through shell trips command-instead-of-module, because the systemd and get_url modules exist for that.
  • Both shell tasks report changed on every run (no-changed-when).
  • A pipe without pipefail means a failed curl can still produce a green task (risky-shell-pipe).
  • One task has no name at all (name[missing]).

A reviewer skimming this in a PR would probably approve it. The linter, however, doesn't skim.

The Minimal Ansible Lint GitHub Actions Workflow

The official action takes a handful of inputs. Per the ansible-lint installation docs, they are args, setup_python, python_version (default 3.14), working_directory, and requirements_file. Create .github/workflows/ansible-lint.yml:

name: ansible-lint
on:
  pull_request:
    paths:
      - "**.yml"
      - "**.yaml"
      - ".ansible-lint"

permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: ansible/ansible-lint@9bce2bfb8f4a984e6b13767bc0d6bf0a49492164 # v26.10.1
        with:
          args: "--profile production"
          requirements_file: requirements.yml
Enter fullscreen mode Exit fullscreen mode

Three details here are deliberate.

First, the action is pinned to a commit SHA. The docs example uses @main, which means your lint rules change whenever upstream merges something. The SHA above is the v26.10.1 tag, which I resolved through the GitHub API. If you're tightening the rest of your pipeline too, the GitHub Actions security guide explains why tags are a weak pin.

Second, permissions: contents: read keeps the token minimal, because a linter needs nothing more.

Third, requirements_file installs the roles and collections your playbooks import. Skip it and you get load failures on any playbook that uses a collection module. Keep the setting in the file, not in extra action inputs. The docs state that extra options won't be added to the action, so behavior changes go in a .ansible-lint config file instead.

For a refresher on how workflow files fit together, see the workflow YAML explainer.

Which Profile Should You Enforce?

ansible-lint groups rules into six cumulative profiles: min, basic, moderate, safety, shared, and production. Each one extends the one before it, according to the profiles documentation. Pick one with --profile on the command line or profile: in .ansible-lint.

I ran the same broken playbook against each profile:

Profile Failures Exit code
min 0 0
basic 4 2
moderate 5 2
safety 8 2
production 14 2

The min profile only checks that Ansible can load the file. Because our playbook passes it, the default run said the last profile met was min. That is a low bar, so don't stop there.

For a new repo, start at production. In practice that is the setting most teams end up on anyway. For an existing one, start at basic and move up one profile at a time as you clean up. Specifically, the safety profile is where the rules about latest, risky permissions, and shell pipes show up, so it's the first level that protects you from real incidents.

The fixed version of the playbook passes --profile production with exit code 0:

- name: Configure web servers
  hosts: web
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: Copy site config
      ansible.builtin.copy:
        src: nginx.conf
        dest: /etc/nginx/nginx.conf
        mode: "0644"
      notify: Restart nginx

    - name: Download release tarball
      ansible.builtin.get_url:
        url: https://example.com/app.tgz
        dest: /tmp/app.tgz
        mode: "0644"

    - name: Unpack release tarball
      ansible.builtin.unarchive:
        src: /tmp/app.tgz
        dest: /opt
        remote_src: true

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
Enter fullscreen mode Exit fullscreen mode

The restart moved into a handler. As a result, nginx only restarts when the config actually changes. That one change removes a no-changed-when hit and makes the play idempotent.

What --fix Actually Fixes

ansible-lint --fix rewrites the file in place. On my test playbook it cut the failures from 14 to 8. It capitalized the task name, added ansible.builtin. to every module, and turned shell: systemctl restart nginx into ansible.builtin.command.

However, it could not fix the rest. The log said it plainly: "Rule specific fix not applied for: name[missing]". It also left latest, the missing mode, and both no-changed-when hits alone, because those need a human to decide what the right behavior is.

⚠️ Note: --fix modifies your files. Run it on a clean branch and read the diff before you commit. Don't run it in CI and push the result back unattended.

Adopting Ansible Lint GitHub Actions Checks on an Existing Repo

Turning on a strict check for a repo with hundreds of old violations makes every PR red on day one. The usual answer is ansible-lint --generate-ignore, which writes a .ansible-lint-ignore file. Each line holds a filename and a rule ID, and ansible-lint stops failing on those pairs.

Here is what that file looked like for my test playbook:

site.yml command-instead-of-module
site.yml command-instead-of-shell
site.yml fqcn[action-core]
site.yml name[casing]
site.yml name[missing]
site.yml no-changed-when
site.yml package-latest
site.yml risky-file-permissions
site.yml risky-shell-pipe
Enter fullscreen mode Exit fullscreen mode

After generating it, the same run reported Passed: 0 failure(s), 14 warning(s) and exited with code 0. Because the check is green, you can merge the workflow and start enforcing rules on new code.

Why an Ignore File Can Hide New Violations

The ignore file works per file and per rule. It does not work per line. I found this out by appending a new task to site.yml that ran rm -rf /tmp/x through ansible.builtin.command with no changed_when. That is a fresh no-changed-when violation.

As a result, the run printed Passed: 0 failure(s), 15 warning(s) and exited with code 0. The new violation showed up as a warning marked # ignored, and the PR stayed green.

This means an ignore entry for no-changed-when in a file silences that rule for every future task in that file. A few ways to deal with it:

  1. Treat .ansible-lint-ignore as debt, not a setting. Review it in PRs and shrink it over time.
  2. Prefer fixing a rule across the repo, then deleting its ignore lines, over leaving ignores in place.
  3. Don't expect --strict to help. It makes warnings fail the run, and ignored violations count as warnings. With the ignore file in place, --strict exited with code 2 on my test, so it can't gate new code while old ignores exist.
  4. Put new playbooks in new files. Rules ignored for legacy.yml don't apply to new.yml.

Running Ansible Lint Locally Before Your GitHub Actions Run

Plus, a failing PR check is slower than a failing terminal. Install the same version the workflow uses and run it before you push:

python3 -m venv .venv
.venv/bin/pip install ansible-core ansible-lint
.venv/bin/ansible-lint --profile production
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: use Python 3.14 or older. On Python 3.15, ansible-core 2.21.5 printed a warning that it supports Python up to 3.14, and ansible itself crashed with module 'dataclasses' has no attribute '_is_type'. The GitHub Action defaults to 3.14, so CI won't hit this, but a fresh laptop with the newest Python might.

ansible-lint also supports several output formats through -f: brief, full, md, json, codeclimate, quiet, pep8, and sarif. Use pep8 for editor integration and codeclimate if your CI tool consumes it.

If you manage infrastructure with both tools, linting is where Ansible and Terraform converge. The Terraform docs action follows the same pattern of a single CI job that fails the PR. For picking between the tools themselves, see OpenTofu vs Terraform. And if you want the wider picture of what breaks pipelines, the GitHub Actions incidents guide is the place to start.

Frequently Asked Questions

Q: Does ansible-lint fail the build by default?
A: Yes. ansible-lint exits with code 2 when it finds violations, and a non-zero exit fails the GitHub Actions step. Warnings from your warn_list or ignore file don't fail the run unless you pass --strict.

Q: Which ansible-lint profile should I use in CI?
A: Use production for new repos and basic for existing ones, then raise it one level at a time. The profiles are cumulative, so safety includes everything in basic and moderate.

Q: Should I pin the ansible-lint action to a tag or a SHA?
A: Pin to a commit SHA and leave the tag in a comment. Tags can move, and the official docs example uses @main, which changes under you. Dependabot can bump a SHA-pinned action for you.

Q: Can ansible-lint fix problems automatically?
A: Partly. ansible-lint --fix handled 6 of 14 violations on my test playbook, mostly module names and casing. It also can't decide changed_when conditions, file modes, or package versions for you.

Q: Why does my CI check pass when I know a task is wrong?
A: Check .ansible-lint-ignore first. An entry there silences a rule for the whole file, so new violations of that rule pass as warnings.

Quick Summary:

  • ansible-lint exits with code 2 on violations, so it works as a required PR check with no extra scripting
  • A four-task test playbook produced 14 violations across nine rule IDs; --profile min passed, production failed all 14
  • Use ansible/ansible-lint pinned to a commit SHA (v26.10.1 is 9bce2bfb8f4a984e6b13767bc0d6bf0a49492164), not the docs example's @main
  • Pass requirements_file so roles and collections install before linting
  • --fix resolved 6 of 14 and left the judgment calls; read the diff before committing
  • --generate-ignore is per file and per rule, so new violations of an ignored rule pass silently, and --strict fails while ignores exist
  • Local installs need Python 3.14 or older for ansible-core 2.21.5

Start with --profile basic on your current repo, generate the ignore file once, and commit the Ansible Lint GitHub Actions workflow. Then delete one ignore line per week until the file is empty.

Top comments (0)