Skip to main content

Turn comments into help screens: Ansible playbooks and roles

ansible-help reads the comments, argument specs, and role metadata a repo already has, and prints what each playbook and role does, what it takes, and what is still open. For engineers who run or maintain playbooks somebody else wrote.

A terminal running ansible-help cert_rotation against the demo repo, with five numbered callouts: the description, the include_role invocation, the inputs table from argument_specs, the first header section with a count of the eleven more it prints, and the hazards list with file and line for each marked comment.

To learn what a role expects, you read its meta/argument_specs.yml, its defaults/, the play that calls it, and every comment that says "do not remove this". In the demo repo that ships with this README, the cert_rotation role keeps 7 of those comments in 5 different directories. ansible-help cert_rotation prints all of them, with the inputs table and the header's intent, on one screen. The documentation is already in the files; the work this tool does is reading it back out.


Install

pipx install ansible-help                  # or: python -m pip install ansible-help
ansible-help                               # run from anywhere inside an Ansible repo
ansible-help <playbook-or-role>            # show one entry
ansible-help -a <playbook-or-role>         # also every other comment and the README section
ansible-help --check                       # report comments and docs that drifted from the code
ansible-help --help                        # the guide: what it reads, why, how to add docs it picks up

House-style header support, play argument specs, -a, --check, and the --help guide (below) are newer than the 1.0.5 release on PyPI. Until the next release ships, install from a checkout to get it:

Prereqs: Python 3.10+.

git clone https://github.com/fullsteam-corp-shared/pypi-ansible-help.git
cd pypi-ansible-help
python -m pip install -e .                 # console script ansible-help -> ansible_help.cli:main
cd examples/demo-repo && ansible-help      # try it on the demo repo

That is the entire setup. The package has no runtime dependencies, no configuration file, and two flags besides help: -a / --all and --check. -h prints the option list; --help prints a guide to what the tool reads, why, how to use it, and how to add docs it picks up, ending with where to extend cli.py.


Why this exists

Two cards for the same file, playbooks/rotate_certs.yml. On the left, amber, ansible-help 1.0.5 prints the title as rotate_certs and dumps every header section as raw text with no inputs parsed. On the right, green and the same size, the current version prints the real title, the purpose, a three-row inputs table, examples, and the first header section; its count line reads 67 lines, 3 inputs, 1 hazard.

Comment headers in this house style have 14 fixed sections, and they are the first thing in every file. To ansible-help 1.0.5 they were noise: it read the first sentence of rotate_certs.yml: renew the ... as rotate_certs., found no Inputs: section, and printed 57 lines of raw header with no inputs table. The low-leverage fix is a second, shorter docblock written for the tool, which drifts from the real header within a release. The high-leverage fix is a parser that reads the header people already maintain, so the help screen and the code review read the same text.

$ ansible-help playbooks/rotate_certs.yml      # ansible-help 1.0.5
rotate_certs

 PURPOSE
 Provides the operator entry point for certificate renewal, so nobody renews a certificate by
 hand on one host and forgets the others.

 INTENDED BEHAVIOR
 Dry-run by default: report which certificates are inside the renewal window and stop. With
 cert_dry_run=false, request new certificates, install them, and reload nginx once per host.

Where it looks

ansible.cfg at the top, with collections_path set to ./collections, fanning out to four cards: local playbooks under playbooks/*.yml, local roles under roles/*/, each collections_path root's ansible_collections tree, and the ~/.ansible/collections fallback used only when collections_path is unset.

ansible-help walks up from the current directory until it finds ansible.cfg, and treats that directory as the repo root. Without an ansible.cfg it stops with error: Could not locate repo root (expected ansible.cfg).

It then looks in these places, in this order:

Order Where When
1 playbooks/*.yml and roles/*/ under the repo root always
2 each entry of collections_path in ansible.cfg (: or ; separated; relative entries resolve against the repo root, ~ expands) when collections_path is set
3 ~/.ansible/collections only when collections_path is unset, matching Ansible's default
4 ./collections when it exists, for older repo-local layouts

In rows 2 to 4, playbooks and roles are read from <root>/ansible_collections/<ns>/<coll>/playbooks/ and .../roles/. Row 2 is the one most repos depend on: the tool reads the same collections_path that ansible-playbook does, so a name that resolves for one resolves for the other.

Each item is listed under one name, and show mode accepts a few more:

Item Listed as Also accepted by show mode
Local playbook playbooks.<file>.yml playbooks.<file>, the file path, and the bare <file> when no role has that name
Local role roles.<name> <name>
Collection playbook or role <ns>.<coll>.<name> nothing else

