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.
CI must not touch managed hosts
Section titled “CI must not touch managed hosts”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.
Installing the tooling reproducibly
Section titled “Installing the tooling reproducibly”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.txtWhat 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.1ansible-lint==26.1.0yamllint==1.38.0Quote 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.ymlPin 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.
The three offline checks
Section titled “The three offline checks”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=localansible_connection=local guarantees that even an accidental real run stays on the runner.
Going further: molecule
Section titled “Going further: molecule”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.
Vault-encrypted content
Section titled “Vault-encrypted content”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.ymlTwo 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: - experimentalprofile 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)") PYThat 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.
Testing roles with molecule
Section titled “Testing roles with molecule”--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:
- Create — provision a container.
- Converge — run the role against it. Catches undefined variables, wrong module arguments, failed
when:expressions — everything a syntax check cannot see. - Idempotence — run it again and fail if anything reports
changed. - Verify — run assertions about the resulting state.
- 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.
Secrets that never reach a fork
Section titled “Secrets that never reach a fork”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.ymlThe 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 githubQuote 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.
Collections are a supply chain
Section titled “Collections are a supply chain”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.
Testing across distributions
Section titled “Testing across distributions”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-ansiblesteps: - 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.
Checking that roles are documented
Section titled “Checking that roles are documented”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 $missingCrude, 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.
What this pipeline still cannot tell you
Section titled “What this pipeline still cannot tell you”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.
--checkis 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.
Detecting drift on real infrastructure
Section titled “Detecting drift on real infrastructure”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.txtTwo 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.
The complete pipeline
Section titled “The complete pipeline”-
Lint job. Checkout,
setup-pythonwith pip caching, install pinned tooling, install pinned collections,yamllint,ansible-lint --format github,--syntax-checkagainst the CI inventory. -
Molecule job, per role. Optional and slower;
fail-fast: falseso every role reports. -
Path filter. Scope the trigger to
playbooks/**,roles/**andinventory/**so unrelated pull requests skip the run.
Exercise
Section titled “Exercise”-
Copy
examples/github-actions/ansible-ci/lint.ymlinto an Ansible repository, add therequirements-ci.txtabove and the placeholder inventory, and push a branch. -
Introduce a duplicate key in a playbook’s
vars:block. Confirmyamllintfails and that Ansible itself would have silently ignored the first block. -
Write a task that uses
command: systemctl restart nginxinstead of theansible.builtin.servicemodule. Confirmansible-lintflags it, and read the rule’s explanation. -
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 githubdoing its job. -
Add a role reference that does not exist and confirm
--syntax-checkcatches it without any connection to a host.
Then what?
Section titled “Then what?”You have now seen all eight CI pipelines in this cluster. The next cluster takes their output and puts it somewhere.
Skip the boilerplate — the Professional Toolkit has five production-ready workflow templates.