Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Quality

Catch broken Markdown links in GitHub Actions

A pull request check that fails on broken docs links and heading anchors and annotates each one on its line, plus a weekly job that probes external URLs.

By 9 min read

To make a pull request fail when it breaks a docs link, run blume validate --strict in a GitHub Actions workflow on every pull request, and make that check required. It reads your Markdown and MDX, resolves each internal link and #fragment against the pages and headings your site actually has, and exits non-zero on a miss. Don't leave out --strict: a broken anchor is a warning, and warnings alone don't fail the command.

By the end, you have a small repository whose pull request fails on a renamed page and a reworded heading, with each broken link annotated on its line, and passes once they're fixed. A second check reads the built HTML, and a weekly job probes external URLs without blocking anyone's merge.

This covers docs built with Blume. If the Markdown you want checked is READMEs spread across a repository rather than a docs site, a general link checker such as Lychee fits better, because blume validate only reads your site's content.

Set up the example

The example is a three-page docs site for Acme's CLI, laid out the way npx blume init scaffolds it, at the root of the repository:

docs/
  configuration.mdx
  index.mdx
  installation.mdx
blume.config.ts
package.json
package-lock.json

Two pages link to a section of the configuration page:

---
title: Acme docs
description: Send transactional email and SMS with the Acme Messages API.
---

