Enforcing API Style Guides with Spectral in CI
This guide is part of Spec Linting & Governance within OpenAPI & AsyncAPI Schema Authoring. It covers the step between having an API style guide and having one that is followed: encoding it as Spectral rules, running them on every change, and reporting the results somewhere reviewers actually look. The situation is common — a well-written conventions document exists, everybody agrees with it, and adherence varies by whoever last touched the specification.
Problem & Context
A style guide that lives only in a document is enforced by whoever happens to review a pull request, if they remember, and if they know the guide well enough to spot a violation. In practice that means new conventions apply to the first few specifications written after they were agreed, and then adherence decays.
The decay is not carelessness. Nobody reads a conventions page before adding an endpoint, and a reviewer looking at a diff has no way to know that operationId is required by the guide unless they already knew. Consistency is exactly the kind of constraint a machine keeps and a human forgets.
Two failure modes make automated linting fail in turn. The first is inventing rules inside the linter config that were never agreed — the check then blocks work on the basis of one person’s preference, and the team’s response is to disable it. The second is turning it on against a mature specification, where years of accumulated findings block a pull request that caused none of them.
Both are avoided by rules that encode existing agreements, severities that mean something, and a baseline that separates history from new work.
Step-by-Step Solution
1. Write rules only for what the guide already says
Start from the document, one rule per stated convention:
# .spectral.yaml
extends: ['spectral:oas']
rules:
# Guide §2.1: every operation carries a unique operationId.
operation-operationId: error
operation-operationId-unique: error
# Guide §2.2: every operation is tagged, so navigation groups correctly.
operation-tags: error
# Guide §3.4: paths are kebab-case plural nouns.
path-kebab-case:
description: 'Paths must be kebab-case (guide §3.4).'
given: $.paths[*]~
severity: error
then:
function: pattern
functionOptions:
match: '^(\/[a-z0-9-]+|\/\{[a-zA-Z0-9_]+\})+$'
# Guide §4.2: every operation documents at least one error response.
operation-error-response:
description: 'Document at least one 4xx response (guide §4.2).'
given: $.paths[*][get,put,post,delete,patch].responses
severity: error
then:
function: schema
functionOptions:
schema:
type: object
anyOf:
- required: ['400']
- required: ['404']
- required: ['409']
- required: ['422']
# Guide §5.1: summaries are short and sentence-case.
operation-summary-length:
description: 'Summaries should be under 60 characters (guide §5.1).'
given: $.paths[*][get,put,post,delete,patch].summary
severity: warn
then:
function: length
functionOptions: { max: 60 }
Citing the section number in each description is the highest-value habit here: a finding then answers “who decided this?” without anyone having to ask.
2. Publish the ruleset as a shared, versioned package
One ruleset, extended everywhere, so changing a convention is a release rather than ten pull requests:
{
"name": "@acme/api-style",
"version": "1.4.0",
"files": ["spectral.yaml", "functions/"],
"main": "spectral.yaml"
}
# each service's .spectral.yaml
extends: ['@acme/api-style']
rules:
# Service-local relaxations must state a reason and an owner.
operation-summary-length: off # legacy summaries; tracked in PLAT-4821
3. Use severity tiers deliberately
The distinction is not how much you care — it is whether shipping the violation breaks something:
error breaks tooling or consumers missing operationId, untagged operation,
undocumented error response, bad path casing
warn should improve long summary, missing example, terse description
info stylistic preference field ordering, description punctuation
If more than roughly a third of your rules are error, the level has stopped meaning “must never ship”.
4. Baseline the legacy violations
Freeze what exists so new work is held to the standard without blocking on history:
# Record current findings as the accepted baseline.
npx @stoplight/[email protected] lint openapi/api.yaml \
--format json --output .spectral-baseline.json --quiet || true
jq 'length' .spectral-baseline.json
47
# On each run, fail only on findings that are not in the baseline.
npx @stoplight/[email protected] lint openapi/api.yaml --format json --quiet \
> /tmp/current.json || true
node -e '
const base=new Set(require("./.spectral-baseline.json")
.map(f=>`${f.code}|${f.path.join(".")}`));
const now=require("/tmp/current.json");
const fresh=now.filter(f=>f.severity===0 && !base.has(`${f.code}|${f.path.join(".")}`));
fresh.forEach(f=>console.error(`NEW ${f.code} at ${f.path.join(".")}: ${f.message}`));
console.log(`${fresh.length} new error(s), ${base.size} baselined`);
process.exit(fresh.length?1:0);
'
0 new error(s), 47 baselined
5. Report findings where reviewers read them
A finding in a log is a finding nobody sees. Annotate the diff:
- name: Lint the specification
run: |
npx @stoplight/[email protected] lint openapi/api.yaml \
--format github-actions --format pretty
Complete Working Example
The workflow, with the baseline comparison as the gate and full output as information:
# .github/workflows/spec-lint.yml
name: Spec lint
on:
pull_request:
paths: ['openapi/**', '.spectral.yaml']
permissions:
contents: read
pull-requests: write
checks: write
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20', cache: 'npm' }
- run: npm ci
# Annotations appear inline on the diff — this is what reviewers see.
- name: Lint (annotations)
continue-on-error: true
run: |
npx @stoplight/[email protected] lint openapi/api.yaml \
--format github-actions || true
- name: Lint (machine-readable)
run: |
npx @stoplight/[email protected] lint openapi/api.yaml \
--format json --quiet > /tmp/current.json || true
echo "findings: $(jq 'length' /tmp/current.json)"
# THE GATE — only findings absent from the baseline block the merge.
- name: Fail on new errors only
run: |
node -e '
const fs = require("fs");
const baseFile = ".spectral-baseline.json";
const base = fs.existsSync(baseFile)
? new Set(JSON.parse(fs.readFileSync(baseFile, "utf8"))
.map(f => `${f.code}|${f.path.join(".")}`))
: new Set();
const now = JSON.parse(fs.readFileSync("/tmp/current.json", "utf8"));
// severity 0 is error in Spectral output.
const fresh = now.filter(f =>
f.severity === 0 && !base.has(`${f.code}|${f.path.join(".")}`));
for (const f of fresh) {
console.error(`::error file=openapi/api.yaml,line=${f.range.start.line + 1}::` +
`${f.code}: ${f.message}`);
}
console.log(`${fresh.length} new error(s); ${base.size} baselined`);
process.exit(fresh.length ? 1 : 0);
'
# Shrinking baselines are progress; report it so the work is visible.
- name: Report baseline size
if: always()
run: |
echo "Baseline: $(jq 'length' .spectral-baseline.json 2>/dev/null || echo 0) known finding(s)" \
>> "$GITHUB_STEP_SUMMARY"
Expected output on a clean change:
findings: 47
0 new error(s); 47 baselined
Baseline: 47 known finding(s)
Reporting the baseline size on every run is a small thing that changes behaviour. A number that visibly shrinks as teams clean up legacy findings turns a static list of known problems into a scoreboard, which is far more effective than a backlog ticket nobody opens.
Gotchas & Edge Cases
Rules nobody agreed to poison the whole check. A linter is not the place to introduce a convention. If a rule cannot cite a section of the guide, it should be a warn at most until the team has actually decided. One contested error is enough to make people stop trusting the rest.
The baseline must be regenerated deliberately, never automatically. A job that refreshes the baseline on every run silently accepts every new violation and the gate does nothing. Regenerate it only in an explicit, reviewed commit — and treat a growing baseline as a finding in itself.
Path-based baseline keys break under restructuring. Keying on the JSON path means moving an operation makes its baselined finding look new. That is usually correct behaviour, but it produces a burst of failures during a refactor; regenerate the baseline as part of the restructuring commit.
Lint the bundled document, not the fragments. Duplicate operationId values across two path files are invisible until the pieces are combined, so a fragment-level lint passes on a specification that is not actually valid.
A rule that fires constantly is a design problem, not a compliance problem. If one rule accounts for most of the baseline, the convention behind it is probably wrong for how the team actually works, or it was written to match an ideal nobody agreed to follow. Revisit the guide rather than the specifications.
extends pulls a moving target unless you pin it. A shared ruleset referenced without a version means your build starts failing because another team tightened a rule. Pin the package version and upgrade it as a reviewed change, exactly as you would any dependency.
A useful sanity check on the ruleset itself: count the rules at each severity. The shape below is what a healthy ruleset looks like — a small hard core, a larger advisory layer, and a tail of preferences.
FAQ
Should every style rule fail the build?
No. Reserve error for conventions that break tooling or consumers — a missing operationId, an untagged operation, an undocumented error response. Everything else is a warning, or the error level stops carrying information and people start ignoring all of it.
How do I introduce linting to an existing large specification?
Generate a baseline of current violations and fail only on new ones. Blocking a pull request on years of accumulated findings it did not cause is the fastest way to get the whole check disabled, and the baseline gives you a visible number to reduce over time.
Where should the ruleset live?
In its own versioned package that every service extends. A copy per repository drifts within weeks, and then no two services are held to the same standard — which defeats the purpose of having a style guide at all.
Related
- Spec Linting & Governance — the parent guide and the wider governance picture.
- Writing custom Spectral rules — building the rules this workflow runs.
- Documenting deprecated endpoints in OpenAPI — deprecation rules that belong in the same ruleset.
- Splitting and bundling OpenAPI with Redocly CLI — producing the bundled document to lint.