# The same audit, as a build check.

> apiglow audit in CI: fail a build on the schema's findings, adopt it with a baseline, report to GitHub, GitLab and SARIF — every option, every recipe.
>
> Source: https://apiglow.dev/docs/audit-ci/index.md


`apiglow audit` runs the [schema audit](/docs/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](/docs/seo/). There is no account, no service and no ruleset to write.

## Run it

```bash
npx apiglow@0.3.0 audit openapi.yaml
```

The report goes to stdout. Everything about the run — each check, the verdict, the warnings — goes to stderr:

```text
PASS  --fail-on error: 0 finding(s) at this severity or above
Audit passed
```

**Pin 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](#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_modules` and `.git` are never walked. A pattern matching nothing is a warning; `--fail-on-unmatched-globs` makes 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](/docs/schema-support/) applied, its hidden operations marked hidden, every spec of a [multi-spec](/docs/multi-spec/) install. Files the config names are read from the config's own directory, as the [bake](/docs/seo/) 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:

```text
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:

```bash
# 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.json
```

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

```json
{ "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:

```bash
npx apiglow@0.3.0 audit openapi.yaml --format github \
  --report markdown="$GITHUB_STEP_SUMMARY" --report sarif=audit.sarif
```

**Every 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 `$ref`s 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|info` keeps 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:

```json
{ "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.
- `rules` carries, 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:

```json
{
  "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](/docs/audit/#configure-the-rules) 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.
- `--offline` refuses every fetch and stops the run with status `2` — 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

```bash
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 JSON
```

Both 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`](https://github.com/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.

```yaml
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:

```yaml
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.sarif
```

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

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

```yaml
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: false
```

### Breaking 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](https://github.com/oasdiff/oasdiff) compares two versions; run it next to the audit:

```yaml
- 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 ERR
```

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

```bash
npx apiglow@0.3.0 audit openapi.yaml --format json --min-severity warning
```

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

