Skip to content

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.

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

.github/workflows/manni-sarif.yml
name: manni meta code scanning
on:
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 1

A distinct category: namespaces the alerts, so manni meta’s findings never merge with another tool’s. Give each SARIF-producing job its own.

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.

.gitlab-ci.yml
validate-metadata:
stage: test
image: node:24
script:
- npx -y @hawkeyexl/manni meta validate "docs/**/*.md"

If you collect machine-readable reports, capture the JSON output as an artifact:

.gitlab-ci.yml
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.json

tee 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:

.gitlab-ci.yml
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.xml

Each file becomes one test, so the widget’s count matches manni meta’s N files checked, M failed summary.

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.

Jenkinsfile
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:

Jenkinsfile
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:

Jenkinsfile
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:

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

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:

.pre-commit-config.yaml
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-meta

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

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.

.pre-commit-config.yaml
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.

Install and test the hook:

Terminal window
pre-commit install # wire the hook into git
pre-commit run manni-meta --all-files # run it across the repo once