Skip to main content
RunBook Academy

ObservabilityLXXXV · CI ValidationCIValidation

YAML Lint

Foundation⏱ ~16 minbash

What you'll learn

  • Run yamllint against the observability configuration tree and interpret the finding code
  • Write a .yamllint configuration that matches the team style without being noisy
  • Distinguish a syntax error (blocker) from a style finding (warning) in CI
  • Wire yamllint into a pre-commit hook and a GitHub Actions job

Prerequisites

Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13

Not yet marked complete on this device.

A team merges a Grafana dashboard provisioning change. The YAML is valid. Prometheus is happy. The change deploys. Grafana fails to load the dashboard at runtime because the key datasources (plural) was spelled datasource (singular) in two places and Grafana silently ignored the panel. A three-character typo shipped to production. yamllint, wired in correctly with a project-aware rule set, catches the duplicate-key mistake in 100 milliseconds. The lesson is that the lint is not just a style tool. With the right configuration, it is the cheapest gate that catches a class of mistakes that the schema-aware checks miss.

What it is

yamllint is a Python linter for YAML files. It validates syntax against the YAML 1.2 specification and applies a configurable set of style rules. The default rule set is opinionated: it flags line length, indentation style, key ordering, truthy values, comment placement, and more. Most teams override the defaults to match their style guide; the override is the .yamllint configuration file at the repo root.

The findings yamllint emits have a three-character code, a severity, and a line and column reference:

observability/grafana/dashboards/checkout.yml
  12:5  warning  truthy  value is not truthy (use 'true' instead of 'yes')
  23:1  error    syntax  found character '\t' that cannot start any token

Two properties:

  1. It is purely structural. yamllint parses the YAML and applies its rules. It does not understand the schema of any specific tool (Prometheus, Grafana, Loki). The schema-aware checks are promtool check config, amtool check-config, etc. yamllint is the layer below.
  2. It is configurable per rule. Each rule has a severity (error, warning, info) and can be disabled. A team that finds line-length too noisy can set its max to a project-appropriate number or disable it.

Why a sysadmin cares

The lint is the cheapest gate that catches a real class of mistake. The four shapes it catches most often:

  1. Syntax error. Tabs instead of spaces, unclosed quotes, duplicate keys. The schema-aware checks also catch these, but yamllint catches them earlier in the pipeline and with better error messages.
  2. Duplicate key. datasources: followed by datasources: further down. The second silently overwrites the first in most YAML parsers (depending on the implementation); the lint flags the duplicate immediately.
  3. Truthy value. enabled: yes parses as a boolean true, but the YAML 1.2 specification defines yes/no as strings, not booleans. The lint flags it; the tool consuming the YAML may or may not coerce.
  4. Style drift. Inconsistent indentation (2 spaces here, 4 spaces there), trailing whitespace, missing document marker. The schema-aware checks pass these files, but the diff is hard to read and merge conflicts are common.

Each of these is invisible to the schema-aware checks. Each is caught in 100 milliseconds per file.

How it works

The mental model:

  yamllint -c .yamllint observability/
        |
        v
  Walk the named paths (or recurse if a directory)
        |
        v
  For each *.yml, *.yaml, *.yaml.j2 file:
    Parse the YAML
    Apply each enabled rule
    Collect findings with code, severity, line, column
        |
        v
  Print findings to stdout
  Exit 1 if any finding has severity "error"
  Exit 0 otherwise (warnings and infos do not fail)

The exit code is the gate. yamllint exits 1 only on error-level findings. Warnings and infos print but do not fail the run; the team’s .yamllint decides which rules are errors.

How to configure it

The .yamllint configuration is a YAML file at the repo root. A reasonable starting point for an observability configuration repo:

# .yamllint
extends: default

