From 7cb95e94724e58592220f8b49c82fb506be49ada Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 03:31:55 +0000 Subject: [PATCH 1/3] sync: a copy over SSH with a deploy key, host keys pinned A GitHub deploy key writes to one repo and nothing else, and GitHub's API can make one (a fine-grained token needs a person in the browser). The key is written 0600 in the run's temp folder; github.com's published host keys are built in and other hosts need theirs in known_hosts, so nothing is trusted on first use. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD --- .github/time/sync.py | 49 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/time/sync.py b/.github/time/sync.py index 689a4f8..78093a6 100644 --- a/.github/time/sync.py +++ b/.github/time/sync.py @@ -20,7 +20,9 @@ time/sync.json: "token": "GITHUB_MIRROR_TOKEN", "import": ["claude/*"]}]}]} `token` names a secret (an environment variable the workflow passes), never the token itself; a copy whose token isn't -set is skipped with a note. The same script syncs Gitea with Gitea: it runs on the home, so a pair the home can't +set is skipped with a note. Or `ssh_key` names a secret holding an SSH private key (a GitHub deploy key: write access +to that one repo and nothing else) for an `ssh://` or `git@host:` URL; the host's keys are pinned (github.com's are +built in, others go in `known_hosts`), never learned on first use. The same script syncs Gitea with Gitea: it runs on the home, so a pair the home can't reach (hb-5's local Gitea from prod) runs from the side that can reach both, with that side's own sync.json. """ from __future__ import annotations @@ -42,6 +44,11 @@ tm = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(tm) CONFIG = "time/sync.json" SECRETS: list[str] = [] # every token used this run, scrubbed from anything printed or posted +# github.com's published SSH host keys (api.github.com/meta), pinned so a copy over SSH never trusts a key on first use +HOST_KEYS = {"github.com": ["github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl", + "github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxk" + "ZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg="]} +ENV: dict[str, str] = {} # extra environment for the git calls to the copy being synced (its SSH command) def scrub(text: str) -> str: @@ -73,9 +80,31 @@ def authed(url: str, token: str, user: str = "x-access-token") -> str: return urllib.parse.urlunsplit((p.scheme, f"{cred}@{p.netloc}", p.path, p.query, p.fragment)) +def ssh_host(url: str) -> str: + """The host of an ssh:// or scp-style (git@host:path) URL; '' for anything else.""" + if url.startswith("ssh://"): + return (urllib.parse.urlsplit(url).hostname or "") + if "://" not in url and "@" in url and ":" in url.split("@", 1)[1]: + return url.split("@", 1)[1].split(":", 1)[0] + return "" + + +def ssh_env(key: str, host: str, known: list[str] | None, folder: str) -> dict[str, str]: + """GIT_SSH_COMMAND for one copy: its deploy key (written 0600 in the run's temp folder) and the host's pinned keys.""" + keys = known or HOST_KEYS.get(host) + if not keys: + raise RuntimeError(f"no pinned host key for {host}: put the host's keys in the copy's known_hosts") + kf, hf = Path(folder, "sync_key"), Path(folder, "known_hosts") + kf.write_text(key.strip() + "\n") + kf.chmod(0o600) + hf.write_text("\n".join(keys) + "\n") + return {"GIT_SSH_COMMAND": f"ssh -i {kf} -o IdentitiesOnly=yes -o UserKnownHostsFile={hf} -o StrictHostKeyChecking=yes " + f"-o BatchMode=yes"} + + def git(*args: str, cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess: p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=300, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}) + env={**os.environ, "GIT_TERMINAL_PROMPT": "0", **ENV}) if check and p.returncode: raise RuntimeError(f"git {args[0]} failed: {scrub(p.stderr.strip())[-400:]}") return p @@ -136,13 +165,20 @@ def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict: git("fetch", "-q", home_url, *[f"+refs/heads/{b}:refs/home/{b}" for b in wanted], cwd=work) for c in entry.get("copies") or []: name, where = str(c.get("name") or c.get("url")), str(c.get("url") or "") - token = os.environ.get(str(c.get("token") or ""), "") if c.get("token") else "" + secret = str(c.get("ssh_key") or c.get("token") or "") + token = os.environ.get(secret, "") if secret else "" r: dict = {"copy": name, "out": {}, "imported": [], "prs": [], "note": ""} report["copies"].append(r) - if c.get("token") and not token: - r["note"] = f"skipped: the secret {c['token']} isn't set" + if secret and not token: + r["note"] = f"skipped: the secret {secret} isn't set" continue - url = authed(where, token, str(c.get("user") or "x-access-token")) + ENV.clear() + if c.get("ssh_key"): + SECRETS.append(token) + ENV.update(ssh_env(token, ssh_host(where), c.get("known_hosts"), work)) + url = where + else: + url = authed(where, token, str(c.get("user") or "x-access-token")) for b in out: r["out"][b] = push_out(work, home, url, b, force) pats = [p for p in c.get("import") or [] if isinstance(p, str)] @@ -160,6 +196,7 @@ def sync_repo(entry: dict, home_url: str, force: bool = False) -> dict: n = ensure_pr(repo, work, branch, base, name, where) if n: r["prs"].append(n) + ENV.clear() return report -- 2.54.0 From e2fb17e532f0d19264cb5f874bf51fba56e3cbe4 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 03:32:06 +0000 Subject: [PATCH 2/3] test: 83 a copy over SSH with a deploy key Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD --- .github/time/test_time.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/time/test_time.py b/.github/time/test_time.py index 30a62cf..b6caaae 100644 --- a/.github/time/test_time.py +++ b/.github/time/test_time.py @@ -1362,6 +1362,25 @@ c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "gh", "url": "https://git assert c83["note"] == "skipped: the secret NO_SUCH_TOKEN_83 isn't set" and c83["out"] == {} assert sy.authed("https://github.com/o/r.git", "t0k/en") == "https://x-access-token:t0k%2Fen@github.com/o/r.git" assert sy.authed("file:///x", "t0k") == "file:///x" and sy.scrub("https://x-access-token:t0k%2Fen@github.com said t0k/en") == "https://x-access-token:***@github.com said ***" +# a copy over SSH with a deploy key: the key is written 0600 for the run only, the host's keys are pinned (never learned) +assert sy.ssh_host("git@github.com:teahubxyz/time.git") == "github.com" and sy.ssh_host("ssh://git@github.com/o/r.git") == "github.com" +assert sy.ssh_host("https://github.com/o/r.git") == "" and sy.ssh_host("file:///x") == "" +kd83 = _tf83.mkdtemp() +env83 = sy.ssh_env("-----BEGIN KEY-----\nabc\n-----END KEY-----", "github.com", None, kd83) +assert "StrictHostKeyChecking=yes" in env83["GIT_SSH_COMMAND"] and f"-i {kd83}/sync_key" in env83["GIT_SSH_COMMAND"] +assert oct(os.stat(os.path.join(kd83, "sync_key")).st_mode)[-3:] == "600" +assert "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" in Path(kd83, "known_hosts").read_text() +try: sy.ssh_env("k", "forge.example", None, kd83); raise AssertionError("an unknown host needs pinned keys") +except RuntimeError as e: assert "known_hosts" in str(e) +os.environ["SYNC_KEY_83"] = "-----BEGIN KEY-----\nk83\n-----END KEY-----" +c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "ssh", "url": "file://" + copy83, "ssh_key": "SYNC_KEY_83", + "known_hosts": ["copy.test ssh-ed25519 AAAA"]}]}, "file://" + home83)["copies"][0] +assert c83["out"] == {"main": "up to date"} and not sy.ENV, (c83, sy.ENV) +assert sy.scrub("leaked -----BEGIN KEY-----\nk83\n-----END KEY----- here") == "leaked *** here" +os.environ.pop("SYNC_KEY_83") +c83 = sy.sync_repo({"repo": "o/r", "copies": [{"name": "ssh", "url": "git@github.com:o/r.git", "ssh_key": "SYNC_KEY_83"}]}, + "file://" + home83)["copies"][0] +assert c83["note"] == "skipped: the secret SYNC_KEY_83 isn't set" sy.tm.gh = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("GET x -> 404: not found")) assert sy.config() == [] and sy.main() == 0, "no sync.json: nothing to sync" print("ALL OK 83") -- 2.54.0 From 0275e30d427bd1513bed61769084cc93556875a2 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Mon, 19 Oct 2026 03:32:46 +0000 Subject: [PATCH 3/3] sync: the GitHub copy over SSH with a deploy key (GITHUB_MIRROR_KEY); the job installs ssh if missing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017K4vyY3yUD3GsvoZaAtKkD --- .github/workflows/sync.yml | 7 +++++-- time/sync.json | 20 +++++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml index 88284d4..beb6331 100644 --- a/.github/workflows/sync.yml +++ b/.github/workflows/sync.yml @@ -3,7 +3,7 @@ name: sync # per branch: main goes out to every copy, fast-forward only (a copy with commits of its own is reported, never # overwritten); the branches a copy is told to send back (claude/* from cloud sessions on GitHub) come in as PRs here, # and Time's council reviews and lands them like any other. Each copy's token is a secret named in sync.json and passed -# below; a copy without one is skipped. On a GitHub copy, turn GitHub Actions off: these workflows must run here only. +# below (a token for https, a deploy key for ssh); a copy without one is skipped. On a GitHub copy, turn GitHub Actions off: these workflows must run here only. on: push: branches: [main] @@ -27,9 +27,12 @@ jobs: with: sparse-checkout: .github/time persist-credentials: false + # a copy over SSH (a deploy key) needs the ssh client; the job image may not have it + - run: command -v ssh || (apt-get update -qq && apt-get install -y -qq --no-install-recommends openssh-client >/dev/null) - run: python3 .github/time/sync.py env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TIME_TOKEN: ${{ secrets.TIME_TOKEN }} # branches and PRs on the home go in as the `time` user SYNC_FORCE: ${{ inputs.force && '1' || '' }} - GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} # github.com/teahubxyz/time: Contents read/write + GITHUB_MIRROR_KEY: ${{ secrets.GITHUB_MIRROR_KEY }} # github.com/teahubxyz/time: a deploy key with write access + GITHUB_MIRROR_TOKEN: ${{ secrets.GITHUB_MIRROR_TOKEN }} # or a token, for a copy over https diff --git a/time/sync.json b/time/sync.json index a042f4c..31cea5f 100644 --- a/time/sync.json +++ b/time/sync.json @@ -1,7 +1,21 @@ { - "_about": "Copies of repos on other forges, kept in step by .github/time/sync.py (the sync workflow). Each repo has one home (this forge) and every branch one writer: the `out` branches go from the home to each copy, fast-forward only; a copy's branches matching its `import` patterns come back to the home as PRs. `token` names the secret holding the copy's token, never the token itself; a copy whose secret isn't set is skipped. A GitHub copy needs GitHub Actions turned off, since these workflows must only run here.", + "_about": "Copies of repos on other forges, kept in step by .github/time/sync.py (the sync workflow). Each repo has one home (this forge) and every branch one writer: the `out` branches go from the home to each copy, fast-forward only; a copy's branches matching its `import` patterns come back to the home as PRs. `token` (https) or `ssh_key` (a deploy key, for ssh/git@ URLs; host keys pinned) names the secret that holds the credential, never the credential itself; a copy whose secret isn't set is skipped. A GitHub copy needs GitHub Actions turned off, since these workflows must only run here.", "repos": [ - {"repo": "teahubxyz/time", "out": ["main"], - "copies": [{"name": "github", "url": "https://github.com/teahubxyz/time.git", "token": "GITHUB_MIRROR_TOKEN", "import": ["claude/*"]}]} + { + "repo": "teahubxyz/time", + "out": [ + "main" + ], + "copies": [ + { + "name": "github", + "url": "git@github.com:teahubxyz/time.git", + "ssh_key": "GITHUB_MIRROR_KEY", + "import": [ + "claude/*" + ] + } + ] + } ] } -- 2.54.0