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
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.