Run it in CI
This page wires manni cite check into CI. It runs it on every push and pull
request. It turns findings into annotations on the line of the sentence, and
fails the job only on an error. It also covers the two things a citation check
needs that a metadata check does not. One is full git history. The other is a
way to adopt it on a corpus whose pins have already drifted.
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 same two manni meta rests on.
Exit codes drive the gate.
| Code | Meaning |
|---|---|
0 | Every citation is current, or every finding is a warning or a notice. |
1 | At least one finding is an error and not in the baseline. |
2 | The check could not run. No inputs and no config, an unknown format, a bad flag. The message goes to stderr, prefixed manni:. |
Warnings and notices never fail the job. source-moved and
claim-changed are warnings by default, and claim-moved is a notice. A page
that says something true should not go red for a comment added above the cited
line. Nor for a typo fixed beside the sentence. The summary counts each apart:
1 file checked, 1 passed, 0 failed, 2 findings (1 warning) (1 notice)Every other rule is an error by default. The severity: map under cite: in
manni.config.yaml can move any of the seventeen rules to error, warning,
notice or off. See the
configuration reference.
-f github produces inline annotations. One workflow command per finding,
pinned to the page and the line of the sentence, with the rule id as the
title. An error and a warning look like this:
::error file=docs/limits.md,line=13,title=manni%3Acite/source-changed::fetch-timeout (lib/limits.ts:2): changed since 9265563, 1 commit. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout.%0Amanni:cite source-changed: The cited lines are not what they were.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#source-changed::warning file=docs/limits.md,line=13,title=manni%3Acite/claim-changed::fetch-timeout: the claim at line 13 has changed since it was pinned. Check it still matches the source, then run manni cite update --accept --only fetch-timeout.%0Amanni:cite claim-changed: The sentence was edited since it was pinned.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#claim-changedEach message ends with what to do. After %0A, GitHub’s escaped line break,
come the rule line and a Guide link to the rule’s section of the
fix page.
GitHub renders each as an annotation on the pull request diff, so a reviewer
sees which sentence went stale without opening the log. A source finding names
the citation by id, then its source as the entry spelled it, then the status.
A claim, marker or anchor finding is already a sentence about the entry, so it
is annotated as it stands. A finding about an entry a sidecar owns is
annotated on the manifest.
Who a finding affects
Section titled “Who a finding affects”A finding says which sentence went stale. Where the repository keeps a content strategy, it also says whom the stale page serves.
The check looks for type: persona and type: journey records in every
collection. Where it finds them, a finding names the records its page names in
personas: and journeys:, sidecars included. A value with no record is left
out. With no records, the output is what it always was. There is no flag and
no config key.
In -f github, -f sarif and -f junit, the message gains one sentence. It
follows the finding’s own text and comes before the guidance lines:
::error file=docs/src/content/docs/cite/ci/index.mdx,line=87,title=manni%3Acite/source-changed::summary-counts-apart (src/cite/reporters/pretty.ts:439-448): changed since b9712a2, 1 commit. Check the sentence against the source, then run manni cite update --accept --only summary-counts-apart. Serves devin (Devin, Platform / CI Engineer) and d5 (Gate citations in CI without blocking on prose).%0Amanni:cite source-changed: The cited lines are not what they were.%0AGuide: https://hawkeyexl.github.io/manni/cite/fix/#source-changedIn pretty, a roll-up follows the last page and comes before the summary
line. It has one row per persona, then one per journey, worst first:
The pages with findings serve 1 persona and 1 journey. devin Devin, Platform / CI Engineer 1 error on 1 page d5 Gate citations in CI without blocking on prose 1 error on 1 page
2 files checked, 1 passed, 1 failed, 1 findingjson carries the same roll-up as a top-level impact list. The
CLI reference has its fields.
The readers never move a verdict. Severities, the exit code and the baseline
stay as they were. A baselined finding names nobody, and a run whose findings
are all baselined reads no record. When the strategy: section is broken, one
notice goes to stderr and the report leaves the readers out:
manni: personas and journeys left out of the report. manni.config.yaml: strategy does not carry "paths". Name a collection under collections:.Output formats
Section titled “Output formats”| Format | Use it for | Shape |
|---|---|---|
pretty | People, and local runs. The default. | One line per citation under its page; the roll-up of who a finding affects; a summary line. -q hides current citations and clean pages. |
github | GitHub Actions annotations. | ::error / ::warning / ::notice workflow commands, one per finding, title=manni:cite/<rule>. A finding on a page that names a persona or journey record says whom the page serves. |
json | Scripts and dashboards. | summary, impact when a finding’s page names a record, and pages[]. Each page holds every citation’s claim and source ends, its origin and anchor, its marker where one anchors it, and every finding’s rule, ruleId, severity, message, line and guidance. A pointer claim adds the value’s line, at. Under --no-check-sources, source statuses are skipped and appear here only. |
sarif | GitHub code scanning and any SARIF consumer. | One result per finding with ruleId: manni:cite/<rule>, level, the annotation’s message, the page as artifactLocation.uri, region.startLine, and a fingerprint stable across a move. Each rule has a plain description and a helpUri to its section of the fix page, and each result carries the guidance lines and properties.guidance. Code scanning drops a location outside the repository, so a manifest that sits outside it is reported on the page. |
junit | The CI “Tests” tab. | One testcase per page, classname manni.cite, a failure per error with type set to the rule id and the annotation’s message. |
update takes pretty or json only. The CLI reference
has every flag. The same output rule holds in every format. A source is
spelled as the page spelled it, never as the decrypted path. See
Public docs, private code for
why that matters.
Full history
Section titled “Full history”actions/checkout fetches one commit by default. When a pin no longer holds,
the check walks back through the commits that touched the source, newest
first. The newest commit where the pin held is its baseline. The diff under
--show-diff runs from there to the working tree. source-never-true is the
finding that the walk reached the file’s first commit and the pin held nowhere.
On a shallow clone the walk can run out first, and the check degrades honestly
rather than guessing:
manni: the source history ends before the pin held; use fetch-depth: 0 to tell never-true from changed✗ docs/limits.md ✗ fetch-timeout :13 current lib/limits.ts:2 changed (history unavailable; fetch-depth: 0) Check the sentence against the source, then run manni cite update --accept --only fetch-timeout. manni:cite source-changed: The cited lines are not what they were. Guide: https://hawkeyexl.github.io/manni/cite/fix/#source-changedThe status is still source-changed, still an error, so the gate holds. The
claim end needs no history, so it is judged either way. What is lost
is the distinction between a line that changed since and a pin that was
wrong from the start. Set fetch-depth: 0 on the checkout that holds the
sources and both come back.
Squash merges need nothing extra. A pin minted on a branch records a branch
commit that a squash, rebase or cherry-pick drops. The walk finds the commit
that main took instead, so the recorded commit-sha is only a hint.
There is no flag to turn git off. The check uses git whenever it is
available, which means git on PATH and the root inside a work tree. A job
without it still runs. The sources are indexed by a directory walk instead of
git ls-files, and the run says once what it gave up:
manni: git is not available here, so citations are checked without history: no never-true, no reanchored claims, no diffs, no commit subjects.It says so only when a citation carries a commit, a pin no longer holds, or
under --show-diff. The
statuses degrade as on a shallow clone, and the gate still holds. To get the
history back, run the job in an image that has git, on a checkout rather than
an unpacked archive.
Standalone workflow
Section titled “Standalone workflow”-
Create the workflow file. Add
.github/workflows/citations.yml:.github/workflows/citations.yml # Check every citation against its source on every push and pull request.name: Check citationson:push:pull_request:jobs:cite:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v7with:fetch-depth: 0 # tells never-true from changed- uses: actions/setup-node@v7with:node-version: 24# `-f github` turns findings into annotations on the line of the# sentence; the exit code fails the job on any error.- name: Check citationsrun: npx --yes @hawkeyexl/manni@latest cite check "docs/**/*.md" -f github -
Adapt the targets. Replace
"docs/**/*.md"with the paths, directories or globs to check, quoted so the shell hands them to the tool intact. With the pages declared as a collection undercollections:inmanni.config.yaml, drop the argument entirely and the same command works locally and in CI. -
Commit and push. On the next pull request the job runs. A clean run passes, a moved end warns or notices without failing, and a changed source fails the job and annotates the sentence.
Add it to a job you already have
Section titled “Add it to a job you already have”The check needs no workflow of its own. A repository that already builds its docs in CI can add one step beside the gates that job runs:
- name: The docs' citations still reach their sources run: npx --yes @hawkeyexl/manni@latest cite check -f githubThat job still wants fetch-depth: 0 on its checkout. Without the source’s
full history, the check cannot tell a changed source from one the pin never
matched. Full history gives the complete picture.
The step fails on an error-severity finding and on nothing else. So it sits beside a frontmatter or a style check without taking the job hostage.
This project runs it that way. The citations on these pages are checked on every pull request, in the job that validates the frontmatter and the glossary.
pre-commit
Section titled “pre-commit”A pre-commit hook catches a broken pin before it reaches CI. manni publishes a pre-commit hook for the citation check, beside the two the metadata tool publishes:
repos: - repo: https://github.com/hawkeyexl/manni rev: v4.28.0 hooks: - id: manni-citepre-commit appends the staged paths, so the hook checks the citations on the pages you are about to commit. The sources resolve from the git root as they always do. Both ends therefore come out of one working tree, which is what makes the hook worth running locally.
A source is read from disk rather than from HEAD. So staging lib/limits.ts
beside docs/limits.md judges the page against the edit you just made. An
edit inside a cited range is source-changed, an error, and the commit stops.
An edit above the range is source-moved, a warning, and the commit lands.
manni cite update rewrites the moved ones.
The hook passes no --no-check-sources, and that is deliberate. The flag
leaves the page-side rules only, so it drops the half that catches a source
edit in the commit that makes it. Where the sources live in a second
checkout, set root: under cite: in
config instead.
rev pins the hook definition, meaning its file pattern and its entry. It
does not pin the CLI. The hook runs @hawkeyexl/manni@latest, so a newer CLI
reaches you without a rev bump. Take the tag from the latest
release, and let
pre-commit autoupdate move it afterwards.
Ramp in with a baseline
Section titled “Ramp in with a baseline”A pin can go stale in a commit that touches no page. So on a corpus that has been cited for a while, the first run will find drift nobody’s pull request caused. The baseline is how the check goes in without blocking on that backlog. It is the same mechanism manni meta uses, in its own file, so the two tools cannot erase each other’s backlog.
-
Record the backlog once.
--write-baselineruns the check, writes every current finding’s fingerprint to.manni-cite-baseline.json, and exits0:Terminal window manni cite check --write-baseline1 file checked, 1 passed, 0 failed, 2 findings (2 baselined)Baseline written to .manni-cite-baseline.json.manni-cite-baseline.json {"version": 1,"generatedWith": "4.13.1","entries": {"docs/limits.md": ["0e5c9308b4e5fd13","d086e4e02879a3dd"]}}Commit the file.
-
Make the PR job report-only. With
--baseline, a finding in the file is shown with a·and counted as baselined. Only a finding that is not in the file fails the job:Terminal window manni cite check --baseline1 file checked, 1 passed, 0 failed, 2 findings (2 baselined) -
Let a scheduled sweep escalate. A second job, on a schedule, runs the check without
--baseline. It fails until the backlog is worked off. It is also where a pin that drifted in a commit touching no page shows up. So that pin never lands on an unrelated pull request. -
Burn the backlog down. Each
updateorupdate --acceptthat resolves a finding removes it from the picture. A fingerprint is made of the schema, the rule, the entry’s position incitationsand itsid(or itssource.integritywhen it has none). So a baselined finding survives a move on either end, and accepting a changed claim never reopens one. Reordering the entries, or inserting one above it, does reopen it. It disappears when the source pin is re-minted, which is the act that resolves it. Regenerate the file with--write-baselinewhenever it is convenient. It records every finding the run sees, so it grows when new findings have appeared since the last one. One exception:manni key rotatere-keys the pin of every encrypted citation, so an id-less one returns as new. Re-record the baseline after a rotation.
Public docs, private code
Section titled “Public docs, private code”When the pages are public and the sources are not, the check splits into two
jobs. A public one runs --no-check-sources and needs no secret, and it still
checks every claim end, because the claim is page-side. A private one checks
the code out beside the docs and runs with --root ../code. It reads the
family encryption key from a secret, MANNI_ENCRYPTION_KEY. The layout, the
encrypted source, the sidecar manifest, and what a reader of the public site
can still see are on their own page.
Verify it works
Section titled “Verify it works”Open a pull request that changes a cited line in a source file. Then check that:
- The job is red, and the changed sentence carries an inline annotation
titled
manni:cite/source-changed. - A pull request that only adds lines above a cited one leaves the job green
with a
manni:cite/source-movedwarning. - One that only adds a paragraph above a cited sentence leaves it green with a
manni:cite/claim-movednotice. - Running
manni cite updatein that branch clears both. - With the hook installed,
pre-commit run manni-cite --all-filesreports the same findings before any of that reaches CI.
To see the same lines locally, run with -f github from your shell; drop the
flag for the readable report.