diff --git a/.github/workflows/docs-ci.yml b/.github/workflows/docs-ci.yml
new file mode 100644
index 000000000..05a6a5767
--- /dev/null
+++ b/.github/workflows/docs-ci.yml
@@ -0,0 +1,44 @@
+name: Docs CI
+
+on:
+ pull_request:
+ branches:
+ - main
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: docs-ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ checks:
+ name: Docs checks
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: "3.12"
+ - name: Install OpenAPI validator
+ run: pip install --quiet pyyaml openapi-spec-validator
+ # continue-on-error is deliberately absent: each check is a hard gate.
+ # Steps run in order and the job reports the first failure.
+ - name: Check frontmatter
+ run: python scripts/check_frontmatter.py
+ - name: Check code samples
+ if: '!cancelled()'
+ run: python scripts/check_code_samples.py
+ - name: Check internal links
+ if: '!cancelled()'
+ run: python scripts/check_links.py
+ - name: Check redirects
+ if: '!cancelled()'
+ run: python scripts/check_redirects.py
+ - name: Check OpenAPI specs
+ if: '!cancelled()'
+ run: python scripts/check_openapi.py
diff --git a/docs.json b/docs.json
index f7308876a..e086cc793 100644
--- a/docs.json
+++ b/docs.json
@@ -409,7 +409,7 @@
"docs/data-governance/data-volume-monitoring",
"docs/data-governance/warehouse-sync-monitoring",
"docs/data-governance/data-clean-up",
- "docs/data-governance/ai-powered-data-governance"
+ "docs/data-governance/ai-powered-data-governance"
]
},
{
@@ -705,7 +705,7 @@
"guides/strategic-playbooks/product-analytics-strategy/finale"
]
},
- "guides/strategic-playbooks/onboarding-playbook",
+ "guides/strategic-playbooks/onboarding-playbook",
"guides/strategic-playbooks/project-migration",
"guides/strategic-playbooks/feature-flag-migration-playbook"
]
@@ -1879,7 +1879,7 @@
},
{
"source": "/docs/formulas",
- "destination": "/changelogs/2023-09-19-formulas"
+ "destination": "/changelogs"
},
{
"source": "/docs/funnels",
@@ -2383,7 +2383,7 @@
},
{
"source": "/docs/tracking/data-warehouse/groups",
- "destination": "/docs/tracking-methods/data-warehouse/sending-group-profiles"
+ "destination": "/docs/tracking-methods/warehouse-connectors"
},
{
"source": "/docs/tracking/data-warehouse/redshift",
@@ -2747,27 +2747,27 @@
},
{
"source": "/guides/plan/setup",
- "destination": "/guides/strategic-playbooks/onboarding-playbook/plan/setup"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/plan/framework",
- "destination": "/guides/strategic-playbooks/onboarding-playbook/plan/framework"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/plan/tracking-strategy",
- "destination": "/guides/strategic-playbooks/onboarding-playbook/plan/tracking-strategy"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/implement/send-your-data",
- "destination": "/guides/strategic-playbooks/onboarding-playbook/implement/send-your-data"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/implement/qa-data-audit",
- "destination": "/guides/strategic-playbooks/onboarding-playbook/implement/qa-data-audit"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/implement/establish-governance",
- "destination": "/guides/strategic-playbooks/onboarding-playbook/implement/establish-governance"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/launch/create-boards",
@@ -2855,7 +2855,7 @@
},
{
"source": "/hc",
- "destination": "docs/getting-started/what-is-mixpanel"
+ "destination": "/docs/what-is-mixpanel"
},
{
"source": "/hc/admin/general_settings",
@@ -3227,23 +3227,23 @@
},
{
"source": "/hc/en-us/articles/13174988269844*",
- "destination": "/changelogs/2022-12-01-improvements"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13175102961428*",
- "destination": "/changelogs/2022-12-13-boards"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13175146659732*",
- "destination": "/changelogs/2023-01-18-table-boards"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13175184938260*",
- "destination": "/changelogs/2023-01-23-users-flows"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13175224500628*",
- "destination": "/changelogs/2023-01-31-embed"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13271005313556*",
@@ -3251,39 +3251,39 @@
},
{
"source": "/hc/en-us/articles/13395179786644*",
- "destination": "/changelogs/2022-07-08-reorient"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395198918804*",
- "destination": "/changelogs/2022-11-03-session-improvements"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395199893652*",
- "destination": "/changelogs/2022-11-07-millisecond"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395205961236*",
- "destination": "/changelogs/2022-07-01-view-users"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395233184916*",
- "destination": "/changelogs/2022-06-16-faster-workflow"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395235568660*",
- "destination": "/changelogs/2022-05-31-improve-conversion-flow"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395324158868*",
- "destination": "/changelogs/2022-05-24-lexicon-context"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395325754772*",
- "destination": "/changelogs/2022-04-18-relative-comparison"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13395328448404*",
- "destination": "/changelogs/2022-03-29-text-boards"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/13530857626132*",
@@ -3291,7 +3291,7 @@
},
{
"source": "/hc/en-us/articles/13756463065492*",
- "destination": "/changelogs/2023-02-28-retention-calendar-interval"
+ "destination": "/changelogs"
},
{
"source": "/hc/en-us/articles/14202292561172*",
@@ -3635,7 +3635,7 @@
},
{
"source": "/hc/en-us/articles/360041995352*",
- "destination": "/docs/tracking/how-tos/events-properties"
+ "destination": "/docs/data-structure/events-and-properties"
},
{
"source": "/hc/en-us/articles/360042412051*",
@@ -4719,43 +4719,43 @@
},
{
"source": "/guides/guides-by-topic/core-reports/create-boards",
- "destination":"/guides/guides-by-topic/core-reports"
+ "destination": "/guides/guides-by-topic/core-reports"
},
{
"source": "/guides/guides-by-topic/core-reports/discover-insights",
- "destination":"/guides/guides-by-topic/core-reports"
+ "destination": "/guides/guides-by-topic/core-reports"
},
{
"source": "/guides/guides-by-topic/core-reports/analyze-conversions",
- "destination":"/guides/guides-by-topic/core-reports"
+ "destination": "/guides/guides-by-topic/core-reports"
},
{
"source": "/guides/guides-by-topic/core-reports/build-user-flows",
- "destination":"/guides/guides-by-topic/core-reports"
+ "destination": "/guides/guides-by-topic/core-reports"
},
{
"source": "/guides/guides-by-topic/core-reports/track-user-retention",
- "destination":"/guides/guides-by-topic/core-reports"
+ "destination": "/guides/guides-by-topic/core-reports"
},
{
"source": "/guides/guides-by-topic/core-reports/define-cohorts",
- "destination":"/guides/guides-by-topic/core-reports"
+ "destination": "/guides/guides-by-topic/core-reports"
},
{
"source": "/guides/strategic-playbooks/onboarding-playbook/plan/*",
- "destination":"/guides/strategic-playbooks/onboarding-playbook"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/strategic-playbooks/onboarding-playbook/implement/*",
- "destination":"/guides/strategic-playbooks/onboarding-playbook"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/strategic-playbooks/onboarding-playbook/launch/*",
- "destination":"/guides/strategic-playbooks/onboarding-playbook"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
},
{
"source": "/guides/strategic-playbooks/onboarding-playbook/beyond-onboarding",
- "destination":"/guides/strategic-playbooks/onboarding-playbook"
+ "destination": "/guides/strategic-playbooks/onboarding-playbook"
}
]
}
diff --git a/docs/mcp.mdx b/docs/mcp.mdx
index 29b000df9..60c847393 100644
--- a/docs/mcp.mdx
+++ b/docs/mcp.mdx
@@ -222,7 +222,7 @@ Any client that supports the MCP JSON config format, including Microsoft Copilot
**Beta.** Service account authentication for MCP is in beta. The interface may change.
-[Service accounts](/reference/Mixpanel%20APIs/authentication/service-accounts) are non-human Mixpanel users designed for scripts, back-end services, and automated workflows. They authenticate via a static header — no browser-based login is required.
+[Service accounts](/reference/service-accounts) are non-human Mixpanel users designed for scripts, back-end services, and automated workflows. They authenticate via a static header — no browser-based login is required.
Use service accounts when you need a headless MCP connection, such as CI/CD pipelines, automated agents, or shared team setups. The service account's project permissions apply: it can only access projects it has been added to.
diff --git a/reference/event-deduplication.mdx b/reference/event-deduplication.mdx
index 8cfd3d3ad..aa6db007b 100644
--- a/reference/event-deduplication.mdx
+++ b/reference/event-deduplication.mdx
@@ -23,7 +23,7 @@ Only the four key event properties listed above are used for deduplication. Addi
Deduplication occurs when a subset of the event data (event name, distinct\_id, timestamp, \$insert\_id) is identical. Other event properties are not considered.
-**Required [Event Object](/docs/data-model#anatomy-of-an-event) attributes**
+**Required [Event Object](/docs/data-structure/events-and-properties) attributes**
diff --git a/scripts/check_code_samples.py b/scripts/check_code_samples.py
new file mode 100644
index 000000000..2c948b6fb
--- /dev/null
+++ b/scripts/check_code_samples.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""
+CI gate: every fenced code block in MDX files must declare a language.
+
+A fenced block opening looks like:
+ ```python
+ ```javascript
+ ```bash
+
+A block with no language identifier:
+ ```
+
+will cause this check to fail.
+
+Excluded directories (same as other checks):
+ - snippets/
+ - openapi/
+"""
+
+import sys
+import glob
+import os
+import re
+
+EXCLUDED_DIRS = {"snippets", "openapi"}
+
+def check_file(path: str, display: str) -> list[str]:
+ errors = []
+ with open(path, encoding="utf-8") as fh:
+ content = fh.read()
+
+ fence_len = 0 # 0 = outside a block; otherwise the opening fence's length
+ for lineno, line in enumerate(content.splitlines(), 1):
+ stripped = line.strip()
+ if not stripped.startswith("```"):
+ continue
+ ticks = len(stripped) - len(stripped.lstrip("`"))
+ rest = stripped[ticks:].strip()
+ if fence_len:
+ # Only a bare fence at least as long as the opener closes the block,
+ # so a ```python block nested inside ````mdx does not end it early.
+ if ticks >= fence_len and not rest:
+ fence_len = 0
+ continue
+ if not rest:
+ errors.append(
+ f"{display}:{lineno}: code block is missing a language identifier"
+ )
+ fence_len = ticks
+
+ return errors
+
+
+def is_excluded(path: str) -> bool:
+ parts = path.replace(os.sep, "/").split("/")
+ return any(part in EXCLUDED_DIRS for part in parts)
+
+
+def main() -> int:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ mdx_files = glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True)
+
+ checked = 0
+ all_errors: list[str] = []
+ for path in sorted(mdx_files):
+ rel = os.path.relpath(path, root)
+ if is_excluded(rel):
+ continue
+ all_errors.extend(check_file(path, rel))
+ checked += 1
+
+ if all_errors:
+ print("Code-sample check FAILED:")
+ for err in all_errors:
+ print(f" {err}")
+ return 1
+
+ print(f"Code-sample check PASSED ({checked} files checked).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_frontmatter.py b/scripts/check_frontmatter.py
new file mode 100644
index 000000000..9dbf32139
--- /dev/null
+++ b/scripts/check_frontmatter.py
@@ -0,0 +1,94 @@
+#!/usr/bin/env python3
+"""
+CI gate: every MDX page must have a 'title' field in its YAML front-matter.
+
+Directories that are intentionally excluded from the check:
+ - snippets/ (reusable MDX components, not standalone pages)
+ - links/ (external-link stubs that use 'url' instead of a body)
+ - openapi/ (OpenAPI spec files, not MDX pages)
+"""
+
+import re
+import sys
+import glob
+import os
+
+EXCLUDED_DIRS = {"snippets", "links", "openapi"}
+
+FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
+
+
+def check_file(path: str, display: str) -> tuple[list[str], str | None]:
+ """Return (errors, title). Title is None when absent or empty."""
+ errors = []
+ with open(path, encoding="utf-8") as fh:
+ content = fh.read()
+
+ m = FRONTMATTER_RE.match(content)
+ if not m:
+ errors.append(f"{display}: missing front-matter block")
+ return errors, None
+
+ fm = m.group(1)
+ title = None
+ title_match = re.search(r"^\s*title\s*:\s*(.*)$", fm, re.MULTILINE)
+ if not title_match:
+ errors.append(f"{display}: front-matter is missing required 'title' field")
+ else:
+ title = title_match.group(1).strip().strip("\"'")
+ if not title:
+ errors.append(f"{display}: front-matter 'title' is empty")
+ title = None
+
+ # A description is what search results and llms.txt entries render, so a
+ # page without one is invisible to both.
+ desc_match = re.search(r"^\s*description\s*:\s*(.*)$", fm, re.MULTILINE)
+ if not desc_match:
+ errors.append(f"{display}: front-matter is missing required 'description' field")
+ elif not desc_match.group(1).strip().strip("\"'"):
+ errors.append(f"{display}: front-matter 'description' is empty")
+
+ return errors, title
+
+
+def is_excluded(path: str) -> bool:
+ parts = path.replace(os.sep, "/").split("/")
+ return any(part in EXCLUDED_DIRS for part in parts)
+
+
+def main() -> int:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ mdx_files = glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True)
+
+ checked = 0
+ all_errors: list[str] = []
+ titles: dict[str, list[str]] = {}
+ for path in sorted(mdx_files):
+ rel = os.path.relpath(path, root)
+ if is_excluded(rel):
+ continue
+ errors, title = check_file(path, rel)
+ all_errors.extend(errors)
+ if title:
+ titles.setdefault(title, []).append(rel)
+ checked += 1
+
+ # Two pages sharing a rendered title are indistinguishable in search
+ # results and to answer engines.
+ for title, pages in sorted(titles.items()):
+ if len(pages) > 1:
+ joined = ", ".join(sorted(pages))
+ all_errors.append(f'duplicate title "{title}" on {len(pages)} pages: {joined}')
+
+ if all_errors:
+ print("Frontmatter check FAILED:")
+ for err in all_errors:
+ print(f" {err}")
+ return 1
+
+ print(f"Frontmatter check PASSED ({checked} files checked).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_links.py b/scripts/check_links.py
new file mode 100644
index 000000000..cf7f7b58e
--- /dev/null
+++ b/scripts/check_links.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""
+CI gate: internal links in MDX files must resolve to an existing page.
+
+Rules:
+ - Only links whose path starts with '/' are checked (relative links are
+ ignored because they are rare and context-dependent).
+ - External URLs (http/https), anchor-only links (#…), and mailto links
+ are skipped.
+ - Query-string parameters and fragment anchors are stripped before
+ looking up the target file.
+ - A link target is considered valid when one of the following is true:
+ 1. /.mdx exists
+ 2. //index.mdx exists (index pages)
+ 3. is the source of a redirect declared in docs.json
+ (redirect sources are valid inbound paths even without a backing file)
+
+Source locations checked:
+ - href="…" attributes (JSX / HTML in MDX)
+ - [text](…) Markdown links
+
+Excluded directories:
+ - snippets/
+ - openapi/
+"""
+
+import json
+import os
+import re
+import sys
+import glob
+from urllib.parse import urlparse, unquote
+
+EXCLUDED_DIRS = {"snippets", "openapi"}
+
+# Path prefixes that point to static assets, not pages — skip these.
+NON_PAGE_PREFIXES = ("/images/", "/icons/", "/logo/", "/favicon")
+
+# href="..." or href='...'
+HREF_RE = re.compile(r"""href=["']([^"']+)["']""")
+# [label](url) – skip image links starting with !
+MD_LINK_RE = re.compile(r"(? str:
+ """Blank out fenced code blocks and inline code spans so that example
+ links inside them are not treated as real links. Line count is preserved
+ so reported line numbers stay accurate."""
+ out: list[str] = []
+ fence_len = 0
+ for line in content.splitlines():
+ stripped = line.strip()
+ if stripped.startswith("```"):
+ ticks = len(stripped) - len(stripped.lstrip("`"))
+ if fence_len:
+ if ticks >= fence_len and not stripped[ticks:].strip():
+ fence_len = 0
+ else:
+ fence_len = ticks
+ out.append("")
+ continue
+ out.append("" if fence_len else INLINE_CODE_RE.sub("", line))
+ return "\n".join(out)
+
+
+def extract_links(content: str) -> list[str]:
+ links: list[str] = []
+ for m in HREF_RE.finditer(content):
+ links.append(m.group(1))
+ for m in MD_LINK_RE.finditer(content):
+ links.append(m.group(1))
+ return links
+
+
+def is_internal(link: str) -> bool:
+ if link.startswith(("http://", "https://", "mailto:", "#")):
+ return False
+ if not link.startswith("/"):
+ return False
+ if link.startswith(NON_PAGE_PREFIXES):
+ return False
+ return True
+
+
+def normalise(link: str) -> str:
+ """Strip fragment and query-string, then decode percent-encoding."""
+ parsed = urlparse(link)
+ path = parsed.path
+ return unquote(path).rstrip("/")
+
+
+def build_valid_paths(root: str) -> tuple[set[str], list[str]]:
+ """Return (exact_paths, wildcard_prefixes) for valid root-relative paths.
+
+ exact_paths – full paths that must match exactly (O(1) lookup via set).
+ wildcard_prefixes – path prefixes derived from wildcard redirect sources
+ (e.g. '/changelogs' from '/changelogs/*'). A link
+ target is valid if it starts with one of these prefixes
+ followed by '/'.
+ """
+ exact: set[str] = set()
+ prefixes: list[str] = []
+
+ # Every .mdx file contributes its path (without extension) and with extension
+ for mdx in glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True):
+ rel = os.path.relpath(mdx, root).replace(os.sep, "/")
+ # e.g. "docs/what-is-mixpanel.mdx"
+ exact.add("/" + rel) # with .mdx
+ exact.add("/" + rel[:-4]) # without .mdx
+ # index pages: "docs/foo/index.mdx" → "/docs/foo"
+ if rel.endswith("/index.mdx"):
+ exact.add("/" + rel[: -len("/index.mdx")])
+
+ # Redirect sources are also valid inbound paths
+ docs_json = os.path.join(root, "docs.json")
+ if os.path.exists(docs_json):
+ with open(docs_json, encoding="utf-8") as fh:
+ data = json.load(fh)
+ for redir in data.get("redirects", []):
+ src = redir.get("source", "")
+ if src.endswith("/*"):
+ # Wildcard source: store the prefix for prefix matching
+ prefix = src[:-2] # e.g. "/changelogs"
+ exact.add(prefix)
+ prefixes.append(prefix)
+ else:
+ exact.add(src)
+
+ return exact, prefixes
+
+
+def is_excluded(path: str) -> bool:
+ parts = path.replace(os.sep, "/").split("/")
+ return any(part in EXCLUDED_DIRS for part in parts)
+
+
+def main() -> int:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ valid_paths, wildcard_prefixes = build_valid_paths(root)
+
+ mdx_files = glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True)
+
+ checked = 0
+ all_errors: list[str] = []
+
+ for path in sorted(mdx_files):
+ rel = os.path.relpath(path, root)
+ if is_excluded(rel):
+ continue
+ checked += 1
+
+ with open(path, encoding="utf-8") as fh:
+ content = strip_code(fh.read())
+
+ for raw_link in extract_links(content):
+ if not is_internal(raw_link):
+ continue
+ target = normalise(raw_link)
+ if not target:
+ continue
+
+ # Direct match (O(1))
+ if target in valid_paths:
+ continue
+
+ # Wildcard-redirect prefix match: check only the small list of
+ # known wildcard prefixes rather than iterating all valid_paths.
+ if any(target.startswith(p + "/") for p in wildcard_prefixes):
+ continue
+
+ all_errors.append(
+ f"{rel}: broken internal link '{raw_link}' → '{target}'"
+ )
+
+ if all_errors:
+ print("Links check FAILED:")
+ for err in all_errors:
+ print(f" {err}")
+ return 1
+
+ print(f"Links check PASSED ({checked} files, {len(valid_paths)} valid paths indexed).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_openapi.py b/scripts/check_openapi.py
new file mode 100644
index 000000000..d493fb5cb
--- /dev/null
+++ b/scripts/check_openapi.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""
+CI gate: validate every OpenAPI specification under openapi/.
+
+Checks performed:
+ 1. The file parses as YAML or JSON.
+ 2. Required top-level structure is present (openapi/swagger version, info
+ with title and version, and paths).
+ 3. Every local $ref ("#/...") resolves to a node that exists in the document.
+ 4. If openapi-spec-validator is installed, the full spec is validated against
+ the OpenAPI schema. Without it, checks 1-3 still run.
+"""
+
+import glob
+import json
+import os
+import sys
+
+import yaml
+
+
+def load(path: str):
+ with open(path, encoding="utf-8") as fh:
+ text = fh.read()
+ if path.endswith(".json"):
+ return json.loads(text)
+ return yaml.safe_load(text)
+
+
+def iter_refs(node, trail="#"):
+ """Yield every ($ref value, location) pair in the document."""
+ if isinstance(node, dict):
+ for key, value in node.items():
+ if key == "$ref" and isinstance(value, str):
+ yield value, trail
+ else:
+ yield from iter_refs(value, f"{trail}/{key}")
+ elif isinstance(node, list):
+ for index, value in enumerate(node):
+ yield from iter_refs(value, f"{trail}/{index}")
+
+
+def resolves(doc, ref: str) -> bool:
+ """Walk a local JSON pointer ("#/components/schemas/Foo") through the doc."""
+ node = doc
+ for part in ref.lstrip("#/").split("/"):
+ # JSON pointer escapes, per RFC 6901.
+ part = part.replace("~1", "/").replace("~0", "~")
+ if isinstance(node, list):
+ if not part.isdigit() or int(part) >= len(node):
+ return False
+ node = node[int(part)]
+ elif isinstance(node, dict):
+ if part not in node:
+ return False
+ node = node[part]
+ else:
+ return False
+ return True
+
+
+def check_file(path: str, display: str) -> list[str]:
+ errors = []
+ try:
+ doc = load(path)
+ except Exception as exc: # noqa: BLE001 - report any parse failure verbatim
+ return [f"{display}: does not parse ({type(exc).__name__}: {exc})"]
+
+ if not isinstance(doc, dict):
+ return [f"{display}: top level is not a mapping"]
+
+ if not (doc.get("openapi") or doc.get("swagger")):
+ errors.append(f"{display}: missing 'openapi' (or 'swagger') version field")
+
+ info = doc.get("info")
+ if not isinstance(info, dict):
+ errors.append(f"{display}: missing 'info' object")
+ else:
+ for field in ("title", "version"):
+ if not info.get(field):
+ errors.append(f"{display}: 'info.{field}' is missing or empty")
+
+ if "paths" not in doc and "webhooks" not in doc:
+ errors.append(f"{display}: missing 'paths'")
+
+ for ref, where in iter_refs(doc):
+ if ref.startswith("#"):
+ if not resolves(doc, ref):
+ errors.append(f"{display}: unresolved local $ref '{ref}' at {where}")
+ elif not ref.startswith(("http://", "https://")):
+ target = os.path.join(os.path.dirname(path), ref.split("#")[0])
+ if not os.path.exists(target):
+ errors.append(f"{display}: $ref points at a missing file '{ref}'")
+
+ return errors
+
+
+def main() -> int:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ spec_dir = os.path.join(root, "openapi")
+ if not os.path.isdir(spec_dir):
+ print("OpenAPI check SKIPPED (no openapi/ directory).")
+ return 0
+
+ specs = sorted(
+ glob.glob(os.path.join(spec_dir, "**", "*.yaml"), recursive=True)
+ + glob.glob(os.path.join(spec_dir, "**", "*.yml"), recursive=True)
+ + glob.glob(os.path.join(spec_dir, "**", "*.json"), recursive=True)
+ )
+
+ try:
+ from openapi_spec_validator import validate as spec_validate
+
+ deep = True
+ except ImportError:
+ spec_validate = None
+ deep = False
+
+ all_errors: list[str] = []
+ for path in specs:
+ rel = os.path.relpath(path, root)
+ errors = check_file(path, rel)
+ if not errors and spec_validate is not None:
+ try:
+ spec_validate(load(path))
+ except Exception as exc: # noqa: BLE001 - surface the validator's message
+ first = str(exc).split("\n")[0]
+ errors.append(f"{rel}: failed OpenAPI schema validation: {first}")
+ all_errors.extend(errors)
+
+ if all_errors:
+ print("OpenAPI check FAILED:")
+ for err in all_errors:
+ print(f" {err}")
+ return 1
+
+ depth = "structure + schema" if deep else "structure only (validator not installed)"
+ print(f"OpenAPI check PASSED ({len(specs)} specs validated, {depth}).")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/check_redirects.py b/scripts/check_redirects.py
new file mode 100644
index 000000000..b5a0b461f
--- /dev/null
+++ b/scripts/check_redirects.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+"""
+CI gate: validate the 'redirects' section of docs.json.
+
+Checks performed:
+ 1. No duplicate redirect source paths.
+ 2. No redirect loops (a self-redirect or a cycle).
+ 3. Every redirect destination resolves to an existing page OR is itself
+ the source of another redirect, including a wildcard one (chained
+ redirects are allowed).
+ Wildcard destinations (containing '*') are skipped because their
+ validity is structural rather than path-based.
+"""
+
+import fnmatch
+import json
+import os
+import sys
+import glob
+
+
+def build_file_paths(root: str) -> set[str]:
+ """Return all root-relative page paths derived from .mdx files."""
+ paths: set[str] = set()
+ for mdx in glob.glob(os.path.join(root, "**", "*.mdx"), recursive=True):
+ rel = os.path.relpath(mdx, root).replace(os.sep, "/")
+ paths.add("/" + rel) # with extension
+ paths.add("/" + rel[:-4]) # without extension
+ if rel.endswith("/index.mdx"):
+ paths.add("/" + rel[: -len("/index.mdx")])
+ return paths
+
+
+def normalise(path: str) -> str:
+ """Strip trailing slash and query string."""
+ return path.split("?")[0].rstrip("/") or "/"
+
+
+def main() -> int:
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ docs_json_path = os.path.join(root, "docs.json")
+
+ if not os.path.exists(docs_json_path):
+ print("Redirects check SKIPPED (docs.json not found).")
+ return 0
+
+ with open(docs_json_path, encoding="utf-8") as fh:
+ data = json.load(fh)
+
+ redirects = data.get("redirects", [])
+ all_errors: list[str] = []
+
+ # ── 1. Duplicate sources ────────────────────────────────────────────────
+ sources: list[str] = [r.get("source", "") for r in redirects]
+ seen: set[str] = set()
+ duplicates: set[str] = set()
+ for src in sources:
+ if src in seen:
+ duplicates.add(src)
+ seen.add(src)
+
+ for dup in sorted(duplicates):
+ all_errors.append(f"docs.json: duplicate redirect source '{dup}'")
+
+ # ── 2. Redirect loops (self-redirects and cycles) ───────────────────────
+ dest_by_source = {r.get("source", ""): r.get("destination", "") for r in redirects}
+ reported_loops: set[str] = set()
+ for start in dest_by_source:
+ hops = {start}
+ node = dest_by_source[start]
+ while node in dest_by_source:
+ if node in hops:
+ if node not in reported_loops:
+ reported_loops.add(node)
+ all_errors.append(
+ f"docs.json: redirect loop starting at '{start}' revisits '{node}'"
+ )
+ break
+ hops.add(node)
+ node = dest_by_source[node]
+
+ # ── 3. Destinations resolve to a known page or another redirect source ──
+ file_paths = build_file_paths(root)
+ exact_sources = {s for s in sources if "*" not in s}
+ wildcard_sources = [s for s in sources if "*" in s]
+
+ def resolves(path: str) -> bool:
+ """A destination is valid if it is a real page, an exact redirect
+ source, or matched by a wildcard redirect source. The last case is a
+ chained redirect, which the CDN follows to a final landing page."""
+ if path in file_paths or path in exact_sources:
+ return True
+ return any(fnmatch.fnmatch(path, pat) for pat in wildcard_sources)
+
+ for redir in redirects:
+ dest = redir.get("destination", "")
+ if not dest:
+ all_errors.append(
+ f"docs.json: redirect from '{redir.get('source')}' has an empty destination"
+ )
+ continue
+
+ # Skip wildcards / external URLs – structural validity only
+ if "*" in dest or dest.startswith("http"):
+ continue
+
+ # Strip anchors and query strings from destination
+ dest_path = normalise(dest.split("#")[0])
+
+ if not resolves(dest_path):
+ all_errors.append(
+ f"docs.json: redirect destination '{dest}' does not resolve "
+ f"to a known page (source: '{redir.get('source')}')"
+ )
+
+ if all_errors:
+ print("Redirects check FAILED:")
+ for err in all_errors:
+ print(f" {err}")
+ return 1
+
+ print(
+ f"Redirects check PASSED ({len(redirects)} redirects validated, "
+ f"{len(file_paths)} pages indexed)."
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())