Whichever name you type for a local playbook, show mode prints the path ansible-playbook accepts, so the usage line can be pasted as is:

$ ansible-help scale_web
Playbook: playbooks/scale_web.yml

ansible-playbook playbooks/scale_web.yml [-e "[replicas=]"]

  replicas      OPTIONAL        Defaults to 3. Aliases: count. Inferred from set_fact input-resolution task (heuristic).

ansible-help playbooks.scale_web.yml prints the same lines. A bare name is tried only after every playbook and role form has failed, so a role named scale_web would win.


What the list shows

The demo repo's 10 playbook files and 3 roles pass through an amber filter into the real ansible-help list output, which shows 7 playbooks. Three red pills sit outside it: acme.ops.smoke_test, hidden by cli_help_hidden: true; acme.ops.run_named, a wrapper with a templated import and no description; and playbooks/purge_cache.yml, shown once as acme.ops.purge_cache.

Run with no arguments, ansible-help lists every playbook and role it can resolve, sorted, with the one-line description it would print in show mode.

$ ansible-help
For more information on a specific playbook or role, type ansible-help <name>

Playbooks:
---------------
acme.ops.backup_db           Take a compressed logical backup of one database.
acme.ops.purge_cache         Purge the CDN cache for the web tier.
playbooks.deploy_web.yml     Deploy a release of the web tier to one environment.
playbooks.provision_web.yml  Provision a new web host and serve static files from one document root.
playbooks.restart_web.yml    
playbooks.rotate_certs.yml   Renew the web tier's TLS certificates before they expire.
playbooks.scale_web.yml      

Roles
---------------
acme.ops.db_backup           Dump, restore-test, and upload a database backup.
roles.cert_rotation          Renew and install TLS certificates for the web tier.
roles.webserver              Install and configure the nginx web tier.
Left out Rule Why
A collection playbook whose top docblock has a line cli_help_hidden: true (or cli_help_hidden=true) explicit opt-out CI fixtures and internal plays stay runnable but unlisted
A collection playbook that only does import_playbook and has no description wrapper-only, nothing to say a templated target such as "{{ target_playbook }}.yml" cannot be followed
A local playbook that a collection playbook imports listed once, under the collection name the collection wrapper shows the local file's description, one hop deep

Descriptions longer than the terminal are cut with ..., so a narrow terminal shows less of each line.


Docblocks: the original convention

Five lines from the docblock of playbooks/deploy_web.yml on the left, each connected by an arrow to the line ansible-help prints for it on the right: the summary sentence becomes the description, and four input bullets become four rows of name, REQUIRED or OPTIONAL, and detail.

A top-of-file # comment block is read as a docblock. Its first sentence is the description, Name: lines start sections, and the input sections become a table. Every convention here predates house-style support and still works; a file is only read the new way when it has a house header.

---
# Deploy a release of the web tier to one environment.
#
# Rolls hosts in batches behind the load balancer, so a bad release stops at the
# first batch instead of reaching every host.
#
# Inputs:
#   - web_env (required, staging or production)
#   - release_tag or tag (required, git tag to deploy)
#   - batch_size (optional, defaults to 1, hosts updated per batch)
#   - overwrite (optional, replace an existing release directory)
#
# Examples:
#   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0"
#   ansible-playbook playbooks/deploy_web.yml \
#     -e "web_env=staging release_tag=v2.4.0 overwrite=true"
#
# Tags:
#   --tags config
#   --skip-tags restart
#
# Rollback:
#   Re-run with the previous release_tag. Old release directories are kept.
$ ansible-help playbooks/deploy_web.yml
Deploy a release of the web tier to one environment

Rolls hosts in batches behind the load balancer, so a bad release stops at the
first batch instead of reaching every host.

ansible-playbook playbooks/deploy_web.yml [-e "[web_env=] [release_tag=] [batch_size=] [overwrite=true]"]

  web_env       REQUIRED        Staging or production
  release_tag   REQUIRED        Alias: tag. Git tag to deploy
  batch_size    OPTIONAL        Defaults to 1. Hosts updated per batch
  overwrite     OPTIONAL        Replace an existing release directory

Examples:

   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0"
   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0 overwrite=true"

Options (tags):

   --tags config
   --skip-tags restart

Rollback:

     Re-run with the previous release_tag. Old release directories are kept.
