Why Ansible needs a pipeline of its own
Application code goes through CI as a matter of course. Automation code, which can reconfigure every server you own in one command, often goes through nothing. A missing colon in a YAML file is found at the worst moment: halfway through a rolling run on production. A deprecated module name works until the next ansible-core upgrade, and then fails everywhere at once.
A lint and syntax pipeline is the cheapest insurance available. It takes a day to set up properly and prevents a class of avoidable incidents. It is also the base layer for everything else on our Ansible service: testing, approvals and platform deployment all assume that what reaches the main branch is at least well formed.
What good looks like
- Every pull request runs the same checks, in the same pinned environment, in a few minutes.
- A failing check blocks the merge. There is no “I will fix it later” path.
- The ruleset is written down in the repository, with each exception explained.
- The pipeline fails for your mistakes, not for random upstream changes.
- Secrets cannot be committed unnoticed.
How it works in practice
The checks, cheapest first
- yamllint. Formatting and structure. Catches tab characters, duplicate keys and trailing spaces.
- ansible-lint. Ansible-specific rules, such as using fully qualified collection names and naming every task.
- Syntax check.
ansible-playbook --syntax-checkon each entry point playbook. - Secret scanning. A tool such as gitleaks over the diff.
- Role tests. Molecule scenarios for roles that changed (see Molecule testing).
- Optional check mode. A
--check --diffrun against staging for playbooks, as a reviewer aid.
A rules file with intent
# .ansible-lint
profile: moderate
exclude_paths:
- .github/
- molecule/
warn_list:
- experimental
skip_list:
- yaml[line-length] # long URLs in templates, reviewed 2026-10
Profiles are cumulative. We usually start at basic or moderate for an inherited codebase, fix what is easy, record the rest, and tighten towards production, which is the strictest and is intended for content you plan to share or publish. Every entry in skip_list carries a comment explaining why, so exceptions stay deliberate.
A GitHub Actions workflow
name: ansible-ci
on:
pull_request:
push:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pinned tooling
run: pip install -r requirements-ci.txt
- name: Install collections
run: ansible-galaxy collection install -r collections/requirements.yml
- name: yamllint
run: yamllint .
- name: ansible-lint
run: ansible-lint
- name: Syntax check
run: |
for pb in playbooks/*.yml; do
ansible-playbook -i inventories/ci/hosts.yml --syntax-check "$pb"
done
The same steps translate directly to GitLab CI, Azure Pipelines or Jenkins. If you are also modernising the pipelines around them, our CI/CD pipeline service and the guide to pipeline best practices cover the wider design. Pin the contents of requirements-ci.txt, for example ansible-core and ansible-lint to exact versions, and update them deliberately through a tool such as Renovate.
A pre-commit hook
Developers should see failures before pushing. A pre-commit configuration that runs yamllint and ansible-lint locally gives the fast feedback loop and keeps CI as the enforcement layer rather than the first place anyone learns of a problem.
Decisions and trade-offs
Strict from day one or gradual? On a new repository, start strict. On an inherited one, gradual is the only realistic path. Failing 400 findings on the first run teaches people to ignore the tool.
Auto-fix or report only? ansible-lint --fix can rewrite some findings, such as module names. We run it in a branch, review the diff and commit, rather than letting CI modify code.
Check mode on production? We avoid it in CI. Credentials with production reach should not sit in pull request pipelines. Staging check mode is a safer compromise.
When this is not enough. Linting proves style and some correctness; it does not prove behaviour. A role that installs the wrong package version will lint perfectly. That is the job of tests and staging runs.
Common failure modes
- Unpinned tooling. A new linter release adds a rule and every pipeline fails overnight. Pin versions.
- Missing collections in CI. Syntax checks fail because a collection is not installed. Install from a requirements file.
- Skips without reasons. The skip list grows silently. We require a comment and review it quarterly.
- Pipelines that can deploy from any branch. Deployment credentials are limited to the protected main branch.
- Slow pipelines. If CI takes 25 minutes, people bypass it. We cache dependencies and run Molecule only for changed roles.
Delivery and handover
You get the workflow files, the lint configuration, pre-commit hooks, a short document describing the checks and how to add a rule, and branch protection settings. If you deploy via AWX, we connect the merge to a project sync so that what is reviewed is what runs. Secret-handling checks tie in with the Vault practices we set up.
A worked example: introducing the linter to a noisy repository
Teams with years of existing playbooks rarely start clean, and switching the linter on at full strictness produces hundreds of findings that everyone learns to ignore. The approach that works is to ratchet. First, run the linter in report mode and record the current findings as a baseline. Second, fail the build only on findings in files changed by the pull request, so new work is clean while old work is untouched. Third, fix legacy findings in small batches, one rule at a time, starting with the ones that indicate real risk such as missing become handling or shell tasks that should be modules.
Every few weeks, move one rule from warning to error. This keeps the profile getting stricter without a single painful migration week. Skips belong in a configuration file with a comment explaining why, never as inline noqa tags scattered through roles, because a central list can be reviewed and an inline tag cannot.
Finally, pin the linter version in the pipeline and upgrade it deliberately. A new release often adds rules, and an unpinned version means yesterday’s green build is red for reasons nobody changed. Treat the upgrade as its own pull request with its own findings list, so the team sees exactly what the new version objects to and can decide what to fix now and what to schedule.
How to tell it is working
- Pull requests fail early and clearly, and the message tells the author how to fix it.
- Findings per pull request trend downward, and the profile gets stricter over time.
- Nobody has pushed a syntax error to production in months.
- Linter upgrades are a planned change, not a surprise.
To get a baseline lint report on your current repository, contact us.