Start by [installing the CLI](/installation), then [create an API key](/configuration#api-keys).
---
title: Installation
description: Install the Acme CLI on macOS, Linux, or Windows.
---

## Install the CLI

Install the CLI with your package manager, then [set your API key](./configuration#api-keys).
---
title: Configuration
description: Configure the Acme CLI with an API key and a default region.
---

## API keys

Create a key in the dashboard and export it as `ACME_API_KEY`.

## Regions

Pick the region closest to your users.
import { defineConfig } from "blume";

export default defineConfig({
  title: "Acme",
  description: "Send transactional email and SMS with the Acme Messages API.",
});

Commit this to main, then run the check once. It prints No broken links found. and exits 0.

npx blume validate --strict

Now make the kind of change that breaks links in real repositories. On a branch, rename the installation page:

git switch -c rename-install
git mv docs/installation.mdx docs/install.mdx

Then reword the heading the other pages link to: change ## API keys to ## Authentication in docs/configuration.mdx. Commit both changes and run the check without --strict:

git commit -am "Rename the install page and the API keys section"
npx blume validate
BLUME_BROKEN_LINK Broken link to /installation: no page resolves to /installation.
  at docs/index.mdx:6:31
  fix: Check the path, or create the target page.
  docs: https://useblume.dev/docs/cli/validate
BLUME_BROKEN_ANCHOR No anchor target on /configuration matches #api-keys.
  at docs/index.mdx:6:72
  docs: https://useblume.dev/docs/cli/validate
BLUME_BROKEN_ANCHOR No anchor target on /configuration matches #api-keys.
  at docs/install.mdx:8:68
  docs: https://useblume.dev/docs/cli/validate

1 error(s), 2 warning(s)

The rename left a link to a route no page serves, which is an error. The new heading generates the id authentication, so both links to #api-keys now land at the top of the page instead of the section. That's a warning, because the page itself still loads. Each finding names the file, line, and column to fix.

Here's why --strict matters. Fix only the page link and the command exits 0 with two warnings. With --strict, warnings fail it too. Info-level notes never do, like BLUME_ASSETS_UNCHECKED, which says asset links went unchecked because there's no public/ folder.

A link counts as valid when it lands on a Markdown page, a custom .astro page, the generated changelog, or the from of a configured redirect.

Source checks and built-site checks

Blume has two link checkers, and they read different things. blume validate reads your content files. blume audit reads the HTML in dist/ after blume build.

blume validateblume audit
ReadsMarkdown and MDX sourceThe built site
Needs a buildNoYes
Broken page linkBLUME_BROKEN_LINK, errorBLUME_AUDIT_LINK_TO_BROKEN, error
Broken anchorBLUME_BROKEN_ANCHOR, warningBLUME_AUDIT_ANCHOR_BROKEN, warning
Points you toFile, line, and columnPage URL and source file

validate checks inline links written as [text](target), image embeds, and a component's string href. It doesn't see reference-style links ([text][ref]), autolinks (<https://…>), lowercase HTML <a href> tags, href={…} expressions, or a link whose label wraps onto a second line, which some formatters produce. It doesn't check your navigation config either.

audit sees every <a href> the build rendered, including all of those and the sidebar and header links. It reports a broken navigation entry once, not once per page. The cost is a build, and findings that name a page rather than a line. Run both: validate first because it's fast and exact, then audit as the backstop.

Add the pull request check

This workflow runs on every pull request and every push to main:

name: Docs links

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  links:
    name: Links
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - name: Check links in the source
        run: |
          status=0
          npx blume validate --strict --json > links.json || status=$?
          jq -r '
            .diagnostics[]
            | (if .severity == "info" then "notice" else .severity end) as $level
            | ([ (if .file then "file=\(.file)" else empty end),
                 (if .line then "line=\(.line)" else empty end),
                 (if .column then "col=\(.column)" else empty end),
                 "title=\(.code)" ] | join(",")) as $props
            | "::\($level) \($props)::\(.message)"
          ' links.json
          exit "$status"
      - run: npx blume build
      - name: Check links in the built site
        run: npx blume audit --only link_to_broken,anchor_broken --fail-on warning

The source step saves the exit code, then uses jq (installed on GitHub's Ubuntu runners) to turn each finding in the JSON report into an ::error or ::warning workflow command with its file, line, and column. GitHub shows those as annotations on the lines they name. The step then exits with the saved code, so it still fails. On the breaking commit, it prints:

::error file=docs/index.mdx,line=6,col=31,title=BLUME_BROKEN_LINK::Broken link to /installation: no page resolves to /installation.
::warning file=docs/index.mdx,line=6,col=72,title=BLUME_BROKEN_ANCHOR::No anchor target on /configuration matches #api-keys.
::warning file=docs/install.mdx,line=8,col=68,title=BLUME_BROKEN_ANCHOR::No anchor target on /configuration matches #api-keys.

The paths are relative to the Blume project. If your docs live in a subfolder of the repository, set working-directory on the steps and add that folder to the front of file=, so the annotations land on the right files.

The audit step narrows the report to its two link checks. --fail-on warning is what makes the anchor check fail, since it's a warning like its validate counterpart. The rest of what the audit checks, like titles, descriptions, canonicals, and the sitemap, belongs to the SEO audit guide. If you add its workflow, its full audit already runs these two checks, so drop the build and audit steps here and keep the source check. That way each pull request builds the site once.

Push the branch and open a pull request. Once the workflow has run, add the Links check under "Require status checks to pass before merging" in your branch protection rule for main. The workflow has no paths filter on purpose: a required check whose workflow is skipped stays pending and blocks the merge.

External links need the network, and their results depend on other people's servers. A pull request that touched no links can fail because another site is down or unreachable from the runner. So keep them out of the required check and probe them on a schedule instead. Blume grades each response rather than treating every failure as a dead link:

ResponseSeverityWhat it usually means
404 or 410ErrorThe page is gone. Fix or remove the link.
No response at allErrorThe host doesn't resolve or refused the connection. Often a dead domain, but a network problem on the runner looks the same.
Any other error status, like 403, 429, or a 5xxWarningRate limiting, bot protection, or an outage. Not proof of anything.
No answer within 10 secondsWarningA slow or overloaded server.

Blume requests each distinct URL once, eight at a time. It sends a HEAD request and retries with GET when the server rejects HEAD (405 or 501) or drops the connection, and it follows redirects. This workflow runs every Monday at 06:00 UTC, and on demand:

name: External links

on:
  schedule:
    - cron: "0 6 * * 1"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  external:
    name: External links
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
      - run: npm ci
      - name: Probe external links
        run: |
          status=0
          npx blume validate --external --json > links.json || status=$?
          {
            echo "| Result | Finding | Source |"
            echo "| --- | --- | --- |"
            jq -r '
              .diagnostics[]
              | select(.code == "BLUME_DEAD_LINK")
              | (if .severity == "error" then "Broken or unreachable" else "Check later" end) as $result
              | "| \($result) | \(.message) | \(.file):\(.line) |"
            ' links.json
          } >> "$GITHUB_STEP_SUMMARY"
          exit "$status"

Leave --strict off here. Without it, the job fails only on errors, so a 429 adds a "Check later" row to the run's summary and the run stays green. With it, every rate limit turns the job red, and people learn to ignore it.

When a run fails, open each "Broken or unreachable" URL yourself before you change anything. A 404 in your browser is a real dead link. For a host that didn't answer, rerun the workflow from the Actions tab and fix the links that fail twice. Scheduled runs use the latest commit on your default branch, and in a public repository GitHub disables them after 60 days without activity.

blume audit --external probes the links in the built HTML with the same grading, as BLUME_AUDIT_EXTERNAL_LINK_BROKEN. Use it instead if the scheduled job builds the site anyway.

Fix and rerun

Back on the branch, fix the anchor at its source. Pin the heading's id:

## Authentication [#api-keys]

Create a key in the dashboard and export it as `ACME_API_KEY`.

The [#api-keys] marker never renders, and the id stays api-keys whatever the heading says next year. That fixes both links at once, plus any on other sites. Pin every heading people are likely to link to. See Custom anchors.

Then point the home page at the new URL, and add a redirect so bookmarks and links from outside keep working:

Start by [installing the CLI](/install), then [create an API key](/configuration#api-keys).
import { defineConfig } from "blume";

export default defineConfig({
  title: "Acme",
  description: "Send transactional email and SMS with the Acme Messages API.",
  redirects: [{ from: "/installation", to: "/install", status: 308 }],
});

Because a redirect's from counts as a valid target, the redirect alone would satisfy validate. Update your own links anyway: a full blume audit reports internal links that go through a redirect as BLUME_AUDIT_LINK_TO_REDIRECT. For moving many URLs at once, see the URL migration guide.

Rerun the check locally, then push:

npx blume validate --strict
git commit -am "Fix links after the rename"
git push

The command prints No broken links found. and exits 0, the new push runs the workflow again, and the pull request can merge once the check passes.

Troubleshooting

A broken anchor doesn't fail the check

Anchors are warnings in both commands. Add --strict to blume validate, and --fail-on warning to blume audit.

A link you know is broken isn't reported

If validate passes, the link is probably one of the forms it doesn't read: a reference-style link, an autolink, a raw <a> tag, or a label that wraps across lines. The audit step after the build catches it. Rewriting it as a one-line inline link moves it back into the fast check.

A link to a custom page is reported as broken

Blume can't list the paths a dynamic route like pages/compare/[tool].astro serves, so validate reports links to them as broken. Give each path its own static file, as Custom pages describes.

An example URL fails the external check

A link to localhost, an internal host, or a placeholder domain like api.acme.example never answers, so --external reports it as an error. There's no ignore list. Write those URLs as inline code instead of links, which neither check reads as a link.

The audit step says there's no build

No build found at …/dist. Run blume build first. means blume audit ran without a build next to it. Keep it in the same job, after npx blume build.

The audit rejects the check names

Check ids use underscores: link_to_broken, not link-to-broken. An unknown name fails the run and suggests the closest match, so a typo can't empty the report and pass. npx blume audit --list-checks prints every id.

The strict check fails on something that isn't a link

validate also reports problems it finds while reading your content, like a page that fails to parse, and --strict fails on those warnings too. Fix them where the finding points: a page that doesn't parse is a page whose links weren't checked.

Next step

Check your links now

Run it at the root of your docs project to see what the pull request check would report today.

npx blume validate --strict
Read the validate reference

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init