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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Redocly CLI | 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 | 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 | 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 | 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.
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=""example" 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.
Related how-tos
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
-
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 -
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
::errorbecomes 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 -
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 -
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