diff --git a/.claude/skills/validate-config-examples/SKILL.md b/.claude/skills/validate-config-examples/SKILL.md index 3ba097b0de..11e7b59fe2 100644 --- a/.claude/skills/validate-config-examples/SKILL.md +++ b/.claude/skills/validate-config-examples/SKILL.md @@ -2,82 +2,105 @@ name: validate-config-examples description: >- Validate Mergify YAML configuration examples embedded in the docs against the - real Mergify schema, so broken config snippets never ship. Use after editing - docs pages that contain .mergify.yml / YAML examples, when reviewing a docs PR - with config snippets, or when asked to check/validate config examples in the - docs. Extracts YAML code fences, classifies them, and validates full Mergify - configs with `mergify config validate` (optionally via the mergify:mergify-config - skill when that plugin is installed). + real Mergify config JSON Schema, so broken config snippets never ship. Use + after editing docs pages that contain .mergify.yml / YAML examples, when + reviewing a docs PR with config snippets, or when asked to check/validate + config examples in the docs. Runs `pnpm check:config-examples`, which extracts + every YAML fence, classifies it, and validates the complete Mergify configs + against public/mergify-configuration-schema.json. --- # Validate Config Examples Mergify config snippets in the docs are the most-copied content on the site. A -broken example erodes trust instantly. This skill finds every YAML example and -validates the ones that are real Mergify configs. - -## Workflow - -1. **Pick the scope.** - - Reviewing a change: run against the changed MDX files only. - - Auditing: run against the whole `src/content/docs/` tree. - -2. **Extract and classify** with the bundled script: - - ```bash - .claude/skills/validate-config-examples/scripts/extract_yaml.py - ``` - - It prints a JSON array of every YAML fence with `file`, `line`, - `classification`, and `code`. Classifications: - - | Classification | Meaning | Action | - | --- | --- | --- | - | `mergify-config` | A real Mergify config (has top-level keys like `queue_rules`, `pull_request_rules`, `merge_protections`) | **Validate it** | - | `github-actions` | A CI workflow YAML, not Mergify config | Skip | - | `partial` | Marked `# partial` or a fragment (no top-level key) | Skip validation | - | `unknown` | YAML that fits none of the above | Eyeball manually | - -3. **Validate each `mergify-config` snippet** with `mergify config validate` — - the authoritative validator (it understands condition syntax and action - options, not just the JSON shape). Write each snippet to a temp file and pass - it with `--config-file` (the command auto-detects `.mergify.yml` from the - current directory otherwise, so the flag is required for a temp file): - - ```bash - printf '%s\n' "$CODE" > /tmp/mergify-example.yml - mergify config validate --config-file /tmp/mergify-example.yml - ``` - - If the **mergify:mergify-config** plugin skill is installed, you can invoke it - instead of calling the CLI directly — it wraps the same command. As a - structural fallback when the CLI is unavailable, validate against - `public/mergify-configuration-schema.json` (JSON Schema). - -4. **Report** each failure as `file:line — `. Fix clear errors directly in - the MDX (wrong key name, bad indentation, invalid condition). For anything - ambiguous, flag rather than guess — a wrong "fix" to a config example is worse - than a flagged one. - -## The fragment convention - -Many doc examples are partial on purpose — they show one rule, not a whole file. -A bare fragment cannot be validated standalone and will false-positive. - -The script treats a snippet as `partial` (and skips it) when: - -- it has no unindented top-level key (it is clearly a fragment), or -- its first lines contain a `# partial` marker comment. - -When you intentionally show a fragment that the script can't auto-detect, add a -leading `# partial` comment to the code block so it is skipped cleanly. Do not add -the marker to examples that are meant to be complete, valid configs — those should -validate. - -## Scope & guardrails - -- Only validates YAML/`yml` fences; ignores all other languages. -- Skips GitHub Actions and other non-Mergify YAML automatically. -- Never edits `src/content/changelog/`. -- Fix only genuine errors; leave valid examples alone even if you'd phrase the - surrounding prose differently. +broken example erodes trust instantly. A `config-examples` CI job validates them +on every PR; this skill is how you run the same check locally and fix what it +finds. + +## Run it + +```bash +pnpm check:config-examples # validate everything under src/content/docs +``` + +Under the hood this runs `node scripts/validate-config-examples.mjs`. It scans +MDX for ```yaml / ```yml fences, classifies each, YAML-parses the complete +Mergify configs, and validates them against `public/mergify-configuration-schema.json` +with [Ajv](https://ajv.js.org/). Exit code is non-zero if any example is invalid; +each failure prints as `file:line — `. + +To validate only the files you touched, pass paths: + +```bash +node scripts/validate-config-examples.mjs src/content/docs/merge-queue +``` + +To inspect the classification (which blocks are validated vs skipped and why), +use `--json`: + +```bash +node scripts/validate-config-examples.mjs --json src/content/docs | jq '.[] | {file, line, classification}' +``` + +## What gets validated vs skipped + +The script validates a block only when it is a **complete** Mergify config — +i.e. it has an unindented top-level key from the config schema (`queue_rules`, +`pull_request_rules`, `merge_protections`, `merge_protections_settings`, +`scopes`, `defaults`, `commands_restrictions`, `extends`, `shared`, ...). + +It automatically **skips**: + +- **Fragments** — a snippet with no top-level key (e.g. a single rule or a bare + list of conditions shown inline). Cannot be validated as a whole config. +- **`...` placeholders** — any block containing a bare `...` line is treated as + deliberately incomplete. +- **GitHub Actions / other CI YAML** — detected by `on:` + `jobs:`/`steps:`/etc. +- **Anything marked `# partial`** in its first lines. + +For a block that *looks* like a full config but should not be validated — most +often **deprecated syntax shown in a migration guide** — put a reader-invisible +MDX comment on the line before the fence: + +````mdx +{/* validate-config-examples: skip — deprecated partition_rules syntax, shown for migration */} + +```yaml +partition_rules: + ... +``` +```` + +Do **not** skip a block just to make CI pass. If an example is meant to be a +real, copy-pasteable config, fix it instead. + +## Fixing failures + +- **`invalid YAML: ...`** — the snippet isn't valid YAML. The most common cause + is an unquoted Mergify template at the start of a value (`{{ ... }}` or a + leading `@`): `message: @{{author}} ...` must be `message: "@{{author}} ..."`, + and `bot_account: {{ author }}` must be `bot_account: "{{ author }}"`. +- **`/ must NOT have additional properties`** — an unknown or misspelled key at + the top level (or a deprecated top-level key). Fix the key, or mark the block + skipped if the old syntax is shown on purpose. +- **`/path must be `** — a value has the wrong type or shape. Match the + schema. + +Fix clear errors directly in the MDX. For anything ambiguous, flag it rather than +guess — a wrong "fix" to a config example is worse than a flagged one. + +## Scope, guardrails & limitations + +- Validation is **structural** (keys, types, shapes). It does **not** check the + semantics of condition expressions (`base=main`), nor Mergify's custom string + formats (`duration`, `template`) — a generic JSON-Schema `duration` validator + would wrongly reject valid values like `checks_timeout: 5 min`, so all `format` + keywords are intentionally ignored. For deep semantic validation of a single + config, `mergify config validate --config-file ` (the **mergify:mergify-config** + skill) remains authoritative — but it fetches the schema from docs.mergify.com, + so it is not used in CI. +- The schema at `public/mergify-configuration-schema.json` is kept fresh by an + automated sync ("chore: sync Mergify JSON Schema files"); the check always runs + against the copy in the repo, so a PR is validated against its own schema. +- Only `yaml`/`yml` fences are considered. Never edits `src/content/changelog/`. +- Fix only genuine errors; leave valid examples alone. diff --git a/.claude/skills/validate-config-examples/scripts/extract_yaml.py b/.claude/skills/validate-config-examples/scripts/extract_yaml.py deleted file mode 100755 index 24a2deb675..0000000000 --- a/.claude/skills/validate-config-examples/scripts/extract_yaml.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env python3 -"""Extract YAML/yml fenced code blocks from MDX docs and classify each. - -Pure stdlib. Does NOT validate — it locates every YAML snippet, records its -file:line, and classifies it so the caller knows which snippets to feed to -`mergify config validate` and which to skip. - -Usage: - extract_yaml.py [ ...] - -If a directory is given, all .mdx files under it are scanned recursively. -Outputs a JSON array to stdout: - [{"file", "line", "lang", "classification", "code"}, ...] - -Classifications: - mergify-config -> a Mergify config; validate it with `mergify config validate` - github-actions -> a CI workflow, not Mergify config; skip - partial -> explicitly marked `# partial` or a fragment; skip validation - unknown -> YAML that is none of the above; review manually -""" - -import json -import os -import re -import sys - -FENCE_RE = re.compile(r"^([ \t]*)(`{3,}|~{3,})([^\n`]*)$") - -# Top-level keys that mark a full Mergify configuration file. -MERGIFY_TOP_KEYS = { - "queue_rules", - "pull_request_rules", - "merge_protections", - "commands_restrictions", - "merge_queue", - "shared", - "defaults", - "extends", - "partition_rules", - "priority_rules", -} - -# Signals that a YAML block is a GitHub Actions / CI workflow, not Mergify config. -CI_SIGNALS = ("runs-on:", "uses:", "jobs:", "steps:") - - -def lang_of(info_string): - """First token of a fence info string, lowercased (e.g. 'yaml title=...').""" - token = info_string.strip().split()[0] if info_string.strip() else "" - # Strip a leading '{' from formats like ```{yaml} - return token.lstrip("{").rstrip("}").lower() - - -def classify(code): - lines = code.splitlines() - stripped = [ln for ln in lines if ln.strip() and not ln.lstrip().startswith("#")] - - # Explicit author marker wins. - for ln in lines[:3]: - if re.match(r"^\s*#\s*partial\b", ln, re.IGNORECASE): - return "partial" - - text = "\n".join(lines) - if any(sig in text for sig in CI_SIGNALS) and "on:" in text: - return "github-actions" - - top_keys = set() - for ln in lines: - m = re.match(r"^([A-Za-z_][\w-]*):", ln) - if m: - top_keys.add(m.group(1)) - if top_keys & MERGIFY_TOP_KEYS: - return "mergify-config" - - # No unindented top-level key at all -> a fragment (e.g. a single rule shown - # inline). Treat as partial: not independently validatable. - if stripped and all(ln.startswith((" ", "\t", "-")) for ln in stripped): - return "partial" - - return "unknown" - - -def extract_from_file(path): - out = [] - with open(path, encoding="utf-8") as fh: - lines = fh.readlines() - - i = 0 - n = len(lines) - while i < n: - m = FENCE_RE.match(lines[i].rstrip("\n")) - if not m: - i += 1 - continue - indent, fence, info = m.group(1), m.group(2), m.group(3) - lang = lang_of(info) - fence_line = i + 1 # 1-based - # Find closing fence: same marker char, at least as long, no info string. - body = [] - j = i + 1 - closed = False - while j < n: - cm = FENCE_RE.match(lines[j].rstrip("\n")) - if cm and cm.group(2)[0] == fence[0] and len(cm.group(2)) >= len(fence) and not cm.group(3).strip(): - closed = True - break - body.append(lines[j].rstrip("\n")) - j += 1 - if lang in ("yaml", "yml"): - # Strip the common fence indentation from the body. - code = "\n".join(ln[len(indent):] if ln.startswith(indent) else ln for ln in body) - out.append( - { - "file": path, - "line": fence_line, - "lang": lang, - "classification": classify(code), - "code": code, - } - ) - i = j + 1 if closed else j - return out - - -def iter_targets(args): - for arg in args: - if os.path.isdir(arg): - for root, _, files in os.walk(arg): - for f in files: - if f.endswith(".mdx"): - yield os.path.join(root, f) - elif arg.endswith(".mdx"): - yield arg - - -def main(argv): - if not argv: - print(__doc__, file=sys.stderr) - return 2 - results = [] - for path in iter_targets(argv): - results.extend(extract_from_file(path)) - json.dump(results, sys.stdout, indent=2) - sys.stdout.write("\n") - return 0 - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 81c79bf590..5a4b15c882 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,6 +65,27 @@ jobs: pnpm install --frozen-lockfile pnpm test + config-examples: + timeout-minutes: 10 + runs-on: ubuntu-24.04 + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v7.0.0 + + - name: Setup pnpm 📦 + uses: pnpm/action-setup@v6.0.9 + + - name: Setup Node 🔧 + uses: actions/setup-node@v6.4.0 + with: + cache: pnpm + node-version-file: .node-version + + - name: Validate embedded Mergify config examples + run: | + pnpm install --frozen-lockfile + pnpm check:config-examples + build: timeout-minutes: 20 runs-on: ubuntu-24.04 diff --git a/package.json b/package.json index 61610d79c5..2f84e9aaf7 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format": "biome format --write .", "format:check": "biome format src integrations plugins scripts", "check": "astro check && eslint . && biome check .", + "check:config-examples": "node scripts/validate-config-examples.mjs", "check:links": "linkinator dist/ --recurse --concurrency 25 --verbosity error --skip 'https?://'" }, "devDependencies": { @@ -35,6 +36,7 @@ "@typescript-eslint/eslint-plugin": "^8.63.0", "@typescript-eslint/parser": "^8.63.0", "agentation": "^3.0.2", + "ajv": "^8.20.0", "astro": "^7.0.7", "astro-auto-import": "^0.5.1", "astro-eslint-parser": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a789a5ab0a..7671a22f67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,9 @@ importers: agentation: specifier: ^3.0.2 version: 3.0.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + ajv: + specifier: ^8.20.0 + version: 8.20.0 astro: specifier: ^7.0.7 version: 7.0.7(@astrojs/markdown-remark@7.2.1)(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.2)(@types/node@26.1.1)(sass-embedded@1.100.0)(sass@1.100.0)(yaml@2.9.0) diff --git a/scripts/validate-config-examples.mjs b/scripts/validate-config-examples.mjs new file mode 100644 index 0000000000..cc6bc839f1 --- /dev/null +++ b/scripts/validate-config-examples.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Validate the Mergify configuration examples embedded in the docs against the + * real Mergify config JSON Schema, so a broken example fails CI instead of + * silently misleading users. + * + * It scans MDX files for ```yaml / ```yml code fences, classifies each block, + * and validates the ones that are complete Mergify configs against + * `public/mergify-configuration-schema.json` (the same schema the docs site + * serves and that `mergify config validate` fetches — but read from disk so a + * PR is checked against its own schema, offline, with no network round-trip). + * + * Validation is STRUCTURAL only: it checks keys, types and shapes, not the + * semantics of condition expressions or Mergify's custom string formats + * (`duration`, `template`, ...). Those custom formats are intentionally NOT + * enforced — a generic JSON-Schema format validator reads `format: duration` + * as ISO-8601 and would reject valid Mergify durations like "5 min". For deep + * semantic checks, `mergify config validate` remains the authoritative tool. + * + * Usage: + * node scripts/validate-config-examples.mjs [paths...] # validate (default: src/content/docs) + * node scripts/validate-config-examples.mjs --json [paths...] # dump classification as JSON + * + * A block is skipped when it is a partial fragment, a `...` placeholder, a + * GitHub Actions workflow, or explicitly marked. To mark a complete-looking + * config that should NOT be validated (e.g. deprecated syntax shown in a + * migration guide), put an MDX comment on the line before the fence: + * + * {/* validate-config-examples: skip — why *\/} + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv2020 from 'ajv/dist/2020.js'; +import yaml from 'js-yaml'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); +const SCHEMA_PATH = path.join(ROOT, 'public', 'mergify-configuration-schema.json'); + +// Top-level keys that mark a complete Mergify configuration file. +const MERGIFY_TOP_KEYS = new Set([ + 'queue_rules', + 'pull_request_rules', + 'merge_protections', + 'merge_protections_settings', + 'commands_restrictions', + 'merge_queue', + 'shared', + 'defaults', + 'extends', + 'scopes', + 'partition_rules', + 'priority_rules', +]); + +// Signals that a YAML block is a GitHub Actions / CI workflow, not Mergify config. +const CI_SIGNALS = ['runs-on:', 'uses:', 'jobs:', 'steps:']; + +const FENCE_RE = /^([ \t]*)(`{3,}|~{3,})([^\n`]*)$/; +const PARTIAL_MARKER_RE = /^\s*#\s*partial\b/i; +const TOP_KEY_RE = /^([A-Za-z_][\w-]*):/; +// MDX comment directive placed on the line before a fence to skip validation. +const SKIP_DIRECTIVE_RE = /\{\/\*\s*validate-config-examples:\s*skip\b/i; + +export function langOf(info) { + const token = info.trim().split(/\s+/)[0] || ''; + return token.replace(/^\{/, '').replace(/\}$/, '').toLowerCase(); +} + +export function classify(code) { + const lines = code.split('\n'); + const meaningful = lines.filter((ln) => ln.trim() && !ln.trimStart().startsWith('#')); + + // Explicit author marker wins. + if (lines.slice(0, 3).some((ln) => PARTIAL_MARKER_RE.test(ln))) return 'partial'; + + // A bare `...` placeholder line means the example is deliberately incomplete. + if (meaningful.some((ln) => ln.trim() === '...')) return 'partial'; + + const text = lines.join('\n'); + if (CI_SIGNALS.some((sig) => text.includes(sig)) && text.includes('on:')) { + return 'github-actions'; + } + + const topKeys = new Set(); + for (const ln of lines) { + const m = ln.match(TOP_KEY_RE); + if (m) topKeys.add(m[1]); + } + for (const k of topKeys) if (MERGIFY_TOP_KEYS.has(k)) return 'mergify-config'; + + // No unindented top-level key at all -> a fragment (e.g. a single rule shown + // inline). Not independently validatable. + if (meaningful.length && meaningful.every((ln) => /^[ \t-]/.test(ln))) return 'partial'; + + return 'unknown'; +} + +export function extractFromFile(file) { + const lines = fs.readFileSync(file, 'utf8').split('\n'); + const out = []; + let i = 0; + const n = lines.length; + while (i < n) { + const m = lines[i].match(FENCE_RE); + if (!m) { + i += 1; + continue; + } + const [, indent, fence, info] = m; + const lang = langOf(info); + const fenceLine = i + 1; // 1-based + + // Nearest non-blank line above the fence, to detect a skip directive. + let p = i - 1; + while (p >= 0 && !lines[p].trim()) p -= 1; + const skip = p >= 0 && SKIP_DIRECTIVE_RE.test(lines[p]); + + const body = []; + let j = i + 1; + let closed = false; + while (j < n) { + const cm = lines[j].match(FENCE_RE); + if (cm && cm[2][0] === fence[0] && cm[2].length >= fence.length && !cm[3].trim()) { + closed = true; + break; + } + body.push(lines[j]); + j += 1; + } + if (lang === 'yaml' || lang === 'yml') { + const code = body + .map((ln) => (ln.startsWith(indent) ? ln.slice(indent.length) : ln)) + .join('\n'); + out.push({ + file: path.relative(ROOT, file), + line: fenceLine, + lang, + classification: skip ? 'skipped' : classify(code), + code, + }); + } + i = closed ? j + 1 : j; + } + return out; +} + +export function* iterMdx(targets) { + for (const t of targets) { + const abs = path.resolve(ROOT, t); + const stat = fs.statSync(abs); + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(abs, { withFileTypes: true, recursive: true })) { + if (entry.isFile() && entry.name.endsWith('.mdx')) { + yield path.join(entry.parentPath ?? entry.path, entry.name); + } + } + } else if (abs.endsWith('.mdx')) { + yield abs; + } + } +} + +/** Compile the Mergify config schema into a structural-only validator. */ +export function createValidator(schemaPath = SCHEMA_PATH) { + const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + // Structural validation only: `validateFormats: false` ignores every `format` + // keyword (Mergify's custom `duration`/`template`/... and standard ones alike) + // without warning, so we never reject a valid Mergify value a generic format + // checker would misread (e.g. `checks_timeout: 5 min` vs ISO-8601 `duration`). + const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false }); + return ajv.compile(schema); +} + +/** Validate the `mergify-config` blocks; returns [{file, line, msg}, ...]. */ +export function validateBlocks(blocks, validate = createValidator()) { + const failures = []; + for (const b of blocks) { + if (b.classification !== 'mergify-config') continue; + let doc; + try { + doc = yaml.load(b.code); + } catch (e) { + failures.push({ + file: b.file, + line: b.line, + msg: `invalid YAML: ${e.message.split('\n')[0]}`, + }); + continue; + } + if (validate(doc)) continue; + const detail = (validate.errors || []) + .slice(0, 3) + .map((e) => `${e.instancePath || '/'} ${e.message}`) + .join('; '); + failures.push({ file: b.file, line: b.line, msg: detail }); + } + return failures; +} + +function main(argv) { + const jsonMode = argv.includes('--json'); + const targets = argv.filter((a) => a !== '--json'); + if (targets.length === 0) targets.push('src/content/docs'); + + const blocks = []; + for (const file of iterMdx(targets)) blocks.push(...extractFromFile(file)); + + if (jsonMode) { + process.stdout.write(`${JSON.stringify(blocks, null, 2)}\n`); + return 0; + } + + const failures = validateBlocks(blocks); + const configs = blocks.filter((b) => b.classification === 'mergify-config'); + const skipped = blocks.filter((b) => b.classification === 'skipped').length; + console.log( + `Checked ${configs.length} Mergify config example(s) ` + + `(${blocks.length} YAML blocks scanned, ${skipped} explicitly skipped).` + ); + if (failures.length === 0) { + console.log('All config examples are valid.'); + return 0; + } + console.error(`\n${failures.length} invalid config example(s):\n`); + for (const f of failures) console.error(` ${f.file}:${f.line} — ${f.msg}`); + console.error( + '\nFix the snippet, or if it is an intentional fragment add a `# partial` ' + + 'comment / `...` placeholder, or mark a deliberately-invalid block with an ' + + 'MDX comment: {/* validate-config-examples: skip — why */}' + ); + return 1; +} + +// Run as a CLI only when invoked directly, so tests can import the helpers. +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exit(main(process.argv.slice(2))); +} diff --git a/scripts/validate-config-examples.test.mjs b/scripts/validate-config-examples.test.mjs new file mode 100644 index 0000000000..99e4c70303 --- /dev/null +++ b/scripts/validate-config-examples.test.mjs @@ -0,0 +1,97 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { + classify, + createValidator, + extractFromFile, + iterMdx, + validateBlocks, +} from './validate-config-examples.mjs'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..'); + +describe('classify', () => { + it('recognizes a complete Mergify config', () => { + expect(classify('queue_rules:\n - name: default\n')).toBe('mergify-config'); + expect(classify('scopes:\n source:\n files: {}\n')).toBe('mergify-config'); + expect(classify('merge_protections_settings:\n foo: bar\n')).toBe('mergify-config'); + }); + + it('treats fragments and placeholders as partial', () => { + expect(classify('- name: a rule\n conditions: []\n')).toBe('partial'); + expect(classify('queue_rules:\n - name: default\n ...\n')).toBe('partial'); + expect(classify('# partial\nconditions:\n - base=main\n')).toBe('partial'); + }); + + it('detects GitHub Actions workflows', () => { + expect(classify('on:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n')).toBe( + 'github-actions' + ); + }); +}); + +describe('validateBlocks', () => { + const validate = createValidator(); + + it('rejects a config with an unknown key (not a no-op)', () => { + const blocks = [ + { + file: 'x.mdx', + line: 1, + classification: 'mergify-config', + code: 'queue_rules:\n - name: d\n bogus_key: 5\n', + }, + ]; + const failures = validateBlocks(blocks, validate); + expect(failures).toHaveLength(1); + expect(failures[0].msg).toMatch(/additional properties|bogus_key/i); + }); + + it('accepts a valid config and a Mergify human duration', () => { + const blocks = [ + { + file: 'x.mdx', + line: 1, + classification: 'mergify-config', + code: 'queue_rules:\n - name: default\n checks_timeout: 45 min\n', + }, + ]; + expect(validateBlocks(blocks, validate)).toHaveLength(0); + }); + + it('flags YAML that does not parse (unquoted template needing quotes)', () => { + const blocks = [ + { + file: 'x.mdx', + line: 1, + classification: 'mergify-config', + code: 'pull_request_rules:\n - name: r\n actions:\n comment:\n message: @{{author}} hello\n', + }, + ]; + const failures = validateBlocks(blocks, validate); + expect(failures).toHaveLength(1); + expect(failures[0].msg).toMatch(/invalid YAML/); + }); +}); + +describe('docs config examples', () => { + it('all embedded Mergify config examples are valid', () => { + const blocks = []; + for (const file of iterMdx(['src/content/docs'])) blocks.push(...extractFromFile(file)); + const failures = validateBlocks(blocks); + if (failures.length) { + const detail = failures.map((f) => `${f.file}:${f.line} — ${f.msg}`).join('\n'); + throw new Error(`Invalid config examples found:\n${detail}`); + } + // Sanity: we are actually exercising a meaningful number of configs. + expect(blocks.filter((b) => b.classification === 'mergify-config').length).toBeGreaterThan(50); + }); + + it('honors the skip directive', () => { + const blocks = extractFromFile( + path.join(ROOT, 'src/content/docs/merge-queue/migrate-partitions-to-scopes.mdx') + ); + expect(blocks.some((b) => b.classification === 'skipped')).toBe(true); + }); +}); diff --git a/src/content/docs/merge-queue/migrate-partitions-to-scopes.mdx b/src/content/docs/merge-queue/migrate-partitions-to-scopes.mdx index a297d2c701..a781f11006 100644 --- a/src/content/docs/merge-queue/migrate-partitions-to-scopes.mdx +++ b/src/content/docs/merge-queue/migrate-partitions-to-scopes.mdx @@ -58,6 +58,8 @@ from Mergify conditions to glob patterns. **Before — partition_rules:** +{/* validate-config-examples: skip — deprecated partition_rules syntax, shown for migration */} + ```yaml partition_rules: - name: backend @@ -264,6 +266,9 @@ See the dedicated guides for [Nx](/merge-queue/scopes/nx), ### Before: partition_rules with full CI on every partition **.mergify.yml:** + +{/* validate-config-examples: skip — deprecated partition_rules syntax, shown for migration */} + ```yaml partition_rules: - name: backend diff --git a/src/content/docs/workflow/actions/backport.mdx b/src/content/docs/workflow/actions/backport.mdx index b3f166975e..e01c95f240 100644 --- a/src/content/docs/workflow/actions/backport.mdx +++ b/src/content/docs/workflow/actions/backport.mdx @@ -119,7 +119,7 @@ author, you can configure the default `backport` actions parameter to use a defaults: actions: backport: - bot_account: {{ author }} + bot_account: "{{ author }}" ``` :::note diff --git a/src/content/docs/workflow/actions/comment.mdx b/src/content/docs/workflow/actions/comment.mdx index 23e1079f36..ff4d4e29b7 100644 --- a/src/content/docs/workflow/actions/comment.mdx +++ b/src/content/docs/workflow/actions/comment.mdx @@ -47,7 +47,7 @@ pull_request_rules: - conflict actions: comment: - message: @{{author}} Your PR is in conflict and cannot be merged. + message: "@{{author}} Your PR is in conflict and cannot be merged." ``` ### Merge Thank You