rules:
  # Syntax-level rules are blockers.
  braces:
    level: error
  brackets:
    level: error
  colons:
    level: error
  commas:
    level: error
  comments:
    level: error
    require-starting-space: true
    min-spaces-from-content: 1
  comments-indentation:
    level: error
  document-end:
    present: false   # many tools require no end marker
  duplicate-keys:
    level: error
  empty-lines:
    max: 2
    max-start: 0
    max-end: 1
  empty-values:
    level: error
  hyphens:
    level: error
    max-spaces-after: 1
  indentation:
    level: error
    spaces: 2
    indent-sequences: consistent
    check-multi-line-strings: false
  key-duplicates:
    level: error
  key-ordering:
    level: warning   # advisory; teams often disable
  new-line-at-end-of-file:
    level: error
  new-lines:
    level: error
    type: unix
  octal-values:
    level: error
    forbid-implicit-octal: true
    forbid-explicit-octal: true
  quoted-strings:
    level: warning   # advisory; many YAML styles don't quote
  trailing-spaces:
    level: error
  truthy:
    level: error
    check-keys: true
    level-key: warning
  # Style rules that are noisy for observability config; disable.
  line-length:
    disable: true

ignore: |
  node_modules/
  .venv/
  vendor/
  observability/test/fixtures/

Two design choices:

  • duplicate-keys: level: error. This is the rule that catches the two-spelled-the-same-key mistake. A team that ships observability configurations has to enable it.
  • line-length: disable: true. Long lines are common in observability configurations (Prometheus selectors, Loki query strings). Enable the rule only if the team is willing to enforce the limit in review.

Locally, as a pre-commit hook:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/adrienverge/yamllint
    rev: v1.35.1
    hooks:
      - id: yamllint
        args: ['-c', '.yamllint']

pre-commit runs yamllint on every staged .yml and .yaml file before the commit lands. A failed lint blocks the commit.

In a GitHub Actions workflow:

# .github/workflows/yamllint.yml
name: yamllint

on:
  pull_request:
    paths:
      - 'observability/**'
      - 'rules/**'
      - '.yamllint'
      - '.github/workflows/yamllint.yml'

permissions:
  contents: read

jobs:
  lint:
    name: yamllint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install yamllint
        run: pip install yamllint==1.35.1
      - name: Lint
        run: yamllint -c .yamllint observability/ rules/

The job runs only on pull requests that touch the configuration. The pip pin (==1.35.1) locks the version; CI must use the same yamllint version as the pre-commit hook.

How to validate it

Three checks confirm the gate is wired correctly.

1. The lint accepts a known-good file.

yamllint -c .yamllint observability/prometheus/prometheus.yml

Expected output, exit 0:

(no output)

No output means no findings. yamllint is quiet on success.

2. The lint rejects a known-bad file.

# observability/prometheus/bad.yml
global:
  scrape_interval: 1m
  evaluation_interval: 30s

scrape_configs:
  - job_name: 'node'
    static_configs:
      - targets: ['localhost:9100']
    scrape_interval_hours: 1   # tab-indented
yamllint -c .yamllint observability/prometheus/bad.yml

Expected output, exit 1:

observability/prometheus/bad.yml
  9:5  error  syntax  found character '\t' that cannot start any token

The error names the file, line, column and finding code. The fix is to replace the tab with spaces.

3. The CI job is wired correctly.

Open a draft pull request that intentionally introduces a duplicate key. Confirm the yamllint job exits non-zero. Revert the change; confirm the job exits 0 and the merge is allowed.

How it can fail

