ci: sync windy-chat + windy-mail, bridge PR CI verdicts back to GitHub
GitHub Actions can't run on the private platform repos. Windy Git already has their code and a working runner, so: - windy-chat and windy-mail were read-only pull mirrors (which can never run Actions); they are now writable, deploy.yml/build-image.yml disabled, and synced from GitHub like the others. - scripts/pr_status_bridge.py mirrors open same-repo GitHub PRs into Windy Git (so pull_request workflows fire) and posts each job's result back as a GitHub commit status (windy-git/<workflow>/<job>) on PR heads and the default-branch head. Fork PRs are never run. Runs after every sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
187
scripts/pr_status_bridge.py
Executable file
187
scripts/pr_status_bridge.py
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Give private GitHub repos a CI signal from Windy Git (P1, 2026-09-23).
|
||||
|
||||
GitHub Actions cannot run on private repos on this account — not even on
|
||||
self-hosted runners ([[reference-github-actions-billing-lock]]). The code is
|
||||
already synced into Windy Git every 15 min and CI runs there, so the only thing
|
||||
missing is the *signal on GitHub*, where people and agents actually read PRs.
|
||||
|
||||
Two jobs, run after every sync:
|
||||
|
||||
1. **Mirror open PRs.** The sync carries branches, not PRs, and the workflows
|
||||
trigger on `pull_request` — so a PR branch alone fires nothing. For each open
|
||||
same-repo GitHub PR we keep one open Windy Git PR with the same head/base.
|
||||
Gitea then fires `pull_request` on open and `synchronize` whenever the sync
|
||||
moves the branch. Windy Git PRs whose GitHub PR closed are closed here too.
|
||||
Fork PRs are ignored: their head branch is never synced, and untrusted fork
|
||||
code on this runner is exactly the blast radius the audit warned about.
|
||||
|
||||
2. **Post results back** as GitHub commit statuses (context
|
||||
`windy-git/<workflow>/<job>`) on each PR head and on the default-branch head.
|
||||
Only posts when a context's state changed, so a 15-min loop doesn't pile
|
||||
hundreds of identical statuses onto one commit.
|
||||
|
||||
Runs ON Veron 1 (localhost Gitea; no Cloudflare hairpin). Needs
|
||||
GITEA_ADMIN_TOKEN and a GITHUB_TOKEN with `repo` scope. Nothing here executes
|
||||
repo code, and no secret is handed to any repo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
GITEA = os.environ.get("BRIDGE_GITEA_URL", "http://localhost:3080").rstrip("/")
|
||||
PUBLIC = "https://app.windygit.com"
|
||||
GITEA_TOKEN = os.environ.get("GITEA_ADMIN_TOKEN", "")
|
||||
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
||||
GH_OWNER = os.environ.get("GITHUB_OWNER", "sneakyfree")
|
||||
WG_OWNER = os.environ.get("WINDYGIT_OWNER", "windyadmin")
|
||||
# Private repos only. Public repos run real GitHub Actions on veron1's GitHub
|
||||
# runner; bridging those too would put two competing verdicts on every commit.
|
||||
REPOS = os.environ.get(
|
||||
"BRIDGE_REPOS", "windy-chat windy-mail windy-calendar Windy-Clone WindyCloud"
|
||||
).split()
|
||||
|
||||
# Gitea run status -> GitHub status state. `skipped` is deliberately absent: a
|
||||
# job skipped by its own `if:` (e.g. substrate-drift's no-secrets path) has no
|
||||
# verdict, and painting it green would be a claim nobody tested.
|
||||
STATE = {
|
||||
"success": "success",
|
||||
"failure": "failure",
|
||||
"cancelled": "error",
|
||||
"running": "pending",
|
||||
"waiting": "pending",
|
||||
"blocked": "pending",
|
||||
}
|
||||
MIRROR_TAG = "[GH#"
|
||||
|
||||
|
||||
def _call(base: str, token_header: str, method: str, path: str, body=None):
|
||||
req = urllib.request.Request(
|
||||
base + path,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": token_header,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
# urllib's default UA is 403'd as a bot by GitHub's edge and CF.
|
||||
"User-Agent": "windy-git-pr-bridge/1",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, None
|
||||
|
||||
|
||||
def gitea(method, path, body=None):
|
||||
return _call(GITEA + "/api/v1", f"token {GITEA_TOKEN}", method, path, body)
|
||||
|
||||
|
||||
def github(method, path, body=None):
|
||||
return _call("https://api.github.com", f"Bearer {GITHUB_TOKEN}", method, path, body)
|
||||
|
||||
|
||||
def sync_prs(repo: str) -> list[str]:
|
||||
"""Mirror open same-repo GitHub PRs into Windy Git. Returns their head shas."""
|
||||
st, gh_prs = github("GET", f"/repos/{GH_OWNER}/{repo}/pulls?state=open&per_page=100")
|
||||
if st != 200:
|
||||
raise RuntimeError(f"{repo}: GitHub PR list -> {st}")
|
||||
st, wg_prs = gitea("GET", f"/repos/{WG_OWNER}/{repo}/pulls?state=open&limit=50")
|
||||
if st != 200:
|
||||
raise RuntimeError(f"{repo}: Windy Git PR list -> {st}")
|
||||
ours = {p["title"].split("]")[0] + "]": p for p in wg_prs if p["title"].startswith(MIRROR_TAG)}
|
||||
|
||||
heads, wanted = [], set()
|
||||
for pr in gh_prs:
|
||||
if pr["head"]["repo"] is None or pr["head"]["repo"]["full_name"] != f"{GH_OWNER}/{repo}":
|
||||
continue # fork PR — never synced, never run here
|
||||
tag = f"{MIRROR_TAG}{pr['number']}]"
|
||||
wanted.add(tag)
|
||||
heads.append(pr["head"]["sha"])
|
||||
if tag in ours:
|
||||
continue
|
||||
st, _ = gitea("POST", f"/repos/{WG_OWNER}/{repo}/pulls", {
|
||||
"head": pr["head"]["ref"],
|
||||
"base": pr["base"]["ref"],
|
||||
"title": f"{tag} {pr['title']}"[:250],
|
||||
"body": f"Mirror of {pr['html_url']} so CI runs here. Do not merge in Windy Git — "
|
||||
"GitHub is the source of truth; merge there.",
|
||||
})
|
||||
print(f" {repo}: opened mirror PR for GH#{pr['number']} -> {st}")
|
||||
|
||||
for tag, p in ours.items():
|
||||
if tag not in wanted:
|
||||
gitea("PATCH", f"/repos/{WG_OWNER}/{repo}/pulls/{p['number']}", {"state": "closed"})
|
||||
print(f" {repo}: closed mirror PR {tag} (closed on GitHub)")
|
||||
return heads
|
||||
|
||||
|
||||
def post_statuses(repo: str, sha: str) -> None:
|
||||
# Gitea caps a page at 50 (MAX_RESPONSE_ITEMS) whatever `limit` says, and a
|
||||
# daily scheduled workflow can push a quiet main's runs off page 1.
|
||||
runs = []
|
||||
for page in range(1, 6):
|
||||
st, body = gitea("GET", f"/repos/{WG_OWNER}/{repo}/actions/tasks?limit=50&page={page}")
|
||||
if st != 200:
|
||||
raise RuntimeError(f"{repo}: Windy Git runs -> {st}")
|
||||
runs += body.get("workflow_runs", [])
|
||||
if len(body.get("workflow_runs", [])) < 50:
|
||||
break
|
||||
latest: dict[str, dict] = {}
|
||||
for r in runs:
|
||||
if r["head_sha"] != sha:
|
||||
continue
|
||||
ctx = f"windy-git/{r['workflow_id'].removesuffix('.yml')}/{r['name']}"
|
||||
if ctx not in latest or r["id"] > latest[ctx]["id"]:
|
||||
latest[ctx] = r
|
||||
if not latest:
|
||||
return
|
||||
|
||||
st, existing = github("GET", f"/repos/{GH_OWNER}/{repo}/commits/{sha}/statuses?per_page=100")
|
||||
current: dict[str, str] = {}
|
||||
for s in existing or []: # newest first
|
||||
current.setdefault(s["context"], s["state"])
|
||||
|
||||
for ctx, r in sorted(latest.items()):
|
||||
state = STATE.get(r["status"])
|
||||
if state is None or current.get(ctx) == state:
|
||||
continue
|
||||
st, _ = github("POST", f"/repos/{GH_OWNER}/{repo}/statuses/{sha}", {
|
||||
"state": state,
|
||||
"context": ctx,
|
||||
"description": f"Windy Git CI on Veron 1: {r['status']}"[:140],
|
||||
"target_url": f"{PUBLIC}/{WG_OWNER}/{repo}/actions/runs/{r['run_number']}",
|
||||
})
|
||||
print(f" {repo}@{sha[:7]} {ctx} = {state} -> {st}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not (GITEA_TOKEN and GITHUB_TOKEN):
|
||||
sys.exit("GITEA_ADMIN_TOKEN and GITHUB_TOKEN are required")
|
||||
failed = 0
|
||||
for repo in REPOS:
|
||||
try:
|
||||
shas = sync_prs(repo)
|
||||
st, br = github("GET", f"/repos/{GH_OWNER}/{repo}")
|
||||
if st == 200:
|
||||
st, b = github("GET", f"/repos/{GH_OWNER}/{repo}/branches/{br['default_branch']}")
|
||||
if st == 200:
|
||||
shas.append(b["commit"]["sha"])
|
||||
for sha in dict.fromkeys(shas):
|
||||
post_statuses(repo, sha)
|
||||
except Exception as e: # one repo's failure must not hide the others'
|
||||
print(f" FAILED {repo}: {e}")
|
||||
failed = 1
|
||||
return failed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,7 +38,7 @@ FAILED=0
|
||||
|
||||
# Repos Windy Git tracks FROM GitHub. Remove a repo from this list at the moment
|
||||
# it flips to Windy-Git-first, or the sync will fight its authors and win.
|
||||
REPOS="${SYNC_REPOS:-windy-calendar windy-search windy-registry Windy-Clone WindyCloud windy-cloud-sites windy-mind eternitas windy-agent windy-git}"
|
||||
REPOS="${SYNC_REPOS:-windy-calendar windy-search windy-registry Windy-Clone WindyCloud windy-cloud-sites windy-mind eternitas windy-agent windy-git windy-chat windy-mail}"
|
||||
|
||||
mkdir -p "$WORK"
|
||||
log() { printf '[sync %s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
|
||||
@@ -70,5 +70,11 @@ for r in $REPOS; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Private repos can't run GitHub Actions; mirror their open PRs here so CI
|
||||
# fires, and post the verdicts back to GitHub as commit statuses.
|
||||
if ! python3 "$(dirname "$0")/pr_status_bridge.py"; then
|
||||
log "FAILED pr status bridge"; FAILED=1
|
||||
fi
|
||||
|
||||
[[ "$FAILED" -ne 0 ]] && { log "COMPLETED WITH FAILURES"; exit 1; }
|
||||
log "all repos in step with GitHub"
|
||||
|
||||
Reference in New Issue
Block a user