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.

Style guide to enforced check A written convention becomes a Spectral rule in a shared versioned ruleset, which runs on every pull request and annotates the diff where reviewers read it. the style guide agreed, written down shared ruleset versioned package spectral lint every pull request error blocks the merge warning baselined known legacy

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
Baseline separates history from new work Existing violations are frozen into a baseline so the check blocks only findings introduced by the change under review. all findings 49 on this branch 47 baselined pre-existing history tracked, not blocking 2 new introduced by this change blocks the merge the author fixes only what they caused

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.

Severity distribution A healthy ruleset has a small number of error rules, more warnings, and a tail of informational preferences. a healthy ruleset is mostly not errors error 8 breaks tooling warn 19 should improve info 6 preference

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.