Six failure modes specific to yamllint:

  1. The configuration is too noisy. Symptom: every PR triggers dozens of findings, the team stops reading them, real errors get lost in the noise. Cause: the .yamllint extends default without overrides, and the default rule set is opinionated about line length, quoting and indentation. Disable or relax the rules that are not blockers for the team.

  2. The ignore: path includes too much. Symptom: real mistakes in the ignored paths ship to production. Cause: the team added a directory to ignore: to silence noise without reading every file in the directory. Audit the ignored paths quarterly; remove the ones that are no longer needed.

  3. The pre-commit hook and the CI use different yamllint versions. Symptom: a finding that passes locally fails in CI (or vice versa). Cause: the pre-commit hook pins v1.35.1 but the GitHub Actions workflow installs 1.35.0. Pin both to the same version.

  4. yamllint cannot parse Jinja-templated YAML. Symptom: yamllint fails to lint a *.yaml.j2 file because the Jinja template syntax is not valid YAML. Cause: the file is a template, not a rendered YAML. Add the file to ignore: or render it before linting.

  5. The truthy rule is too strict. Symptom: the lint flags enabled: yes even in configurations where the consuming tool accepts yes as a boolean. Cause: YAML 1.2 defines yes/no as strings, not booleans, but many tools coerce. Set truthy.check-keys: false or relax the rule to warning.

  6. The lint runs against the wrong directory. Symptom: a change in observability/ is not linted. Cause: the CI job’s paths: trigger is too narrow (for example, observability/prometheus/** only). Broaden the trigger to cover the whole configuration tree.

How to troubleshoot it

In order:

  1. Read the finding code. yamllint’s three-character code (for example, syntax, duplicate-keys, truthy) is the key to the rule. The yamllint -d flag prints the rule documentation for the named code.
  2. Run on one file at a time. When the lint rejects a directory, isolate the offending file with a single path argument and rerun.
  3. Confirm the .yamllint configuration is in scope. yamllint walks up the directory tree looking for the configuration file. If no .yamllint is found, the default rule set is used.
  4. Check the ignore paths. A file in ignore: is silently skipped. Confirm the file is not in ignore: when it should be linted.
  5. Confirm the yamllint version. yamllint --version must match the version pinned in CI.

Security implications

  • yamllint prints file content on error. A configuration that contains a basic-auth password or a bearer token will leak the secret into the CI log on parse error. Use a secret manager or a credential file mounted at reload time, not committed credentials.
  • yamllint does not authenticate against any service. It is read-only and does not expose any new endpoint. Safe to run from any workstation.
  • The truthy rule may force a style change that exposes a hidden coercion. A enabled: yes that was silently coerced to true becomes enabled: true after the lint flag, and the consuming tool’s behaviour is now explicit. This is good practice but should be a deliberate change.

Performance implications

The lint is fast. A configuration tree of two hundred YAML files validates in well under a second on commodity hardware. The cost is dominated by the YAML parse for each file; CI budgets the lint at 100–500 ms per file. There is no production cost from running the lint; it is purely a pre-merge gate.

Production guidance

  • Run yamllint on every pull request that touches the configuration. The cost is sub-second; the benefit is a class of mistakes that would otherwise cost a reload or a missed metric.
  • Start from the default rule set and override the rules that are noisy for the team. Disable line-length and quoted-strings if the team is not ready to enforce them.
  • Pin the yamllint version in CI to the same version as the pre-commit hook.
  • Audit the ignore: paths quarterly. Remove the ones that are no longer needed.
  • Wire yamllint at every stage: pre-commit hook for immediate feedback, CI job for the merge gate, nightly run for files the pre-commit hook missed.

Verification

You should now be able to answer:

  • What four shapes does yamllint catch that the schema-aware checks do not?
  • How do you make a yamllint rule a blocker (exit 1) instead of a warning (exit 0)?
  • Why does a Jinja-templated YAML file need to be rendered before linting?
  • What is the difference between yamllint and promtool check config?
  • Why must the pre-commit hook and the CI job pin the same yamllint version?

Quiz

Knowledge check · 8 questions

  1. Q1. yamllint differs from promtool check config in that yamllint:

  2. Q2. The .yamllint rule that catches a duplicate key in the same mapping is:

  3. Q3. yamllint exits 1 only when a finding has severity error. Warnings and infos print but do not fail the run.

  4. Q4. Which of these findings are caught by yamllint?

  5. Q5. Name the file that controls yamllint behaviour at the repo root.

  6. Q6. The lint flags enabled: yes as a truthy value. The team accepts yes as a boolean in their tooling. The right configuration is:

  7. Q7. yamllint can parse a *.yaml.j2 Jinja template file without any preprocessing.

  8. Q8. The right cadence for yamllint is:

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