Skip to content

Commit 77ab263

Browse files
authored
ci(docs): block merges on parser-level MDX validation (NVIDIA#2145)
Signed-off-by: Mark Chmarny <mark@chmarny.com>
1 parent f2400a4 commit 77ab263

15 files changed

Lines changed: 2975 additions & 47 deletions

.github/workflows/fern-docs-ci.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
# limitations under the License.
1414

1515
# Validates Fern docs configuration on pull requests that touch docs or fern/.
16+
#
17+
# NOTE: `fern check` validates docs.yml/nav configuration — it does NOT parse
18+
# MDX, so it cannot catch a construct that aborts `fern generate --docs` at
19+
# publish time. Parser-level MDX validation is the `docs-mdx` job in
20+
# merge-gate.yaml, which is the blocking check (see docs/contributor/tests.md
21+
# "Docs MDX Gate"). This workflow is advisory nav/link validation.
1622

1723
name: Fern Docs CI
1824

.github/workflows/merge-gate.yaml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ jobs:
5656
bom: ${{ steps.changes.outputs.bom }}
5757
tuning: ${{ steps.changes.outputs.tuning }}
5858
notices: ${{ steps.changes.outputs.notices }}
59+
docs: ${{ steps.changes.outputs.docs }}
5960
steps:
6061
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
6162
with:
@@ -143,6 +144,31 @@ jobs:
143144
- 'validators/performance/requirements.txt'
144145
- 'validators/performance/licenses/**'
145146
- 'THIRD_PARTY_NOTICES.md'
147+
docs:
148+
# Inputs to the parser-level MDX gate. Only the Fern-published
149+
# trees matter — a change anywhere under them can introduce a
150+
# construct that parses as CommonMark but aborts
151+
# `fern generate --docs`.
152+
#
153+
# docs/index.yml is Fern's navigation manifest AND the source the
154+
# checkers derive their file list from, so it is both an input and
155+
# a trigger: adding a page there must run the gate over it.
156+
# docs/README.md is the published landing page — the earlier
157+
# per-subdirectory globs missed it entirely.
158+
#
159+
# Everything the gate is MADE of is listed too, so a change to the
160+
# gate cannot skip its own verification: the two checkers, the page
161+
# enumerator, the locked parser toolchain, this workflow, and the
162+
# Makefile — whose `check-docs-mdx-parse` target is what the job
163+
# actually invokes, matching why the `renovate` and `notices`
164+
# filters watch it.
165+
- 'docs/**'
166+
- 'tools/check-docs-mdx'
167+
- 'tools/check-docs-mdx-parse'
168+
- 'tools/docs-published-files'
169+
- 'tools/mdx/**'
170+
- 'Makefile'
171+
- '.github/workflows/merge-gate.yaml'
146172
147173
# code = true iff at least one changed file is NOT docs/markdown/LICENSE.
148174
#
@@ -385,6 +411,47 @@ jobs:
385411
steps:
386412
- run: echo "No Renovate config changes — validation not required"
387413

414+
# ---------------------------------------------------------------------------
415+
# MDX docs gate — parses every Fern-published doc with the real MDX parser.
416+
#
417+
# `fern check` (Fern Docs CI) does not parse MDX, and the job that does
418+
# (`fern generate --docs --preview`) runs in a workflow_run companion whose
419+
# status never lands on the PR head SHA, so it can never be required. That
420+
# gap let a bare `<=` merge green and then fail every docs publish, including
421+
# a release tag (#2050). This job is the blocking replacement: hermetic, no
422+
# DOCS_FERN_TOKEN, no dependency on Fern's SaaS being up at merge time.
423+
# ---------------------------------------------------------------------------
424+
425+
docs-mdx:
426+
needs: [check-paths]
427+
if: needs.check-paths.outputs.docs == 'true'
428+
runs-on: ubuntu-latest
429+
timeout-minutes: 10
430+
steps:
431+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
432+
with:
433+
persist-credentials: false
434+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
435+
with:
436+
node-version: '20'
437+
# The bash approximation runs first: it is instant and its messages name
438+
# the specific hazard, so the common cases fail fast with better output.
439+
# The parser then decides — it is the authoritative check, and GITHUB_ACTIONS
440+
# makes a missing Node or a failed dependency install a hard error rather
441+
# than the local warn-and-skip.
442+
- name: Check MDX safety (pattern approximation)
443+
run: make check-docs-mdx
444+
- name: Validate docs with the real MDX parser
445+
run: make check-docs-mdx-parse
446+
447+
docs-mdx-skip:
448+
needs: [check-paths]
449+
if: needs.check-paths.outputs.docs != 'true'
450+
runs-on: ubuntu-latest
451+
timeout-minutes: 1
452+
steps:
453+
- run: echo "No published-docs changes — MDX parse gate not required"
454+
388455
# ---------------------------------------------------------------------------
389456
# BOM version freshness — ensures the committed container-images.md matches
390457
# the registry pins even on docs-only PRs (which skip the full `tests` job).
@@ -534,6 +601,8 @@ jobs:
534601
- verify-licenses-skip
535602
- verify-renovate
536603
- verify-renovate-skip
604+
- docs-mdx
605+
- docs-mdx-skip
537606
- bom-freshness
538607
- bom-freshness-skip
539608
- tuning-freshness

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,7 @@ aicr-e2e.xml
179179
# the repo root. The private-Sigstore e2e's run.sh builds the proxy to $TMPDIR,
180180
# so this only guards an accidental local build; the source stays tracked.
181181
/tlsproxy
182+
183+
# Resolved MDX toolchain for tools/check-docs-mdx-parse. The manifest and
184+
# lockfile in tools/mdx/ ARE committed; only the installed tree is not.
185+
tools/mdx/node_modules/

.settings.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ linting:
3939
addlicense: 'v1.2.0'
4040
# renovate: datasource=github-releases depName=google/go-licenses depType=linting
4141
go_licenses: 'v2.0.1'
42+
# MDX toolchain for tools/check-docs-mdx-parse (the parser-level docs gate) is
43+
# owned by tools/mdx/package.json + package-lock.json, not listed here. The
44+
# lockfile freezes the full transitive tree, which a version string cannot;
45+
# this gate decides which docs merge, so its verdict must be reproducible.
46+
# Renovate updates it natively via the npm manager. Same single-source-of-truth
47+
# split as the Go toolchain living in .go-version.
4248

4349
# Security Tools
4450
security_tools:

DEVELOPMENT.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,16 @@ make lint
222222
make lint-go # Go linting only
223223
make lint-yaml # YAML linting only
224224
make license # License header check
225+
226+
# Docs published to Fern are parsed as MDX; both checks gate the merge
227+
make check-docs-mdx # fast pattern approximation (no dependencies)
228+
make check-docs-mdx-parse # real MDX parser, authoritative (needs Node 20+)
225229
```
226230

231+
`check-docs-mdx-parse` warns and skips when Node is unavailable locally, but
232+
hard-fails in CI. See
233+
[Docs MDX Gate](docs/contributor/tests.md#docs-mdx-gate).
234+
227235
### 5. Run E2E Tests
228236

229237
```bash

Makefile

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ ifeq ($(IMAGE_REGISTRY),)
88
IMAGE_REGISTRY := ghcr.io/nvidia
99
endif
1010
IMAGE_TAG ?= latest
11-
YAML_FILES := $(shell find . -type f \( -iname "*.yml" -o -iname "*.yaml" \) ! -path "./examples/*" ! -path "./bundle/*" ! -path "./bundles/*" ! -path "*/testdata/*")
11+
YAML_FILES := $(shell find . -type f \( -iname "*.yml" -o -iname "*.yaml" \) ! -path "./examples/*" ! -path "./bundle/*" ! -path "./bundles/*" ! -path "*/testdata/*" ! -path "*/node_modules/*")
1212
COMMIT := $(shell git rev-parse HEAD)
1313
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
1414
GO_VERSION := $(shell cat .go-version 2>/dev/null)
@@ -105,7 +105,7 @@ generate: ## Runs go generate for code generation
105105
@echo "Code generation completed"
106106

107107
.PHONY: lint
108-
lint: lint-go lint-yaml license check-agents-sync check-docs-filenames check-docs-mdx bom-pinning-check ## Lints the entire project (Go, YAML, license headers, and chart-version pins)
108+
lint: lint-go lint-yaml license check-agents-sync check-docs-filenames check-docs-mdx check-docs-mdx-parse bom-pinning-check ## Lints the entire project (Go, YAML, license headers, and chart-version pins)
109109
@echo "Completed Go and YAML lints and ensured license headers"
110110

111111
# Standalone target — NOT part of `make lint` because it requires Docker
@@ -141,6 +141,21 @@ check-docs-filenames: ## Enforces lowercase kebab-case filenames in docs/
141141
check-docs-mdx: ## Checks docs/ markdown for MDX compatibility (void elements, bare braces, HTML comments, autolinks, bare <tags>)
142142
@./tools/check-docs-mdx
143143

144+
# Parser-level docs gate — runs the SAME MDX parser Fern does over every
145+
# published doc, so a construct that would abort `fern generate --docs` at
146+
# publish time fails here instead. This is the authoritative check;
147+
# check-docs-mdx above is a fast, dependency-free approximation kept as a
148+
# strict subset of it.
149+
#
150+
# Part of `make lint` (and therefore `make qualify`). It needs Node 20+ and one
151+
# `npm ci` of the locked tree in tools/mdx. With Node installed, a local
152+
# `make qualify` predicts CI as usual; WITHOUT it this check warns and skips, so
153+
# a local pass no longer implies a CI pass and invalid MDX can still be rejected
154+
# by the merge-gate `docs-mdx` job, where a missing Node is a HARD FAILURE.
155+
.PHONY: check-docs-mdx-parse
156+
check-docs-mdx-parse: ## Validates docs/ with the real MDX parser (requires Node; CI-blocking)
157+
@./tools/check-docs-mdx-parse
158+
144159
.PHONY: lint-go
145160
lint-go: ## Lints Go files with golangci-lint and go vet
146161
@set -e; \
@@ -179,7 +194,8 @@ LICENSE_IGNORES = \
179194
-ignore 'recipes/evidence/*/*/*.yaml' \
180195
-ignore 'THIRD_PARTY_NOTICES.md' \
181196
-ignore 'validators/performance/licenses/**' \
182-
-ignore '.licenses-cache/**'
197+
-ignore '.licenses-cache/**' \
198+
-ignore 'tools/mdx/node_modules/**'
183199

184200
# The two recipes/evidence patterns in LICENSE_IGNORES match exactly the
185201
# generated, header-less pointer shapes MarshalPointer emits — the transient

docs/contributor/tests.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,8 @@ half of the pipeline and skips deploy-side assertions.
479479
`make qualify` is the canonical pre-push command. It runs:
480480

481481
- `test-coverage` — `go test -race ./...` plus the 80% coverage floor.
482-
- `lint` — golangci-lint with `.golangci.yaml` plus yamllint.
482+
- `lint` — golangci-lint with `.golangci.yaml`, yamllint, and the docs checks
483+
(filenames, MDX patterns, MDX parse — see [Docs MDX Gate](#docs-mdx-gate)).
483484
- `e2e` — the end-to-end pipeline runner.
484485
- `scan` — Grype vulnerability scan.
485486
- `license-check` — license header / dependency-license sweep.
@@ -501,6 +502,66 @@ This applies even to PRs labeled `documentation` when they include
501502
incidental Go changes. Do not rely on CI to surface lint failures —
502503
the pre-push gate is local.
503504

505+
## Docs MDX Gate
506+
507+
Fern renders published docs through an MDX parser, so a construct that is valid
508+
CommonMark can still abort `fern generate --docs` at publish time. A bare `<=`
509+
in prose is the classic case — MDX reads the `<` as the start of a JSX tag and
510+
fails with `Unexpected character = (U+003D) before name`.
511+
512+
**Which files are checked.** Both checks derive their file list from
513+
`docs/index.yml` via `tools/docs-published-files` — Fern's navigation manifest
514+
is the authoritative statement of what gets parsed. Globbing
515+
`docs/user`/`docs/integrator`/`docs/contributor` instead was a denylist in
516+
disguise: it missed `docs/README.md`, the published landing page, so a hazard
517+
there passed both gates and still broke the publish. Add a page to
518+
`docs/index.yml` and it is gated that day; a file that is not published is not
519+
gated at all.
520+
521+
Two checks cover this, both run by `make lint`:
522+
523+
| Check | What it is | Speed |
524+
|-------|-----------|-------|
525+
| `make check-docs-mdx` | Pattern-based bash approximation. Names the specific hazard, needs no dependencies. | Instant |
526+
| `make check-docs-mdx-parse` | The real MDX parser (`@mdx-js/mdx`, locked in `tools/mdx/package-lock.json`). Authoritative. | ~2 s + one `npm ci` |
527+
528+
The parser is the source of truth. The bash rules are deliberately kept as a
529+
strict **subset** of what it rejects: a miss is caught by the parse gate, but a
530+
false positive would force you to mangle prose the publish step would have
531+
accepted. That is why `< 500` and `< 10 s` are fine (MDX only enters tag mode
532+
when a name-ish character follows `<` immediately) while `<= 2,000` and `<30 s`
533+
are not.
534+
535+
Hazards only the parser sees: a stray closing tag (`</div>`), an unclosed
536+
fragment (`<>`), a placeholder sharing a line with well-formed JSX, unbalanced
537+
expression braces spanning lines, and any acorn-level syntax error.
538+
539+
Well-formed JSX is fine in both — `<Component />` and `<span>text</span>` parse,
540+
and the Fern component set is authored that way. So is YAML frontmatter: Fern
541+
strips it before MDX, so a `title: gate <= 2,000` is valid, and both checks skip
542+
it by line number so later diagnostics still cite the true line.
543+
544+
`check-docs-mdx-parse` needs Node 20+. Without it the script prints a warning
545+
and exits 0 locally, but **hard-fails under CI** — the `docs-mdx` job in
546+
`merge-gate.yaml` blocks on it, and the merge gate is the only required status
547+
check. This is the one place where a green local `make qualify` does not
548+
guarantee a green CI: if you have no Node, the MDX gate did not actually run.
549+
550+
Fixing a violation is usually one of:
551+
552+
```markdown
553+
gate <= 2,000 → gate `<= 2,000`
554+
<30 s → `<30 s` or &lt;30 s
555+
<br> → <br />
556+
{template} → \{template\}
557+
```
558+
559+
Note that `fern check` (the Fern Docs CI job) does **not** parse MDX, and the
560+
job that does — `fern generate --docs --preview` — runs as a `workflow_run`
561+
companion whose status never lands on the PR head SHA, so it cannot be a
562+
required check. `make check-docs-mdx-parse` exists to close that gap without a
563+
token or a dependency on Fern's service at merge time.
564+
504565
## Common Gotchas
505566

506567
- **`goreleaser` fails when both `GITLAB_TOKEN` and `GITHUB_TOKEN`

0 commit comments

Comments
 (0)