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 fourteen 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::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.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.
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; 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>. |
json |
Scripts and dashboards. | summary plus pages[], each with every citation’s claim and source ends, its origin and anchor, and every finding’s rule, ruleId, severity, message and line. 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 page as artifactLocation.uri, region.startLine, and a fingerprint stable across a move. 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. |
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. A citation records the
commit its pin was minted at, and two of the check’s answers need the file as
it was then. source-never-true is the finding that the pin never matched at
that commit, and the diff under --show-diff runs from that commit to HEAD.
On a shallow clone neither is possible, and the check degrades honestly rather
than guessing:
manni: commit 9265563 not found in history; use fetch-depth: 0 to enable never-true and diffs✗ docs/limits.md ✗ fetch-timeout :13 current lib/limits.ts:2 changed (history unavailable: commit 9265563 not found; fetch-depth: 0)The 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.
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 diffs, no commit subjects.It says so only when a citation carries a commit, 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 # never-true and --show-diff need the pin's commit- uses: actions/setup-node@v6with: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 -y @hawkeyexl/manni 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.
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 rule and the entry’sid(or itssource.integritywhen it has none). So a baselined finding survives a move on either end, and accepting a changed claim never reopens one. It disappears only when the source pin is re-minted, which is the act that resolves it. Regenerate the file with--write-baselinewhenever it is convenient; it only ever shrinks. 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.
To see the same lines locally, run with -f github from your shell; drop the
flag for the readable report.