DEV Community

Cover image for How to add a markdown quality gate to your GitHub Actions workflow
Kazu
Kazu

Posted on • Edited on

How to add a markdown quality gate to your GitHub Actions workflow

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

gomarklint on GitHub
gomarklint docs

Top comments (0)