CI recipes for code scanning, GitLab CI, Jenkins, and pre-commit
manni meta is a plain CLI with a stable exit-code contract. It drops into any CI system that can run a Node.js command and read its exit code. This page gives complete, runnable recipes for four common setups. The GitHub Actions gate is on its own page; the first recipe here adds persistence on top of it.
Every recipe relies on the same contract. manni meta exits 0 when all files are
valid and 1 when one or more files fail validation. An operational or usage
error exits 2. Each platform fails the job on a non-zero exit, so the gate is
enforced without extra scripting. For the full definition, see Exit codes & PR
annotations.
GitHub code scanning (upload-sarif)
Section titled “GitHub code scanning (upload-sarif)”--format github renders annotations on the pull request and then loses them
with the job log. --format sarif turns the same findings into tracked
alerts in the Security tab, with state across commits. That is what makes
“when did this regress” and “is our metadata debt going down” answerable.
name: manni meta code scanningon: pull_request: push: branches: [main]
jobs: metadata: runs-on: ubuntu-latest permissions: contents: read # Required by upload-sarif. Without it the upload fails with a 403. security-events: write steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: node-version: '24'
# continue-on-error is load-bearing: validate exits 1 when there are # findings, and the findings are precisely what is worth uploading. - name: Validate metadata id: manni continue-on-error: true run: npx -y @hawkeyexl/manni meta validate "docs/**/*.md" -f sarif > manni.sarif
# if: always() so the upload still runs when the step above reported # findings. Without both of these, nothing is ever uploaded. - name: Upload to code scanning if: always() uses: github/codeql-action/upload-sarif@v3 with: sarif_file: manni.sarif category: manni
# Optional: keep the job red so the gate still blocks the merge. Drop # this step to make code scanning purely informational. - name: Fail on findings if: steps.manni.outcome == 'failure' run: exit 1A distinct category: namespaces the alerts, so manni meta’s findings never merge
with another tool’s. Give each SARIF-producing job its own.
GitLab CI
Section titled “GitLab CI”Add a job to .gitlab-ci.yml. The official node:24 image already has Node.js
and npx, so the job is a single script line. GitLab marks the job failed on
any non-zero exit, which is exactly manni meta’s contract.
validate-metadata: stage: test image: node:24 script: - npx -y @hawkeyexl/manni meta validate "docs/**/*.md"validate-metadata: stage: test image: node:24 # Targets come from collections: in manni.config.yaml at the repo root. script: - npx -y @hawkeyexl/manni meta validateIf you collect machine-readable reports, capture the JSON output as an artifact:
validate-metadata: stage: test image: node:24 script: - npx -y @hawkeyexl/manni meta validate "docs/**/*.md" --format json | tee manni-report.json artifacts: when: always paths: - manni-report.jsontee writes the report to a file while passing manni meta’s exit code through, so
the job still fails on validation errors. when: always keeps the artifact even
when the job fails.
For the merge request’s Tests widget rather than a raw artifact, emit JUnit
XML and hand it to artifacts:reports:junit:
validate-metadata: stage: test image: node:24 script: - npx -y @hawkeyexl/manni meta validate "docs/**/*.md" --format junit | tee manni-junit.xml artifacts: when: always reports: junit: manni-junit.xmlEach file becomes one test, so the widget’s count matches manni meta’s
N files checked, M failed summary.
Jenkins
Section titled “Jenkins”Use a declarative Jenkinsfile stage. A sh step fails the stage when the
command returns a non-zero exit code, so the metadata gate works with no
explicit status check.
pipeline { agent any stages { stage('Validate metadata') { steps { // Requires Node.js 24+ on the agent (npx ships with npm). sh 'npx -y @hawkeyexl/manni meta validate "docs/**/*.md"' } } }}If your agents do not have Node.js 24+ on the PATH, provision it first. With
the NodeJS plugin installed and a tool named node24 configured in Manage
Jenkins → Tools, wrap the stage:
pipeline { agent any tools { nodejs 'node24' } stages { stage('Validate metadata') { steps { sh 'npx -y @hawkeyexl/manni meta validate "docs/**/*.md"' } } }}To archive a machine-readable report, write JSON to a file and archive it. Run manni meta in a way that preserves its exit code:
stage('Validate metadata') { steps { sh 'npx -y @hawkeyexl/manni meta validate "docs/**/*.md" --format json | tee manni-report.json' } post { always { archiveArtifacts artifacts: 'manni-report.json', allowEmptyArchive: true } }}To get manni meta into Jenkins’ own Test Result page instead, emit JUnit XML
and pass it to the junit step:
stage('Validate metadata') { steps { sh 'npx -y @hawkeyexl/manni meta validate "docs/**/*.md" --format junit | tee manni-junit.xml' } post { always { junit 'manni-junit.xml' } }}post { always { ... } } matters for the same reason if: always() does in the
SARIF recipe. The run worth reporting is the one that failed.
pre-commit
Section titled “pre-commit”A pre-commit hook catches metadata errors before they reach CI. manni meta publishes two pre-commit hooks, so the whole configuration is a few lines:
repos: - repo: https://github.com/hawkeyexl/manni rev: v2.2.0 hooks: - id: manni-meta-derive # only where manni.config.yaml sets derive.fields - id: manni-metamanni-meta validates the staged files. manni-meta-derive runs first and
stamps the managed fields into them,
including provenance. With
MANNI_GENERATED_BY exported, an agent’s uncommitted lines are attributed to
it, so the edit and its record land in one commit. Leave the line out when the
config manages no fields. derive has nothing to do then, and says so with
exit 2.
When manni-meta-derive changes a file, pre-commit stops the commit once, as
it does for any formatter. Re-stage the files and commit again. The second
attempt passes, because the stamp now reads as current.
rev has to be a concrete tag, because pre-commit does not follow a floating
major the way uses: hawkeyexl/manni@v2 does. So this is one place a copied
snippet really will go stale. Take the tag from the latest
release, and let
pre-commit autoupdate move it for you afterwards.
What rev pins is the hook definition, meaning its file pattern and its entry.
It does not pin the CLI. The published hook runs the newest published CLI,
@hawkeyexl/manni@latest, so a newer CLI reaches you without a rev bump.
The hook fails the commit when manni meta returns a non-zero exit code. It already
knows which files to look at, covering every extension manni meta reads, matched
case-insensitively. You do not write a files pattern, and that matters more
than it sounds. A hand-written one is easy to narrow to \.md$ and then quietly
stop checking the AsciiDoc and DITA in the same repo.
The hook needs nothing more to judge managed stewardship
fields, because validate compares a
stamp against the evidence on staged files too.
pre-commit without the published hook
Section titled “pre-commit without the published hook”On an air-gapped machine, or under a policy against remote hook repos, you can
define a local hook instead of fetching from GitHub. You then own the files
pattern, so keep it in step with the formats you actually use.
repos: - repo: local hooks: - id: manni-meta name: manni meta validate entry: npx -y @hawkeyexl/manni meta validate language: system # Run only on Markdown; pre-commit appends the staged file paths. files: \.md$With entry: npx -y @hawkeyexl/manni meta validate and no positional paths in the entry,
pre-commit appends the list of staged, matched files as arguments. Each commit
then validates exactly what changed. language: system tells pre-commit to use
the npx already on your PATH rather than managing its own environment;
Node.js 24+ must be installed locally.
repos: - repo: local hooks: - id: manni-meta name: manni meta validate entry: npx -y @hawkeyexl/manni meta validate "docs/**/*.md" language: system pass_filenames: false always_run: trueSet pass_filenames: false so pre-commit does not append staged paths, and
manni meta validates the fixed glob in the entry instead. always_run: true makes
the hook run even when no matching files are staged.
Install and test the hook:
pre-commit install # wire the hook into gitpre-commit run manni-meta --all-files # run it across the repo once