Skip to content

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.

Two properties carry the integration, and they are the same two manni meta rests on.

Exit codes drive the gate.

CodeMeaning
0Every citation is current, or every finding is a warning or a notice.
1At least one finding is an error and not in the baseline.
2The 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-changed

Each 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.

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-changed

In 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 finding

json 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:.
FormatUse it forShape
prettyPeople, 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.
githubGitHub 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.
jsonScripts 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.
sarifGitHub 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.
junitThe 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.

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-changed

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.

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.

  1. 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 citations
    on:
    push:
    pull_request:
    jobs:
    cite:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v7
    with:
    fetch-depth: 0 # tells never-true from changed
    - uses: actions/setup-node@v7
    with:
    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 citations
    run: npx --yes @hawkeyexl/manni@latest cite check "docs/**/*.md" -f github
  2. 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 under collections: in manni.config.yaml, drop the argument entirely and the same command works locally and in CI.

  3. 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.

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 github

That 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.

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:

.pre-commit-config.yaml
repos:
- repo: https://github.com/hawkeyexl/manni
rev: v4.28.0
hooks:
- id: manni-cite

pre-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.

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.

  1. Record the backlog once. --write-baseline runs the check, writes every current finding’s fingerprint to .manni-cite-baseline.json, and exits 0:

    Terminal window
    manni cite check --write-baseline
    1 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.

  2. 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 --baseline
    1 file checked, 1 passed, 0 failed, 2 findings (2 baselined)
  3. 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.

  4. Burn the backlog down. Each update or update --accept that resolves a finding removes it from the picture. A fingerprint is made of the schema, the rule, the entry’s position in citations and its id (or its source.integrity when 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-baseline whenever 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 rotate re-keys the pin of every encrypted citation, so an id-less one returns as new. Re-record the baseline after a rotation.

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.

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-moved warning.
  • One that only adds a paragraph above a cited sentence leaves it green with a manni:cite/claim-moved notice.
  • Running manni cite update in that branch clears both.
  • With the hook installed, pre-commit run manni-cite --all-files reports 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.