Blog

Docs-as-code CI: lint, link-check and build your docs on every pull request

A hands-on guide with working config for Vale, markdownlint, lychee and Spectral, wired into GitHub Actions, so every docs pull request is checked before anyone reviews it.

Code pull requests get tests. Docs pull requests usually get a quick “LGTM”. Reviewers read for meaning, so they miss the mechanical mistakes: a link to a page that was renamed, a heading that skips a level, a term spelled three ways, a schema field with no description.

Machines are good at exactly those. This guide sets up five checks that run on every docs pull request in GitHub Actions:

Check Tool Catches
Build Your docs framework Broken internal links, bad nav, syntax errors
Prose Vale Style guide rules, product terms, “e.g.” and friends
Structure markdownlint Skipped heading levels, broken anchors, messy lists
Links lychee Broken links and anchors in the built site, dead external links
API spec Spectral Missing descriptions, invalid OpenAPI, your own rules

Every file below was run against a sample project before publishing, with the versions current in October 2026: Vale 3.24, markdownlint-cli2 0.23, lychee 0.24, Spectral CLI 6.17 and MkDocs 1.6.1. The example uses MkDocs; the section on building covers Docusaurus, VitePress and Mintlify too.

The repository

.
├── docs/                      # Markdown pages
│   ├── index.md
│   └── guides/refunds.md
├── openapi/openapi.yaml       # the API spec
├── mkdocs.yml
├── requirements-docs.txt      # mkdocs==1.6.1
├── .vale.ini
├── .markdownlint-cli2.jsonc
├── .spectral.yaml
└── .github/
    ├── styles/config/vocabularies/Docs/accept.txt
    └── workflows/
        ├── docs.yml           # runs on every pull request
        └── external-links.yml # runs weekly

1. Build the docs, strictly

The cheapest check is the one your docs framework already has: build the site and fail on warnings. A strict build catches links to pages that don’t exist, nav entries that point nowhere, and syntax that won’t render.

For MkDocs, that’s one flag:

mkdocs build --strict

A link to a missing page now stops the build:

WARNING -  Doc file 'guides/refunds.md' contains a link 'statuses.md', but the target 'guides/statuses.md' is not found among documentation files.
Aborted with 1 warnings in strict mode!

Other frameworks:

Framework Command Notes
Docusaurus npm run build Fails on broken links by default (onBrokenLinks: 'throw'). Broken anchors only warn by default; set onBrokenAnchors: 'throw'
VitePress npx vitepress build docs Fails on dead links unless you set ignoreDeadLinks
Mintlify npx mint validate and npx mint broken-links Validates the build and checks links without deploying
MkDocs mkdocs build --strict Without --strict, broken links are only warnings

2. Vale for prose

Vale checks writing against a style guide. Start with an existing one, such as the Google developer documentation style guide, and add your own terms.

.vale.ini:

StylesPath = .github/styles
MinAlertLevel = warning
Packages = Google
Vocab = Docs

[*.md]
BasedOnStyles = Vale, Google

vale sync downloads the Google package into StylesPath. Add .github/styles/Google/ to .gitignore so only your own files are committed.

Your product terms go in .github/styles/config/vocabularies/Docs/accept.txt, one per line:

OpenAPI
(?i)webhooks?

One thing that catches people out: Vale enforces each vocabulary entry with exactly the case you wrote. Add refunds and every “Refunds” at the start of a heading becomes an error. Keep the vocabulary for product and technical terms, and use (?i) for entries that can be written either way.

What a failing line looks like:

docs/guides/refunds.md
 15:49  error  Use 'for example' instead of 'e.g.'.  Google.Latin

3. markdownlint for structure

markdownlint checks the Markdown itself: heading order, list style, anchors that point nowhere. .markdownlint-cli2.jsonc:

{
  "config": {
    "default": true,
    // Prose wraps however each writer's editor likes.
    "MD013": false,
    // Inline HTML is fine (MDX components, <details>).
    "MD033": false
  }
}

Two rules earn their place on docs sites in particular. MD001 catches a heading that jumps from ## to ####, which breaks the page outline and most docs sidebars. MD051 catches a link to #paging on a page whose heading is “Pagination”:

docs/guides/refunds.md:13 error MD001/heading-increment Heading levels should only increment by one level at a time [Expected: h3; Actual: h4]
docs/guides/refunds.md:15:113 error MD051/link-fragments Link fragments should be valid [Context: "[paging](#paging)"]

If your docs are MDX, start with the .md files and add .mdx once you’ve checked the results: JSX components can trip some rules.

4. Spectral for the API spec

If your reference docs come from an OpenAPI file, the spec is documentation too. Spectral lints it with the built-in OpenAPI rules plus any of your own. .spectral.yaml:

extends: ["spectral:oas"]
rules:
  # Every schema property needs a description: it becomes the docs.
  property-description:
    description: Schema properties must have a description.
    severity: error
    given: "$.components.schemas..properties[*]"
    then:
      field: description
      function: truthy

A new field without a description now fails the check:

 46:18  error  property-description  Schema properties must have a description.  components.schemas.RefundList.properties.has_more

That custom rule is the one to copy. A field with no description renders as a blank row in your API reference, and it’s the most common gap in generated docs.

lychee checks links. Split it into two jobs, because the two kinds of link fail for different reasons.

Internal links on every pull request. Run lychee on the built site with --offline, so it only checks links within your docs, including anchors. It’s fast and never flaky:

lychee --offline --include-fragments --root-dir "$PWD/site" --no-progress site

It catches what the build can miss, such as an anchor that no longer exists:

[site/guides/refunds/index.html]:
[ERROR] file:///…/site/guides/refunds/index.html#paging | Cannot find fragment

External links once a week. Other people’s sites go down, rate-limit you and move pages. If that blocks a pull request, people learn to ignore the check. Run external links on a schedule instead, and accept 429 Too Many Requests as a pass.

The workflow

.github/workflows/docs.yml runs on every pull request that touches the docs:

name: Docs checks

on:
  pull_request:
    paths:
      - "docs/**"
      - "openapi/**"
      - "mkdocs.yml"
      - ".vale.ini"
      - ".github/styles/**"
      - ".github/workflows/docs.yml"

permissions:
  contents: read

jobs:
  prose:
    name: Vale and markdownlint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - name: Vale
        uses: errata-ai/vale-action@v3
        with:
          files: docs
          fail_on_error: true

      - name: markdownlint
        uses: DavidAnson/markdownlint-cli2-action@v24
        with:
          globs: docs/**/*.md

  spec:
    name: Spectral
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npx --yes @stoplight/spectral-cli@6 lint openapi/openapi.yaml --fail-severity error

  build:
    name: Build and internal links
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
      - run: pip install -r requirements-docs.txt

      - name: Build (fails on broken internal links)
        run: mkdocs build --strict

      - name: Check links in the built site
        uses: lycheeverse/lychee-action@v2
        with:
          args: --offline --include-fragments --root-dir ${{ github.workspace }}/site --no-progress site

.github/workflows/external-links.yml checks external links every Monday, and can be run by hand:

name: External links

on:
  schedule:
    - cron: "0 6 * * 1" # Mondays, 06:00 UTC
  workflow_dispatch:

permissions:
  contents: read

jobs:
  links:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: lycheeverse/lychee-action@v2
        with:
          args: --no-progress --max-retries 3 --accept 200..=299,429 "docs/**/*.md"

Both files pass actionlint. A few details worth knowing:

  • The three jobs run in parallel, so a docs PR gets all its results in about the time of the slowest one.
  • The Vale action only reports on lines the pull request changed (its filter_mode defaults to added). Existing pages don’t block your first PR; they get fixed as people touch them.
  • markdownlint checks every file it’s given, changed or not. On an existing site, run it once locally, fix what it finds or turn off the rules you disagree with, then turn on the check.
  • Make the jobs required. In your branch protection rules or rulesets, add “Vale and markdownlint”, “Spectral” and “Build and internal links” as required status checks. A check that can be ignored will be.

Rolling it out on an existing docs site

Turning on every rule at once against a few hundred pages produces a wall of errors, and the check gets switched off. Roll it out in steps:

  1. Start with the build and internal links. They have almost no false positives, and broken links are what readers notice most.
  2. Add Spectral next, if you have a spec. Fix the errors once; new fields then stay documented.
  3. Add markdownlint after one clean-up pass, with the rules you disagree with turned off.
  4. Add Vale last, at MinAlertLevel = error. Lower it to warning once the team is used to it.

What CI can’t check

With these checks in place, every docs pull request builds, links resolve, terms are consistent and the spec is complete. What none of them can tell you is whether the page is still true.

A page can pass every check above and still describe last month’s API: the old page-size limit, an enum value that was removed, a webhook field that was renamed. CI runs on the docs pull requests people open. It can’t open the one nobody remembered to write.

That’s the half DocFit handles. When a code pull request merges, DocFit reads the change and its linked ticket, finds the docs pages it affects, edits them in your framework, and runs the same kinds of checks before you see anything: your own docs build and lint scripts (so markdownlint and Vale, if your repo runs them), links, and Redocly lint on the spec. Then it opens a docs pull request, and the CI from this guide runs on it like any other.

See how DocFit works, or start free below and see what it would have changed in your docs for your last few merged pull requests.

All posts

Your next merge shouldn't create a docs ticket.

Connect the GitHub App and run a silent dry run on your last 5 merged PRs inside the dashboard. Nothing is opened or posted on your repos until you choose.