See it as a diagram
Everything below, as a diagram you can edit. Describe yours and see it in seconds.
No account needed · Editable canvas, not a picture
Link checkers: the highest-value check per minute of setup
Broken links are the most reader-visible form of doc decay and the cheapest to catch. lychee, a Rust checker with retries and caching, is the current default choice; it runs locally as lychee --no-progress 'docs/**/*.md' and in GitHub Actions via lycheeverse/lychee-action, with a cache that stops it re-hitting every external URL on every run. markdown-link-check is the Node alternative if you want per-file invocation in a pre-commit hook.
Two configuration notes from practice: exclude internal-only hosts CI cannot reach, or the job cries wolf until someone mutes it, and run external-link checking on a schedule rather than per-PR, since external sites break on their own clock and should not block unrelated merges.
Prose linters: Vale for style, markdownlint for structure
Vale lints prose against rule packages, including implementations of the Google and Microsoft style guides, plus custom rules, banned phrases, casing, terminology. Running vale --minAlertLevel=error docs/ in CI keeps only rule violations you have promoted to errors as merge blockers, which is the setting that keeps teams from ripping the linter out in week two. markdownlint-cli2 covers structure, heading hierarchy, list formatting, bare URLs, and runs as markdownlint-cli2 "docs/**/*.md".
Prose linters check style, not correctness. A fluent, well-formed page describing a deleted service passes both tools. That gap is what the next two checks exist for.
The freshness gate
Give critical pages a front matter field, last_reviewed: 2026-08-12, plus a tier marker, and a small script fails the build when any critical page's review date is older than the window. This is the check that catches the failure link checkers cannot: the page nobody has touched, describing a system everyone has.
name: docs-checks
on:
pull_request:
paths: ['docs/**', '**/*.md']
jobs:
links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: lycheeverse/lychee-action@v2
with:
args: --no-progress --cache 'docs/**/*.md' 'README.md'
prose:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: errata-ai/vale-action@v2
with:
files: docs
freshness:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Critical pages reviewed within 90 days
run: |
cutoff=$(date -d '90 days ago' +%F)
stale=0
for f in $(grep -rl 'tier: critical' docs); do
reviewed=$(grep -oP 'last_reviewed: \K\d{4}-\d{2}-\d{2}' "$f" || true)
if [ -z "$reviewed" ] || [ "$reviewed" \< "$cutoff" ]; then
echo "STALE: $f (last_reviewed: $reviewed)"
stale=1
fi
done
exit $staleRules that demand doc updates with code changes
Danger runs a script against each PR's metadata and can warn or fail based on what changed. The docs rule is four lines: if files under a watched service path changed and no file under that service's docs path did, post a warning asking for the doc update or an explicit reason it is not needed. Teams usually start with warn rather than fail, because plenty of code changes legitimately need no doc change, and a hard fail teaches people to make junk edits to satisfy the bot.
The same idea scales down to plain CI: a shell step comparing git diff --name-only against two path lists needs no framework at all. What Danger adds is the PR comment surface and per-rule messaging.
// dangerfile.js
const changed = danger.git.modified_files.concat(danger.git.created_files);
const touchedPayments = changed.some((f) => f.startsWith('services/payments/'));
const touchedTheirDocs = changed.some((f) => f.startsWith('docs/payments/'));
if (touchedPayments && !touchedTheirDocs) {
warn(
'services/payments changed but docs/payments did not. ' +
'Update the doc, or note in the PR description why it still holds.'
);
}Where diagram regeneration fits in the same pipeline
Every check above verifies text. Architecture diagrams need the equivalent of the Danger rule with the repair attached: after a merge that touches infrastructure paths, a pipeline step asks an agent connected to Datadef's MCP server (registry name io.datadef/mcp) to update the diagram from what the repo now contains. The agent reads the code, the diagram gets redrawn, and because the published diagram is an embed URL rather than a committed image, every README and wiki page showing it follows within minutes. The full recipe lives in a living diagram from GitHub Actions.
The honest boundaries: Datadef does not watch the repository, so the trigger is your pipeline's job, exactly like every other check on this page. The MCP connection needs an API key, available on paid plans. And the embed URL requires the project to be shared public, which matters for confidential systems. For prose that must track code, function references, walkthroughs, this is out of Datadef's scope; a code-coupled docs tool like Swimm is the honest recommendation there.
A pipeline that fits in one afternoon
FAQ
What documentation checks should run in CI?
Can CI detect outdated documentation?
Should documentation checks block merges?
How do I require doc updates when code changes?
lychee or markdown-link-check?