From 1634ecb3ac9adbdd71d1e51b279a48440273797b Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Fri, 15 May 2026 18:25:21 -0400 Subject: [PATCH 1/4] Add standalone connector docs verification workflow --- .github/workflows/connector-docs-verify.yaml | 318 +++++++++++++++++++ .github/workflows/verify.yaml | 2 +- README.md | 29 ++ docs/connector-docs-verify.md | 97 ++++++ docs/verify-workflow.md | 11 +- tools/mdx-lint/mdx-lint.mjs | 154 ++++++++- 6 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/connector-docs-verify.yaml create mode 100644 docs/connector-docs-verify.md diff --git a/.github/workflows/connector-docs-verify.yaml b/.github/workflows/connector-docs-verify.yaml new file mode 100644 index 0000000..4cd2e67 --- /dev/null +++ b/.github/workflows/connector-docs-verify.yaml @@ -0,0 +1,318 @@ +on: + workflow_call: + inputs: + ref: + required: true + type: string + +permissions: + contents: read + pull-requests: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check docs change + id: docs-change + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + REPOSITORY: ${{ github.event.repository.full_name }} + run: | + set -euo pipefail + + if [ -z "$PR_NUMBER" ]; then + echo "validate=unknown" >> "$GITHUB_OUTPUT" + exit 0 + fi + + API_URL="${GITHUB_API_URL:-https://api.github.com}" + PAGE=1 + SHOULD_VALIDATE=false + while true; do + FILES=$(curl -fsSL \ + -H "Authorization: Bearer $GH_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "${API_URL}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${PAGE}") + + if echo "$FILES" | jq -e '.[] | select(.filename == "docs/connector.mdx")' >/dev/null; then + SHOULD_VALIDATE=true + break + fi + + FILE_COUNT=$(echo "$FILES" | jq 'length') + if [ "$FILE_COUNT" -lt 100 ]; then + break + fi + PAGE=$((PAGE + 1)) + done + + echo "validate=$SHOULD_VALIDATE" >> "$GITHUB_OUTPUT" + if [ "$SHOULD_VALIDATE" != "true" ]; then + echo "docs/connector.mdx unchanged; skipping MDX validation" + fi + + - name: Setup Node + if: steps.docs-change.outputs.validate != 'false' + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install MDX lint dependencies + if: steps.docs-change.outputs.validate != 'false' + run: | + mkdir -p /tmp/mdx-lint + npm init -y --silent --prefix /tmp/mdx-lint > /dev/null 2>&1 + npm install --ignore-scripts --silent --prefix /tmp/mdx-lint @mdx-js/mdx@3.1.1 remark-gfm@4.0.1 remark-frontmatter@5.0.0 2>&1 | tail -n 1 + + - name: Checkout caller repo + if: steps.docs-change.outputs.validate != 'false' + uses: actions/checkout@v5 + with: + repository: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }} + ref: ${{ inputs.ref }} + path: _caller + persist-credentials: false + + - name: Resolve docs validation + if: steps.docs-change.outputs.validate != 'false' + id: docs + shell: bash + env: + PRECHECK: ${{ steps.docs-change.outputs.validate }} + run: | + set -euo pipefail + + if [ "$PRECHECK" = "unknown" ] && [ ! -f "_caller/docs/connector.mdx" ]; then + echo "validate=false" >> "$GITHUB_OUTPUT" + echo "docs/connector.mdx missing; skipping MDX validation" + exit 0 + fi + + if [ ! -f "_caller/docs/connector.mdx" ]; then + echo "::error file=docs/connector.mdx::docs/connector.mdx changed but is missing at the checked-out ref." + exit 1 + fi + + echo "validate=true" >> "$GITHUB_OUTPUT" + + - name: Validate MDX documentation + if: steps.docs.outputs.validate == 'true' + shell: bash {0} + run: | + cat > /tmp/mdx-lint/mdx-lint.mjs << 'LINT_EOF' + import { compile } from "@mdx-js/mdx"; + import remarkGfm from "remark-gfm"; + import remarkFrontmatter from "remark-frontmatter"; + + const ALLOWED_COMPONENTS = new Set([ + "Tip", + "Warning", + "Note", + "Info", + "Icon", + "Frame", + "Card", + "Check", + "Tabs", + "Tab", + "Steps", + "Step", + ]); + + const EVENT_HANDLER_RE = /\son[a-z]+\s*=/i; + const MDX_COMPONENT_TAG_RE = /<\/?([A-Za-z][A-Za-z0-9.]*)\b/g; + + function decodeHtmlEntities(input) { + return input + .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + .replace(/&#([0-9]+);?/g, (_, dec) => + String.fromCodePoint(Number.parseInt(dec, 10)), + ) + .replace(/:/gi, ":") + .replace(/&tab;/gi, "\t") + .replace(/&newline;/gi, "\n") + .replace(/&/gi, "&"); + } + + function containsDangerousUrl(input) { + const decoded = decodeHtmlEntities(input); + const normalized = [...decoded] + .filter((ch) => !/[\s\p{C}]/u.test(ch)) + .join("") + .toLowerCase(); + + return ( + normalized.includes("javascript:") || + normalized.includes("vbscript:") || + normalized.includes("data:text/html") + ); + } + + function openingFence(line) { + const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); + if (!match) { + return null; + } + const marker = match[2]; + const info = match[3] ?? ""; + const char = marker[0]; + if (char === "`" && info.includes("`")) { + return null; + } + return { char, length: marker.length }; + } + + function isClosingFence(line, fence) { + const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line); + return ( + match !== null && + match[2][0] === fence.char && + match[2].length >= fence.length + ); + } + + function stripInlineCode(line) { + let output = ""; + let index = 0; + while (index < line.length) { + if (line[index] !== "`") { + output += line[index]; + index += 1; + continue; + } + + const end = line.indexOf("`", index + 1); + if (end === -1) { + output += line.slice(index); + break; + } + + output += " ".repeat(end - index + 1); + index = end + 1; + } + return output; + } + + function validateSource(content) { + if (!content.trim()) { + throw new Error("documentation cannot be empty"); + } + if (content.includes("\0")) { + throw new Error("documentation contains NUL bytes"); + } + if (content.includes("\ufeff")) { + throw new Error("documentation contains byte order marks"); + } + + let inFence = false; + let fence = null; + const lines = content.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + if (inFence) { + if (isClosingFence(line, fence)) { + inFence = false; + fence = null; + } + continue; + } + + const marker = openingFence(line); + if (marker) { + inFence = true; + fence = marker; + continue; + } + + const checkLine = stripInlineCode(line); + const trimmed = checkLine.trim().toLowerCase(); + if ( + trimmed === "import" || + trimmed.startsWith("import ") || + trimmed === "export" || + trimmed.startsWith("export ") + ) { + throw new Error(`line ${lineNumber} contains MDX import/export`); + } + if (checkLine.includes("{") || checkLine.includes("}")) { + throw new Error(`line ${lineNumber} contains MDX expression braces`); + } + if (EVENT_HANDLER_RE.test(checkLine)) { + throw new Error(`line ${lineNumber} contains an event handler attribute`); + } + if (containsDangerousUrl(checkLine)) { + throw new Error(`line ${lineNumber} contains a dangerous URL scheme`); + } + for (const match of checkLine.matchAll(MDX_COMPONENT_TAG_RE)) { + const component = match[1]; + if (!ALLOWED_COMPONENTS.has(component)) { + throw new Error( + `line ${lineNumber} contains disallowed JSX component "${component}"`, + ); + } + } + } + if (inFence) { + throw new Error("documentation contains an unclosed code fence"); + } + } + + let content = ""; + for await (const chunk of process.stdin) { + content += chunk; + } + + try { + validateSource(content); + } catch (err) { + process.stderr.write(`mdx-lint: ${err.message}\n`); + process.exit(1); + } + + let compiled; + try { + compiled = String( + await compile(content, { + outputFormat: "function-body", + remarkPlugins: [remarkGfm, remarkFrontmatter], + }), + ); + } catch (err) { + process.stderr.write(`mdx-lint: ${err.message}\n`); + process.exit(1); + } + + const refPattern = /_missingMdxReference\("([^"]+)"/g; + const unknown = new Set(); + let match; + while ((match = refPattern.exec(compiled)) !== null) { + const name = match[1]; + if (!ALLOWED_COMPONENTS.has(name)) { + unknown.add(name); + } + } + + if (unknown.size > 0) { + for (const name of unknown) { + process.stderr.write( + `mdx-lint: Unknown component <${name}>. Allowed: ${[...ALLOWED_COMPONENTS].join(", ")}\n`, + ); + } + process.exit(1); + } + LINT_EOF + + node /tmp/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx + LINT_RC=$? + + if [ "$LINT_RC" -eq 0 ]; then + echo "MDX validation passed" + else + echo "::error file=docs/connector.mdx::MDX validation failed. See log output above for details." + exit 1 + fi diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index a871216..2ae0f7c 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -126,7 +126,7 @@ jobs: const ALLOWED = new Set([ "Tip","Warning","Note","Info","Icon", - "Frame","Card","Tabs","Tab","Steps","Step", + "Frame","Card","Check","Tabs","Tab","Steps","Step", ]); let content = ""; diff --git a/README.md b/README.md index 1fd4e4b..94219e3 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,35 @@ jobs: | `run_tests` | No | `true` | Run `go test` | | `connector` | No | `""` | Connector name — triggers [regression testing](docs/verify-workflow.md#regression) when set | +## Connector Docs Verify Workflow + +Runs a standalone `docs/connector.mdx` validation check for connector repositories. See [detailed documentation](docs/connector-docs-verify.md) for behavior and rollout notes. + +The workflow is safe to require on every connector pull request because it always reports a status. It skips validation when `docs/connector.mdx` is unchanged, fails if that file was changed and removed, and validates MDX safety when the file changed. + +### Usage + +```yaml +name: Connector Docs + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + +jobs: + connector-docs: + uses: ConductorOne/github-workflows/.github/workflows/connector-docs-verify.yaml@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} +``` + +When used with the job id `connector-docs`, the required status check context is `connector-docs / validate`. + +Roll out the caller workflow before marking the check required. The shared workflow ref, such as `v4`, must include `connector-docs-verify.yaml` before connector repos call it. + ## Available Actions ### Get Baton diff --git a/docs/connector-docs-verify.md b/docs/connector-docs-verify.md new file mode 100644 index 0000000..685f9c1 --- /dev/null +++ b/docs/connector-docs-verify.md @@ -0,0 +1,97 @@ +# Connector Docs Verify Workflow + +The `connector-docs-verify.yaml` workflow runs a standalone +`docs/connector.mdx` validation check for connector repositories. + +## Overview + +This workflow is meant to be safe as a required status check on every connector +pull request: + +1. It checks out the caller repository at the requested ref. +2. It checks whether `docs/connector.mdx` changed in the pull request. +3. If the file is unchanged, the job exits successfully. +4. If the file changed, the job validates that the file still exists and passes + MDX safety checks. + +The required check context is stable when the caller job id is +`connector-docs`: + +```text +connector-docs / validate +``` + +## MDX Checks + +The validator compiles MDX without evaluating PR content and rejects unsafe +constructs before compilation: + +- empty documentation +- NUL bytes or byte-order marks +- unclosed code fences +- MDX imports or exports outside code fences +- MDX expression braces outside code fences +- event-handler attributes +- dangerous URL schemes after simple entity decoding +- unsupported JSX components + +Allowed JSX components: + +- `Card` +- `Check` +- `Frame` +- `Icon` +- `Info` +- `Note` +- `Step` +- `Steps` +- `Tab` +- `Tabs` +- `Tip` +- `Warning` + +Keep the allowlist aligned with the connector registry renderer and server-side +documentation validators. + +## Usage + +```yaml +name: Connector Docs + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + +jobs: + connector-docs: + uses: ConductorOne/github-workflows/.github/workflows/connector-docs-verify.yaml@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} +``` + +## Rollout Notes + +Do not add workflow-level `paths` filters to the caller workflow when this +check is required by branch rules. A required check that never runs leaves pull +requests stuck waiting. + +The workflow itself handles the path check and reports success when +`docs/connector.mdx` is unchanged. + +Use a staged rollout: + +1. Merge this workflow and update the shared workflow ref used by connector + repos, such as the `v4` tag, so callers can resolve + `connector-docs-verify.yaml`. +2. Add the caller workflow to connector repos without requiring the status + check yet. +3. Confirm `connector-docs / validate` appears and passes on both docs and + non-doc pull requests. +4. Require `connector-docs / validate` in branch rules only after the check is + present on targeted repos. + +The existing `verify.yaml` docs job is a compatibility check. Use this +standalone workflow as the required docs safety gate. diff --git a/docs/verify-workflow.md b/docs/verify-workflow.md index 7231066..dcc294f 100644 --- a/docs/verify-workflow.md +++ b/docs/verify-workflow.md @@ -8,7 +8,8 @@ When a pull request is opened or code is pushed to main, the shared verify workf 1. Runs `golangci-lint` on the connector code 2. Runs `go test` (optional, enabled by default) -3. Runs baton-regression verification (optional, when `connector` is provided) +3. Validates `docs/connector.mdx` when the file exists +4. Runs baton-regression verification (optional, when `connector` is provided) ## Jobs @@ -20,6 +21,14 @@ Checks out the caller repo and runs `golangci-lint` with a 6-minute timeout. If Runs `go test -v -covermode=count -json ./...` and annotates results. Skipped if `run_tests: false`. +### docs + +Validates `docs/connector.mdx` when the file exists. The validator compiles MDX +without evaluating PR content and rejects unsupported JSX components. + +For branch rules that need a standalone docs safety check, prefer the stricter +[`connector-docs-verify.yaml`](connector-docs-verify.md) workflow. + ### regression Runs the [baton-regression](https://github.com/ConductorOne/baton-regression) verification when `connector` is non-empty. The workflow is hosted in this repo but checks out baton-regression source from main at runtime. The regression job: diff --git a/tools/mdx-lint/mdx-lint.mjs b/tools/mdx-lint/mdx-lint.mjs index d4d4fd4..9d234f4 100644 --- a/tools/mdx-lint/mdx-lint.mjs +++ b/tools/mdx-lint/mdx-lint.mjs @@ -1,13 +1,15 @@ #!/usr/bin/env node // -// MDX lint: validates that docs/connector.mdx parses without errors. +// MDX lint: validates that docs/connector.mdx parses without errors and avoids +// unsafe MDX constructs. // // Uses compile() only — no run()/eval. This validates syntax (malformed tags, // unclosed components, bad nesting) without executing any code from the input. // Safe to run on untrusted PR content. // // Also checks that JSX component names are in the allowed set (compile() alone -// doesn't validate component names — that only fails at runtime). +// doesn't validate component names — that only fails at runtime). Keep this in +// sync with the registry renderer and documentation update validators. // // Usage: // node mdx-lint.mjs < docs/connector.mdx @@ -30,20 +32,164 @@ const ALLOWED_COMPONENTS = new Set([ "Icon", "Frame", "Card", + "Check", "Tabs", "Tab", "Steps", "Step", ]); +const EVENT_HANDLER_RE = /\son[a-z]+\s*=/i; +const MDX_COMPONENT_TAG_RE = /<\/?([A-Za-z][A-Za-z0-9.]*)\b/g; + +function decodeHtmlEntities(input) { + return input + .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => + String.fromCodePoint(Number.parseInt(hex, 16)), + ) + .replace(/&#([0-9]+);?/g, (_, dec) => + String.fromCodePoint(Number.parseInt(dec, 10)), + ) + .replace(/:/gi, ":") + .replace(/&tab;/gi, "\t") + .replace(/&newline;/gi, "\n") + .replace(/&/gi, "&"); +} + +function containsDangerousUrl(input) { + const decoded = decodeHtmlEntities(input); + const normalized = [...decoded] + .filter((ch) => !/[\s\p{C}]/u.test(ch)) + .join("") + .toLowerCase(); + + return ( + normalized.includes("javascript:") || + normalized.includes("vbscript:") || + normalized.includes("data:text/html") + ); +} + +function openingFence(line) { + const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); + if (!match) { + return null; + } + const marker = match[2]; + const info = match[3] ?? ""; + const char = marker[0]; + if (char === "`" && info.includes("`")) { + return null; + } + return { char, length: marker.length }; +} + +function isClosingFence(line, fence) { + const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line); + return ( + match !== null && + match[2][0] === fence.char && + match[2].length >= fence.length + ); +} + +function stripInlineCode(line) { + let output = ""; + let index = 0; + while (index < line.length) { + if (line[index] !== "`") { + output += line[index]; + index += 1; + continue; + } + + const end = line.indexOf("`", index + 1); + if (end === -1) { + output += line.slice(index); + break; + } + + output += " ".repeat(end - index + 1); + index = end + 1; + } + return output; +} + +function validateSource(content) { + if (!content.trim()) { + throw new Error("documentation cannot be empty"); + } + if (content.includes("\0")) { + throw new Error("documentation contains NUL bytes"); + } + if (content.includes("\ufeff")) { + throw new Error("documentation contains byte order marks"); + } + + let inFence = false; + let fence = null; + const lines = content.split(/\r?\n/); + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + if (inFence) { + if (isClosingFence(line, fence)) { + inFence = false; + fence = null; + } + continue; + } + + const marker = openingFence(line); + if (marker) { + inFence = true; + fence = marker; + continue; + } + + const checkLine = stripInlineCode(line); + const trimmed = checkLine.trim().toLowerCase(); + if ( + trimmed === "import" || + trimmed.startsWith("import ") || + trimmed === "export" || + trimmed.startsWith("export ") + ) { + throw new Error(`line ${lineNumber} contains MDX import/export`); + } + if (checkLine.includes("{") || checkLine.includes("}")) { + throw new Error(`line ${lineNumber} contains MDX expression braces`); + } + if (EVENT_HANDLER_RE.test(checkLine)) { + throw new Error(`line ${lineNumber} contains an event handler attribute`); + } + if (containsDangerousUrl(checkLine)) { + throw new Error(`line ${lineNumber} contains a dangerous URL scheme`); + } + for (const match of checkLine.matchAll(MDX_COMPONENT_TAG_RE)) { + const component = match[1]; + if (!ALLOWED_COMPONENTS.has(component)) { + throw new Error( + `line ${lineNumber} contains disallowed JSX component "${component}"`, + ); + } + } + } + if (inFence) { + throw new Error("documentation contains an unclosed code fence"); + } +} + async function main() { let content = ""; for await (const chunk of process.stdin) { content += chunk; } - if (!content.trim()) { - process.exit(0); + try { + validateSource(content); + } catch (err) { + process.stderr.write(`mdx-lint: ${err.message}\n`); + process.exit(1); } // Compile: catches syntax errors (malformed tags, bad nesting, etc.) From 53530da70e762ef0c45e671233ef929e7807da3b Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Fri, 15 May 2026 20:38:54 -0400 Subject: [PATCH 2/4] Harden connector docs verification workflow --- .github/workflows/connector-docs-verify.yaml | 224 ++--------------- README.md | 2 + docs/connector-docs-verify.md | 13 +- tools/mdx-lint/mdx-lint.mjs | 243 +++++++++---------- tools/mdx-lint/package-lock.json | 10 +- tools/mdx-lint/package.json | 10 +- 6 files changed, 146 insertions(+), 356 deletions(-) diff --git a/.github/workflows/connector-docs-verify.yaml b/.github/workflows/connector-docs-verify.yaml index 4cd2e67..40d124f 100644 --- a/.github/workflows/connector-docs-verify.yaml +++ b/.github/workflows/connector-docs-verify.yaml @@ -54,18 +54,27 @@ jobs: echo "docs/connector.mdx unchanged; skipping MDX validation" fi + - name: Checkout workflow repo + if: steps.docs-change.outputs.validate != 'false' + uses: actions/checkout@v5 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: _workflow + persist-credentials: false + - name: Setup Node if: steps.docs-change.outputs.validate != 'false' uses: actions/setup-node@v4 with: node-version: 20 + cache: npm + cache-dependency-path: _workflow/tools/mdx-lint/package-lock.json - name: Install MDX lint dependencies if: steps.docs-change.outputs.validate != 'false' run: | - mkdir -p /tmp/mdx-lint - npm init -y --silent --prefix /tmp/mdx-lint > /dev/null 2>&1 - npm install --ignore-scripts --silent --prefix /tmp/mdx-lint @mdx-js/mdx@3.1.1 remark-gfm@4.0.1 remark-frontmatter@5.0.0 2>&1 | tail -n 1 + npm ci --ignore-scripts --silent --prefix _workflow/tools/mdx-lint 2>&1 | tail -n 1 - name: Checkout caller repo if: steps.docs-change.outputs.validate != 'false' @@ -100,214 +109,9 @@ jobs: - name: Validate MDX documentation if: steps.docs.outputs.validate == 'true' - shell: bash {0} + shell: bash run: | - cat > /tmp/mdx-lint/mdx-lint.mjs << 'LINT_EOF' - import { compile } from "@mdx-js/mdx"; - import remarkGfm from "remark-gfm"; - import remarkFrontmatter from "remark-frontmatter"; - - const ALLOWED_COMPONENTS = new Set([ - "Tip", - "Warning", - "Note", - "Info", - "Icon", - "Frame", - "Card", - "Check", - "Tabs", - "Tab", - "Steps", - "Step", - ]); - - const EVENT_HANDLER_RE = /\son[a-z]+\s*=/i; - const MDX_COMPONENT_TAG_RE = /<\/?([A-Za-z][A-Za-z0-9.]*)\b/g; - - function decodeHtmlEntities(input) { - return input - .replace(/&#x([0-9a-f]+);?/gi, (_, hex) => - String.fromCodePoint(Number.parseInt(hex, 16)), - ) - .replace(/&#([0-9]+);?/g, (_, dec) => - String.fromCodePoint(Number.parseInt(dec, 10)), - ) - .replace(/:/gi, ":") - .replace(/&tab;/gi, "\t") - .replace(/&newline;/gi, "\n") - .replace(/&/gi, "&"); - } - - function containsDangerousUrl(input) { - const decoded = decodeHtmlEntities(input); - const normalized = [...decoded] - .filter((ch) => !/[\s\p{C}]/u.test(ch)) - .join("") - .toLowerCase(); - - return ( - normalized.includes("javascript:") || - normalized.includes("vbscript:") || - normalized.includes("data:text/html") - ); - } - - function openingFence(line) { - const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); - if (!match) { - return null; - } - const marker = match[2]; - const info = match[3] ?? ""; - const char = marker[0]; - if (char === "`" && info.includes("`")) { - return null; - } - return { char, length: marker.length }; - } - - function isClosingFence(line, fence) { - const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line); - return ( - match !== null && - match[2][0] === fence.char && - match[2].length >= fence.length - ); - } - - function stripInlineCode(line) { - let output = ""; - let index = 0; - while (index < line.length) { - if (line[index] !== "`") { - output += line[index]; - index += 1; - continue; - } - - const end = line.indexOf("`", index + 1); - if (end === -1) { - output += line.slice(index); - break; - } - - output += " ".repeat(end - index + 1); - index = end + 1; - } - return output; - } - - function validateSource(content) { - if (!content.trim()) { - throw new Error("documentation cannot be empty"); - } - if (content.includes("\0")) { - throw new Error("documentation contains NUL bytes"); - } - if (content.includes("\ufeff")) { - throw new Error("documentation contains byte order marks"); - } - - let inFence = false; - let fence = null; - const lines = content.split(/\r?\n/); - for (const [index, line] of lines.entries()) { - const lineNumber = index + 1; - if (inFence) { - if (isClosingFence(line, fence)) { - inFence = false; - fence = null; - } - continue; - } - - const marker = openingFence(line); - if (marker) { - inFence = true; - fence = marker; - continue; - } - - const checkLine = stripInlineCode(line); - const trimmed = checkLine.trim().toLowerCase(); - if ( - trimmed === "import" || - trimmed.startsWith("import ") || - trimmed === "export" || - trimmed.startsWith("export ") - ) { - throw new Error(`line ${lineNumber} contains MDX import/export`); - } - if (checkLine.includes("{") || checkLine.includes("}")) { - throw new Error(`line ${lineNumber} contains MDX expression braces`); - } - if (EVENT_HANDLER_RE.test(checkLine)) { - throw new Error(`line ${lineNumber} contains an event handler attribute`); - } - if (containsDangerousUrl(checkLine)) { - throw new Error(`line ${lineNumber} contains a dangerous URL scheme`); - } - for (const match of checkLine.matchAll(MDX_COMPONENT_TAG_RE)) { - const component = match[1]; - if (!ALLOWED_COMPONENTS.has(component)) { - throw new Error( - `line ${lineNumber} contains disallowed JSX component "${component}"`, - ); - } - } - } - if (inFence) { - throw new Error("documentation contains an unclosed code fence"); - } - } - - let content = ""; - for await (const chunk of process.stdin) { - content += chunk; - } - - try { - validateSource(content); - } catch (err) { - process.stderr.write(`mdx-lint: ${err.message}\n`); - process.exit(1); - } - - let compiled; - try { - compiled = String( - await compile(content, { - outputFormat: "function-body", - remarkPlugins: [remarkGfm, remarkFrontmatter], - }), - ); - } catch (err) { - process.stderr.write(`mdx-lint: ${err.message}\n`); - process.exit(1); - } - - const refPattern = /_missingMdxReference\("([^"]+)"/g; - const unknown = new Set(); - let match; - while ((match = refPattern.exec(compiled)) !== null) { - const name = match[1]; - if (!ALLOWED_COMPONENTS.has(name)) { - unknown.add(name); - } - } - - if (unknown.size > 0) { - for (const name of unknown) { - process.stderr.write( - `mdx-lint: Unknown component <${name}>. Allowed: ${[...ALLOWED_COMPONENTS].join(", ")}\n`, - ); - } - process.exit(1); - } - LINT_EOF - - node /tmp/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx + node _workflow/tools/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx LINT_RC=$? if [ "$LINT_RC" -eq 0 ]; then diff --git a/README.md b/README.md index 94219e3..be59303 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,8 @@ Runs a standalone `docs/connector.mdx` validation check for connector repositori The workflow is safe to require on every connector pull request because it always reports a status. It skips validation when `docs/connector.mdx` is unchanged, fails if that file was changed and removed, and validates MDX safety when the file changed. +The reusable workflow checks out its own validator at the exact workflow commit, installs locked dependencies with npm lifecycle scripts disabled, and checks out caller code without persisted credentials. + ### Usage ```yaml diff --git a/docs/connector-docs-verify.md b/docs/connector-docs-verify.md index 685f9c1..8027b9b 100644 --- a/docs/connector-docs-verify.md +++ b/docs/connector-docs-verify.md @@ -8,10 +8,14 @@ The `connector-docs-verify.yaml` workflow runs a standalone This workflow is meant to be safe as a required status check on every connector pull request: -1. It checks out the caller repository at the requested ref. +1. It checks out this workflow repository at the exact workflow commit. 2. It checks whether `docs/connector.mdx` changed in the pull request. -3. If the file is unchanged, the job exits successfully. -4. If the file changed, the job validates that the file still exists and passes +3. It installs the checked-in MDX validator dependencies with npm lifecycle + scripts disabled. +4. It checks out the caller repository at the requested ref with persisted + credentials disabled. +5. If the file is unchanged, the job exits successfully. +6. If the file changed, the job validates that the file still exists and passes MDX safety checks. The required check context is stable when the caller job id is @@ -24,11 +28,10 @@ connector-docs / validate ## MDX Checks The validator compiles MDX without evaluating PR content and rejects unsafe -constructs before compilation: +constructs by walking the MDX AST before compilation: - empty documentation - NUL bytes or byte-order marks -- unclosed code fences - MDX imports or exports outside code fences - MDX expression braces outside code fences - event-handler attributes diff --git a/tools/mdx-lint/mdx-lint.mjs b/tools/mdx-lint/mdx-lint.mjs index 9d234f4..4655fbe 100644 --- a/tools/mdx-lint/mdx-lint.mjs +++ b/tools/mdx-lint/mdx-lint.mjs @@ -3,24 +3,24 @@ // MDX lint: validates that docs/connector.mdx parses without errors and avoids // unsafe MDX constructs. // -// Uses compile() only — no run()/eval. This validates syntax (malformed tags, -// unclosed components, bad nesting) without executing any code from the input. -// Safe to run on untrusted PR content. -// -// Also checks that JSX component names are in the allowed set (compile() alone -// doesn't validate component names — that only fails at runtime). Keep this in -// sync with the registry renderer and documentation update validators. +// Uses parse()/compile() only; never run()/eval(). This validates syntax and +// walks the MDX AST without executing any code from the input. Safe to run on +// untrusted PR content. // // Usage: // node mdx-lint.mjs < docs/connector.mdx // // Exit codes: // 0 - valid MDX -// 1 - compilation error (message on stderr) +// 1 - validation or compilation error (message on stderr) import { compile } from "@mdx-js/mdx"; -import remarkGfm from "remark-gfm"; import remarkFrontmatter from "remark-frontmatter"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; +import { visit } from "unist-util-visit"; // Components supported by the registry's MDX renderer (mdx-compile.mjs). // Keep in sync with the component map in the registry-api's ui/mdx-compile.mjs. @@ -39,8 +39,7 @@ const ALLOWED_COMPONENTS = new Set([ "Step", ]); -const EVENT_HANDLER_RE = /\son[a-z]+\s*=/i; -const MDX_COMPONENT_TAG_RE = /<\/?([A-Za-z][A-Za-z0-9.]*)\b/g; +const URL_ATTRIBUTE_NAMES = new Set(["href", "src", "action", "formaction"]); function decodeHtmlEntities(input) { return input @@ -57,7 +56,7 @@ function decodeHtmlEntities(input) { } function containsDangerousUrl(input) { - const decoded = decodeHtmlEntities(input); + const decoded = decodeHtmlEntities(String(input)); const normalized = [...decoded] .filter((ch) => !/[\s\p{C}]/u.test(ch)) .join("") @@ -70,115 +69,105 @@ function containsDangerousUrl(input) { ); } -function openingFence(line) { - const match = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line); - if (!match) { - return null; - } - const marker = match[2]; - const info = match[3] ?? ""; - const char = marker[0]; - if (char === "`" && info.includes("`")) { - return null; - } - return { char, length: marker.length }; +function at(node) { + return node.position?.start?.line + ? `line ${node.position.start.line}` + : "document"; } -function isClosingFence(line, fence) { - const match = /^( {0,3})(`{3,}|~{3,})[ \t]*$/.exec(line); - return ( - match !== null && - match[2][0] === fence.char && - match[2].length >= fence.length - ); +function fail(node, message) { + throw new Error(`${at(node)} ${message}`); } -function stripInlineCode(line) { - let output = ""; - let index = 0; - while (index < line.length) { - if (line[index] !== "`") { - output += line[index]; - index += 1; - continue; - } - - const end = line.indexOf("`", index + 1); - if (end === -1) { - output += line.slice(index); - break; - } - - output += " ".repeat(end - index + 1); - index = end + 1; +function validateUrlNode(node) { + if (node.url && containsDangerousUrl(node.url)) { + fail(node, "contains a dangerous URL scheme"); } - return output; } -function validateSource(content) { - if (!content.trim()) { - throw new Error("documentation cannot be empty"); +function validateJsxAttribute(attribute, node) { + if (attribute.type === "mdxJsxExpressionAttribute") { + fail(node, "contains a JSX expression attribute"); } - if (content.includes("\0")) { - throw new Error("documentation contains NUL bytes"); - } - if (content.includes("\ufeff")) { - throw new Error("documentation contains byte order marks"); + if (attribute.type !== "mdxJsxAttribute") { + fail(node, "contains an unsupported JSX attribute"); } - let inFence = false; - let fence = null; - const lines = content.split(/\r?\n/); - for (const [index, line] of lines.entries()) { - const lineNumber = index + 1; - if (inFence) { - if (isClosingFence(line, fence)) { - inFence = false; - fence = null; + const name = String(attribute.name ?? ""); + if (/^on[a-z]/i.test(name)) { + fail(node, "contains an event handler attribute"); + } + if (attribute.value && typeof attribute.value === "object") { + fail(node, "contains a JSX attribute expression"); + } + if (typeof attribute.value === "string") { + if (URL_ATTRIBUTE_NAMES.has(name.toLowerCase())) { + if (containsDangerousUrl(attribute.value)) { + fail(node, "contains a dangerous URL scheme"); } - continue; } - - const marker = openingFence(line); - if (marker) { - inFence = true; - fence = marker; - continue; + if (containsDangerousUrl(attribute.value)) { + fail(node, "contains a dangerous URL scheme"); } + } +} - const checkLine = stripInlineCode(line); - const trimmed = checkLine.trim().toLowerCase(); - if ( - trimmed === "import" || - trimmed.startsWith("import ") || - trimmed === "export" || - trimmed.startsWith("export ") - ) { - throw new Error(`line ${lineNumber} contains MDX import/export`); - } - if (checkLine.includes("{") || checkLine.includes("}")) { - throw new Error(`line ${lineNumber} contains MDX expression braces`); - } - if (EVENT_HANDLER_RE.test(checkLine)) { - throw new Error(`line ${lineNumber} contains an event handler attribute`); - } - if (containsDangerousUrl(checkLine)) { - throw new Error(`line ${lineNumber} contains a dangerous URL scheme`); - } - for (const match of checkLine.matchAll(MDX_COMPONENT_TAG_RE)) { - const component = match[1]; - if (!ALLOWED_COMPONENTS.has(component)) { - throw new Error( - `line ${lineNumber} contains disallowed JSX component "${component}"`, - ); - } - } +function validateJsxElement(node) { + if (!node.name) { + fail(node, "contains a JSX fragment"); + } + if (node.name.includes(".")) { + fail(node, `contains disallowed JSX component "${node.name}"`); } - if (inFence) { - throw new Error("documentation contains an unclosed code fence"); + if (!ALLOWED_COMPONENTS.has(node.name)) { + fail(node, `contains disallowed JSX component "${node.name}"`); + } + + for (const attribute of node.attributes ?? []) { + validateJsxAttribute(attribute, node); } } +function validateTree(tree) { + visit(tree, (node) => { + switch (node.type) { + case "mdxjsEsm": + fail(node, "contains MDX import/export"); + break; + case "mdxFlowExpression": + case "mdxTextExpression": + fail(node, "contains an MDX expression"); + break; + case "mdxJsxFlowElement": + case "mdxJsxTextElement": + validateJsxElement(node); + break; + case "html": + fail(node, "contains raw HTML"); + break; + case "link": + case "image": + case "definition": + validateUrlNode(node); + break; + case "text": + if (containsDangerousUrl(node.value)) { + fail(node, "contains a dangerous URL scheme"); + } + break; + } + }); +} + +function parse(content) { + const processor = unified() + .use(remarkParse) + .use(remarkMdx) + .use(remarkFrontmatter) + .use(remarkGfm); + return processor.parse(content); +} + async function main() { let content = ""; for await (const chunk of process.stdin) { @@ -186,48 +175,32 @@ async function main() { } try { - validateSource(content); + if (!content.trim()) { + throw new Error("documentation cannot be empty"); + } + if (content.includes("\0")) { + throw new Error("documentation contains NUL bytes"); + } + if (content.includes("\ufeff")) { + throw new Error("documentation contains byte order marks"); + } + + const tree = parse(content); + validateTree(tree); } catch (err) { process.stderr.write(`mdx-lint: ${err.message}\n`); process.exit(1); } - // Compile: catches syntax errors (malformed tags, bad nesting, etc.) - let compiled; try { - compiled = String( - await compile(content, { - outputFormat: "function-body", - remarkPlugins: [remarkGfm, remarkFrontmatter], - }), - ); + await compile(content, { + outputFormat: "function-body", + remarkPlugins: [remarkGfm, remarkFrontmatter], + }); } catch (err) { process.stderr.write(`mdx-lint: ${err.message}\n`); process.exit(1); } - - // Check for unknown components: compile() generates _missingMdxReference("Name", true) - // for any JSX component not provided at runtime. We scan the compiled output for these - // references — this avoids false positives from angle brackets in inline text (e.g. - // `` inside backticks). - const refPattern = /_missingMdxReference\("([^"]+)"/g; - const unknown = new Set(); - let match; - while ((match = refPattern.exec(compiled)) !== null) { - const name = match[1]; - if (!ALLOWED_COMPONENTS.has(name)) { - unknown.add(name); - } - } - - if (unknown.size > 0) { - for (const name of unknown) { - process.stderr.write( - `mdx-lint: Unknown component <${name}>. Allowed: ${[...ALLOWED_COMPONENTS].join(", ")}\n`, - ); - } - process.exit(1); - } } main(); diff --git a/tools/mdx-lint/package-lock.json b/tools/mdx-lint/package-lock.json index 7c3a062..9ff9c93 100644 --- a/tools/mdx-lint/package-lock.json +++ b/tools/mdx-lint/package-lock.json @@ -5,9 +5,13 @@ "packages": { "": { "dependencies": { - "@mdx-js/mdx": "^3.1.1", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.1" + "@mdx-js/mdx": "3.1.1", + "remark-frontmatter": "5.0.0", + "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", + "remark-parse": "11.0.0", + "unified": "11.0.5", + "unist-util-visit": "5.1.0" } }, "node_modules/@mdx-js/mdx": { diff --git a/tools/mdx-lint/package.json b/tools/mdx-lint/package.json index 146d832..e26cf8b 100644 --- a/tools/mdx-lint/package.json +++ b/tools/mdx-lint/package.json @@ -2,8 +2,12 @@ "private": true, "type": "module", "dependencies": { - "@mdx-js/mdx": "^3.1.1", - "remark-frontmatter": "^5.0.0", - "remark-gfm": "^4.0.1" + "@mdx-js/mdx": "3.1.1", + "remark-frontmatter": "5.0.0", + "remark-gfm": "4.0.1", + "remark-mdx": "3.1.1", + "remark-parse": "11.0.0", + "unified": "11.0.5", + "unist-util-visit": "5.1.0" } } From aa66e9465a28f7d2328112fb775f202cae019095 Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Mon, 18 May 2026 10:02:01 -0400 Subject: [PATCH 3/4] Handle connector docs rename validation --- .github/workflows/connector-docs-verify.yaml | 8 +++++++- tools/mdx-lint/mdx-lint.mjs | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/connector-docs-verify.yaml b/.github/workflows/connector-docs-verify.yaml index 40d124f..245e3e4 100644 --- a/.github/workflows/connector-docs-verify.yaml +++ b/.github/workflows/connector-docs-verify.yaml @@ -30,6 +30,7 @@ jobs: API_URL="${GITHUB_API_URL:-https://api.github.com}" PAGE=1 + PAGE_LIMIT=30 SHOULD_VALIDATE=false while true; do FILES=$(curl -fsSL \ @@ -37,12 +38,17 @@ jobs: -H "Accept: application/vnd.github+json" \ "${API_URL}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${PAGE}") - if echo "$FILES" | jq -e '.[] | select(.filename == "docs/connector.mdx")' >/dev/null; then + if echo "$FILES" | jq -e '.[] | select(.filename == "docs/connector.mdx" or .previous_filename == "docs/connector.mdx")' >/dev/null; then SHOULD_VALIDATE=true break fi FILE_COUNT=$(echo "$FILES" | jq 'length') + if [ "$PAGE" -ge "$PAGE_LIMIT" ] && [ "$FILE_COUNT" -eq 100 ]; then + echo "::warning::PR file list reached the GitHub API cap; validating docs/connector.mdx because changed files cannot be proven complete." + SHOULD_VALIDATE=true + break + fi if [ "$FILE_COUNT" -lt 100 ]; then break fi diff --git a/tools/mdx-lint/mdx-lint.mjs b/tools/mdx-lint/mdx-lint.mjs index 4655fbe..276c65f 100644 --- a/tools/mdx-lint/mdx-lint.mjs +++ b/tools/mdx-lint/mdx-lint.mjs @@ -65,7 +65,7 @@ function containsDangerousUrl(input) { return ( normalized.includes("javascript:") || normalized.includes("vbscript:") || - normalized.includes("data:text/html") + normalized.includes("data:") ); } From 636e67394ec2f694c289a427d02a55bc7b99928b Mon Sep 17 00:00:00 2001 From: Steve Gontzes Date: Mon, 18 May 2026 10:20:17 -0400 Subject: [PATCH 4/4] Add connector docs workflow tests --- .github/workflows/connector-docs-verify.yaml | 57 ++------- .github/workflows/verify.yaml | 79 ++++--------- docs/connector-docs-verify.md | 6 +- tools/connector-docs/check-docs-change.mjs | 109 ++++++++++++++++++ .../connector-docs/check-docs-change.test.mjs | 98 ++++++++++++++++ tools/mdx-lint/mdx-lint.mjs | 56 +++++---- tools/mdx-lint/mdx-lint.test.mjs | 86 ++++++++++++++ tools/mdx-lint/package.json | 3 + 8 files changed, 364 insertions(+), 130 deletions(-) create mode 100644 tools/connector-docs/check-docs-change.mjs create mode 100644 tools/connector-docs/check-docs-change.test.mjs create mode 100644 tools/mdx-lint/mdx-lint.test.mjs diff --git a/.github/workflows/connector-docs-verify.yaml b/.github/workflows/connector-docs-verify.yaml index 245e3e4..7c36424 100644 --- a/.github/workflows/connector-docs-verify.yaml +++ b/.github/workflows/connector-docs-verify.yaml @@ -13,55 +13,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - name: Check docs change - id: docs-change - shell: bash - env: - GH_TOKEN: ${{ github.token }} - PR_NUMBER: ${{ github.event.pull_request.number || '' }} - REPOSITORY: ${{ github.event.repository.full_name }} - run: | - set -euo pipefail - - if [ -z "$PR_NUMBER" ]; then - echo "validate=unknown" >> "$GITHUB_OUTPUT" - exit 0 - fi - - API_URL="${GITHUB_API_URL:-https://api.github.com}" - PAGE=1 - PAGE_LIMIT=30 - SHOULD_VALIDATE=false - while true; do - FILES=$(curl -fsSL \ - -H "Authorization: Bearer $GH_TOKEN" \ - -H "Accept: application/vnd.github+json" \ - "${API_URL}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100&page=${PAGE}") - - if echo "$FILES" | jq -e '.[] | select(.filename == "docs/connector.mdx" or .previous_filename == "docs/connector.mdx")' >/dev/null; then - SHOULD_VALIDATE=true - break - fi - - FILE_COUNT=$(echo "$FILES" | jq 'length') - if [ "$PAGE" -ge "$PAGE_LIMIT" ] && [ "$FILE_COUNT" -eq 100 ]; then - echo "::warning::PR file list reached the GitHub API cap; validating docs/connector.mdx because changed files cannot be proven complete." - SHOULD_VALIDATE=true - break - fi - if [ "$FILE_COUNT" -lt 100 ]; then - break - fi - PAGE=$((PAGE + 1)) - done - - echo "validate=$SHOULD_VALIDATE" >> "$GITHUB_OUTPUT" - if [ "$SHOULD_VALIDATE" != "true" ]; then - echo "docs/connector.mdx unchanged; skipping MDX validation" - fi - - name: Checkout workflow repo - if: steps.docs-change.outputs.validate != 'false' uses: actions/checkout@v5 with: repository: ${{ job.workflow_repository }} @@ -70,13 +22,20 @@ jobs: persist-credentials: false - name: Setup Node - if: steps.docs-change.outputs.validate != 'false' uses: actions/setup-node@v4 with: node-version: 20 cache: npm cache-dependency-path: _workflow/tools/mdx-lint/package-lock.json + - name: Check docs change + id: docs-change + env: + GITHUB_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + REPOSITORY: ${{ github.event.repository.full_name }} + run: node _workflow/tools/connector-docs/check-docs-change.mjs + - name: Install MDX lint dependencies if: steps.docs-change.outputs.validate != 'false' run: | diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml index 2ae0f7c..6654df0 100644 --- a/.github/workflows/verify.yaml +++ b/.github/workflows/verify.yaml @@ -82,12 +82,32 @@ jobs: docs: runs-on: ubuntu-latest steps: + - name: Checkout workflow repo + uses: actions/checkout@v5 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: _workflow + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: _workflow/tools/mdx-lint/package-lock.json + + - name: Install MDX lint dependencies + run: | + npm ci --ignore-scripts --silent --prefix _workflow/tools/mdx-lint 2>&1 | tail -n 1 + - name: Checkout caller repo uses: actions/checkout@v5 with: - repository: ${{ github.event.repository.full_name }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.event.repository.full_name }} ref: ${{ inputs.ref }} path: _caller + persist-credentials: false - name: Check for docs/connector.mdx id: check-docs @@ -99,64 +119,11 @@ jobs: echo "No docs/connector.mdx found, skipping MDX validation" fi - - name: Setup Node - if: steps.check-docs.outputs.has_docs == 'true' - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Install MDX lint dependencies - if: steps.check-docs.outputs.has_docs == 'true' - run: | - mkdir -p /tmp/mdx-lint && cd /tmp/mdx-lint - npm init -y --silent > /dev/null 2>&1 - npm install --silent @mdx-js/mdx@3 remark-gfm@4 remark-frontmatter@5 2>&1 | tail -n 1 - - name: Validate MDX documentation if: steps.check-docs.outputs.has_docs == 'true' - shell: bash {0} + shell: bash run: | - # Inline MDX lint: compile-only (no eval), with component allowlist. - # Use bash {0} (no -e) so we can capture the exit code ourselves. - # Write the lint script to the install directory so ESM resolution works - cat > /tmp/mdx-lint/mdx-lint.mjs << 'LINT_EOF' - import { compile } from "@mdx-js/mdx"; - import remarkGfm from "remark-gfm"; - import remarkFrontmatter from "remark-frontmatter"; - - const ALLOWED = new Set([ - "Tip","Warning","Note","Info","Icon", - "Frame","Card","Check","Tabs","Tab","Steps","Step", - ]); - - let content = ""; - for await (const chunk of process.stdin) content += chunk; - if (!content.trim()) process.exit(0); - - let compiled; - try { - compiled = String(await compile(content, { - outputFormat: "function-body", - remarkPlugins: [remarkGfm, remarkFrontmatter], - })); - } catch (err) { - console.error("mdx-lint: " + err.message); - process.exit(1); - } - - const refs = [...compiled.matchAll(/_missingMdxReference\("([^"]+)"/g)] - .map(m => m[1]) - .filter(name => !ALLOWED.has(name)); - const unique = [...new Set(refs)]; - if (unique.length > 0) { - for (const name of unique) { - console.error("mdx-lint: Unknown component <" + name + ">. Allowed: " + [...ALLOWED].join(", ")); - } - process.exit(1); - } - LINT_EOF - - node /tmp/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx + node _workflow/tools/mdx-lint/mdx-lint.mjs < _caller/docs/connector.mdx LINT_RC=$? if [ "$LINT_RC" -eq 0 ]; then diff --git a/docs/connector-docs-verify.md b/docs/connector-docs-verify.md index 8027b9b..3cdd005 100644 --- a/docs/connector-docs-verify.md +++ b/docs/connector-docs-verify.md @@ -96,5 +96,7 @@ Use a staged rollout: 4. Require `connector-docs / validate` in branch rules only after the check is present on targeted repos. -The existing `verify.yaml` docs job is a compatibility check. Use this -standalone workflow as the required docs safety gate. +The existing `verify.yaml` docs job reuses the same MDX validator for +compatibility. Use this standalone workflow as the required docs safety gate +because it always reports the `connector-docs / validate` check that branch +rules target. diff --git a/tools/connector-docs/check-docs-change.mjs b/tools/connector-docs/check-docs-change.mjs new file mode 100644 index 0000000..ea44c7c --- /dev/null +++ b/tools/connector-docs/check-docs-change.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { appendFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const CONNECTOR_DOCS_PATH = "docs/connector.mdx"; +const PER_PAGE = 100; +const PAGE_LIMIT = 30; + +function normalizeApiUrl(apiUrl) { + return apiUrl.replace(/\/+$/, ""); +} + +function docsPathChanged(file) { + return ( + file?.filename === CONNECTOR_DOCS_PATH || + file?.previous_filename === CONNECTOR_DOCS_PATH + ); +} + +function writeOutput(name, value) { + const line = `${name}=${value}\n`; + if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, line); + } else { + process.stdout.write(line); + } +} + +export async function checkConnectorDocsChange({ + apiUrl = "https://api.github.com", + fetchFn = fetch, + prNumber, + repository, + token, +} = {}) { + if (!prNumber) { + return { validate: "unknown", reason: "not_pull_request" }; + } + if (!repository) { + throw new Error("repository is required when prNumber is set"); + } + + for (let page = 1; ; page += 1) { + const url = + `${normalizeApiUrl(apiUrl)}/repos/${repository}/pulls/${prNumber}/files` + + `?per_page=${PER_PAGE}&page=${page}`; + const headers = { + Accept: "application/vnd.github+json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetchFn(url, { headers }); + if (!response.ok) { + throw new Error( + `GitHub PR files request failed: ${response.status} ${response.statusText}`, + ); + } + + const files = await response.json(); + if (!Array.isArray(files)) { + throw new Error("GitHub PR files response was not an array"); + } + + if (files.some(docsPathChanged)) { + return { validate: "true", reason: "docs_path_changed" }; + } + + // GitHub's PR files API is capped at 3000 files. If the final reachable + // page is full, the file list might be incomplete; force validation rather + // than proving docs/connector.mdx unchanged from partial data. + if (page >= PAGE_LIMIT && files.length === PER_PAGE) { + return { validate: "true", reason: "api_cap" }; + } + + if (files.length < PER_PAGE) { + return { validate: "false", reason: "docs_path_unchanged" }; + } + } +} + +async function main() { + const result = await checkConnectorDocsChange({ + apiUrl: process.env.GITHUB_API_URL || "https://api.github.com", + prNumber: process.env.PR_NUMBER || "", + repository: process.env.REPOSITORY || process.env.GITHUB_REPOSITORY || "", + token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "", + }); + + writeOutput("validate", result.validate); + if (result.reason === "docs_path_unchanged") { + process.stdout.write(`${CONNECTOR_DOCS_PATH} unchanged; skipping MDX validation\n`); + } + if (result.reason === "api_cap") { + process.stdout.write( + "::warning::PR file list reached the GitHub API cap; validating " + + `${CONNECTOR_DOCS_PATH} because changed files cannot be proven complete.\n`, + ); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + process.stderr.write(`${err.message}\n`); + process.exit(1); + }); +} diff --git a/tools/connector-docs/check-docs-change.test.mjs b/tools/connector-docs/check-docs-change.test.mjs new file mode 100644 index 0000000..1e9ced9 --- /dev/null +++ b/tools/connector-docs/check-docs-change.test.mjs @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { checkConnectorDocsChange } from "./check-docs-change.mjs"; + +function mockFetch(pages) { + const calls = []; + const fetchFn = async (url, options) => { + calls.push({ url, options }); + const files = pages[calls.length - 1] ?? []; + return { + ok: true, + status: 200, + statusText: "OK", + async json() { + return files; + }, + }; + }; + fetchFn.calls = calls; + return fetchFn; +} + +describe("checkConnectorDocsChange", () => { + it("returns unknown outside pull requests", async () => { + const result = await checkConnectorDocsChange(); + assert.deepEqual(result, { validate: "unknown", reason: "not_pull_request" }); + }); + + it("returns false when connector docs are unchanged", async () => { + const fetchFn = mockFetch([[{ filename: "README.md" }]]); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + token: "token", + }); + assert.deepEqual(result, { + validate: "false", + reason: "docs_path_unchanged", + }); + assert.equal(fetchFn.calls.length, 1); + assert.equal(fetchFn.calls[0].options.headers.Authorization, "Bearer token"); + }); + + it("detects direct connector docs changes", async () => { + const fetchFn = mockFetch([[{ filename: "docs/connector.mdx" }]]); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }); + assert.deepEqual(result, { validate: "true", reason: "docs_path_changed" }); + }); + + it("detects connector docs renames", async () => { + const fetchFn = mockFetch([ + [{ filename: "docs/old-connector.mdx", previous_filename: "docs/connector.mdx" }], + ]); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }); + assert.deepEqual(result, { validate: "true", reason: "docs_path_changed" }); + }); + + it("forces validation at the GitHub PR files API cap", async () => { + const fullPage = Array.from({ length: 100 }, (_, index) => ({ + filename: `generated/file-${index}.txt`, + })); + const fetchFn = mockFetch(Array.from({ length: 30 }, () => fullPage)); + const result = await checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }); + assert.deepEqual(result, { validate: "true", reason: "api_cap" }); + assert.equal(fetchFn.calls.length, 30); + }); + + it("fails closed on GitHub API errors", async () => { + const fetchFn = async () => ({ + ok: false, + status: 502, + statusText: "Bad Gateway", + }); + await assert.rejects( + () => + checkConnectorDocsChange({ + fetchFn, + prNumber: "12", + repository: "example/repo", + }), + /GitHub PR files request failed: 502 Bad Gateway/, + ); + }); +}); diff --git a/tools/mdx-lint/mdx-lint.mjs b/tools/mdx-lint/mdx-lint.mjs index 276c65f..18e429f 100644 --- a/tools/mdx-lint/mdx-lint.mjs +++ b/tools/mdx-lint/mdx-lint.mjs @@ -14,6 +14,8 @@ // 0 - valid MDX // 1 - validation or compilation error (message on stderr) +import { pathToFileURL } from "node:url"; + import { compile } from "@mdx-js/mdx"; import remarkFrontmatter from "remark-frontmatter"; import remarkGfm from "remark-gfm"; @@ -159,7 +161,7 @@ function validateTree(tree) { }); } -function parse(content) { +function parseMdx(content) { const processor = unified() .use(remarkParse) .use(remarkMdx) @@ -168,39 +170,47 @@ function parse(content) { return processor.parse(content); } -async function main() { - let content = ""; - for await (const chunk of process.stdin) { - content += chunk; +export async function lintMdxContent(content) { + if (!content.trim()) { + throw new Error("documentation cannot be empty"); } - - try { - if (!content.trim()) { - throw new Error("documentation cannot be empty"); - } - if (content.includes("\0")) { - throw new Error("documentation contains NUL bytes"); - } - if (content.includes("\ufeff")) { - throw new Error("documentation contains byte order marks"); - } - - const tree = parse(content); - validateTree(tree); - } catch (err) { - process.stderr.write(`mdx-lint: ${err.message}\n`); - process.exit(1); + if (content.includes("\0")) { + throw new Error("documentation contains NUL bytes"); + } + if (content.includes("\ufeff")) { + throw new Error("documentation contains byte order marks"); } + const tree = parseMdx(content); + validateTree(tree); + try { await compile(content, { outputFormat: "function-body", remarkPlugins: [remarkGfm, remarkFrontmatter], }); + } catch (err) { + throw new Error(err.message); + } +} + +async function readStdin() { + let content = ""; + for await (const chunk of process.stdin) { + content += chunk; + } + return content; +} + +async function main() { + try { + await lintMdxContent(await readStdin()); } catch (err) { process.stderr.write(`mdx-lint: ${err.message}\n`); process.exit(1); } } -main(); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/tools/mdx-lint/mdx-lint.test.mjs b/tools/mdx-lint/mdx-lint.test.mjs new file mode 100644 index 0000000..4e687ed --- /dev/null +++ b/tools/mdx-lint/mdx-lint.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { lintMdxContent } from "./mdx-lint.mjs"; + +async function expectValid(content) { + await assert.doesNotReject(() => lintMdxContent(content)); +} + +async function expectInvalid(content, pattern) { + await assert.rejects(() => lintMdxContent(content), pattern); +} + +describe("mdx-lint policy", () => { + it("allows supported connector docs content", async () => { + await expectValid(`--- +title: Example Connector +--- + +# Example Connector + +Use this connector to sync users and groups. + + + + Create a read-only token. + + + + + +\`\`\`json +{"tenant": "{tenant}", "url": "data:image/png;base64,abc"} +\`\`\` + +Inline placeholders like \`\` and \`{tenant}\` are allowed. +`); + }); + + it("allows normal prose and safe links", async () => { + await expectValid(`This connector syncs users and groups. + +Read the public setup guide at https://example.com/docs. +`); + }); + + it("rejects unknown JSX components", async () => { + await expectInvalid("\n", /disallowed JSX component "Unknown"/); + }); + + it("rejects MDX imports and exports", async () => { + await expectInvalid('import Thing from "./thing.js"\n', /MDX import\/export/); + await expectInvalid("export const value = 1\n", /MDX import\/export/); + }); + + it("rejects MDX expressions", async () => { + await expectInvalid("{process.env.SECRET}\n", /MDX expression/); + }); + + it("rejects raw HTML elements", async () => { + await expectInvalid("
raw html
\n", /disallowed JSX component "div"/); + }); + + it("rejects event handler attributes", async () => { + await expectInvalid('\n', /event handler attribute/); + }); + + it("rejects encoded dangerous URLs", async () => { + await expectInvalid( + "[link](javascript:alert(1))\n", + /dangerous URL scheme/, + ); + }); + + it("rejects data URLs", async () => { + await expectInvalid( + "[image](data:image/png;base64,abc)\n", + /dangerous URL scheme/, + ); + }); + + it("rejects BOM and NUL bytes", async () => { + await expectInvalid("\ufeff# Title\n", /byte order marks/); + await expectInvalid("hello\0world\n", /NUL bytes/); + }); +}); diff --git a/tools/mdx-lint/package.json b/tools/mdx-lint/package.json index e26cf8b..3fe1131 100644 --- a/tools/mdx-lint/package.json +++ b/tools/mdx-lint/package.json @@ -1,6 +1,9 @@ { "private": true, "type": "module", + "scripts": { + "test": "node --test" + }, "dependencies": { "@mdx-js/mdx": "3.1.1", "remark-frontmatter": "5.0.0",