Skip to content

Lint Markdown and validate links in CI

Date: 2026-07-13

Status

Accepted

Context

EOS contains contributor-facing documentation at the repository root, in the central docs directory, and alongside applications. Formatting mistakes and broken links in these documents make the repository harder to navigate and can remain unnoticed until after a change is merged.

The repository also contains Markdown used as agent instructions, generated skills, tool-specific configuration, and release changelogs generated by Changesets. Those files follow formats imposed by their consumers and may intentionally differ from normal documentation conventions. They are not user-authored documentation and should not require directory-local lint configuration or be rewritten by documentation tooling.

We need a CI check that:

  • applies consistent Markdown rules to user-authored documentation;
  • detects broken local links without depending on external services;
  • can be run locally through the existing Nix development shell;
  • keeps the lint and link-checking responsibilities separate;
  • excludes agent, generated-skill, dependency, and build-output directories.

Decision

We will use markdownlint-cli2 to lint Markdown and Lychee to validate links.

The check uses the recursive **/*.md glob with central exclusions for agent instructions, tool-specific content, generated skills, release changelogs, dependencies, and build output. The excluded paths include .agents, .claude, .opencode, .apm, and **/CHANGELOG.md. All formatter, linter, and link-checker exclusions are configured at the repository root; directory-local markdownlint files are not used.

markdownlint-cli2 is installed as a workspace development dependency and is configured from the repository root. Oxfmt formats Markdown at markdownlint’s default 80-column width and wraps prose automatically. docs:lint runs Oxfmt and applies supported markdownlint fixes locally, while docs:check verifies formatting, Markdown rules, and links without changing files. CI runs the same read-only Markdown command and validates local links through the official lycheeverse/lychee-action GitHub Action. Lychee runs in offline mode so external URLs are not requested. It is provided by the Nix development shell for local checks. Turborepo exposes the documentation tasks consistently with the repository’s other checks.

Consequences

Positive

  • Documentation formatting and links are checked before changes merge.
  • Contributors can run the same checks locally with the repository’s standard toolchain.
  • One central configuration defines the documentation ownership boundary.
  • Agent, generated-skill, and release changelog files are not rewritten by documentation tooling.
  • Linting and link validation can evolve independently.

Negative

  • External URLs are not validated, so an external destination can break without failing CI.
  • Agent and generated-skill Markdown is outside the formatting and link-checking boundary.

Neutral

  • The documentation job is separate from the source-code lint job so failures are easier to identify.

Alternatives considered

markdownlint-cli

markdownlint-cli provides similar linting from the command line. We chose markdownlint-cli2 because it has straightforward glob and configuration support for a repository-wide CI command and is the current CLI maintained alongside the markdownlint ecosystem.

markdownlint as a library

Using the underlying library directly would allow a custom Node.js wrapper. We rejected this because EOS does not need custom lint orchestration, and owning a wrapper would add code without improving the checks.

markdown-link-check is a Node.js-based link checker and would keep both tools in the workspace dependency graph. We chose Lychee because it provides fast local checking as a standalone Nix package and a maintained GitHub Action designed for CI use.