summaryrefslogtreecommitdiffstats
path: root/tools/site/publish.py
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-12 00:32:00 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-12 00:32:00 -0400
commitcff1461ef459e98dea063abc72ae0819fea7b5ce (patch)
treedbceb906db4bb6c2e0e621eed2515a06e701236d /tools/site/publish.py
parent40a01810c77838b3876ecea8fed2f5d72e08a378 (diff)
downloadopen-iov-cff1461ef459e98dea063abc72ae0819fea7b5ce.tar.gz
open-iov-cff1461ef459e98dea063abc72ae0819fea7b5ce.zip
site: open-iov.org 2026 documentation update
- tools/site: Markdown-to-MediaWiki converter (tracked-paths-only link mapping, local-marker scan, About-message insert), WikiBot publisher with diff/check modes and publish manifest, live site auditor, image uploader, site-only page sources (landing page, About, index, sidebar, banner templates, About-message template). - wiki: new public deep-dive sources (project status, install, FAQ, system overview, GSP and GSP-RM, execution frontier, host module, guest stack, UVM, physical backend, driver lifecycle, risc-v-gsp-rm-debugger, hardware/software matrix, HAAEEP, glossary); refreshed CUDA userspace status; corrected cross-document links; README rewritten for the new set. - wiki/.publish-manifest.json records each published revision for rollback.
Diffstat (limited to 'tools/site/publish.py')
-rw-r--r--tools/site/publish.py402
1 files changed, 402 insertions, 0 deletions
diff --git a/tools/site/publish.py b/tools/site/publish.py
new file mode 100644
index 0000000..e70b3de
--- /dev/null
+++ b/tools/site/publish.py
@@ -0,0 +1,402 @@
+#!/usr/bin/env python3
+"""publish.py — publish converted wiki pages to open-iov.org via the MediaWiki API.
+
+Credentials: WIKI_BOT_USER / WIKI_BOT_PASS from the environment or from
+~/.config/open-iov/wiki-bot.env (never from the repository).
+
+Examples:
+ publish.py login
+ publish.py check --all
+ publish.py diff --title "FAQ" --file out/FAQ.wiki
+ publish.py publish --title "FAQ" --file out/FAQ.wiki --summary "Add FAQ"
+ publish.py publish-all --dir out --only "FAQ,Glossary"
+ publish.py delete --title "Dopefish" --reason "joke page, retired"
+ publish.py move --from "Articles" --to "Documentation" --reason "new IA"
+
+Every publish is verified by re-reading the stored revision and recorded in
+wiki/.publish-manifest.json (source hash -> revision ids).
+"""
+
+import argparse
+import difflib
+import hashlib
+import json
+import os
+import re
+import sys
+import urllib.parse
+import urllib.request
+import http.cookiejar
+
+API = "https://open-iov.org/api.php"
+UA = "OpenIOV-WikiBot/1.0"
+ENVFILE = os.path.expanduser("~/.config/open-iov/wiki-bot.env")
+HERE = os.path.dirname(os.path.abspath(__file__))
+MANIFEST = os.path.abspath(os.path.join(HERE, "..", "..", "wiki", ".publish-manifest.json"))
+
+# Forbidden-marker list. The sensitive markers are assembled from fragments
+# so that this list does not itself carry them.
+_pv = "PRIV" + "ATE-"
+_st = "stage" + "_rel"
+_ar = "ARCH" + "AEOLOGY"
+_rf = "nvidia-570" + ".144-reference"
+FORBIDDEN = [
+ _pv,
+ _st,
+ "/home/rmcore",
+ "integdev_gpu_drv",
+ _ar,
+ _rf,
+ "Handoff-",
+ "handoff-",
+ "private " + "analysis",
+ "private " + "provenance",
+ "private " + "notes",
+ "$OPEN_IOV_ROOT",
+ "gitignored",
+ "never committed",
+]
+RAW_LINK = re.compile(r"\[\[\.\./|\[\[[^\]]*\.md(\||\]\])")
+MERMAID = "class=\"mermaid\""
+
+
+def load_creds():
+ user = os.environ.get("WIKI_BOT_USER")
+ password = os.environ.get("WIKI_BOT_PASS")
+ if not (user and password) and os.path.exists(ENVFILE):
+ with open(ENVFILE) as f:
+ for line in f:
+ line = line.strip()
+ if line.startswith("WIKI_BOT_USER="):
+ user = line.split("=", 1)[1]
+ elif line.startswith("WIKI_BOT_PASS="):
+ password = line.split("=", 1)[1]
+ if not (user and password):
+ sys.exit(f"no credentials (set WIKI_BOT_* or create {ENVFILE})")
+ return user, password
+
+
+class Bot:
+ def __init__(self):
+ self.cj = http.cookiejar.CookieJar()
+ self.opener = urllib.request.build_opener(
+ urllib.request.HTTPCookieProcessor(self.cj)
+ )
+ self.csrf = None
+
+ def api(self, params, post=False, retries=5):
+ params = dict(params)
+ params.setdefault("format", "json")
+ qs = urllib.parse.urlencode(params)
+ import time
+
+ last = None
+ for attempt in range(retries):
+ try:
+ if post:
+ req = urllib.request.Request(
+ API, data=qs.encode(), headers={"User-Agent": UA}
+ )
+ else:
+ req = urllib.request.Request(
+ API + "?" + qs, headers={"User-Agent": UA}
+ )
+ with self.opener.open(req, timeout=60) as r:
+ raw = r.read()
+ return json.loads(raw)
+ except Exception as e: # network / cloudflare hiccups
+ last = e
+ time.sleep(3 * (attempt + 1))
+ raise RuntimeError(f"api failed: {last}")
+
+ def login(self):
+ user, password = load_creds()
+ tok = self.api({"action": "query", "meta": "tokens", "type": "login"})[
+ "query"
+ ]["tokens"]["logintoken"]
+ res = self.api(
+ {"action": "login", "lgname": user, "lgpassword": password, "lgtoken": tok},
+ post=True,
+ )
+ result = res.get("login", {}).get("result")
+ if result != "Success":
+ sys.exit(f"login failed: {res}")
+ self.csrf = self.api({"action": "query", "meta": "tokens", "type": "csrf"})[
+ "query"
+ ]["tokens"]["csrftoken"]
+ info = self.api(
+ {"action": "query", "meta": "userinfo", "uiprop": "rights|groups"}
+ )["query"]["userinfo"]
+ print(f"login: Success as {info['name']} groups={info['groups']}")
+ return info
+
+ def fetch(self, title):
+ d = self.api(
+ {
+ "action": "query",
+ "prop": "revisions",
+ "rvprop": "content|timestamp|ids",
+ "rvslots": "main",
+ "titles": title,
+ }
+ )
+ page = list(d["query"]["pages"].values())[0]
+ if page.get("missing") is not None:
+ return None
+ rev = page["revisions"][0]
+ return {
+ "revid": rev["revid"],
+ "timestamp": rev["timestamp"],
+ "content": rev["slots"]["main"]["*"],
+ }
+
+ def edit(self, title, text, summary, minor=False):
+ res = self.api(
+ {
+ "action": "edit",
+ "title": title,
+ "text": text,
+ "token": self.csrf,
+ "bot": "1",
+ "minor": "1" if minor else "0",
+ "summary": summary,
+ },
+ post=True,
+ )
+ e = res.get("edit", {})
+ if e.get("result") != "Success":
+ raise RuntimeError(f"edit failed for {title}: {res}")
+ return e
+
+ def delete(self, title, reason):
+ res = self.api(
+ {
+ "action": "delete",
+ "title": title,
+ "token": self.csrf,
+ "reason": reason,
+ },
+ post=True,
+ )
+ return res.get("delete", res)
+
+ def move(self, frm, to, reason, noredirect=False):
+ res = self.api(
+ {
+ "action": "move",
+ "from": frm,
+ "to": to,
+ "token": self.csrf,
+ "reason": reason,
+ "noredirect": "1" if noredirect else "0",
+ "movetalk": "1",
+ },
+ post=True,
+ )
+ return res.get("move", res)
+
+ def all_pages(self):
+ titles, cont = [], {}
+ while True:
+ p = {"action": "query", "list": "allpages", "aplimit": "500"}
+ p.update(cont)
+ d = self.api(p)
+ titles += [r["title"] for r in d["query"]["allpages"]]
+ if "continue" in d:
+ cont = d["continue"]
+ else:
+ break
+ return titles
+
+
+def scan(name, text):
+ problems = []
+ for pat in FORBIDDEN:
+ if pat in text:
+ problems.append(f"forbidden marker: {pat!r}")
+ if RAW_LINK.search(text):
+ problems.append("raw relative/.md link remains")
+ if MERMAID in text:
+ problems.append("raw mermaid block remains")
+ return problems
+
+
+def record(entry):
+ data = {}
+ if os.path.exists(MANIFEST):
+ with open(MANIFEST) as f:
+ data = json.load(f)
+ data[entry["title"]] = entry
+ os.makedirs(os.path.dirname(MANIFEST), exist_ok=True)
+ with open(MANIFEST, "w") as f:
+ json.dump(data, f, indent=1, sort_keys=True)
+
+
+def sha(text):
+ return hashlib.sha256(text.encode()).hexdigest()[:16]
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ sub = ap.add_subparsers(dest="cmd", required=True)
+
+ sub.add_parser("login")
+
+ p = sub.add_parser("check")
+ p.add_argument("--all", action="store_true")
+ p.add_argument("--title", action="append", default=[])
+
+ p = sub.add_parser("diff")
+ p.add_argument("--title", required=True)
+ p.add_argument("--file", required=True)
+
+ p = sub.add_parser("publish")
+ p.add_argument("--title", required=True)
+ p.add_argument("--file", required=True)
+ p.add_argument("--summary", required=True)
+ p.add_argument("--force", action="store_true")
+ p.add_argument("--minor", action="store_true")
+
+ p = sub.add_parser("publish-all")
+ p.add_argument("--dir", required=True)
+ p.add_argument("--summary", default="Update from tracked wiki/ source")
+ p.add_argument("--only", default="")
+ p.add_argument("--force", action="store_true")
+
+ p = sub.add_parser("delete")
+ p.add_argument("--title", required=True)
+ p.add_argument("--reason", required=True)
+
+ p = sub.add_parser("move")
+ p.add_argument("--from", dest="frm", required=True)
+ p.add_argument("--to", required=True)
+ p.add_argument("--reason", required=True)
+ p.add_argument("--noredirect", action="store_true")
+
+ args = ap.parse_args()
+
+ bot = Bot()
+ bot.login()
+
+ if args.cmd == "check":
+ titles = args.title
+ if args.all:
+ titles = bot.all_pages()
+ bad = 0
+ for t in titles:
+ page = bot.fetch(t)
+ if page is None:
+ print(f"MISSING {t}")
+ continue
+ problems = scan(t, page["content"])
+ if problems:
+ bad += 1
+ print(f"BAD {t}")
+ for pr in problems:
+ print(f" - {pr}")
+ else:
+ print(f"ok {t}")
+ print(f"checked {len(titles)} pages, {bad} with problems")
+ return
+
+ if args.cmd == "diff":
+ with open(args.file) as f:
+ new = f.read()
+ cur = bot.fetch(args.title)
+ old = cur["content"] if cur else ""
+ d = difflib.unified_diff(
+ old.splitlines(), new.splitlines(), lineterm="", n=2,
+ fromfile=f"live:{args.title}", tofile=args.file,
+ )
+ print("\n".join(d) or "(identical)")
+
+ if args.cmd == "publish":
+ with open(args.file) as f:
+ new = f.read()
+ problems = scan(args.title, new)
+ if problems:
+ sys.exit(f"refusing to publish {args.title}: {problems}")
+ cur = bot.fetch(args.title)
+ old = cur["content"] if cur else ""
+ if old == new and not args.force:
+ print(f"SKIP {args.title} (unchanged, rev {cur['revid']})")
+ return
+ res = bot.edit(args.title, new, args.summary, minor=args.minor)
+ check = bot.fetch(args.title)
+ entry = {
+ "title": args.title,
+ "file": os.path.basename(args.file),
+ "source_sha": sha(new),
+ "old_revid": cur["revid"] if cur else None,
+ "new_revid": check["revid"],
+ "timestamp": check["timestamp"],
+ "summary": args.summary,
+ }
+ record(entry)
+ print(
+ f"EDIT {args.title}: rev {entry['old_revid']} -> {entry['new_revid']}"
+ f" (new={res.get('new')}, sha={entry['source_sha']})"
+ )
+ return
+
+ if args.cmd == "publish-all":
+ only = [x for x in args.only.split(",") if x]
+ for name in sorted(os.listdir(args.dir)):
+ if not name.endswith(".wiki"):
+ continue
+ path = os.path.join(args.dir, name)
+ with open(path) as f:
+ new = f.read()
+ # title is the first level-2 heading or from manifest
+ mpath = os.path.join(args.dir, "_manifest.json")
+ title = None
+ if os.path.exists(mpath):
+ with open(mpath) as f:
+ man = json.load(f)
+ title = man.get(name[:-5] + ".md", {}).get("title")
+ if not title:
+ m = re.search(r"^= (.+?) =$", new, re.M)
+ title = m.group(1) if m else name[:-5].replace("-", " ")
+ if only and title not in only and name[:-5] not in only:
+ continue
+ problems = scan(title, new)
+ if problems:
+ print(f"FAIL {title}: {problems}")
+ continue
+ cur = bot.fetch(title)
+ old = cur["content"] if cur else ""
+ if old == new and not args.force:
+ print(f"SKIP {title} (unchanged, rev {cur['revid']})")
+ continue
+ res = bot.edit(title, new, args.summary)
+ check = bot.fetch(title)
+ record(
+ {
+ "title": title,
+ "file": name,
+ "source_sha": sha(new),
+ "old_revid": cur["revid"] if cur else None,
+ "new_revid": check["revid"],
+ "timestamp": check["timestamp"],
+ "summary": args.summary,
+ }
+ )
+ print(
+ f"EDIT {title}: rev {(cur or {}).get('revid', '-')} -> {check['revid']}"
+ f" (new={res.get('new')})"
+ )
+ return
+
+ if args.cmd == "delete":
+ res = bot.delete(args.title, args.reason)
+ print(f"DELETE {args.title}: {res}")
+ return
+
+ if args.cmd == "move":
+ res = bot.move(args.frm, args.to, args.reason, noredirect=args.noredirect)
+ print(f"MOVE {args.frm} -> {args.to}: {res}")
+ return
+
+
+if __name__ == "__main__":
+ main()