Docblock element Read as Printed as
First sentence-like line description, up to the first . followed by a space or the line end, so 24.04, README.md, and 3.5 do not cut it short the first output line, trailing . dropped
==== banners, ns.coll.name identifier lines, single-word headings, CLI_HELP_INPUTS / END_CLI_HELP_INPUTS / Canonical CLI lines skipped when picking the description
Inputs:, Required inputs:, Command Line Arguments:, Parameters: the first of these present the inputs table and the ansible-playbook ... [-e "..."] line
Examples:, Example:, Run:, Run from repo root (.env and tool resolution rely on $PWD): ansible-playbook commands, wrapped lines joined Examples:
Tags: lines starting -- Options (tags):
Any other Name: section context printed under its own heading
cli_help_hidden: true list opt-out nothing; see the list rules above

Input lines take two forms, and a section should use one of them:

Form Example Row
Bullet with qualifiers - release_tag or tag (required, git tag to deploy) release_tag REQUIRED Alias: tag. Git tag to deploy
Name, qualifiers, then - description (bullet optional) db_name (required) - Database to dump. db_name REQUIRED Database to dump

Qualifiers are comma separated: required or optional (an entry is REQUIRED unless it says optional), defaults to <value>, and free text that becomes detail. An input named overwrite is shown as [overwrite=true] in the invocation line.

When a playbook has no docblock

A play argument spec, when the playbook has one, comes before the input section (see Play argument specs). With no input section, show mode falls back to heuristics, in this order:

Heuristic Trigger Detail column says
Role argument specs the play's only task is ansible.builtin.include_role the role's meta/argument_specs.yml options
set_fact input resolution a task sets <name>_input and matches item.key | lower == '...' or in [...] Inferred from set_fact input-resolution task (heuristic).
include_role var mapping selectattr('key', 'match', '(?i)^name$') in a thin wrapper Mapped to include_role vars from CLI/runtime vars (heuristic).
roles: list and play vars: none of the above found an input, and the play lists roles or sets literal vars each listed role's argument_specs options, then its meta/main.yml dependencies' options (each row ending From role <name>), then play vars as Defaults to <value> (set by the play)

The last row only fills a table that would otherwise be empty, so a playbook that already printed inputs prints the same ones. A trailing comment on a play var line becomes part of its row: common_action: plan # override with -e 'common_action=apply' reads Defaults to plan (set by the play): override with -e 'common_action=apply'. Play vars whose names start with _ are play-private helpers and are not listed as inputs. Vars that an include_role task in the play passes itself are dropped, since the caller cannot set them.

$ ansible-help playbooks/restart_web.yml
Playbook: playbooks/restart_web.yml

ansible-playbook playbooks/restart_web.yml [-e "[web_env=] [web_port=] [web_worker_count=]"]

  web_env               REQUIRED        Environment the host serves, staging or production
  web_port              OPTIONAL        Defaults to 8080. Port nginx listens on behind the load balancer
  web_worker_count      OPTIONAL        Defaults to 4. nginx worker processes per host
$ ansible-help playbooks/scale_web.yml
Playbook: playbooks/scale_web.yml

ansible-playbook playbooks/scale_web.yml [-e "[replicas=]"]

  replicas      OPTIONAL        Defaults to 3. Aliases: count. Inferred from set_fact input-resolution task (heuristic).

Roles

A role's description is meta/main.yml galaxy_info.description when present. Otherwise it is the first line of the docblock in tasks/main.yml (or meta/main.yml), and meta/argument_specs.yml short_description only when both are missing. Inputs come from meta/argument_specs.yml options first, including multi-line, folded, and list descriptions, then from a docblock input section. A role that documents no inputs of its own gets the options of its meta/main.yml dependencies, followed depth first, with each row ending in From role <name>. Examples come from the docblock; with none, the tool prints a generic ansible-playbook <playbook> -e <first input>=<value> line.

$ ansible-help roles.webserver
Install and configure the nginx web tier

ansible.builtin.include_role name=webserver vars: [[web_env=] [web_port=] [web_worker_count=]]

  web_env               REQUIRED        Environment the host serves, staging or production
  web_port              OPTIONAL        Defaults to 8080. Port nginx listens on behind the load balancer
  web_worker_count      OPTIONAL        Defaults to 4. nginx worker processes per host

Renders one server block per host and reloads nginx only when the rendered
configuration changed.

Examples:

   ansible-playbook playbooks/deploy_web.yml -e "web_env=staging release_tag=v2.4.0"

House-style headers

