Skip to content

Ansible CI with GitHub Actions: Lint and Verify Playbooks

Lesson 8 of 8Intermediate12 min readGitHub Actions & CI/CD · Continuous IntegrationVerified: actions/setup-python v7, ansible-lint, August 2026

Ansible has no compiler. A playbook with a typo in a module name, an undefined variable or a malformed loop is perfectly valid YAML, and you find out it was wrong when it fails halfway through configuring a production host — leaving that host in a state neither the old configuration nor the new one describes.

CI is the compiler Ansible does not have.

The complete workflow is at examples/github-actions/ansible-ci/lint.yml, validated by npm run check:workflows.

The boundary here is the same one as Terraform CI, and for the same reason. A pull request is untrusted input; a playbook run is a change to real machines.

Everything on this page validates the playbooks — parsing, structure, module usage, variable resolution. Nothing connects to a managed host. A GitHub-hosted runner should not hold SSH keys for your fleet, and a pull request should not be able to reach it.

Ansible, ansible-lint and the collections all version independently, and a floating install means a pull request can fail because an upstream release changed a rule overnight.

- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements-ci.txt
- name: Install Ansible tooling
run: |
python -m pip install --upgrade pip
pip install -r requirements-ci.txt

What it doesInstalls a pinned set of Ansible tooling from a requirements file, using pip's cache keyed on that file.

Why we run it`pip install ansible-lint` with no constraint installs whatever is newest. A new lint rule then fails pull requests that changed nothing, and the fix gets attributed to the wrong commit.

Expected resultA cache hit on repeat runs and identical tool versions across every run of the same commit.

A minimal requirements-ci.txt pins the tools, not just names them:

ansible-core==2.20.1
ansible-lint==26.1.0
yamllint==1.38.0

Quote the Python version in YAML. python-version: 3.13 unquoted is the float 3.13, and python-version: 3.10 unquoted becomes 3.1 — a real and frequently-hit bug covered in YAML syntax.

Collections are a second, separate dependency system:

- name: Install collections
run: ansible-galaxy collection install -r requirements.yml

Pin versions in requirements.yml too. A collection is executable code downloaded from Galaxy at build time; treating it as unversioned is the same supply-chain exposure as an unpinned action.

yamllint checks the file as YAML — indentation, duplicate keys, line length, trailing spaces:

- name: Lint YAML
run: yamllint .

Duplicate keys are the finding that justifies the step on its own. YAML silently keeps the last occurrence, so a playbook with two vars: blocks quietly discards the first, and nothing else in the pipeline notices.

ansible-lint understands Ansible semantics — module names, deprecated syntax, idempotency smells, missing name: on tasks:

- name: Lint Ansible
run: ansible-lint --format github

--format github emits ::error file=…,line=…:: workflow commands, so findings appear as annotations on the changed lines in the pull request diff instead of only in the log. This is the single highest-value flag on this page: it turns a log nobody opens into inline review comments.

syntax check parses the playbook, resolves roles and imports, and expands includes:

- name: Syntax check
run: |
ansible-playbook \
--inventory inventory/ci \
--syntax-check \
playbooks/site.yml

--syntax-check does not connect to anything. It needs an inventory only because the playbook’s hosts: patterns have to resolve to something, so inventory/ci is a committed placeholder:

[web]
ci-placeholder ansible_connection=local
[db]
ci-placeholder-db ansible_connection=local

ansible_connection=local guarantees that even an accidental real run stays on the runner.

Molecule runs a role against a throwaway container and asserts the result. It is the closest thing Ansible has to a unit test:

molecule:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
role: [common, webserver, database]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements-ci.txt
- run: pip install -r requirements-ci.txt
- name: Test the role
run: molecule test
working-directory: roles/${{ matrix.role }}

The default scenario provisions a container, converges the role, runs verification, then runs the role a second time and asserts nothing changed. That idempotence check is what catches the classic Ansible bug: a task using command where a module exists, which reports “changed” on every run and makes it impossible to tell a real drift from noise.

