My team's docs repo crossed 400 files last year. We merged a PR that quietly introduced three broken external links, one heading that jumped from H2 to H4, and a fenced code block without a language tag. None of it failed CI. A reader filed a GitHub issue two weeks later.
Adding a markdown quality gate seemed like an obvious fix, but the first option we tried pulled in a Node.js runtime setup step, a pinned Node version, and a question from a teammate: "why does our docs pipeline touch Node?" This tutorial walks through building a working workflow from scratch — the file path, the triggers, the config, and what a real failure looks like.
Step 1: Create the workflow file
Create .github/workflows/markdown-lint.yml in your repository. Start with just the trigger:
name: Markdown lint
on:
pull_request:
paths:
- "**/*.md"
push:
branches:
- main
paths:
- "**/*.md"
The paths filter means the job only runs when a .md file changed. On a busy repo this matters — you don't want a Go or Python change triggering a docs check.
Step 2: Add the job skeleton
Extend the file with a job and the checkout step:
name: Markdown lint
on:
pull_request:
paths:
- "**/*.md"
push:
branches:
- main
paths:
- "**/*.md"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Nothing surprising here. Checkout is always first — the linter needs the files on disk.
Step 3: Add the markdown linter to your GitHub Actions job
We'll use gomarklint — a single static binary with no runtime dependencies. The entire lint step is one line:
name: Markdown lint
on:
pull_request:
paths:
- "**/*.md"
push:
branches:
- main
paths:
- "**/*.md"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Markdown
uses: shinagawa-web/gomarklint-action@v1
That's the complete workflow. Push this file and the markdown lint check will run on every PR that touches a .md file.
Step 4: See a real failure
Without any configuration, gomarklint runs its default rules. A PR that introduces a heading jump produces output like this:
docs/api/endpoints.md:23: heading-level: expected H3, got H4 (skipped a level)
docs/contributing.md:11: fenced-code-language: code block has no language identifier
docs/contributing.md:58: external-link: https://old-domain.example.com/guide returned 404
The job exits non-zero. The PR check goes red. The broken content doesn't merge.
The external link check is the one that would have caught our production issue. gomarklint makes real HTTP requests to each linked URL and reports anything that returns 4xx or 5xx, or times out. No second tool required.
Step 5: Add a config file
Create .gomarklint.yaml at the repo root to tune which rules run and how:
rules:
heading-level:
enabled: true
minLevel: 2
fenced-code-language:
enabled: true
external-link:
enabled: true
timeoutSeconds: 10
skipPatterns:
- "localhost"
- "example\\.com"
minLevel: 2 means H1 is reserved for the document title and the linter won't flag its absence in sub-pages. skipPatterns lets you exclude URLs that are intentionally unreachable in CI (local dev URLs, placeholder domains).
The gomarklint action picks up .gomarklint.yaml automatically — no flag needed.
Step 6: Make the markdown lint check block merges
In your repository settings, go to Branches → Branch protection rules → Require status checks to pass before merging and add lint (or whatever you named the job). From this point the check is a hard gate, not advisory.
The full workflow, with config wired in, looks like this:
name: Markdown lint
on:
pull_request:
paths:
- "**/*.md"
push:
branches:
- main
paths:
- "**/*.md"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint Markdown
uses: shinagawa-web/gomarklint-action@v1
The config lives in .gomarklint.yaml at the root. The action finds it automatically.
What the rules actually catch
gomarklint's default rule set covers the problems that consistently show up in team docs repositories:
- Heading level jumps (H2 → H4 with no H3)
- Duplicate headings within a file
- More than one H1 per file
- Missing blank lines around headings, lists, and code blocks
- Fenced code blocks with no language identifier
- Images with missing or empty alt text
- Bare URLs that aren't wrapped in link syntax
- Empty link destinations
- External links that return 4xx/5xx or time out
All rules are on by default and individually toggleable in .gomarklint.yaml.
What it doesn't solve yet
The gate catches structural and link problems reliably. It doesn't enforce prose style — things like passive voice, sentence length, or terminology consistency require a different category of tool. For those, tools like Vale fill the gap, and you can add Vale as a second step in the same job without any conflict.
I'm currently looking at integrating internal anchor validation (catching [see this](#old-anchor) when old-anchor no longer exists after a heading rename). That's the one class of broken link this workflow still misses.
What's the first markdown rule you'd want failing your CI today — structure, links, or something else?
Footnote: why gomarklint over markdownlint-cli2
The other widely-used option is markdownlint-cli2, which has 50+ rules and a large community. The tradeoff is the runtime: it requires a Node.js setup step in CI (~15–20 seconds), which adds friction in non-JavaScript repos.
| markdownlint-cli2 | gomarklint | |
|---|---|---|
| Runtime | Node.js required | None (single binary) |
| Install in CI | npm install -g markdownlint-cli2 |
GitHub Action or curl one-liner |
| HTTP link validation | Separate tool needed | Built in |
| Rules | 50+ | 14 structural + link validation |
If you already have Node.js in your pipeline, markdownlint-cli2 is a solid choice. If you don't, gomarklint avoids adding it.
Top comments (0)