summaryrefslogtreecommitdiff
path: root/scripts/docs_gates/gates.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/docs_gates/gates.py')
-rw-r--r--scripts/docs_gates/gates.py96
1 files changed, 96 insertions, 0 deletions
diff --git a/scripts/docs_gates/gates.py b/scripts/docs_gates/gates.py
new file mode 100644
index 00000000..7b5cbf1d
--- /dev/null
+++ b/scripts/docs_gates/gates.py
@@ -0,0 +1,96 @@
+"""Deploy-blocking sanity gates (spec §7.1).
+
+Gates: file-count vs plan cap (<= 80% of 100k), per-file < 25 MiB, index.html +
+critical-page presence, page-count delta vs previous deploy, canonical URLs must
+start with the https://docs.vyos.io/en/<slug>/ prefix, declared PDF present,
+Pagefind non-empty.
+Exit 0 = deployable; exit 1 = blocked (one line per failed gate on stderr).
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+FILE_CAP = 100_000
+CAP_FRACTION = 0.8
+MAX_FILE = 25 * 1024 * 1024
+COUNT_DELTA_MIN_RATIO = 0.5 # new build must have >= 50% of previous page count
+CANONICAL_RE = re.compile(r'<link\s+rel="canonical"\s+href="([^"]+)"')
+
+
+def _fail(msgs: list[str], msg: str) -> None:
+ msgs.append(msg)
+ print(f"GATE-FAIL: {msg}", file=sys.stderr)
+
+
+def run(artifact: Path, slug: str, versions: Path, previous_meta: Path | None,
+ critical: list[str]) -> int:
+ fails: list[str] = []
+ root = artifact / "en" / slug
+ manifest = json.loads(versions.read_text())
+ entry = next((v for v in manifest["versions"] if v["slug"] == slug), None)
+ if entry is None:
+ _fail(fails, f"slug {slug} not in versions.json")
+ return 1
+
+ files = [p for p in artifact.rglob("*") if p.is_file()]
+ if len(files) > FILE_CAP * CAP_FRACTION:
+ _fail(fails, f"file count {len(files)} > 80% of {FILE_CAP} cap")
+ for p in files:
+ if p.stat().st_size > MAX_FILE:
+ _fail(fails, f"{p.relative_to(artifact)} exceeds 25 MiB")
+
+ for rel in ["index.html", *critical]:
+ if not (root / rel).is_file():
+ _fail(fails, f"critical page missing: en/{slug}/{rel}")
+
+ pagefind = root / "pagefind"
+ if not pagefind.is_dir() or not any(pagefind.iterdir()):
+ _fail(fails, "Pagefind index missing or empty")
+
+ if entry.get("pdf"):
+ expected = artifact / entry["pdf"].lstrip("/")
+ if not expected.is_file():
+ _fail(fails, f"declared PDF missing: {entry['pdf']}")
+
+ pages = [p for p in root.rglob("*.html")]
+ if previous_meta is not None and previous_meta.is_file():
+ prev = json.loads(previous_meta.read_text())
+ if prev.get("page_count") and len(pages) < prev["page_count"] * COUNT_DELTA_MIN_RATIO:
+ _fail(fails, f"page count collapsed: {len(pages)} vs previous {prev['page_count']}")
+
+ want = f"https://docs.vyos.io/en/{slug}/"
+ for p in pages:
+ m = CANONICAL_RE.search(p.read_text(errors="ignore"))
+ if m is None:
+ _fail(fails, f"missing canonical link in en/{slug}/{p.relative_to(root)}")
+ break # one example is enough to block
+ if not m.group(1).startswith(want):
+ _fail(fails, f"bad canonical in en/{slug}/{p.relative_to(root)}: {m.group(1)}")
+ break # one example is enough to block
+
+ return 1 if fails else 0
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--artifact", type=Path, required=True)
+ ap.add_argument("--slug", required=True)
+ ap.add_argument("--versions", type=Path, required=True)
+ ap.add_argument("--previous-meta", type=Path, default=None)
+ ap.add_argument("--critical-list", type=Path,
+ default=Path("scripts/docs_gates/critical-pages.txt"))
+ a = ap.parse_args()
+ # Strip BEFORE the comment test: an indented " # note" line is a comment, not a page
+ # every deployable build must contain — the unstripped test turned it into a live entry,
+ # and a comment can never exist as a file, so it would block the deploy as a missing page.
+ lines = (line.strip() for line in a.critical_list.read_text().splitlines())
+ critical = [line for line in lines if line and not line.startswith("#")]
+ return run(a.artifact, a.slug, a.versions, a.previous_meta, critical)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())