How-to › Describe your API

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.

Audience
Platform team
Level
intermediate
Topic
Lint and govern API specs
Verified

A pull request edits one schema in your OpenAPI document and merges. The lint job is green, because it runs on the files the diff touched, and the file it touched is a fragment that matches no OpenAPI rule. Every path references that schema, so the generated SDK ships the broken example, and the next ten pull requests build on it.

What you get

You will end up with a workflow that fails a pull request on a lint error, annotates warnings, keeps a JUnit report, and lints the whole document. This is for you if spec lint runs in CI as advice rather than as a gate.

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Redocly CLIYou bundle or document with Redocly already and want one toolchain for lint as wellIts own rule names and severity levels, so each rule you gate on has to be raised to error in redocly.yamlYour rules are Spectral rulesets with custom functions you cannot port
reviewdogA legacy spec with hundreds of findings, where comments on the changed lines beat a red buildA token, a second binary in the job, and an exit code of 0 by default whatever it findsYou want the merge blocked, which is what this page sets up
Spectral CLI in GitHub ActionsYour rulesets are written for Spectral and the job can afford a Node start on every runA Node install in the job and a lint time that grows with the size of the documentLint time on a large document is the complaint
vacuumThe same ruleset file has to run in seconds over a large spec or over many of themIts own rules and severity levels behind the spectral: alias, so findings differ from those of Spectral unless each gated rule is pinnedYour 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.

extends: spectral:oas
rules:
  operation-operationId: error
  operation-description: error
  oas3-valid-schema-example: error

oas3-valid-schema-example is the rule the pull request below trips. The same three rules in the Redocly vocabulary:

extends:
  - recommended
rules:
  operation-operationId: error
  operation-description: error
  no-invalid-schema-examples: error

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.

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 out of the log and attaches each one to the file and line it names.2 if: always() keeps the artifact upload running after a failed lint step, the run whose report you want.

Run the step’s command against the base branch:

npx spectral lint --fail-severity=error -f github-actions main/openapi.yaml
::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:

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.
diff -r main pr
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:

npx spectral lint --fail-severity=error -f github-actions -f junit -o.github-actions '<stdout>' -o.junit spectral-junit.xml pr/openapi.yaml
::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:

sed "s#$(pwd)/##g" spectral-junit.xml
<?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 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:

npx redocly lint pr/openapi.yaml --format=github-actions
::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:

npx vacuum lint --no-update-check -b -q --github-annotations -r .spectral.yaml pr/openapi.yaml
::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 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:

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/)
})
node --test gate.test.mjs
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:

npx spectral lint --fail-severity=error -f github-actions pr/components/error.yaml
::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 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.

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.

Footnotes

  1. reviewdog 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. ↩︎ Back to text

  2. The whole integration between a linter and the review pane is two colons at the start of a line. GitHub’s workflow commands 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. ↩︎ Back to text

  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 emits through its XML formatter. There is no official specification for the JUnit XML file format, a community reference 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. ↩︎ Back to text

  4. The vacuum about page 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 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.