The header of playbooks/rotate_certs.yml on the left rebuilds the ansible-help output on the right line by line: the title loses its rotate_certs.yml: prefix and trailing period and gains a capital, the PURPOSE paragraph becomes the summary, the inputs table is filled from the cert_rotation role's argument_specs, both ansible-playbook commands lift out of MAINTENANCE into Examples with the backslash continuation joined into one line, the other sections cross over in header order as printed headings while the three placeholder sections (UNKNOWN, NOT APPLICABLE, NOT APPLICABLE) dissolve as skipped, and the warning comment on line 69 flies into Hazards and open items as playbooks/rotate_certs.yml:69.

A file is read as house style when its top comment block contains at least 2 of the 14 section headings, each followed by a dashed underline. For a role, the header is looked for in tasks/main.yml, then meta/main.yml, and the description prefers galaxy_info.description, then argument_specs short_description, then the header title. Everything else goes through the docblock parser above, so a repo can migrate one file at a time.

Header part Printed as Notes
<file> (context): responsibility. title line the description file prefix removed, first letter capitalized
PURPOSE the paragraph under the description
Inputs table the ansible-playbook or include_role line and table from a play argument spec (below), argument_specs, bullet lines under INPUTS, the heuristics above, or the roles in a play's roles: list and their meta/main.yml dependencies; literal play vars: become optional inputs with the play's default, and vars an include_role task passes itself are dropped
Any ansible-playbook ... line, in any section Examples: house headers keep commands in MAINTENANCE, so the tool looks everywhere
Every other section, in header order its own sentence-case heading (Intended behavior:, Implementation overview:, Key elements:, ...), body verbatim, after the examples; INPUTS prints as Inputs (from header): a section whose body is only UNKNOWN, NOT APPLICABLE, or REQUIRES FURTHER REPOSITORY CONTEXT is not printed
$ ansible-help playbooks/rotate_certs.yml
Renew the web tier's TLS certificates before they expire

Provides the operator entry point for certificate renewal, so nobody renews a certificate by
hand on one host and forgets the others.

ansible-playbook playbooks/rotate_certs.yml [-e "[cert_domain=] [cert_renew_days_before=] [cert_dry_run=]"]

  cert_domain                   REQUIRED        Domain the certificate is issued for
  cert_renew_days_before        OPTIONAL        Defaults to 21. Renew when fewer than this many days remain
  cert_dry_run                  OPTIONAL        Defaults to true. Report what would be renewed and change nothing

Examples:

   ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com"
   ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com cert_dry_run=false"

Intended behavior:

   Dry-run by default: report which certificates are inside the renewal window and stop. With
   cert_dry_run=false, request new certificates, install them, and reload nginx once per host.

Implementation overview:

   One play over the web group runs the cert_rotation role, which checks expiry, renews, installs,
   and notifies the reload handler.

Inputs (from header):

   cert_domain is required. cert_renew_days_before and cert_dry_run have role defaults.

Outputs and side effects:

   In dry-run, prints the certificates due for renewal and changes nothing. Otherwise writes new
   certificate and key files under /etc/ssl/web and reloads nginx.

Key elements:

   The play "Rotate web tier certificates" and its cert_rotation role.

Callers and consumers:

   Operators run this with ansible-playbook. A scheduled job is planned but does not exist yet.

Internal dependencies:

   roles/cert_rotation.

External dependencies:

   The ACME endpoint named in roles/cert_rotation/vars/main.yml, and nginx on every web host.

Constraints and assumptions:

   The web hosts must reach the ACME endpoint on 443. Run against one environment at a time.

Maintenance:

   Keep these operator commands aligned with the role defaults:
     ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com"
     ansible-playbook playbooks/rotate_certs.yml \
       -e "cert_domain=shop.example.com cert_dry_run=false"

Hazards and open items:

   playbooks/rotate_certs.yml:69
     ⚠ serial: 1 is NOT a leftover from debugging. Renewing every host at once reloads every nginx
     at once, and the load balancer has no healthy backend for the length of the reload.

The header's INTEGRATION AND LINKAGE, DATA FLOW, and DEPENDENCY RELATIONSHIPS sections hold only UNKNOWN or NOT APPLICABLE, so they are the three left out. Compare it with the 1.0.5 output under Why this exists: same file, 67 lines instead of 57, with the inputs table and examples the old output never found and without the placeholder sections it printed.

Hazards and open items

A rail listing the cert_rotation role's tasks, handlers, defaults, vars, and meta directories feeds seven cards, one per marked comment, each showing its file and line and the first line of the comment, colored by marker: amber for warnings and NOT OPTIONAL, green for ANSWERED, cyan for DELIBERATE, rust for PARKED.

