diff --git a/.agents/docs/2026-10-19-xpkgindex-update-design.md b/.agents/docs/2026-10-19-xpkgindex-update-design.md index 8fc2a13f..1fe4be3d 100644 --- a/.agents/docs/2026-10-19-xpkgindex-update-design.md +++ b/.agents/docs/2026-10-19-xpkgindex-update-design.md @@ -623,3 +623,16 @@ skill 明确普通包 PR 不改根 README 或站点配置;用户明确要求 工具 #2 已 squash 合并为 `6c0bee2941e36132bc2ac3d1f7edbef8b5790320`,61 项回归和全部 7 个 CI checks 成功;索引固定使用此提交。 本地 8 项编排/基线回归覆盖 bot/head/仓库/文件边界、默认分支移动、局部 dispatch 恢复、重复 PR、人工 PR 和局部提案失败。 Actionlint、真实 PR CI 与 bot 生产验证结果在本节补充;未完成前不再报告无人值守链路已通过。 + + +### 16.5 bot PR 实测发现的 CI 展示边界 + +#546 已在 50 个执行 checks 成功后 squash 合入 `f20ab0f`,其中全部 44 个候选验证通过。 +main Action `39316000537` 已由真正的 `github-actions[bot]` 创建 libuv #547, +仅一个描述符新增 10 行、无删除。重复 Action `39316324744` 返回 pending,未创建重复 PR、未重复 dispatch。 +四类显式 dispatch 无需审批并全部成功,但实测 GitHub PR 的 statusCheckRollup 仍为空,原生 PR 事件产生未执行的审批请求。 + +因此提案 job 额外取得 statuses:write,等待四类 dispatch 的真实结果,对同一 PR head 写 +`xpkgindex-update/ci` 汇总状态并链接提案运行;summary 和 artifact 包含四个实际运行链接。 +仅所有运行成功且 PR 仍是同一 head 才可标绿;失败、取消、超时、head 不符均不得标绿。 +不是 dispatch 成功就通过,也不批准或删除原生 PR 审批记录。后续日常运行可为 pending PR 补齐状态。 diff --git a/.github/workflows/xpkgindex-update.yml b/.github/workflows/xpkgindex-update.yml index 448a196b..e97ee4c7 100644 --- a/.github/workflows/xpkgindex-update.yml +++ b/.github/workflows/xpkgindex-update.yml @@ -224,11 +224,12 @@ jobs: github.ref_name == github.event.repository.default_branch needs: scan runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 permissions: contents: write pull-requests: write actions: write + statuses: write steps: - uses: actions/checkout@v4 with: diff --git a/docs/automatic-updates.md b/docs/automatic-updates.md index 0a4e1b61..a0b7f575 100644 --- a/docs/automatic-updates.md +++ b/docs/automatic-updates.md @@ -68,7 +68,7 @@ and referenced by platform entries. Different resources remain separate. ## Workflow and credentials The scan and build jobs have read access. Only the default-branch `propose` job -receives `contents: write`, `pull-requests: write` and `actions: write`. +receives `contents: write`, `pull-requests: write`, `actions: write` and `statuses: write`. It consumes the exact scan artifact using the pinned CLI; no local user token or long-lived secret is involved. Organization and repository settings must 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. `max_prs` in [.github/xpkgindex-update.yml](../.github/xpkgindex-update.yml) limits new PRs per run. No update PR can recursively create more PRs. +The proposal job waits for the four real dispatch results and publishes the +`xpkgindex-update/ci` commit status on the same PR head. Only four successful +workflows produce success; failure, cancellation, timeout or a changed head +cannot pass. Its link leads to the proposal report containing the CI run links. +Native PR approval requests can be left untouched: validation uses the explicit +dispatches and this aggregate status. + The temporary `wellwei/libmysqlclient` outage exception retains its scan error; its affected consumers are reported as **NOT TESTED**, not successful builds. diff --git a/docs/zh/automatic-updates.md b/docs/zh/automatic-updates.md index 8a7ffa4f..1aa704d2 100644 --- a/docs/zh/automatic-updates.md +++ b/docs/zh/automatic-updates.md @@ -58,7 +58,7 @@ python -m xpkgindex_update apply --plan plan.json --package compat:foo ## workflow 与认证 扫描和构建 job 只有读取权限。只有默认分支的 `propose` job 获取 -`contents: write`、`pull-requests: write`、`actions: write`, +`contents: write`、`pull-requests: write`、`actions: write`、`statuses: write`, 使用固定 CLI 消费原始扫描 artifact,不依赖本地用户 token 或长期 secret。 组织和仓库须允许 GitHub Actions 创建 PR。GitHub 的对应开关也允许审批, 但此 workflow 不会审批或合并 PR。 @@ -75,6 +75,10 @@ python -m xpkgindex_update apply --plan plan.json --package compat:foo [中央配置](../../.github/xpkgindex-update.yml) 的 `max_prs` 限制每轮新 PR 数量。 更新分支上的 CI 不会递归创建 PR。 +提案 job 等待四类 dispatch CI 的真实结果,并将 `xpkgindex-update/ci` 汇总状态写到 PR 的同一 head。 +只有四类均成功才标绿;失败、取消、超时或 head 不一致均不能通过。PR 中的状态链接指向含各 CI 运行链接的提案报告。 +GitHub 自动产生的原生 PR 审批请求无需批准;验证使用显式 dispatch 和该汇总状态。 + 暂时保留 `wellwei/libmysqlclient` 源故障豁免:扫描错误仍记录,受影响消费者标为 **NOT TESTED**,不计为成功构建。 diff --git a/tests/autoupdate/automation.py b/tests/autoupdate/automation.py index ce2e6fc7..7a483381 100755 --- a/tests/autoupdate/automation.py +++ b/tests/autoupdate/automation.py @@ -8,6 +8,7 @@ import re import subprocess import sys +import time from pathlib import Path @@ -60,6 +61,71 @@ def dispatch(pr, repo, identity): return results + +def publish_status(repo, head, state): + context = "xpkgindex-update/ci" + current = json.loads(gh("api", f"repos/{repo}/commits/{head}/status")) + if any(s["context"] == context and s["state"] == state for s in current["statuses"]): + return + descriptions = { + "pending": "Waiting for candidate, consumer, site and openkal CI", + "success": "All four CI workflows passed for this exact PR head", + "failure": "An update CI workflow failed; inspect the actual runs", + "error": "CI verification timed out; no successful result was recorded", + } + gh("api", "--method", "POST", f"repos/{repo}/statuses/{head}", + "-f", f"state={state}", "-f", f"context={context}", + "-f", f"description={descriptions[state]}", "-f", + f"target_url=https://github.com/{repo}/actions/runs/{os.environ['GITHUB_RUN_ID']}") + + +def wait_for_ci(outcomes, repo, output, *, timeout=2400, interval=20): + """Report real dispatched results on the PR; never infer success from dispatch.""" + pending = [r for r in outcomes if r.get("head") and r.get("ci")] + deadline = time.monotonic() + timeout + last = {} + failed = False + while pending: + for record in pending[:]: + runs = [] + for ci in record["ci"]: + found = json.loads(gh( + "run", "list", "--repo", repo, "--workflow", ci["workflow"], + "--branch", record["branch"], "--commit", record["head"], + "--event", "workflow_dispatch", "--limit", "1", "--json", + "databaseId,status,conclusion,url,headSha")) + if found and found[0]["headSha"] != record["head"]: + raise ValueError("dispatched CI head differs from the update PR") + runs.append(found[0] if found else {"status": "queued"}) + state = "pending" + if any(r["status"] == "completed" and r["conclusion"] != "success" for r in runs): + state = "failure" + elif all(r["status"] == "completed" for r in runs): + number = record["url"].rsplit("/", 1)[-1] + verify_pr(number, repo, record["head"]) + state = "success" + elif time.monotonic() >= deadline: + state = "error" + if last.get(record["id"]) != state: + publish_status(repo, record["head"], state) + print(f"{record['id']} CI: {state}", flush=True) + last[record["id"]] = state + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + links = [f"[{ci['workflow']}]({run['url']})" for ci, run in zip(record["ci"], runs) if run.get("url")] + with open(summary, "a", encoding="utf-8") as stream: + stream.write(f"\n### {record['id']}: {state}\n\n" + " · ".join(links) + "\n") + record["verification"] = state + record["runs"] = runs + if state != "pending": + failed |= state != "success" + pending.remove(record) + output.write_text(json.dumps({"packages": outcomes}, indent=2) + "\n", encoding="utf-8") + if pending: + time.sleep(interval) + return int(failed) + + def propose(plan, output): repo = os.environ["GITHUB_REPOSITORY"] base = os.environ["DEFAULT_BRANCH"] @@ -96,12 +162,14 @@ def propose(plan, output): raise ValueError("expected exactly one pending update PR") number = matches[0]["number"] pr = verify_pr(number, repo) - outcomes.append({**record, "url": pr["html_url"], "ci": dispatch(pr, repo, record["id"])}) + outcomes.append({**record, "url": pr["html_url"], "head": pr["head"]["sha"], + "branch": pr["head"]["ref"], "ci": dispatch(pr, repo, record["id"])}) except (ValueError, subprocess.CalledProcessError) as error: outcomes.append({**record, "status": "error", "reason": str(error)}) output.write_text(json.dumps({"packages": outcomes}, indent=2) + "\n", encoding="utf-8") + verification = wait_for_ci(outcomes, repo, output) print(output.read_text(encoding="utf-8")) - return result.returncode or int(any(r["status"] == "error" for r in outcomes)) + return result.returncode or verification or int(any(r["status"] == "error" for r in outcomes)) def main(): diff --git a/tests/autoupdate/test_automation.py b/tests/autoupdate/test_automation.py index 69ad8824..1230e948 100644 --- a/tests/autoupdate/test_automation.py +++ b/tests/autoupdate/test_automation.py @@ -116,7 +116,8 @@ def gh(*args): with patch.object(automation, "gh", side_effect=gh), \ patch.object(automation.subprocess, "run", side_effect=run), \ patch.object(automation, "verify_pr", return_value=PR), \ - patch.object(automation, "dispatch", return_value=[{"status": "dispatched"}]) as dispatch: + patch.object(automation, "dispatch", return_value=[{"status": "dispatched"}]) as dispatch, \ + patch.object(automation, "wait_for_ci", return_value=0): self.assertEqual(automation.propose(plan, output), 1) dispatch.assert_called_once_with(PR, REPO, "compat:foo") result = json.loads(output.read_text())["packages"] @@ -146,3 +147,48 @@ def gh(*args): self.assertEqual(automation.propose(plan, output), 0) dispatch.assert_not_called() self.assertEqual(json.loads(output.read_text())["packages"][0]["ci"], [{"status": "ordinary-pr-ci"}]) + + def test_ci_summary_waits_for_all_four_exact_head_runs(self): + outcomes = [{"id": "compat:foo", "head": "head", "branch": PR["head"]["ref"], + "url": PR["html_url"], "ci": [{"workflow": f"ci-{n}.yml"} for n in range(4)]}] + call = 0 + + def gh(*args): + nonlocal call + call += 1 + complete = call > 4 + return json.dumps([{"databaseId": call, "headSha": "head", "url": "run", + "status": "completed" if complete else "in_progress", "conclusion": "success" if complete else None}]) + + with tempfile.TemporaryDirectory() as directory, \ + patch.object(automation, "gh", side_effect=gh), \ + patch.object(automation, "publish_status") as publish, \ + patch.object(automation, "verify_pr", return_value=PR) as verify, \ + patch.object(automation.time, "sleep"): + self.assertEqual(automation.wait_for_ci(outcomes, REPO, Path(directory) / "report.json"), 0) + self.assertEqual([c.args[2] for c in publish.call_args_list], ["pending", "success"]) + verify.assert_called_once_with("7", REPO, "head") + self.assertEqual(outcomes[0]["verification"], "success") + + def test_failed_cancelled_wrong_head_and_timeout_never_report_success(self): + for state in ("failure", "cancelled", "wrong-head", "timeout"): + with self.subTest(state=state), tempfile.TemporaryDirectory() as directory: + outcomes = [{"id": "compat:foo", "head": "head", "branch": PR["head"]["ref"], + "url": PR["html_url"], "ci": [{"workflow": "validate.yml"}]}] + run = {"headSha": "wrong" if state == "wrong-head" else "head", + "status": "in_progress" if state == "timeout" else "completed", "conclusion": state} + with patch.object(automation, "gh", return_value=json.dumps([run])), \ + patch.object(automation, "publish_status") as publish: + if state == "wrong-head": + with self.assertRaisesRegex(ValueError, "head differs"): + automation.wait_for_ci(outcomes, REPO, Path(directory) / "report.json") + else: + self.assertEqual(automation.wait_for_ci(outcomes, REPO, Path(directory) / "report.json", timeout=0), 1) + self.assertNotIn("success", [c.args[2] for c in publish.call_args_list]) + + def test_status_is_not_republished_when_unchanged(self): + with patch.object(automation, "gh", return_value=json.dumps({"statuses": [ + {"context": "xpkgindex-update/ci", "state": "success"}, + ]})) as gh: + automation.publish_status(REPO, "head", "success") + self.assertEqual(gh.call_count, 1)