Skip to main content
RunBook Academy

Git, CI/CD & GitOpsCX · Ansible Delivery PipelineLint

YAML and lint in CI — yamllint and ansible-lint as the first gates

Advanced⏱ ~26 mingitansible

What you'll learn

  • Explain why yamllint and ansible-lint run before any other gate in the pipeline
  • Configure yamllint with a minimum Ansible-friendly ruleset and run it in CI
  • Configure ansible-lint, exclude non-Ansible paths, and run it as a required check
  • Recognise which classes of bug yamllint and ansible-lint cannot catch

Prerequisites

Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x

Not yet marked complete on this device.

The first two gates a commit meets on its way through an Ansible pipeline are yamllint and ansible-lint. They are deliberately the cheapest gates in the pipeline: they read files in the working tree, apply a rule set, and exit. They never connect to a managed host, never resolve variables against an inventory, and never execute a task. Their job is to catch the mistakes that should never reach the slower, more expensive gates downstream.

Why these two come first

A typical Ansible pull request touches YAML files, Jinja templates, role metadata, and playbooks. Four classes of mistake dominate the failure modes of an unwritten gate:

  • YAML structural errors - wrong indentation, mixed tabs and spaces, document-start markers, line-length violations, truthy strings parsed as booleans.
  • Ansible best-practice violations - bare command: modules where ansible.builtin.command: is required, missing name: on tasks, deprecated FQCNs, ignore_errors: yes without a justification comment.
  • Role metadata errors - missing meta/main.yml fields, unsupported galaxy_info keys, invalid dependency declarations.
  • Variable-naming and truthy-string pitfalls - yes/no interpreted as booleans, unquoted version strings parsed as numbers, copy-pasted indentation drift between tasks.

yamllint and ansible-lint catch each of these classes without needing the inventory, the vault, or a managed host. They run in milliseconds on a laptop and in seconds in CI. Putting them first means a contributor gets the lint signal before they wait for Molecule to provision a container.

flowchart LR
    A["Working tree"] --> B["yamllint ."]
    B -->|fail| BX["PR blocked, comment posted"]
    B -->|pass| C["ansible-lint --exclude .github/"]
    C -->|fail| CX["PR blocked, comment posted"]
    C -->|pass| D["ansible-playbook --syntax-check"]

The diagram is intentionally small. Two gates, both static, both fast, both credential-free.

yamllint in CI

yamllint is a general-purpose YAML linter, not an Ansible tool. Its strength is that it knows nothing about Ansible semantics, which is the point: it catches the YAML errors that would confuse the Ansible parser before the Ansible parser ever sees the file. A typical CI invocation runs against the whole repository:

yamllint .

The exit code is what CI uses as a gate: zero for clean, non-zero for any rule violation. A minimal .yamllint configuration that plays well with Ansible is:

extends: default
rules:
  line-length:
    max: 160
  truthy:
    allowed-values: ['true', 'false']
  comments:
    min-spaces-from-content: 1
  document-start:
    present: false

The two non-default choices matter. truthy.allowed-values keeps Ansible’s yes/no play keys working without flagging them. document-start: false lets playbooks omit the --- marker that Ansible itself does not require. The rest of the ruleset is default, which is enough to catch the bulk of structural drift without becoming a discussion forum in code review.

ansible-lint in CI

ansible-lint is the Ansible-specific half of the lint gate. It understands FQCNs, role layout, task structure, and the rules that the community has codified as Ansible best practice. A typical CI invocation excludes CI scaffolding:

ansible-lint --exclude .github/

The --exclude .github/ argument is the difference between a useful gate and a noisy one. .github/workflows/ is YAML, and yamllint is happy to lint it, but ansible-lint will try to interpret the workflow files as playbooks and report failures that are not failures. Excluding the CI configuration directory keeps the lint scope aligned with what the tool was written to analyse.

Ansible-lint reads its configuration from .ansible-lint at the repository root. A baseline that fits most teams:

profile: production
exclude_paths:
  - .github/
  - .cache/
  - .ansible/
skip_list:
  - role-name  # acceptable while the codebase predates the FQCN rule

The profile: production line activates the strictest preset. Rules that the team has decided to defer live in skip_list, with a comment in the configuration file pointing at the issue tracker entry that justifies the deferral.

What these two gates cannot catch

It is as important to know what the lint gates cannot catch as to know what they can. A playbook that passes yamllint and ansible-lint can still:

  • Reference a variable that does not exist in any inventory or role defaults.
  • Call a module with arguments that fail at apply time on a real host.
  • Be idempotent on the first run and non-idempotent on the second.
  • Work on the developer’s laptop and fail on a managed host with a different Python version.
  • Remove a file the host depends on, install a package the host already has, or restart a service that does not exist.

Each of these failure modes is caught by a downstream gate. The lint gates are a filter, not a proof.

Production discipline

  1. Both linters run on every PR that touches YAML. Skipping the lint stage for “documentation-only” PRs is how a malformed README change breaks the next time a non-doc change inherits the same path.
  2. The lint job has no vault password. A lint job that can decrypt vault variables is a lint job that needs the same secret-scanning treatment as the apply job.
  3. Lint output is posted back to the PR. A green check with no log line is hard to debug when the next PR fails; an inline annotation per finding is the difference.
  4. --exclude lists are committed. A exclude path that lives in CI configuration and not in .ansible-lint will silently break when the CI environment changes.
  5. The lint stage fails closed. A new rule added to a newer ansible-lint release that produces a warning is treated as a failure on the next PR run, not left to accumulate.

Cross-course references

  • Ansible for Production Sysadmins - Part X (YAML) covers the YAML rules that yamllint enforces, including the truthy-string pitfall.
  • Ansible for Production Sysadmins - Part XII (LintRules) covers the FQCN rule and the playbook-style rules ansible-lint ships by default.
  • This course, Part LI (AnsibleCI) - lesson git-cicd-gitops-li-02 covers the per-rule mechanics this Part assembles into a CI job.
  • This course, Part CVIII (IaCIntegration) - lesson git-cicd-gitops-cviii-03 covers how the lint stage compares to tflint and kubeconform in other pipelines.

Quiz

Knowledge check · 4 questions

  1. Q1. A playbook passes yamllint but fails ansible-lint with 'Use FQCN for module command'. What is the smallest, most targeted fix?

  2. Q2. Running ansible-lint with `--exclude .github/` is required in CI to keep the workflow YAML files from being parsed as playbooks and producing false positives.

  3. Q3. Name three classes of bug that yamllint catches but ansible-lint does not, and one class that ansible-lint catches but yamllint does not.

  4. Q4. Diagnose a CI pipeline that runs only yamllint and produces a green badge on a broken playbook.

    A team runs `yamllint .` as their sole lint gate. A contributor writes a playbook task `command: systemctl restart nginx`. yamllint parses the YAML as well-formed and the PR is merged. The apply job then fails on every host with 'ERROR! No module named command' because ansible-core requires the FQCN.

Passing score: 75%. Answers are checked in this browser.