Skip to content

Commit fde605c

Browse files
authored
ci: show verified bot update results in PR checks (#548)
1 parent f20ab0f commit fde605c

6 files changed

Lines changed: 145 additions & 6 deletions

File tree

‎.agents/docs/2026-10-19-xpkgindex-update-design.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,3 +623,16 @@ skill 明确普通包 PR 不改根 README 或站点配置;用户明确要求
623623
工具 #2 已 squash 合并为 `6c0bee2941e36132bc2ac3d1f7edbef8b5790320`,61 项回归和全部 7 个 CI checks 成功;索引固定使用此提交。
624624
本地 8 项编排/基线回归覆盖 bot/head/仓库/文件边界、默认分支移动、局部 dispatch 恢复、重复 PR、人工 PR 和局部提案失败。
625625
Actionlint、真实 PR CI 与 bot 生产验证结果在本节补充;未完成前不再报告无人值守链路已通过。
626+
627+
628+
### 16.5 bot PR 实测发现的 CI 展示边界
629+
630+
#546 已在 50 个执行 checks 成功后 squash 合入 `f20ab0f`,其中全部 44 个候选验证通过。
631+
main Action `39316000537` 已由真正的 `github-actions[bot]` 创建 libuv #547,
632+
仅一个描述符新增 10 行、无删除。重复 Action `39316324744` 返回 pending,未创建重复 PR、未重复 dispatch。
633+
四类显式 dispatch 无需审批并全部成功,但实测 GitHub PR 的 statusCheckRollup 仍为空,原生 PR 事件产生未执行的审批请求。
634+
635+
因此提案 job 额外取得 statuses:write,等待四类 dispatch 的真实结果,对同一 PR head 写
636+
`xpkgindex-update/ci` 汇总状态并链接提案运行;summary 和 artifact 包含四个实际运行链接。
637+
仅所有运行成功且 PR 仍是同一 head 才可标绿;失败、取消、超时、head 不符均不得标绿。
638+
不是 dispatch 成功就通过,也不批准或删除原生 PR 审批记录。后续日常运行可为 pending PR 补齐状态。

‎.github/workflows/xpkgindex-update.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,12 @@ jobs:
224224
github.ref_name == github.event.repository.default_branch
225225
needs: scan
226226
runs-on: ubuntu-latest
227-
timeout-minutes: 45
227+
timeout-minutes: 60
228228
permissions:
229229
contents: write
230230
pull-requests: write
231231
actions: write
232+
statuses: write
232233
steps:
233234
- uses: actions/checkout@v4
234235
with:

‎docs/automatic-updates.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ and referenced by platform entries. Different resources remain separate.
6868
## Workflow and credentials
6969

7070
The scan and build jobs have read access. Only the default-branch `propose` job
71-
receives `contents: write`, `pull-requests: write` and `actions: write`.
71+
receives `contents: write`, `pull-requests: write`, `actions: write` and `statuses: write`.
7272
It consumes the exact scan artifact using the pinned CLI; no local user token
7373
or long-lived secret is involved. Organization and repository settings must
7474
allow GitHub Actions to create PRs. The combined setting also permits approvals,
@@ -89,6 +89,13 @@ creating duplicates; existing runs, including failures, are not silently rerun.
8989
`max_prs` in [.github/xpkgindex-update.yml](../.github/xpkgindex-update.yml) limits
9090
new PRs per run. No update PR can recursively create more PRs.
9191

92+
The proposal job waits for the four real dispatch results and publishes the
93+
`xpkgindex-update/ci` commit status on the same PR head. Only four successful
94+
workflows produce success; failure, cancellation, timeout or a changed head
95+
cannot pass. Its link leads to the proposal report containing the CI run links.
96+
Native PR approval requests can be left untouched: validation uses the explicit
97+
dispatches and this aggregate status.
98+
9299
The temporary `wellwei/libmysqlclient` outage exception retains its scan error;
93100
its affected consumers are reported as **NOT TESTED**, not successful builds.
94101

‎docs/zh/automatic-updates.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ python -m xpkgindex_update apply --plan plan.json --package compat:foo
5858
## workflow 与认证
5959

6060
扫描和构建 job 只有读取权限。只有默认分支的 `propose` job 获取
61-
`contents: write`、`pull-requests: write`、`actions: write`,
61+
`contents: write`、`pull-requests: write`、`actions: write`、`statuses: write`,
6262
使用固定 CLI 消费原始扫描 artifact,不依赖本地用户 token 或长期 secret。
6363
组织和仓库须允许 GitHub Actions 创建 PR。GitHub 的对应开关也允许审批,
6464
但此 workflow 不会审批或合并 PR。
@@ -75,6 +75,10 @@ python -m xpkgindex_update apply --plan plan.json --package compat:foo
7575
[中央配置](../../.github/xpkgindex-update.yml) 的 `max_prs` 限制每轮新 PR 数量。
7676
更新分支上的 CI 不会递归创建 PR。
7777

78+
提案 job 等待四类 dispatch CI 的真实结果,并将 `xpkgindex-update/ci` 汇总状态写到 PR 的同一 head。
79+
只有四类均成功才标绿;失败、取消、超时或 head 不一致均不能通过。PR 中的状态链接指向含各 CI 运行链接的提案报告。
80+
GitHub 自动产生的原生 PR 审批请求无需批准;验证使用显式 dispatch 和该汇总状态。
81+
7882
暂时保留 `wellwei/libmysqlclient` 源故障豁免:扫描错误仍记录,受影响消费者标为
7983
**NOT TESTED**,不计为成功构建。
8084

‎tests/autoupdate/automation.py‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import subprocess
1010
import sys
11+
import time
1112
from pathlib import Path
1213

1314

@@ -60,6 +61,71 @@ def dispatch(pr, repo, identity):
6061
return results
6162

6263

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+
63129
def propose(plan, output):
64130
repo = os.environ["GITHUB_REPOSITORY"]
65131
base = os.environ["DEFAULT_BRANCH"]
@@ -96,12 +162,14 @@ def propose(plan, output):
96162
raise ValueError("expected exactly one pending update PR")
97163
number = matches[0]["number"]
98164
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"])})
100167
except (ValueError, subprocess.CalledProcessError) as error:
101168
outcomes.append({**record, "status": "error", "reason": str(error)})
102169
output.write_text(json.dumps({"packages": outcomes}, indent=2) + "\n", encoding="utf-8")
170+
verification = wait_for_ci(outcomes, repo, output)
103171
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))
105173

