# How to gate a pull request on OpenAPI lint results

> Make Spectral a required check that fails on errors and annotates warnings, and lint the whole document so an edit to a shared component cannot merge unseen.

Source: https://voxgig.com/howto/gate-a-pull-request-on-openapi-lint

- Audience: platform-team
- Level: intermediate
- Verified: 2026-10-17
- Published: 2026-10-17

## Short answer

Run `spectral lint --fail-severity=error -f github-actions -f junit` against the root OpenAPI document in a pull request workflow, and make that job a required status check in branch protection. A warning prints as an annotation and passes. An error fails the job, so the branch rule blocks the merge. Lint the whole document on every pull request, because a job that lints only the files in the diff passes an edit to a component every path references.

---
## You will need

Node 22 or later, a repository whose pull requests already run a GitHub Actions workflow, and an
OpenAPI 3 document with a Spectral ruleset beside it. Verified 2026-10-17 against Node 22.22.0,
`@stoplight/spectral-cli` 6.17.0, `@redocly/cli` 2.64.1, and `@quobix/vacuum` 0.33.1. The sample
holds two copies of a small invoicing API: `main/` is the base branch and `pr/` is the branch
after a pull request that adds one line to a shared component.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Redocly CLI](https://redocly.com/docs/cli/commands/lint) | You bundle or document with Redocly already and want one toolchain for lint as well | Its own rule names and severity levels, so each rule you gate on has to be raised to error in `redocly.yaml` | Your rules are Spectral rulesets with custom functions you cannot port |
| [reviewdog](https://github.com/reviewdog/reviewdog#readme) | A legacy spec with hundreds of findings, where comments on the changed lines beat a red build | A token, a second binary in the job, and an exit code of 0 by default whatever it finds | You want the merge blocked, which is what this page sets up |
| [Spectral CLI in GitHub Actions](https://docs.stoplight.io/docs/spectral/9ffa04e052cc1-spectral-cli) | Your rulesets are written for Spectral and the job can afford a Node start on every run | A Node install in the job and a lint time that grows with the size of the document | Lint time on a large document is the complaint |
| [vacuum](https://quobix.com/vacuum/commands/lint/) | The same ruleset file has to run in seconds over a large spec or over many of them | Its own rules and severity levels behind the `spectral:` alias, so findings differ from those of Spectral unless each gated rule is pinned | Your ruleset calls a custom function written for the Spectral function API |

vacuum accepts a Spectral ruleset file but maps `spectral:oas` to its own rule set and severity
levels, which is the price of its speed. Redocly brings its own rule vocabulary, so a gated rule
has to be named twice. reviewdog turns findings into review comments and exits 0 by default,[^1]
which is the cost of advice. A comment can be scrolled past, while a failed required check holds
the merge until the line is fixed.

## Pin the severity of every rule you gate on

`--fail-severity=error` makes Spectral exit 1 when any result is an error and 0 otherwise, so each
rule's severity decides what blocks a merge. Pin it for the rules that matter, because the other
two linters carry their own defaults.

```yaml title=".spectral.yaml"
extends: spectral:oas
rules:
  operation-operationId: error
  operation-description: error
  oas3-valid-schema-example: error
```

[`oas3-valid-schema-example`](https://github.com/stoplightio/spectral/blob/develop/docs/reference/openapi-rules.md#oas3-valid-schema-example)
is the rule the pull request below trips. The same three rules in the Redocly vocabulary:

```yaml title="redocly.yaml"
extends:
  - recommended
rules:
  operation-operationId: error
  operation-description: error
  no-invalid-schema-examples: error
```

[`no-invalid-schema-examples`](https://redocly.com/docs/cli/rules/oas/no-invalid-schema-examples)
is a warning in the Redocly recommended set. Without the last line, Redocly passes the pull
request that Spectral fails.

## Lint the root document in the workflow

The workflow runs one lint step over the root document and keeps two outputs: annotation lines in
the log from the `github-actions` format, and a report file from the `junit` format.

```yaml title="openapi-lint.yml"
name: openapi-lint
on:
  pull_request:
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - name: Lint the whole document, not the changed files
        run: >
          npx spectral lint --fail-severity=error
          -f github-actions -f junit
          -o.github-actions "<stdout>" -o.junit spectral-junit.xml
          openapi.yaml
      - name: Keep the JUnit report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: spectral-junit
          path: spectral-junit.xml
```

Each `-o.<format>` names where the output of that format goes, and `"<stdout>"` is how Spectral
spells the log. GitHub reads
[`::error` and `::warning` lines](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-error-message)
out of the log and attaches each one to the file and line it names.[^2] `if: always()` keeps the
[artifact upload](https://docs.github.com/en/actions/tutorials/store-and-share-data) running
after a failed lint step, the run whose report you want.

Run the step's command against the base branch:

```bash
npx spectral lint --fail-severity=error -f github-actions main/openapi.yaml
```

```text output
::warning title=info-contact,file=main/openapi.yaml,col=6,endColumn=62,line=2,endLine=5::Info object must have "contact" object.%0ADocumentation: https://meta.stoplight.io/docs/spectral/docs/reference/openapi-rules.md#info-contact
```

One warning and exit code 0: the job stays green, and the annotation sits on line 2. The pull
request edits the component every operation's error responses reference:

```yaml title="main/components/error.yaml"
Problem:
  type: object
  description: An error, in the shape of RFC 9457.
  required: [status, title]
  properties:
    type:
      type: string
      description: A URI identifying the problem type.
      example: https://api.example.com/problems/not-found
    title:
      type: string
      example: Not Found
    status:
      type: integer
    detail:
      type: string
      example: No invoice has the id inv_8f3k2.
```

```bash
diff -r main pr
```

```text output
diff -r main/components/error.yaml pr/components/error.yaml
15a16
>       example: "404"
```

The pull request adds an example to `status`, as a string. Run the step against the branch, with
both formats:

```bash
npx spectral lint --fail-severity=error -f github-actions -f junit -o.github-actions '<stdout>' -o.junit spectral-junit.xml pr/openapi.yaml
```

```text output
::error title=oas3-valid-schema-example,file=pr/components/error.yaml,col=16,endColumn=21,line=16,endLine=16::"example" property type must be integer%0ADocumentation: https://meta.stoplight.io/docs/spectral/docs/reference/openapi-rules.md#oas3-valid-schema-example
::warning title=info-contact,file=pr/openapi.yaml,col=6,endColumn=62,line=2,endLine=5::Info object must have "contact" object.%0ADocumentation: https://meta.stoplight.io/docs/spectral/docs/reference/openapi-rules.md#info-contact
```

Exit code 1. The error names `pr/components/error.yaml` line 16 rather than any of the five
operations whose responses changed with it, so the annotation lands on the line the author wrote.

The JUnit report groups the same findings by source file. The `sed` strips the absolute working
directory that Spectral writes into each suite name:

```bash
sed "s#$(pwd)/##g" spectral-junit.xml
```

```text output
<?xml version="1.0" encoding="utf-8"?>
<testsuites>
<testsuite package="org.spectral" time="0" tests="1" errors="0" failures="1" name="pr/components/error.yaml">
<testcase time="0" name="org.spectral.oas3-valid-schema-example(#/Problem/properties/status/example)" classname="pr/components/error"><failure message="&quot;example&quot; property type must be integer"><![CDATA[line 16, col 16, "example" property type must be integer (oas3-valid-schema-example) at path #/Problem/properties/status/example]]></failure></testcase>
</testsuite>
<testsuite package="org.spectral" time="0" tests="0" errors="0" failures="0" name="pr/openapi.yaml">
</testsuite>
</testsuites>
```

One suite per source file, with the failure filed under the component.[^3]

## Make the check required

Branch protection is where a red job becomes a blocked merge. Under the rule for your default
branch, turn on
[Require status checks before merging](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging)
and pick `lint`, the job name from the workflow. GitHub's documentation states the condition:
required checks must have a successful, skipped, or neutral status before a collaborator can
merge. The job has to report on every pull request for the rule to work, which is why the
workflow carries no `paths:` filter.

## Run the same gate through Redocly or vacuum

The Redocly lint command takes the same format name and exits 1 once the rule in `redocly.yaml`
is an error:

```bash
npx redocly lint pr/openapi.yaml --format=github-actions
```

```text output
::error title=no-invalid-schema-examples,file=pr/components/error.yaml,line=16,col=16,endLine=16,endColumn=21::Example value must conform to the schema: type must be integer.%0A%0AReference: https://redocly.com/docs/cli/rules/oas/no-invalid-schema-examples%0A%0A
::warning title=info-license,file=pr/openapi.yaml,line=2,col=1,endLine=2,endColumn=5::Info object should contain `license` field.%0A%0AReference: https://redocly.com/docs/cli/rules/oas/info-license%0A%0A
::warning title=no-server-example.com,file=pr/openapi.yaml,line=7,col=10,endLine=7,endColumn=36::Server `url` should not point to example.com or localhost.%0A%0AReference: https://redocly.com/docs/cli/rules/oas/no-server-example-com%0A%0A
```

vacuum takes the Spectral ruleset file as it is, through `-r`, and emits the same annotation
shape:

```bash
npx vacuum lint --no-update-check -b -q --github-annotations -r .spectral.yaml pr/openapi.yaml
```

```text output
::error title=oas3-valid-schema-example,file=pr/components/error.yaml,col=7,endColumn=14,line=16,endLine=16::got string, want integer
::warning title=component-description,file=pr/openapi.yaml,col=5,endColumn=11,line=112,endLine=112::`securitySchemes` component `bearer` is missing a description
::warning title=camel-case-properties,file=pr/openapi.yaml,col=9,endColumn=20,line=124,endLine=124::property `customer_id` is `snake_case` not `camelCase`
::warning title=camel-case-properties,file=pr/openapi.yaml,col=9,endColumn=20,line=143,endLine=143::property `customer_id` is `snake_case` not `camelCase`
```

The error matches the Spectral one in file and line. The two warnings about `customer_id` come
from a rule the `spectral:oas` ruleset does not contain. The vacuum
[ruleset documentation](https://quobix.com/vacuum/rulesets/understanding/) says the `spectral:`
prefix is a compatibility alias that does not load the built-in rulesets of Spectral.[^4] So the
alias resolves to the vacuum recommended set, with the severity vacuum gives each rule, and a
finding travels between the two tools only for a rule the file pins. `--no-update-check` keeps
the run off the network, and `-b -q` drop the banner and the color.

## Check it worked

The suite runs the three linters the way the workflow step would and pins each verdict to an
exit code and an annotation. The two tests that carry the page are adjacent:

```js title="gate.test.mjs"
test('the pull request fails, and the error names the shared file rather than a path', () => {
  const r = spectral('pr/openapi.yaml')
  assert.equal(r.status, 1)
  assert.equal(r.errors.length, 1)
  assert.match(r.errors[0], /^::error title=oas3-valid-schema-example,file=pr\/components\/error\.yaml,/)
})

test('linting only the changed file passes, which is the pitfall', () => {
  const r = spectral('pr/components/error.yaml')
  assert.equal(r.status, 0)
  assert.equal(r.errors.length, 0)
  assert.equal(r.warnings.length, 1)
  assert.match(r.warnings[0], /title=unrecognized-format/)
})
```

```bash
node --test gate.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 9609.734687
```

`# pass 6` covers the base branch passing with its warning, the pull request failing under all
three linters, the lone component file passing, and the JUnit report.

## When it goes wrong

The pull request merges with the broken example in it. The job was built to be quick, so it hands
the linter the files `git diff --name-only` lists, and the only file in this diff is the
component:

```bash
npx spectral lint --fail-severity=error -f github-actions pr/components/error.yaml
```

```text output
::warning title=unrecognized-format,file=pr/components/error.yaml,col=1,endColumn=26,line=1,endLine=37::The provided document does not match any of the registered formats [OpenAPI 2.0 (Swagger), OpenAPI 3.x, OpenAPI 3.0.x, OpenAPI 3.1.x, OpenAPI 3.2.x]
```

Exit code 0. The fragment has no `openapi` key, so no format matches, no OpenAPI rule runs, and
the one finding says Spectral does not know what the file is. Under
`--fail-severity=error` that is a pass. Redocly and vacuum refuse the lone file outright, which
fails the job for the wrong reason. Pass the root document and let the resolver follow the
references.

The required check never reports and the pull request cannot merge. When the workflow carries a
`paths:` filter and the pull request changes no matching file, GitHub keeps the check in a
Pending state and blocks the merge. The
[troubleshooting page](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/troubleshooting-required-status-checks)
is direct about the fix: avoid requiring workflows that can be skipped. Run the job on every pull
request and keep the step cheap.

The same pull request passes under Redocly or vacuum. Under their defaults, Redocly reported the
bad example as a warning and exited 0, and vacuum did the same until `.spectral.yaml` named
`oas3-valid-schema-example`. A rule left to defaults is decided by whichever tool runs it.

## When not to do this

Do not gate on warnings. `--fail-severity=warn` turns every `info-contact` into a blocked merge,
and a team that cannot merge a typo fix learns to set rules to `off` rather than fix findings.
Keep errors for the rules a generator or a consumer would trip over, and let warnings annotate.

Do not make the check required on a document that already fails it. Four hundred findings block
every pull request from the first day, which is how a gate gets removed within a week. Baseline
the findings first, or start with the advisory comments reviewdog posts, and make the check
required once the base branch is clean.

Do not lint only the files a diff touched to save time. The resolver needs the root, and the
saving is under a second.

Do not expect the gate to catch what no rule describes. A document that satisfies every rule can
remove a field a client reads; breaking-change detection is a separate check with a page of its
own.

## Related how-tos

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Set up a Spectral lint loop for an agent editing OpenAPI](/howto/spectral-lint-loop-for-a-coding-agent)

- [Detect breaking changes between two OpenAPI versions in CI](/howto/detect-breaking-changes-between-openapi-versions-in-ci)

## Last verified

Verified 2026-10-17 against Node 22.22.0, `@stoplight/spectral-cli` 6.17.0, `@redocly/cli` 2.64.1,
and `@quobix/vacuum` 0.33.1, installed from npm in the page's code directory. Every output block
is what the command preceding it printed. The workflow file was written against GitHub's
documentation and never executed, since the sample has no GitHub runner, and the branch
protection setting was read from the documentation rather than clicked. The Redocly and vacuum
runs under default severity levels, and both tools' answers to the lone component file, were
made while writing and are described rather than captured.

[^1]: [reviewdog](https://github.com/reviewdog/reviewdog#exit-codes) documents that by default,
with `-fail-level=none`, it returns 0 as its exit code even when it finds errors. A tool whose
job is to post comments exits as though it had found none, by design. The flag that makes it fail
is one a team has to go looking for.

[^2]: The whole integration between a linter and the review pane is two colons at the start of a
line. [GitHub's workflow commands](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-an-error-message)
are read out of the job log. A line that opens with `::error` becomes an annotation with a file
and a line number; any other line stays a log line. Spectral, Redocly, and vacuum each print that
prefix, which is as much protocol as the three have in common.

[^3]: The report declares `package="org.spectral"`, a Java package name on the output of a
JavaScript tool. The format it writes is the one the
[Ant build tool's JUnit task](https://ant.apache.org/manual/Tasks/junit.html) emits through its
XML formatter. There is no official specification for the JUnit XML file format,
[a community reference](https://github.com/testmoapp/junitxml) notes, and the schema files that
exist are usually outdated. A footnote there adds that the format was first introduced by Ant
rather than by JUnit.

[^4]: The vacuum [about page](https://quobix.com/vacuum/about/) gives the origin. Spectral was the
only credible linter to build on and was slow against large and many specs, so a colleague asked
for a drop-in replacement and the author built one. The
[ruleset documentation](https://quobix.com/vacuum/rulesets/understanding/) explains that the
`spectral:` prefix is a compatibility alias that does not load the built-in rulesets of Spectral.
A drop-in replacement, in this sense, accepts the file and substitutes its own rules behind it.