Containers are a real constraint, not a detail. Systemd, kernel modules, mounts and firewall rules behave differently or not at all inside one. Roles that manage those need either a privileged container image built for the purpose or a different test strategy.

Repositories with Ansible Vault files need the password available for a syntax check that touches them:

- name: Write the vault password
env:
VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
run: |
printf '%s' "$VAULT_PASSWORD" > "$RUNNER_TEMP/vault-pass"
chmod 600 "$RUNNER_TEMP/vault-pass"
- name: Syntax check
run: ansible-playbook --vault-password-file "$RUNNER_TEMP/vault-pass" --syntax-check playbooks/site.yml

Two habits are doing the work here. The secret arrives through env: and is referenced as a shell variable, never interpolated directly into the command line — the rule that prevents script injection. And the file is written to $RUNNER_TEMP, not the workspace, so it cannot be picked up by a later upload-artifact glob.

Configuring ansible-lint so people act on it

Section titled “Configuring ansible-lint so people act on it”

ansible-lint out of the box reports a lot, and a repository adopting it mid-life will have hundreds of findings. The usual outcome is that the step is set to continue-on-error: true and never looked at again.

A configuration file is what makes it adoptable:

{/* .ansible-lint */}
profile: production
exclude_paths:
- .github/
- molecule/
skip_list:
- yaml[line-length]
warn_list:
- experimental

profile is the most useful setting. The profiles are cumulative — min, basic, moderate, safety, shared, production — so a repository can adopt basic, fix what it reports, and step up. That turns an unbounded backlog into a sequence of finishable pieces.

warn_list reports without failing, which is where a rule belongs while the team is deciding whether they agree with it. skip_list disables it entirely. Prefer warn_list first: a rule silently skipped is a decision nobody revisits.

Variable precedence, and why CI catches so little of it

Section titled “Variable precedence, and why CI catches so little of it”

Ansible resolves a variable from more than twenty sources with a defined precedence order, and nothing on this page validates that resolution. A playbook that is syntactically perfect can still apply the wrong value because a group var shadowed a role default in a way nobody intended.

The offline checks cannot see this, because precedence depends on the inventory being used. What CI can do is assert on the resolved values for a known inventory:

- name: Assert resolved variables for the CI inventory
run: |
ansible-inventory --inventory inventory/ci --list --yaml > resolved.yaml
python - <<'PY'
import yaml
data = yaml.safe_load(open("resolved.yaml"))
hostvars = data.get("_meta", {}).get("hostvars", {})
for host, vars_ in hostvars.items():
assert vars_.get("app_port") == 8080, f"{host}: unexpected app_port"
print(f"checked {len(hostvars)} host(s)")
PY

That is a test of your inventory, which is usually the least tested and most surprising part of an Ansible repository. It catches the case where adding a host to a new group silently changed a value somewhere else.

--syntax-check parses. Molecule executes, which is the only way to catch the errors that matter:

molecule:
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
role: [common, webserver, database]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip
cache-dependency-path: requirements-ci.txt
- run: pip install -r requirements-ci.txt
- name: Test the role
run: molecule test
working-directory: roles/${{ matrix.role }}

The default scenario runs a sequence worth knowing, because each step catches a different class of bug:

  1. Create — provision a container.
  2. Converge — run the role against it. Catches undefined variables, wrong module arguments, failed when: expressions — everything a syntax check cannot see.
  3. Idempotence — run it again and fail if anything reports changed.
  4. Verify — run assertions about the resulting state.
  5. Destroy — tear it down.

Step 3 is the one that earns molecule its place. A role using command where a module exists reports changed on every run, which makes it impossible to distinguish real drift from noise across a whole fleet — and it is invisible until something runs the role twice.

