|
8 | 8 | import re |
9 | 9 | import subprocess |
10 | 10 | import sys |
| 11 | +import time |
11 | 12 | from pathlib import Path |
12 | 13 |
|
13 | 14 |
|
@@ -60,6 +61,71 @@ def dispatch(pr, repo, identity): |
60 | 61 | return results |
61 | 62 |
|
62 | 63 |
|
| 64 | + |
| 65 | +def publish_status(repo, head, state): |
| 66 | + context = "xpkgindex-update/ci" |
| 67 | + current = json.loads(gh("api", f"repos/{repo}/commits/{head}/status")) |
| 68 | + if any(s["context"] == context and s["state"] == state for s in current["statuses"]): |
| 69 | + return |
| 70 | + descriptions = { |
| 71 | + "pending": "Waiting for candidate, consumer, site and openkal CI", |
| 72 | + "success": "All four CI workflows passed for this exact PR head", |
| 73 | + "failure": "An update CI workflow failed; inspect the actual runs", |
| 74 | + "error": "CI verification timed out; no successful result was recorded", |
| 75 | + } |
| 76 | + gh("api", "--method", "POST", f"repos/{repo}/statuses/{head}", |
| 77 | + "-f", f"state={state}", "-f", f"context={context}", |
| 78 | + "-f", f"description={descriptions[state]}", "-f", |
| 79 | + f"target_url=https://github.com/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}") |
| 80 | + |
| 81 | + |
| 82 | +def wait_for_ci(outcomes, repo, output, *, timeout=2400, interval=20): |
| 83 | + """Report real dispatched results on the PR; never infer success from dispatch.""" |
| 84 | + pending = [r for r in outcomes if r.get("head") and r.get("ci")] |
| 85 | + deadline = time.monotonic() + timeout |
| 86 | + last = {} |
| 87 | + failed = False |
| 88 | + while pending: |
| 89 | + for record in pending[:]: |
| 90 | + runs = [] |
| 91 | + for ci in record["ci"]: |
| 92 | + found = json.loads(gh( |
| 93 | + "run", "list", "--repo", repo, "--workflow", ci["workflow"], |
| 94 | + "--branch", record["branch"], "--commit", record["head"], |
| 95 | + "--event", "workflow_dispatch", "--limit", "1", "--json", |
| 96 | + "databaseId,status,conclusion,url,headSha")) |
| 97 | + if found and found[0]["headSha"] != record["head"]: |
| 98 | + raise ValueError("dispatched CI head differs from the update PR") |
| 99 | + runs.append(found[0] if found else {"status": "queued"}) |
| 100 | + state = "pending" |
| 101 | + if any(r["status"] == "completed" and r["conclusion"] != "success" for r in runs): |
| 102 | + state = "failure" |
| 103 | + elif all(r["status"] == "completed" for r in runs): |
| 104 | + number = record["url"].rsplit("/", 1)[-1] |
| 105 | + verify_pr(number, repo, record["head"]) |
| 106 | + state = "success" |
| 107 | + elif time.monotonic() >= deadline: |
| 108 | + state = "error" |
| 109 | + if last.get(record["id"]) != state: |
| 110 | + publish_status(repo, record["head"], state) |
| 111 | + print(f"{record['id']} CI: {state}", flush=True) |
| 112 | + last[record["id"]] = state |
| 113 | + summary = os.environ.get("GITHUB_STEP_SUMMARY") |
| 114 | + if summary: |
| 115 | + links = [f"[{ci['workflow']}]({run['url']})" for ci, run in zip(record["ci"], runs) if run.get("url")] |
| 116 | + with open(summary, "a", encoding="utf-8") as stream: |
| 117 | + stream.write(f"\n### {record['id']}: {state}\n\n" + " · ".join(links) + "\n") |
| 118 | + record["verification"] = state |
| 119 | + record["runs"] = runs |
| 120 | + if state != "pending": |
| 121 | + failed |= state != "success" |
| 122 | + pending.remove(record) |
| 123 | + output.write_text(json.dumps({"packages": outcomes}, indent=2) + "\n", encoding="utf-8") |
| 124 | + if pending: |
| 125 | + time.sleep(interval) |
| 126 | + return int(failed) |
| 127 | + |
| 128 | + |
63 | 129 | def propose(plan, output): |
64 | 130 | repo = os.environ["GITHUB_REPOSITORY"] |
65 | 131 | base = os.environ["DEFAULT_BRANCH"] |
@@ -96,12 +162,14 @@ def propose(plan, output): |
96 | 162 | raise ValueError("expected exactly one pending update PR") |
97 | 163 | number = matches[0]["number"] |
98 | 164 | pr = verify_pr(number, repo) |
99 | | - outcomes.append({**record, "url": pr["html_url"], "ci": dispatch(pr, repo, record["id"])}) |
| 165 | + outcomes.append({**record, "url": pr["html_url"], "head": pr["head"]["sha"], |
| 166 | + "branch": pr["head"]["ref"], "ci": dispatch(pr, repo, record["id"])}) |
100 | 167 | except (ValueError, subprocess.CalledProcessError) as error: |
101 | 168 | outcomes.append({**record, "status": "error", "reason": str(error)}) |
102 | 169 | output.write_text(json.dumps({"packages": outcomes}, indent=2) + "\n", encoding="utf-8") |
| 170 | + verification = wait_for_ci(outcomes, repo, output) |
103 | 171 | print(output.read_text(encoding="utf-8")) |
104 | | - return result.returncode or int(any(r["status"] == "error" for r in outcomes)) |
| 172 | + return result.returncode or verification or int(any(r["status"] == "error" for r in outcomes)) |
105 | 173 |
|
106 | 174 |
|
107 | 175 | def main(): |
|
0 commit comments