Your user clicked the "Getting Started" link in your README. They got a 404. You didn't know because the link had been broken for three weeks and no tool in your pipeline caught it.
That's the problem. Dead links in Markdown documentation are silent failures — they don't break tests, don't fail builds, and don't show up in code review. They only surface when a real person hits them.
I built gomarklint to close that gap. Here's what I learned running it across 180 Markdown files totaling 100,000+ lines.
The Three Kinds of Broken Links
Most link checkers treat all links the same. That's a mistake, because internal relative links, external HTTP links, and anchor fragment links fail for completely different reasons.
Internal relative links break when files get moved or renamed during a refactor. A link like [setup](./docs/setup.md) is valid the day you write it and invalid the day someone renames the file without updating references.
External HTTP links break for reasons you don't control — a third-party API changes its URL structure, a service goes down, a library migrates its docs to a new domain. These are often the most embarrassing failures because they affect the first impression a new user has of your project.
Anchor fragment links — links like [see config](#configuration) — break silently when headings change. If someone renames "Configuration" to "Config Options," every anchor pointing to #configuration is now dead, and no compiler will warn you.
gomarklint checks all three categories in a single pass. Enable link checking in your config:
{
"enableLinkCheck": true,
"skipLinkPatterns": ["localhost", "example.com"]
}
The skipLinkPatterns field lets you suppress false positives from placeholder URLs without disabling the check entirely.
The CI Hook: Failing the Build Before the PR Merges
Detection only matters if it runs before code ships. A link checker you run manually is a link checker you forget to run.
Here is a complete GitHub Actions workflow that checks every Markdown file on pull requests targeting main:
name: Markdown Link Check
on:
pull_request:
branches: [main]
jobs:
link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Install gomarklint
run: go install github.com/shinagawa-web/gomarklint@latest
- name: Check links
run: gomarklint --enable-link-check ./...
The total runtime across 180 files is under 50ms — faster than most network round-trips to fetch a single external URL. Because gomarklint is a single compiled binary with no runtime dependency (no Node.js, no Ruby), the only meaningful CI time is the go install step, which caches cleanly via actions/setup-go.
The "Redirect Mask" Trap
The most elusive bug I found during development: external links that return 301 or 302 redirects were being silently treated as valid.
A link to http://old-docs.example.com/api might redirect to https://new-docs.example.com/api today. But in three months the redirect itself is removed, and your link goes from "redirects correctly" to "hard 404" overnight. The link check that passed six months ago is now wrong, and nothing told you the situation changed.
The fix: treat 3xx responses as warnings, not passes. In gomarklint, you can configure followRedirects: false to surface these — which forces you to update the link to the final destination rather than relying on a redirect that may not be permanent.
{
"enableLinkCheck": true,
"followRedirects": false
}
This is stricter than most teams want by default, but for docs you intend to maintain for years, it pays off.
What's Not Solved Yet
Link checking against rate-limited external services is still genuinely hard. GitHub, npm, and PyPI all throttle rapid HEAD requests, which produces false 429 failures in CI. The current approach — exponential backoff with a configurable retry count — helps, but a long PR queue can still hit limits.
The approach I'm exploring next is a two-tier strategy: fail the build on internal and anchor-fragment errors (always reliable, zero network cost), and report external link failures as warnings rather than hard errors, so they surface without blocking a merge.
If you've dealt with flaky external link checks in CI — whether in this tool or another — I'd genuinely like to know what threshold or retry logic worked for your repository size.
Check it out on GitHub: shinagawa-web/gomarklint
Top comments (0)