Vault-encrypted content is the recurring difficulty in Ansible CI. A pull request from a fork receives no secrets, so the vault password is absent and any check touching encrypted files fails.

The right structure is two jobs with different trust levels:

jobs:
lint:
{/* Runs on every pull request, including forks. No secrets required. */}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: yamllint .
- run: ansible-lint --format github
vault-checks:
{/* Only on push to the default branch, where secrets are available. */}
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Write the vault password
env:
VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
run: |
printf '%s' "$VAULT_PASSWORD" > "$RUNNER_TEMP/vault-pass"
chmod 600 "$RUNNER_TEMP/vault-pass"
- name: Syntax check with vault
run: |
ansible-playbook \
--vault-password-file "$RUNNER_TEMP/vault-pass" \
--inventory inventory/ci \
--syntax-check playbooks/site.yml

The lint job is the required check, because it can run for every contributor. The vault job runs after merge, which means a vault-related breakage is caught on main rather than on the pull request — an accepted trade, and a much better one than making the vault password reachable from untrusted code.

A related practice worth adopting: keep a committed, non-secret copy of the structure of your vaulted variables — the keys, with placeholder values — so that a pull request adding a new variable can be checked for completeness without decrypting anything.

Testing against more than one ansible-core

Section titled “Testing against more than one ansible-core”

A repository consumed by several teams cannot assume everyone upgrades together, and ansible-core deprecations land on a predictable schedule:

strategy:
fail-fast: false
matrix:
ansible-core: ["2.19", "2.20"]
steps:
- run: pip install "ansible-core~=${{ matrix.ansible-core }}.0" ansible-lint yamllint
- run: ansible-lint --format github

Quote the versions. 2.20 unquoted is the float 2.2, which is the YAML trap from YAML syntax and one that Ansible’s own version numbering walks straight into.

Two versions is usually enough: the one your control node runs and the next one. Testing further back maintains compatibility nobody has asked for, and fail-fast: false ensures you learn which version broke rather than that one did.

ansible-galaxy collection install -r requirements.yml downloads and installs executable code at build time. It is the same category of dependency as an action in a uses: line, and it deserves the same treatment.

{/* requirements.yml */}
collections:
- name: community.general
version: "12.4.0"
- name: ansible.posix
version: "3.0.1"
roles:
- name: geerlingguy.postgresql
version: "4.0.2"

Pin exact versions, not ranges. An unpinned collection means a build can behave differently today than yesterday with no commit in between — and a collection is code that runs against your infrastructure, so “it changed and we did not notice” has a wider blast radius than a linting difference.

Cache the installed collections, keyed on the file that determines them:

- uses: actions/cache@v6
with:
path: ~/.ansible/collections
key: galaxy-${{ runner.os }}-${{ hashFiles('requirements.yml') }}

The key hashes requirements.yml, so a version bump invalidates it and nothing else does — the rule from caching.

Roles that install packages and manage services are the ones most likely to be distribution-specific, and molecule can run the same scenario against several base images:

strategy:
fail-fast: false
matrix:
include:
- role: webserver
image: geerlingguy/docker-ubuntu2404-ansible
- role: webserver
image: geerlingguy/docker-rockylinux9-ansible
- role: database
image: geerlingguy/docker-ubuntu2404-ansible
steps:
- name: Test
run: molecule test
working-directory: roles/${{ matrix.role }}
env:
MOLECULE_DISTRO: ${{ matrix.image }}

include without axes gives exactly these three jobs rather than the product — the pattern from matrix builds. Not every role needs every distribution, and generating combinations you do not support wastes runner minutes to answer a question you did not ask.

The scenario’s molecule.yml reads MOLECULE_DISTRO for the image, which keeps one scenario file serving every matrix leg.

fail-fast: false matters more here than usual: “it works on Ubuntu and fails on Rocky” is the exact finding this matrix exists to produce, and the default would cancel the second leg as soon as the first one failed.