106174

107175
def main():

‎tests/autoupdate/test_automation.py‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ def gh(*args):
116116
with patch.object(automation, "gh", side_effect=gh), \
117117
patch.object(automation.subprocess, "run", side_effect=run), \
118118
patch.object(automation, "verify_pr", return_value=PR), \
119-
patch.object(automation, "dispatch", return_value=[{"status": "dispatched"}]) as dispatch:
119+
patch.object(automation, "dispatch", return_value=[{"status": "dispatched"}]) as dispatch, \
120+
patch.object(automation, "wait_for_ci", return_value=0):
120121
self.assertEqual(automation.propose(plan, output), 1)
121122
dispatch.assert_called_once_with(PR, REPO, "compat:foo")
122123
result = json.loads(output.read_text())["packages"]
@@ -146,3 +147,48 @@ def gh(*args):
146147
self.assertEqual(automation.propose(plan, output), 0)
147148
dispatch.assert_not_called()
148149
self.assertEqual(json.loads(output.read_text())["packages"][0]["ci"], [{"status": "ordinary-pr-ci"}])
150+
151+
def test_ci_summary_waits_for_all_four_exact_head_runs(self):
152+
outcomes = [{"id": "compat:foo", "head": "head", "branch": PR["head"]["ref"],
153+
"url": PR["html_url"], "ci": [{"workflow": f"ci-{n}.yml"} for n in range(4)]}]
154+
call = 0
155+
156+
def gh(*args):
157+
nonlocal call
158+
call += 1
159+
complete = call > 4
160+
return json.dumps([{"databaseId": call, "headSha": "head", "url": "run",
161+
"status": "completed" if complete else "in_progress", "conclusion": "success" if complete else None}])
162+
163+
with tempfile.TemporaryDirectory() as directory, \
164+
patch.object(automation, "gh", side_effect=gh), \
165+
patch.object(automation, "publish_status") as publish, \
166+
patch.object(automation, "verify_pr", return_value=PR) as verify, \
167+
patch.object(automation.time, "sleep"):
168+
self.assertEqual(automation.wait_for_ci(outcomes, REPO, Path(directory) / "report.json"), 0)
169+
self.assertEqual([c.args[2] for c in publish.call_args_list], ["pending", "success"])
170+
verify.assert_called_once_with("7", REPO, "head")
171+
self.assertEqual(outcomes[0]["verification"], "success")
172+
173+
def test_failed_cancelled_wrong_head_and_timeout_never_report_success(self):
174+
for state in ("failure", "cancelled", "wrong-head", "timeout"):
175+
with self.subTest(state=state), tempfile.TemporaryDirectory() as directory:
176+
outcomes = [{"id": "compat:foo", "head": "head", "branch": PR["head"]["ref"],
177+
"url": PR["html_url"], "ci": [{"workflow": "validate.yml"}]}]
178+
run = {"headSha": "wrong" if state == "wrong-head" else "head",
179+
"status": "in_progress" if state == "timeout" else "completed", "conclusion": state}
180+
with patch.object(automation, "gh", return_value=json.dumps([run])), \
181+
patch.object(automation, "publish_status") as publish:
182+
if state == "wrong-head":
183+
with self.assertRaisesRegex(ValueError, "head differs"):
184+
automation.wait_for_ci(outcomes, REPO, Path(directory) / "report.json")
185+
else:
186+
self.assertEqual(automation.wait_for_ci(outcomes, REPO, Path(directory) / "report.json", timeout=0), 1)
187+
self.assertNotIn("success", [c.args[2] for c in publish.call_args_list])
188+
189+
def test_status_is_not_republished_when_unchanged(self):
190+
with patch.object(automation, "gh", return_value=json.dumps({"statuses": [
191+
{"context": "xpkgindex-update/ci", "state": "success"},
192+
]})) as gh:
193+
automation.publish_status(REPO, "head", "success")
194+
self.assertEqual(gh.call_count, 1)

0 commit comments

Comments
 (0)