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.

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.

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.

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.

  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 # never-true and --show-diff need the pin's commit
    - uses: actions/setup-node@v6
    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 -y @hawkeyexl/manni 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.

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 rule and the entry’s 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. It disappears only 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 only ever shrinks. 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.

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