bridge: post an error status when Windy Git ignores an invalid workflow
Gitea drops an invalid workflow file with one log line and fires no run, so the GitHub PR showed nothing and lanes waited for CI that never came (windytalk #100). The bridge now reads each workflow file at the commit it reports on and posts windy-git/<wf>/workflow = error with the reason. Verified: 0 false positives on all 23 bridged repos' main; catches windytalk #100's broken commits (invalid YAML at line 12), fix commit clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ repo code, and no secret is handed to any repo.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -36,6 +37,8 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
GITEA = os.environ.get("BRIDGE_GITEA_URL", "http://localhost:3080").rstrip("/")
|
GITEA = os.environ.get("BRIDGE_GITEA_URL", "http://localhost:3080").rstrip("/")
|
||||||
PUBLIC = "https://app.windygit.com"
|
PUBLIC = "https://app.windygit.com"
|
||||||
GITEA_TOKEN = os.environ.get("GITEA_ADMIN_TOKEN", "")
|
GITEA_TOKEN = os.environ.get("GITEA_ADMIN_TOKEN", "")
|
||||||
@@ -86,6 +89,62 @@ for _entry in os.environ.get(
|
|||||||
NON_BLOCKING[_repo.strip()] = {j.strip() for j in _jobs.split(",") if j.strip()}
|
NON_BLOCKING[_repo.strip()] = {j.strip() for j in _jobs.split(",") if j.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
# Gitea reads the FIRST of these dirs that has workflow files at a commit (1.24).
|
||||||
|
WORKFLOW_DIRS = (".gitea/workflows", ".github/workflows")
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_problem(text: str) -> str | None:
|
||||||
|
"""Why Gitea would drop this workflow file, or None if it looks runnable.
|
||||||
|
|
||||||
|
Gitea skips an invalid workflow with one log line and fires no run at all,
|
||||||
|
so on GitHub the PR just shows nothing, and people wait for CI that is never
|
||||||
|
coming. These are the shapes we have actually hit, not a full schema.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError as e:
|
||||||
|
mark = getattr(e, "problem_mark", None)
|
||||||
|
return f"invalid YAML at line {mark.line + 1}" if mark else "invalid YAML"
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
return "not a YAML mapping"
|
||||||
|
if "on" not in doc and True not in doc: # YAML 1.1 reads a bare `on` as True
|
||||||
|
return "no `on:` trigger"
|
||||||
|
jobs = doc.get("jobs")
|
||||||
|
if not isinstance(jobs, dict) or not jobs:
|
||||||
|
return "no `jobs:`"
|
||||||
|
for name, job in jobs.items():
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
return f"job `{name}` is not a mapping"
|
||||||
|
if "runs-on" not in job and "uses" not in job:
|
||||||
|
return f"job `{name}` has no `runs-on:`"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def invalid_workflows(repo: str, sha: str) -> dict[str, tuple[str, str]]:
|
||||||
|
"""{context: (path, problem)} for each workflow file at `sha` that won't run."""
|
||||||
|
for d in WORKFLOW_DIRS:
|
||||||
|
st, entries = gitea("GET", f"/repos/{WG_OWNER}/{repo}/contents/{d}?ref={sha}")
|
||||||
|
if st == 404:
|
||||||
|
continue
|
||||||
|
if st != 200:
|
||||||
|
raise RuntimeError(f"{repo}: Windy Git {d}@{sha[:7]} -> {st}")
|
||||||
|
files = [e for e in entries or [] if e.get("type") == "file"
|
||||||
|
and e["name"].endswith((".yml", ".yaml"))]
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
bad = {}
|
||||||
|
for e in files:
|
||||||
|
st, f = gitea("GET", f"/repos/{WG_OWNER}/{repo}/contents/{e['path']}?ref={sha}")
|
||||||
|
if st != 200:
|
||||||
|
raise RuntimeError(f"{repo}: Windy Git {e['path']}@{sha[:7]} -> {st}")
|
||||||
|
problem = workflow_problem(base64.b64decode(f["content"]).decode("utf-8", "replace"))
|
||||||
|
if problem:
|
||||||
|
stem = re.sub(r"\.ya?ml$", "", e["name"])
|
||||||
|
bad[f"windy-git/{stem}/workflow"] = (e["path"], problem)
|
||||||
|
return bad
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _call(base: str, token_header: str, method: str, path: str, body=None):
|
def _call(base: str, token_header: str, method: str, path: str, body=None):
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
base + path,
|
base + path,
|
||||||
@@ -183,7 +242,8 @@ def post_statuses(repo: str, sha: str) -> None:
|
|||||||
ctx = f"windy-git/{r['workflow_id'].removesuffix('.yml')}/{r['name']}"
|
ctx = f"windy-git/{r['workflow_id'].removesuffix('.yml')}/{r['name']}"
|
||||||
if ctx not in latest or r["id"] > latest[ctx]["id"]:
|
if ctx not in latest or r["id"] > latest[ctx]["id"]:
|
||||||
latest[ctx] = r
|
latest[ctx] = r
|
||||||
if not latest:
|
bad = invalid_workflows(repo, sha)
|
||||||
|
if not (latest or bad):
|
||||||
return
|
return
|
||||||
|
|
||||||
st, existing = github("GET", f"/repos/{GH_OWNER}/{repo}/commits/{sha}/statuses?per_page=100")
|
st, existing = github("GET", f"/repos/{GH_OWNER}/{repo}/commits/{sha}/statuses?per_page=100")
|
||||||
@@ -191,6 +251,21 @@ def post_statuses(repo: str, sha: str) -> None:
|
|||||||
for s in existing or []: # newest first
|
for s in existing or []: # newest first
|
||||||
current.setdefault(s["context"], s["state"])
|
current.setdefault(s["context"], s["state"])
|
||||||
|
|
||||||
|
for ctx, (path, problem) in sorted(bad.items()):
|
||||||
|
if current.get(ctx) == "error":
|
||||||
|
continue
|
||||||
|
st, _ = github(
|
||||||
|
"POST",
|
||||||
|
f"/repos/{GH_OWNER}/{repo}/statuses/{sha}",
|
||||||
|
{
|
||||||
|
"state": "error",
|
||||||
|
"context": ctx,
|
||||||
|
"description": f"Windy Git ignored this workflow, no CI ran: {problem}"[:140],
|
||||||
|
"target_url": f"{PUBLIC}/{WG_OWNER}/{repo}/src/commit/{sha}/{path}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f" {repo}@{sha[:7]} {ctx} = error ({problem}) -> {st}")
|
||||||
|
|
||||||
for ctx, r in sorted(latest.items()):
|
for ctx, r in sorted(latest.items()):
|
||||||
state = STATE.get(r["status"])
|
state = STATE.get(r["status"])
|
||||||
if state is None or current.get(ctx) == state:
|
if state is None or current.get(ctx) == state:
|
||||||
|
|||||||
Reference in New Issue
Block a user