In a house-style playbook or role, comments that start with a status marker are collected under Hazards and open items:, each with its path:line. For a role, the tool reads every YAML file under tasks/, handlers/, defaults/, vars/, and meta/. For a playbook, it reads the playbook file itself. Files without a house header get no hazards section, which keeps their output as it was.

Marker Means
⚠ (including ⚠ STILL OPEN) a hazard, or a guard that must not be removed
✅ (✅ ANSWERED, ✅ HALF-ANSWERED) a question closed, with the evidence
PARKED a known gap, deferred on purpose
DELIBERATE looks like an omission, is not
NOT OPTIONAL a step the system is broken without

A marker only counts at the start of a comment line, so a ✅ inside a task's msg: string is not a hazard. An entry runs until a blank # line, the next marker, a banner, or code.

$ ansible-help cert_rotation
Hazards and open items:

   roles/cert_rotation/tasks/main.yml:70
     ⚠ STILL OPEN (2026-09-30): hosts with no certificate yet fail the task above instead of being
     treated as due. Decide whether first issuance belongs in this role before relying on it for a
     new host.

   roles/cert_rotation/tasks/renew.yml:7
     ✅ ANSWERED (2026-09-22): the ACME client keeps the old key when asked to, so a renewal does not
     invalidate any pinned public key. Verified against the staging endpoint.

   roles/cert_rotation/tasks/renew.yml:18
     NOT OPTIONAL: nginx reads the key as a non-root worker. Without this mode the reload succeeds
     and the next TLS handshake fails.

   roles/cert_rotation/handlers/main.yml:2
     DELIBERATE: reload, not restart. A restart drops in-flight connections; a reload picks up the
     new certificate without dropping them.

Play argument specs: validate_argspec

Five source lines from playbooks/provision_web.meta.yml and playbooks/provision_web.yml, each on a cyan card with its file and line number, and under each the line ansible-help prints for it on a green card: the spec description becomes the summary, the web_env description becomes a REQUIRED row, default: /srv/www becomes the web_doc_root row's Defaults to /srv/www, the play's webserver role adds the web_port row, and validate_argspec: true becomes the line naming the provision web host spec in provision_web.meta.yml.

ansible-core 2.20 added argument validation for plays, as a tech preview. A play opts in with the validate_argspec keyword, and its spec lives in <playbook>.meta.yml next to the playbook. ansible-playbook validates the play's variables against that spec before the first task, so the spec cannot quietly disagree with what a run accepts. ansible-help reads the same file, so the inputs table it prints is the one ansible-core enforces.

---
- name: provision web host
  hosts: web
  validate_argspec: true
  roles:
    - webserver
  tasks:
    - name: Create the document root
      ansible.builtin.file:
        path: "{{ web_doc_root }}"
        state: directory
        mode: "0755"
---
argument_specs:
  provision web host:
    description: Provision a new web host and serve static files from one document root.
    options:
      web_env:
        description: Environment the host serves, staging or production.
        type: str
        required: true
        choices: [staging, production]
      web_doc_root:
        description: Directory nginx serves static files from.
        type: str
        default: /srv/www
$ ansible-help playbooks/provision_web.yml
Playbook: playbooks/provision_web.yml

Provision a new web host and serve static files from one document root.

ansible-playbook playbooks/provision_web.yml [-e "[web_env=] [web_doc_root=] [web_port=] [web_worker_count=]"]

  web_env         	REQUIRED	Environment the host serves, staging or production
  web_doc_root    	OPTIONAL	Defaults to /srv/www. Directory nginx serves static files from
  web_port        	OPTIONAL	Defaults to 8080. Port nginx listens on behind the load balancer
  web_worker_count	OPTIONAL	Defaults to 4. nginx worker processes per host

Validated by ansible-core 2.20+ before the first task: validate_argspec "provision web host" in playbooks/provision_web.meta.yml
In the files Read as
validate_argspec: true the spec named after the play's name, as ansible-core does
validate_argspec: <name> that spec; a templated value ({{ ... }}) cannot be resolved without a run, so it is skipped
<playbook>.meta.yml, then .meta.yaml, then .meta the meta file, in ansible-core's lookup order
The selected spec's options the inputs table, ahead of a header INPUTS section or a docblock Inputs: section; rows for the roles in the play's roles: list follow, because -e still sets them
The spec's description, else the meta file's top-level description the description, when the header or docblock gives none; list mode uses it too
A selected spec that is missing or has no options mapping a Warning: line under the table, because ansible-playbook stops with a parser error at that play
The meta file itself never listed as a playbook; with -a, its comments are printed after the playbook's

