mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 19:35:27 +08:00
ci: sync landing docs submodule on main updates
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user