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.
How the gate works
Section titled “How the gate works”Two properties carry the integration, and they are the family’s rather than this tool’s.
Exit codes drive the gate.
| Code | Meaning |
|---|---|
0 | Every linted file was clean. |
1 | At least one error-level finding. |
2 | The 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.
The workflow
Section titled “The workflow”-
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: guidespaths: ["docs/**/*.md"]exclude: ["**/drafts/**"]lint:templates:- ./templates.yaml -
Add the workflow.
.github/workflows/structure.yml:.github/workflows/structure.yml # Check that every page has the shape its doctype promises.name: Check structureon:push:pull_request:jobs:lint:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v7- uses: actions/setup-node@v6with: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 structurerun: npx -y @hawkeyexl/manni lint check -f github -
Narrow it, if you want to.
--collectionruns 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 githubThe flag is repeatable, one name per occurrence, never comma-separated.
-
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.
Run it beside the metadata gate
Section titled “Run it beside the metadata gate”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 githubif: 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.
Output formats
Section titled “Output formats”| Format | Use it for | Shape |
|---|---|---|
pretty | People, 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. |
github | GitHub 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. |
json | Scripts 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. |
sarif | GitHub 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. |
junit | The 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.
Upload SARIF to code scanning
Section titled “Upload SARIF to code scanning”Annotations vanish with the job log. Code scanning keeps a finding open until someone closes it, and groups it by rule id across runs.
- 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-lintA 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.
Publish JUnit to the tests tab
Section titled “Publish JUnit to the tests tab”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.xmlOne 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.
When nothing gets linted
Section titled “When nothing gets linted”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.
Ramp it in
Section titled “Ramp it in”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 -1It always exits 0, and the last line reads 142 files, 37 routed, 105 unrouted.
Verify it works
Section titled “Verify it works”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 withmanni: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.