Skip to content

Gate document structure in CI

You already run manni meta validate over these pages. Structure is the other half of the same question. Metadata says what a page is, and structure says whether it is shaped like one. This page wires that into the same job, reading the same config file, over the same collections.

Nothing is installed into the repository. The recipes run npx, which fetches the published package on demand; the runner needs Node.js 24 or newer.

Two properties carry the integration, and they are the family’s rather than this tool’s.

Exit codes drive the gate.

CodeMeaning
0Every linted file was clean.
1At least one error-level finding.
2The run could not happen. That covers a bad flag value, an unknown tool, an unresolvable template, or a config carrying a moved key. It also covers no inputs and no config, and a run in which every file was skipped. The message goes to stderr, prefixed manni:.

Keep 1 and 2 apart. A job that treats every non-zero code as “the docs are wrong” reports a misconfigured linter as a failing docset. The two need opposite responses. That is the same split manni meta validate and manni a11y check use.

Exit 1 is defined as at least one error-level finding, not “any finding”. Every structural finding is an error but one. A rule asking about content this file’s format cannot report is a warning, it annotates as ::warning, and it never fails the job.

-f github produces inline annotations. One workflow command per finding, anchored to the file and the line, with the rule id as the title:

::error file=docs/rotate-key.md,line=22,col=1,title=manni%3Alint/structure/missing-section::Rotate an API key: Missing section "See also"

A missing section is anchored where it would go. This page ends without its See also, so the annotation lands at the end of the file. A gap in the middle of a page anchors on the section standing where the missing one belongs.

GitHub renders each as an annotation on the pull request diff, so a reviewer sees which page lost its shape without opening the log. The colon in the rule id is percent-encoded, because a workflow command’s properties are comma-and-colon delimited. A clean run prints nothing at all and exits 0.

  1. Declare the pages once. Every manni tool reads the family-level collections: list, so the metadata gate and the structure gate cover exactly the same files:

    manni.config.yaml
    collections:
    - name: guides
    paths: ["docs/**/*.md"]
    exclude: ["**/drafts/**"]
    lint:
    templates:
    - ./templates.yaml
  2. Add the workflow. .github/workflows/structure.yml:

    .github/workflows/structure.yml
    # Check that every page has the shape its doctype promises.
    name: Check structure
    on:
    push:
    pull_request:
    jobs:
    lint:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v7
    - uses: actions/setup-node@v6
    with:
    node-version: 24
    # No paths: the collections in manni.config.yaml supply them, so this
    # is the same command people run locally.
    - name: Check document structure
    run: npx -y @hawkeyexl/manni lint check -f github
  3. Narrow it, if you want to. --collection runs over one declared set at a time, which is how a monorepo gates two docsets in two jobs:

    - run: npx -y @hawkeyexl/manni lint check --collection guides -f github

    The flag is repeatable, one name per occurrence, never comma-separated.

  4. Commit and push. On the next pull request the job runs. A page missing a section its template requires fails the job, and the annotation lands where that section belongs.

The two commands read one config file and one document set, so they belong in one job:

- name: Check metadata
run: npx -y @hawkeyexl/manni meta validate -f github
- name: Check structure
if: always()
run: npx -y @hawkeyexl/manni lint check -f github

if: always() on the second step matters. Without it a metadata failure hides every structural finding, and a contributor fixes one round of annotations only to meet another.

FormatUse it forShape
prettyPeople, and local runs. The default.One line per file, findings indented under it, a summary line. check also names the jobs it ran, on stderr.
githubGitHub Actions annotations.Workflow commands, one per finding, title=manni:lint/<job>/<rule>. The command is the finding’s severity, so a warning is ::warning. Nothing at all on a clean run.
jsonScripts and dashboards.A top-level array, one object per file, each with file, success, and errors[] carrying type, ruleId, tool, heading, message, position and severity.
sarifGitHub code scanning and any SARIF consumer.One result per finding with ruleId: manni:lint/<job>/<rule>, level, and the full line/column span. Skipped files ride along as tool notifications, never as results.
junitThe CI “Tests” tab.One testcase per linted file, classname="manni.lint", one failure per finding whose type is the rule id.

Every shape is in the CLI reference. templates and tools take pretty or json only, because neither reports on documents.

Annotations vanish with the job log. Code scanning keeps a finding open until someone closes it, and groups it by rule id across runs.

.github/workflows/structure.yml
- name: Check document structure
run: npx -y @hawkeyexl/manni lint check -f sarif > lint.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: lint.sarif
category: manni-lint

A clean run still writes a complete SARIF log with an empty results array, because upload-sarif rejects an empty file. Only -f github may print nothing when there is nothing to say.

Run the job from the root of the repository that holds the pages. SARIF paths are relative to a SRCROOT base, and code scanning drops a result whose URI does not resolve against the repository root.

For a dashboard that already reads JUnit, or a runner whose UI has a tests tab:

- name: Check document structure
run: npx -y @hawkeyexl/manni lint check -f junit > lint-results.xml
continue-on-error: true
- uses: actions/upload-artifact@v4
if: always()
with:
name: structure-results
path: lint-results.xml

One testcase per file, so the tab counts pages rather than findings. A skipped page rides along as a passing testcase. The <failure> element’s type attribute is the rule id, which is what groups a recurring failure across runs.

Two operational failures look like success if you are not watching for them, and both are exit 2 rather than 0 on purpose.

No files matched. A glob that matches nothing, or a collection whose paths have moved:

manni: No files matched. Patterns tried: "docs/**/*.md".
Nothing was linted, so this is an error rather than a pass.
Pass --allow-empty (or set allowEmpty: true) if matching nothing is expected.

--allow-empty is for the case where an empty list is legitimate. A shared CI template does that, and so does a pre-commit hook run over changed files only.

Every file was skipped. Pages were found, and none of them routed to a template:

manni: Nothing was checked: all 12 file(s) were skipped. 12 had no template: give a page a "type:" that a template serves, pass -t/--template <ref>, or set "lint.template" as a default. Run "manni lint structure <paths> --explain" to see how each file resolved.

This is the normal first-run result on a docset that does not declare doctypes yet. Run manni lint structure --explain to see the routing, then either add type: to the pages or set lint.template as a default.

manni lint has no baseline file, unlike manni meta and manni cite. Two things ramp it in instead, and both are plain configuration rather than a mode.

Start with one collection. Gate the docset that already declares doctypes, and leave the rest reporting nothing because its pages are skipped.

Start with one doctype. A page with no type: is skipped, not failed, so adding type: how-to to a page is the act that opts it in. The backlog burns down one page at a time, and every page that opts in is gated from that moment.

To see how far along you are without failing anything, run the explain mode in a report-only step:

- name: How many pages route to a template?
run: npx -y @hawkeyexl/manni lint structure --explain | tail -1

It always exits 0, and the last line reads 142 files, 37 routed, 105 unrouted.

Open a pull request that deletes a required section from a page that declares a type:. Then check that:

  • The job is red, and the page carries an inline annotation titled manni:lint/structure/missing-section, anchored where the deleted section used to be.
  • Restoring the section turns the job green.
  • A page with no type: produces no annotation and does not fail the job.
  • A type: no template serves fails the job with manni:lint/structure/unknown-type, and the message names the doctypes that do exist.

To see the same lines locally, run with -f github from your shell; drop the flag for the readable report.