--check reports a bad selector as argspec-missing on its validate_argspec line, and an option with no description as spec-no-description. A play spec's options count as defined, so a role var that the play spec declares is not reported as undeclared-input. Playbooks without validate_argspec print exactly what they printed before.


Every other comment: -a

An animated terminal runs ansible-help -a playbooks/rotate_certs.yml: the 67 lines of default output scroll past, then a scan line sweeps rotate_certs.yml, stamping the house header on lines 2 to 67 as already on screen and the marked comment on line 69 as already under Hazards, and keeps the trailing comment on line 74, which travels along a wire into the output under All comments. A second scan finds the heading rotate_certs.yml in playbooks/README.md and sends that section, fenced code included, under From playbooks/README.md. A counter rolls from 67 to 86 lines, and a tally lists 66 header lines and 2 marked lines not printed again and 0 lint directives such as noqa and yamllint.

The default screen is for running a playbook; the comments that explain why a task looks the way it does stay in the file. ansible-help -a <playbook-or-role> (or --all, before or after the name) prints the default output unchanged, then appends two parts. All comments: is every comment in the files, verbatim, under its path:line: for a playbook, the playbook file; for a role, every YAML file under tasks/, handlers/, defaults/, vars/, and meta/. After it come the README docs that describe the entry, under From <path>:.

In the files With -a
A run of # lines printed verbatim from its first line; a bare # keeps the paragraph break, banner lines are dropped
A trailing comment on a code line printed with its code line, so the note keeps its subject
The house header and marked comments skipped, because they are already on screen
Lint directives (# noqa, # yamllint, # nosec, # pylint, # type:, # pragma, # fmt:) dropped; they are for tools, not readers
README.md next to the playbook, then the repo-root README.md the section whose heading names the playbook file, down to the next heading at its level or above; # lines inside a fenced block are not headings
A role's README.md printed in full

In the demo playbook every header line and the one marked comment are already on screen, so All comments: holds a single inline comment, followed by the rotate_certs.yml section of playbooks/README.md. The first 62 lines are the default output shown above:

$ ansible-help -a playbooks/rotate_certs.yml
Hazards and open items:

   playbooks/rotate_certs.yml:69
     ⚠ serial: 1 is NOT a leftover from debugging. Renewing every host at once reloads every nginx
     at once, and the load balancer has no healthy backend for the length of the reload.

All comments:

   playbooks/rotate_certs.yml:74
     gather_facts: false  # the role reads expiry with openssl on the host, so facts are never used

From playbooks/README.md:

   ## rotate_certs.yml

   Run it once without `cert_dry_run=false` and read the list of certificates due. Then run it
   again with `cert_dry_run=false` inside a change window:

   ```bash
   ansible-playbook playbooks/rotate_certs.yml -e "cert_domain=shop.example.com cert_dry_run=false"
   ```

   A host that has never had a certificate fails the expiry check. First issuance is still an open
   question in the `cert_rotation` role, so issue that host's first certificate by hand.

A file without a house header skips nothing, so its docblock appears twice: once parsed at the top, once verbatim under All comments:.


Find drift: --check

ansible-help --check against the demo repo: a scan beam sweeps a tree of the 10 playbooks and 3 roles it checked, and each drifted comment it catches lights amber and travels to the terminal as a compiler-style finding: two documented-unused inputs in acme.ops backup_db.yml at lines 11 and 12, two in playbooks/deploy_web.yml at lines 9 and 11, and a header-placeholder at playbooks/rotate_certs.yml:45 and roles/cert_rotation/tasks/main.yml:43, while the other files get green checks, a counter ticks to 6 findings, and the exit code flips to 1.

Help text read from comments is only as good as the comments, and comments drift: a renamed input stays in the docblock, a deleted file stays in a note, a header section stays UNKNOWN. ansible-help --check reads the same files the help screens come from and reports each place where the comments or docs no longer match the code. It exits 1 on any finding and 0 when clean, so it can gate a pull request the same way a linter does.

With no target it checks every local and collection playbook and role. ansible-help --check <playbook-or-role> checks one entry; a playbook target also checks the roles in its roles: list. Each finding is one compiler-style line, <path>:<line>: <kind>: <message>, so editors and CI annotators can jump to it.

Kind Reported when
undeclared-input a role's YAML files or templates read a var with | default(...), and nothing in the repo defines it (vars and defaults keys, inventory keys, set_fact, register, loop_var, vars: blocks, any role's argument_specs, any play argument spec) and it is not an Ansible magic var: only -e or inventory can set it, and argument_specs does not declare it. Reported once per var per role
documented-unused an input named in a house INPUTS bullet, a docblock Inputs: / Parameters: bullet, or the backticked first column of a parameters table in the playbook's README section appears in no YAML or .j2 code
stale-path a comment names a file path, or a bare *.yml file name, that exists nowhere in the git repo; state/, .tofu-data/, .terraform/, node_modules/, and virtualenvs are skipped without being opened
spec-no-description an option in a role's argument_specs or a play argument spec has no description
argspec-missing a play's validate_argspec selects a spec that <playbook>.meta.yml does not have, or that has no options mapping, or the meta file is missing; ansible-playbook stops with a parser error at that play
header-missing-section, header-placeholder a house header lacks one of the 14 sections, or a section still says UNKNOWN or REQUIRES FURTHER REPOSITORY CONTEXT; NOT APPLICABLE is an answer and is not reported

