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.
5. Link checks: internal on every PR, external every week
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_modedefaults toadded). 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:
- Start with the build and internal links. They have almost no false positives, and broken links are what readers notice most.
- Add Spectral next, if you have a spec. Fix the errors once; new fields then stay documented.
- Add markdownlint after one clean-up pass, with the rules you disagree with turned off.
- Add Vale last, at
MinAlertLevel = error. Lower it towarningonce 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.
