Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .agents/docs/2026-10-19-xpkgindex-update-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 补齐状态。
3 changes: 2 additions & 1 deletion .github/workflows/xpkgindex-update.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 8 additions & 1 deletion docs/automatic-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.

Expand Down
6 changes: 5 additions & 1 deletion docs/zh/automatic-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand All @@ -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**,不计为成功构建。

Expand Down
72 changes: 70 additions & 2 deletions tests/autoupdate/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import subprocess
import sys
import time
from pathlib import Path


Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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():
Expand Down
48 changes: 47 additions & 1 deletion tests/autoupdate/test_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Loading