An Ansible repository accumulates roles whose variables are documented nowhere, and the cost lands on whoever next has to use one.

- name: Check role documentation
run: |
missing=0
for role in roles/*/; do
name="$(basename "$role")"
if [ ! -f "${role}README.md" ]; then
echo "::error file=${role}::role ${name} has no README.md"
missing=1
fi
if [ -f "${role}defaults/main.yml" ] && [ -f "${role}README.md" ]; then
while read -r var; do
grep -q "$var" "${role}README.md" || \
echo "::warning file=${role}defaults/main.yml::${name}: ${var} is not documented"
done < <(grep -oE '^[a-z_][a-z0-9_]*:' "${role}defaults/main.yml" | tr -d ':')
fi
done
exit $missing

Crude, and it works: a missing README is an error, an undocumented default variable is a warning, and both appear as annotations against the file rather than in a log. Warnings for the variables rather than errors is deliberate — the aim is a gentle ratchet, not a wall that makes people delete the check.

Worth being explicit about, because a green Ansible pipeline can feel like more assurance than it is:

  • That the playbook does the right thing on a real host. Containers differ from machines, and molecule’s coverage stops where systemd and the kernel begin.
  • That variable precedence resolves as intended for the production inventory. The check earlier on this page tests the CI inventory; the real one has more groups and more overrides.
  • That a change is safe to apply now. Ansible has no plan step comparable to Terraform’s. --check is the nearest thing and it requires real access, which is why it is a scheduled job on an internal runner rather than part of CI.
  • That a task is idempotent against real state. Molecule proves idempotence against a fresh container. A host that has drifted for two years is a different question.

The honest summary is that Ansible CI validates the automation, and validating the outcome needs a scheduled run against real infrastructure with read-only credentials. Both are worth having, and they answer different questions.

Everything above validates the playbooks. A separate, scheduled workflow can answer a different question: does the fleet still match them?

on:
schedule:
- cron: '0 5 * * 1'
workflow_dispatch:
jobs:
drift:
runs-on: [self-hosted, ansible-control]
environment: production-readonly
steps:
- uses: actions/checkout@v7
- name: Report drift
run: |
ansible-playbook \
--inventory inventory/production \
--check --diff \
playbooks/site.yml | tee drift.txt

Two constraints make this defensible, and both are non-negotiable:

It runs on a self-hosted runner inside the network, because a GitHub-hosted runner would need SSH access to your fleet from the public internet. Read secure self-hosted runners before setting one up — in particular, it must not be attached to a public repository.

It is triggered by schedule or workflow_dispatch only — never by pull_request. As the warning earlier on this page says, --check is not a security boundary: it connects with real credentials, and tasks with check_mode: false execute for real. A pull request that could trigger this would be a pull request that can run commands on production.

  1. Lint job. Checkout, setup-python with pip caching, install pinned tooling, install pinned collections, yamllint, ansible-lint --format github, --syntax-check against the CI inventory.

  2. Molecule job, per role. Optional and slower; fail-fast: false so every role reports.

  3. Path filter. Scope the trigger to playbooks/**, roles/** and inventory/** so unrelated pull requests skip the run.

  1. Copy examples/github-actions/ansible-ci/lint.yml into an Ansible repository, add the requirements-ci.txt above and the placeholder inventory, and push a branch.

  2. Introduce a duplicate key in a playbook’s vars: block. Confirm yamllint fails and that Ansible itself would have silently ignored the first block.

  3. Write a task that uses command: systemctl restart nginx instead of the ansible.builtin.service module. Confirm ansible-lint flags it, and read the rule’s explanation.

  4. Remove a name: from a task and confirm the finding appears as an annotation on the exact line in the pull request diff — that is --format github doing its job.

  5. Add a role reference that does not exist and confirm --syntax-check catches it without any connection to a host.

You have now seen all eight CI pipelines in this cluster. The next cluster takes their output and puts it somewhere.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.