The same audit, as a build check.
apiglow audit runs the schema audit from the command line. It is the same engine and the same 137 rules as the #/audit page, and it produces the same report. The command only adds what a pipeline needs around it: an exit status, reports a machine or a CI platform can read, and a baseline so an existing API can adopt the check without fixing its whole history first.
It ships in the npm package, under the same MIT license, and runs on Node 24 or newer, like the bake. There is no account, no service and no ruleset to write.
Run it
npx apiglow@0.3.0 audit openapi.yamlThe report goes to stdout. Everything about the run — each check, the verdict, the warnings — goes to stderr:
PASS --fail-on error: 0 finding(s) at this severity or above
Audit passedPin the version. A new release can add a rule, and a rule added under a pipeline is a build that fails for a reason nobody changed. Bump the pin on purpose, the way you bump the CDN tag.
What the command accepts:
- A schema — a path or a URL, JSON or YAML, valid or not. A broken file still gets a report (see exit status).
- Several schemas — several paths, or a pattern quoted so that the command expands it rather than the shell:
'apis/**/openapi.{yaml,json}'. Each file is audited as its own spec, under its path relative to the working directory, so the reports and the baseline name the file, and adding a schema never shifts the others.node_modulesand.gitare never walked. A pattern matching nothing is a warning;--fail-on-unmatched-globsmakes it stop the run. --config apidoc.config.json— the JSON object your page inlines in#api-doc-config. This audits what the documentation actually shows: its overlays applied, its hidden operations marked hidden, every spec of a multi-spec install. Files the config names are read from the config’s own directory, as the bake reads them.
What fails the build
Four checks, each reported on its own PASS / FAIL line. Any failing check fails the run.
| Option | Fails when | Default |
|---|---|---|
--fail-on error|warning|info|none | a finding of that severity, or a worse one, is present | error |
--min-grade A|B|C|D|F | the aggregate grade is below it | off |
--min-score 0-100 | the aggregate score is below it | off |
--baseline <file> | the baseline lists findings that no longer occur | on with a baseline |
When --fail-on fails, stderr lists the findings that made it fail — the first twenty, each at file:line:column, then a count:
FAIL --fail-on warning: 14 finding(s) at this severity or above
warning responses-success — openapi.yaml:2149:9 · GET /failures/forbidden · /paths/~1failures~1forbidden/get/responses
warning oauth-legacy-flows — openapi.yaml:2924:11 · components.securitySchemes.petstore_auth · …Exit status
| Status | Meaning |
|---|---|
0 | every check passed |
1 | a check failed |
2 | the audit could not run: an unknown option, a value out of range, a schema that cannot be reached, a rule configuration it cannot read |
Every option is validated before anything is loaded: a typo in a threshold costs neither a download nor a silent “no threshold”.
A file the command can open always gets a report, however broken it is. A duplicate key, a tab used as indentation, a trailing comma or a file cut short is a document-syntax finding at its line and column, and the rest of the file is still graded. A file holding no OpenAPI description at all — empty, a list, Swagger 1.x, OpenAPI 4 — is a document-openapi finding. Both are errors, so the default --fail-on error fails the run with status 1. Status 2 is left for a schema that could not be reached.
Adopt it on an existing API
An API with years of history will not pass on day one, and should not have to. A baseline records the findings the team has seen and accepted. Committed next to the schema, it makes --fail-on count only the findings it does not list:
# once: record what is there today
npx apiglow@0.3.0 audit openapi.yaml --write-baseline audit-baseline.json
# in CI: fail only on what is new
npx apiglow@0.3.0 audit openapi.yaml --baseline audit-baseline.jsonHow it behaves:
- A finding is identified by its rule and its JSON pointer — the two things that survive an unrelated edit. Messages and line numbers do not; a renamed parameter rewrites both.
- Counted, not a set. A pointer listed twice covers two occurrences; a third is new.
- The grade and the score ignore it. They describe the document. A baseline accepts findings, not a lower grade.
- It is not an ignore list. Every finding is still in the report; in JSON, the known ones carry
known: true. - The file is sorted throughout. Writing it again on an unchanged schema rewrites it byte for byte, so a review diff shows only what moved.
{ "format": "apiglow-audit-baseline", "version": 1,
"specs": { "default": { "parameter-described": ["/paths/~1pets/get/parameters/0"] } } }Stale entries fail the run. When a finding is fixed, its entry matches nothing any more. Left behind, it would one day hide a new finding that happens to land on the same pointer — so the run fails and lists it. --prune-baseline <file> writes the baseline without the stale entries (it may be the --baseline file itself) and passes. --allow-stale-baseline only lists them.
A file that is not a baseline this version writes stops the run with status 2. It is never read as empty, which would fail every finding, nor as covering everything, which would pass a regression.
Reports
--format | For | What it is |
|---|---|---|
text | a terminal | the console report, folded by rule like the page; the default |
markdown | a person | the page’s Markdown export — a pull-request comment, a GitHub job summary |
json | a script or an agent | the full report in a versioned contract |
sarif | GitHub code scanning | SARIF 2.1.0: findings open and close as alerts |
github | a GitHub pull request | workflow commands that annotate the diff at the line |
codequality | a GitLab merge request | GitLab Code Quality, for the merge-request widget |
One run, several reports. --format and --output set the report on stdout. --report <format>=<file>, repeatable, writes one more report per occurrence from the same run — the JSON for a script, the Markdown for the job summary, without auditing twice:
npx apiglow@0.3.0 audit openapi.yaml --format github \
--report markdown="$GITHUB_STEP_SUMMARY" --report sarif=audit.sarifEvery finding is placed. A schema read from a file gives each finding file:line:column — where its node sits in the file you edit, following $refs into other files too. Terminals and editors turn that into a link, and the three CI formats use it to put the finding on the right line.
What a report lists, in every format:
--min-severity error|warning|infokeeps the findings that severe or worse. The GitHub REST schema yields some 5,000 findings, 4,000 of them notes.--only-new, with--baseline, keeps only the findings the baseline does not list.
Neither changes the verdict: counts, scores, the grade and every check still read the whole report. Text and Markdown say how many findings each filter left out. A --fail-on less severe than --min-severity is refused, since a job would fail on findings its report does not show.
--language fr writes the report in French: messages, rationales, fixes. The command’s own lines on stderr stay in English.
The JSON report
What a script or an agent reads. format and version name the shape, and a change of shape bumps version, so a consumer can refuse what it does not know:
{ "format": "apiglow-audit-report", "version": 1,
"tool": { "name": "apiglow", "version": "0.3.0" },
"passed": false,
"specs": [{ "id": "default", "source": "openapi.yaml", "passed": false,
"gates": [{ "gate": "fail-on", "threshold": "error", "actual": 2, "passed": false }],
"report": { "…": "…", "categories": [{ "findings": [{ "fingerprint": "9f3c…" }] }] } }],
"rules": { "parameter-described": { "category": "completeness",
"label": "…", "message": "…", "why": "…", "fix": "…" } } }- Every finding carries a fingerprint that stays the same from one run to the next while the document around it changes. It is what a CI surface or an agent tracking a finding keys on.
rulescarries, once, the texts of every rule that fired — its label, why it matters and how to fix it — so a consumer explaining a finding needs nothing but the file.
Rule configuration
Switch a rule off, or change its severity, for the whole document or only under some paths. The block is the same one the page reads, so the published grade and the CI grade agree:
{
"audit": {
"rules": { "property-described": "off", "parameter-described": "error" },
"overrides": [
{ "paths": ["/paths/~1legacy~1*"], "rules": { "response-example": "off" }, "reason": "frozen legacy API" }
]
}
}The CLI reads it from the config passed with --config, or from --audit-config <file> — a JSON file holding the block alone, for a run that has no host config or that should grade differently from the published page. The audit guide has the precedence rules.
Two differences from the page. An entry the CLI cannot read stops the run with status 2, each entry named: a pipeline never passes on a configuration nobody wrote. And features.audit: false does not apply — that switch removes the page from published documentation, and an author running the command asked for the report.
A grade computed with any of this is labelled custom rule set wherever it appears, so it never passes for the default one.
Network
A schema, an external $ref or an overlay behind a URL is fetched. Two options keep that predictable in a CI job:
--fetch-timeout <seconds>(default 30): a host that accepts the connection and never answers fails the job in its first minute, not at the platform’s own timeout.--offlinerefuses every fetch and stops the run with status2— even one that would otherwise be skipped with a warning, like an overlay. A job asked to stay off the network never passes on a different document.
The rules, from the terminal
npx apiglow@0.3.0 audit --explain oauth-legacy-flows # one rule: category, severity, why, how to fix
npx apiglow@0.3.0 audit --list-rules # every rule, as JSONBoth honor --language and refuse anything else on the line: a run that looked like an audit and printed a rule would mislead whoever reads its exit status.
Recipes
GitHub Actions
The apiglow/audit-action runs the command in one step: annotations on the pull request’s diff, the Markdown report on the job summary, and SARIF to code scanning when you ask for it. Its tag is the CLI version it runs.
on: pull_request
jobs:
schema-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: apiglow/audit-action@v0.3.0
with:
schema: openapi.yaml
baseline: audit-baseline.json| Input | Default | |
|---|---|---|
schema | — | Path, URL or quoted pattern; one per line for several |
config | — | The page’s JSON config, instead of schema |
baseline | — | A baseline file: only the findings it does not list fail the job |
fail-on | error | error, warning, info or none |
min-severity | info | List only the findings this severe or worse |
annotations | true | Annotate the diff |
summary | true | Write the Markdown report to the job summary |
sarif | false | Upload SARIF to code scanning — the job needs security-events: write |
args | — | Any other option: --min-grade B --language fr |
version | the action’s | The apiglow version to run |
node-version | 24 | The Node.js version to run it on |
Its outputs are passed, the path of the JSON report, and the path of the sarif file when there is one.
The same job by hand, every piece in sight:
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- name: Schema audit
run: |
npx --yes apiglow@0.3.0 audit openapi.yaml --baseline audit-baseline.json \
--format github --report markdown="$GITHUB_STEP_SUMMARY" --report sarif=audit.sarif
- if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: audit.sarifGitHub shows ten annotations of each kind per step and drops the rest silently, so new findings come first, and a last notice says how many were left out. Code scanning reads at most 25,000 results per run; a larger run keeps the new findings first, then the most severe.
GitLab CI
The findings in the merge-request widget, the JSON report kept as an artifact:
schema-audit:
image: node:24
script:
- npx --yes apiglow@0.3.0 audit 'apis/**/openapi.yaml'
--baseline audit-baseline.json
--report codequality=gl-code-quality-report.json --report json=audit.json
artifacts:
when: always
reports:
codequality: gl-code-quality-report.json
paths: [audit.json]The fingerprint is what lets the widget tell new findings from resolved ones.
pre-commit
A local hook. pass_filenames: false, because the command names its schema itself — handed the staged files, it would audit only those, under ids the baseline does not know. Any YAML change triggers it, since a $ref can sit in another file.
repos:
- repo: local
hooks:
- id: apiglow-audit
name: Schema audit
entry: npx --yes apiglow@0.3.0 audit openapi.yaml --baseline audit-baseline.json
language: system
files: \.ya?ml$
pass_filenames: falseBreaking changes
Out of scope, on purpose. The audit grades one version of the document, and its baseline answers “what is new”, not “did the contract break”. oasdiff compares two versions; run it next to the audit:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Breaking changes
run: |
git show "origin/$GITHUB_BASE_REF:openapi.yaml" > base.yaml
docker run --rm -v "$PWD:/specs:ro" tufin/oasdiff:v1.32.1 \
breaking /specs/base.yaml /specs/openapi.yaml --fail-on ERRAn agent fixing the schema
Hand it the JSON report. Every finding is placed at file:line:column, its rule’s message, rationale and fix are in rules, and the fingerprint survives the edit around it. The loop is audit, fix, audit again until every check passes; with a baseline, --only-new keeps the agent on what a change introduced.
npx apiglow@0.3.0 audit openapi.yaml --format json --min-severity warningThe npm package ships that loop as an agent skill: skills/apiglow-audit/SKILL.md. Copy the folder from node_modules/apiglow/skills/ to where your agent reads skills — .claude/skills/ for Claude Code, for instance. Beyond the commands, the skill holds what an agent must not do on its own: invent what the API does to fill a description or an example, or make the run pass by switching a rule off, writing a baseline or lowering --fail-on. Each of those is the owner’s decision.
Every option
| Option | What it does |
|---|---|
<spec>… | Path, URL or quoted pattern of the schema; several are audited each under its path |
--config <file> | Audit what the documentation shows: overlays, hidden operations, rule configuration, every spec |
--audit-config <file> | The rule configuration alone; replaces the config’s own audit block |
--fail-on-unmatched-globs | Stop when a pattern matches no file |
--fail-on <severity> | Fail on a finding this severe or worse: error (default), warning, info, none |
--min-grade <letter> | Fail below this grade |
--min-score <0-100> | Fail below this score |
--baseline <file> | Count only the findings the baseline does not list; fail on stale entries |
--write-baseline <file> | Record every current finding, and pass |
--prune-baseline <file> | With --baseline: write it without its stale entries |
--allow-stale-baseline | With --baseline: list stale entries without failing |
--format <format> | text (default), markdown, json, sarif, github, codequality |
--output <file> | Write the report to a file instead of stdout |
--report <format>=<file> | Write one more report from the same run; repeatable |
--min-severity <severity> | List only the findings this severe or worse — the verdict is unchanged |
--only-new | With --baseline: list only the findings it does not know |
--language <code> | en (default) or fr, for the report’s texts |
--offline | Never fetch; a URL anywhere stops the run |
--fetch-timeout <seconds> | Wait for each fetched document (default 30) |
--explain <rule> | Print one rule, audit nothing |
--list-rules | Print every rule as JSON, audit nothing |