The demo repo is not clean, on purpose: its playbooks are stubs, so two docblocks list inputs that no task reads, and two headers still have an unanswered section.

$ ansible-help --check
Checked 10 playbook(s), 3 role(s)

collections/ansible_collections/acme/ops/playbooks/backup_db.yml:11: documented-unused: backup_bucket is documented as an input in the docblock but no code uses it
collections/ansible_collections/acme/ops/playbooks/backup_db.yml:12: documented-unused: retention_days is documented as an input in the docblock but no code uses it
playbooks/deploy_web.yml:9: documented-unused: release_tag is documented as an input in the docblock but no code uses it
playbooks/deploy_web.yml:11: documented-unused: overwrite is documented as an input in the docblock but no code uses it
playbooks/rotate_certs.yml:45: header-placeholder: INTEGRATION AND LINKAGE is still a placeholder (UNKNOWN)
roles/cert_rotation/tasks/main.yml:43: header-placeholder: INTEGRATION AND LINKAGE is still a placeholder (REQUIRES FURTHER REPOSITORY CONTEXT)

6 finding(s)

A clean entry prints the count and exits 0:

$ ansible-help --check roles.webserver
Checked 0 playbook(s), 1 role(s)

0 finding(s)

check.py checks those exit codes along with the output: 1 for the whole demo repo and for --check playbooks/rotate_certs.yml, 0 for --check roles.webserver.


The loop

Every block of output in this README, and every line of output in the figures, is a capture from a real run against examples/demo-repo/. check.py re-runs them and fails when anything drifted.

python3 examples/demo-repo/expected/check.py              # re-run every capture; check figure text and README blocks
python3 examples/demo-repo/expected/check.py --update     # accept new output after an intended change
python3 images/build_figures.py                           # redraw the figures from the captures
python3 -m build                                          # the package build CI runs on every PR

After a change to src/ansible_help/cli.py, run check.py first. A changed capture is either the change you meant, which you accept with --update and then rebuild the figures, or a regression in a file format you did not mean to touch. The legacy files in the demo repo (deploy_web.yml, backup_db.yml, the heuristic playbooks, webserver) are the backward-compatibility fixture: their captures should not move when house-style parsing changes.

Releases are automated. One workflow, version-control.yml, runs action-version-control v2, which picks its step from the pull request event. On a pull request to main it checks that every commit is a conventional commit. Each merge rebuilds a release/next pull request from the merged commits, with the changelog and the version in pyproject.toml and src/ansible_help/__init__.py bumped. Merging release/next tags the release, creates the GitHub Release, and publishes to PyPI. ci.yml runs the tests and the package build on every pull request.


Folder structure

pypi-ansible-help/
├── README.md                          # this file; also the PyPI long description
├── pyproject.toml                     # package metadata, console script, version
├── CHANGELOG.md                       # generated by the release workflow
├── .changes/                          # per-version changelog fragments
├── .github/workflows/                 # ci (tests, build), version-control (commit gate, release PR, PyPI)
├── src/ansible_help/
│   ├── __init__.py                    # __version__
│   └── cli.py                         # the entire tool
├── examples/demo-repo/                # a small Ansible repo every README capture comes from
│   ├── ansible.cfg                    # collections_path = ./collections
│   ├── playbooks/                     # docblock, house-style, no-docblock, and validate_argspec playbooks; README.md for -a
│   ├── roles/                         # webserver (docblock), cert_rotation (house style)
│   ├── collections/                   # acme.ops: wrappers, a hidden playbook, a role
│   └── expected/                      # captured output and check.py
└── images/                            # animated SVG figures and the script that draws them

