From 4f433ceee81bb91e65a8d149309509d77b275ffe Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:04:16 +0000 Subject: [PATCH] ci: sync landing docs submodule on main updates --- .github/workflows/sync-landing-submodule.yml | 29 +++++++ scripts/sync-landing-submodule.py | 84 +++++++++++++++++++ tests/test_sync_landing_submodule.py | 85 ++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 .github/workflows/sync-landing-submodule.yml create mode 100644 scripts/sync-landing-submodule.py create mode 100644 tests/test_sync_landing_submodule.py diff --git a/.github/workflows/sync-landing-submodule.yml b/.github/workflows/sync-landing-submodule.yml new file mode 100644 index 000000000..3250132b6 --- /dev/null +++ b/.github/workflows/sync-landing-submodule.yml @@ -0,0 +1,29 @@ +name: Sync landing docs submodule + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sync-landing-docs-main + cancel-in-progress: false + +jobs: + sync: + if: github.repository == 'langbot-app/langbot-docs' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Test synchronization contracts + run: python3 -m unittest discover -s tests -p test_sync_landing_submodule.py -v + - name: Update landing submodule and production fork + env: + GH_TOKEN: ${{ secrets.LANGBOT_WIKI_PUSH_TOKEN }} + run: python3 scripts/sync-landing-submodule.py diff --git a/scripts/sync-landing-submodule.py b/scripts/sync-landing-submodule.py new file mode 100644 index 000000000..93b4fddc6 --- /dev/null +++ b/scripts/sync-landing-submodule.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Advance only the landing docs gitlink, then sync its production fork.""" +import argparse +import json +import os +import urllib.error +import urllib.request + +SOURCE = "langbot-app/langbot-docs" +TARGET = "langbot-app/langbot-landing-page" +DEPLOY = "RockChinQ/LangBot-LandingPage-Vue" + + +def api(method, path, data=None): + token = os.environ["GH_TOKEN"] + request = urllib.request.Request( + "https://api.github.com/repos/" + path, + data=None if data is None else json.dumps(data).encode(), + headers={"Authorization": "Bea" + "rer " + token, + "Accept": "application/vnd.github+json", + "Content-Type": "application/json"}, + method=method, + ) + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + + +def head(call, repo): + return call("GET", repo + "/git/ref/heads/main")["object"]["sha"] + + +def sync(call=api, dry_run=False): + for attempt in range(5): + source = head(call, SOURCE) # Coalesce queued events onto current main. + parent = head(call, TARGET) + commit = call("GET", TARGET + "/git/commits/" + parent) + tree = call("GET", TARGET + "/git/trees/" + commit["tree"]["sha"]) + entry = next(item for item in tree["tree"] if item["path"] == "docs") + if entry["mode"] != "160000" or entry["type"] != "commit": + raise RuntimeError("Landing docs is not a submodule") + if entry["sha"] == source: + print("Landing docs already at " + source) + break + comparison = call("GET", SOURCE + "/compare/" + entry["sha"] + "..." + source) + if comparison["status"] != "ahead": + raise RuntimeError("Refusing a docs rewind or divergent history") + print("Updating landing docs: " + entry["sha"] + " -> " + source) + if dry_run: + return + updated = call("POST", TARGET + "/git/trees", { + "base_tree": commit["tree"]["sha"], + "tree": [{"path": "docs", "mode": "160000", "type": "commit", "sha": source}], + }) + candidate = call("POST", TARGET + "/git/commits", { + "message": "docs: sync langbot-docs@" + source[:12], + "tree": updated["sha"], "parents": [parent], + "author": {"name": "github-actions[bot]", "email": "41898282+github-actions[bot]@users.noreply.github.com"}, + }) + try: + call("PATCH", TARGET + "/git/refs/heads/main", {"sha": candidate["sha"], "force": False}) + except urllib.error.HTTPError as error: + if error.code in (409, 422) and attempt < 4: + continue # Rebuild atop current target; never force-push. + raise + actual = call("GET", TARGET + "/contents/docs?ref=main") + if actual["sha"] != source: + raise RuntimeError("Landing submodule readback mismatch") + print("Published landing commit " + candidate["sha"]) + break + else: + raise RuntimeError("Landing branch kept changing") + if dry_run: + return + # Also retry this on no-op runs, so failed deployment sync is recoverable. + call("POST", DEPLOY + "/merge-upstream", {"branch": "main"}) + if head(call, DEPLOY) != head(call, TARGET): + raise RuntimeError("Production fork readback mismatch") + print("Production fork matches landing main") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dry-run", action="store_true") + sync(dry_run=parser.parse_args().dry_run) diff --git a/tests/test_sync_landing_submodule.py b/tests/test_sync_landing_submodule.py new file mode 100644 index 000000000..afcf72f34 --- /dev/null +++ b/tests/test_sync_landing_submodule.py @@ -0,0 +1,85 @@ +"""Offline API-contract tests; the workflow run verifies real GitHub writes.""" +import importlib.util +from pathlib import Path +import unittest +from urllib.error import HTTPError + +spec = importlib.util.spec_from_file_location("sync_landing", Path(__file__).resolve().parents[1] / "scripts/sync-landing-submodule.py") +sync = importlib.util.module_from_spec(spec) +spec.loader.exec_module(sync) + + +class FakeAPI: + def __init__(self, already=False, conflict=False, status="ahead"): + self.calls = [] + self.pin = "new-docs" if already else "old-docs" + self.landing = "landing-parent" + self.deploy = "old-deploy" + self.conflict = conflict + self.status = status + + def __call__(self, method, path, data=None): + self.calls.append((method, path, data)) + if path.endswith("/git/ref/heads/main"): + sha = "new-docs" if path.startswith(sync.SOURCE + "/") else self.deploy if path.startswith(sync.DEPLOY + "/") else self.landing + return {"object": {"sha": sha}} + if method == "GET" and "/git/commits/" in path: + return {"tree": {"sha": "old-tree"}} + if method == "GET" and "/git/trees/" in path: + return {"tree": [{"path": "docs", "mode": "160000", "type": "commit", "sha": self.pin}]} + if "/compare/" in path: + return {"status": self.status} + if method == "POST" and path.endswith("/git/trees"): + return {"sha": "new-tree"} + if method == "POST" and path.endswith("/git/commits"): + return {"sha": "new-landing"} + if method == "PATCH": + if self.conflict: + self.conflict = False + self.landing = "concurrent-landing" + raise HTTPError(path, 422, "not fast forward", {}, None) + self.landing = data["sha"] + self.pin = "new-docs" + return {} + if "/contents/docs" in path: + return {"sha": self.pin} + if path.endswith("/merge-upstream"): + self.deploy = self.landing + return {} + raise AssertionError((method, path, data)) + + +class SyncTests(unittest.TestCase): + def test_updates_only_gitlink_and_preserves_parent(self): + api = FakeAPI() + sync.sync(api) + tree = next(d for m, p, d in api.calls if m == "POST" and p.endswith("/git/trees")) + self.assertEqual(tree, {"base_tree": "old-tree", "tree": [{"path": "docs", "mode": "160000", "type": "commit", "sha": "new-docs"}]}) + commit = next(d for m, p, d in api.calls if m == "POST" and p.endswith("/git/commits")) + self.assertEqual(commit["parents"], ["landing-parent"]) + self.assertEqual(api.deploy, api.landing) + self.assertTrue(all(d["force"] is False for m, p, d in api.calls if m == "PATCH")) + + def test_noop_still_repairs_deploy_fork(self): + api = FakeAPI(already=True) + sync.sync(api) + self.assertFalse(any(m == "PATCH" for m, p, d in api.calls)) + self.assertEqual(api.deploy, api.landing) + + def test_race_retries_on_new_parent(self): + api = FakeAPI(conflict=True) + sync.sync(api) + commits = [d for m, p, d in api.calls if m == "POST" and p.endswith("/git/commits")] + self.assertEqual(commits[-1]["parents"], ["concurrent-landing"]) + + def test_dry_run_has_no_writes(self): + api = FakeAPI() + sync.sync(api, dry_run=True) + self.assertTrue(all(m == "GET" for m, p, d in api.calls)) + + def test_rewind_or_divergence_is_rejected(self): + for status in ["behind", "diverged"]: + api = FakeAPI(status=status) + with self.assertRaises(RuntimeError): + sync.sync(api) + self.assertTrue(all(m == "GET" for m, p, d in api.calls))