Make it your own

Treat this as a reader for whatever conventions your repo already follows. Four places to extend it: add a section name to the docblock tables in cli.py when your team uses a different heading for inputs; add a marker to STATUS_MARKER_RE when your team flags hazards with another word; add a prefix to LINT_DIRECTIVE_RE when -a prints a tool directive your repo uses; copy examples/demo-repo/ and its check.py pattern into your own repo so your playbooks' help output is checked the same way this README is. ansible-help --help ends with where in cli.py a new input source or a new --check finding goes. What transfers when the tool changes is the comments: a header and a marked hazard still document the file for the next person who opens it, with or without ansible-help.


Where it can still fail

  • It reads files, not runs. Variables set in inventory, group_vars, or at runtime are invisible. Treat the inputs table as what the author documented, and ansible-playbook --syntax-check plus the role's argument_specs validation as the truth.
  • YAML is read line by line, with regular expressions, not with a YAML parser. Unusual indentation, flow-style mappings, or anchors in argument_specs.yml and meta/main.yml can be missed. If an input is missing from the table, check that file's layout first.
  • Mixing the two input-line forms in one section reorders the table. Plain bullets keep their place at the top, and lines in the name (required) - description form are appended after them. Use one form per section to control the order.
  • Imports are followed one hop. A wrapper that imports a wrapper, or imports a templated path, shows its own docblock or nothing.
  • cli_help_hidden: true only hides collection playbooks. Local playbooks under playbooks/ are always listed.
  • A playbook's hazards and -a comments come only from the playbook file. Markers and comments in the roles it runs show up under ansible-help <role> and ansible-help -a <role>, not under the playbook.
  • -a finds playbook docs only under a heading that names the file. A section titled "Certificate rotation" is not matched; put rotate_certs.yml in the heading, as playbooks/README.md in the demo repo does.
  • --check must be the first argument. ansible-help --check cert_rotation works; ansible-help cert_rotation --check exits 2 with error: unsupported arguments.
  • --check exits 1 for an unresolvable target as well as for findings. A CI step cannot tell the two apart by exit code; the target error goes to stderr as error: could not resolve playbook or role target: <name>.
  • undeclared-input treats a var defined anywhere in the repo as defined. A var that an unrelated role or an inventory file happens to define hides a real gap in this role's argument_specs. Read the role's | default(...) reads yourself when an input looks missing.
  • documented-unused is word-based. A documented name that appears anywhere in YAML or template code, as a key, in a string, or in another role, counts as used, so it misses an input that only shares a name with something else.
  • stale-path matches by path suffix or file name across the whole git repo. A comment naming vars/main.yml passes as long as any vars/main.yml exists, even in another role. The repo is the nearest directory with .git, so inside a monorepo that is the whole monorepo.
  • A playbook target checks only the roles in its roles: list. Roles a play reaches through include_role or import_role are not checked with it; ansible-help --check playbooks/restart_web.yml checks 0 roles. Run --check on the role, or with no target.
  • Play argument validation is a tech preview in ansible-core 2.20. The keyword or the meta file layout can still change upstream. ansible-help does not read a .meta.json file, and skips a validate_argspec value that is templated.
  • List mode clips descriptions to the terminal width. Set COLUMNS for a stable width in scripts, as check.py does.

License

MIT, as declared in pyproject.toml.


Built so the help screen and the code review read the same comments.

Metadata

Release files for ansible-help 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for ansible-help 1.1.0
File Size Uploaded
ansible_help-1.1.0.tar.gz 77.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ansible-help 1.1.0
File Interpreter ABI Platform
ansible_help-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 120.2 kB

Release files / ansible_help-1.1.0.tar.gz

Download URL ansible_help-1.1.0.tar.gz
Size 77.8 kB
Tags Source
SHA-256 checksum
How to use checksums
61f0edbc055e168ced3f94185b0fdcdd19eba71ca46bf19da1242f35c366fcca
BLAKE2b-256 checksum
How to use checksums
00995e8c480df56f218c4116d2262a63c3cb8e02b015b5f2b09ba730302c5044
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / ansible_help-1.1.0-py3-none-any.whl

Download URL ansible_help-1.1.0-py3-none-any.whl
Size 42.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
eef6258f340649006d527e19752fff4823d04269fb20e356eb414908bb043e08
BLAKE2b-256 checksum
How to use checksums
598f747f572af8250d7ef2e472b350631b1647e8d8f56edb95d8da265bab5631
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.5

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page