Prhub

#3251 ci(lark): rerun failed nightly jobs once and report flaky vs failed

原始 PR 作者 yueming-yuan 合并时间 2026-09-15 07:23 文件变更 3 提交数 2 评论 0 代码增减 +156 / -14

执行摘要

nightly 失败 job 自动重跑一次,卡片区分 flaky 与真实失败

nightly 只运行一次,任何失败 job 都会发红卡,flaky 失败与真实失败看起来完全一样;随后需要人工手动重跑失败 job,而这会发布第二张卡,第一张卡从不与结果 reconcile。PR body 给出了具体案例 run 34614419736:attempt-1 卡片出现在 Sep 11 17:20Z,attempt-2 卡片出现在 Sep 12 04:12Z。目标是让 flaky 与真实失败在卡片上区分开,并消除人工重跑与卡片不一致的流程。

值得精读。这个 PR 是 CI 通知链路上的一次小而有代表性的可靠性改进:自动重跑 + flaky 区分的状态机设计(attempt 1 重跑、attempt 2 报告、拒跑兜底、防循环守卫)清晰且可测试。特别值得关注两点:一是作者对未验证路径的诚实标注与 fallback 设计(先合入、后观察、再切换 token 来源),二是 identity boundaries 测试随权限提升同步收紧,保证 actions: write 是唯一新增写权限。建议后续跟踪第一个失败的 nightly,确认 workflow_run 事件确实触发,否则需要切换到 App token。

讨论亮点

本 PR 没有实质性 review 评论或讨论线程:claude[bot] 只发布了仓库配置的引导说明,guapisolo 与 yushengsu-thu 直接 APPROVED,无 review comments。唯一的技术不确定性由作者本人写在 PR body 的 “Not validated until it runs on main” 一节:workflow_run: completed 对 github-actions[bot] 以 job token 发起的 rerun attempt 是否触发尚未验证;作者说明该模式在手动 rerun 时已验证(notifier run 34672389600 在 attempt 2 完成时触发),仓库内常见的 auto-retry 方案依赖同一行为,但 miles 仓库本身没有实际运行过该路径。作者给出的 fallback 是改用 CI analysis App 的 token 并配 permission-actions: write。

实现拆解

  1. 重跑决策入口:.github/workflows/scripts/lark_notify.py 的 cmd_ci_status 在拿到 completed run 与 job 列表后,先计算 failed = failed_job_names(jobs) 与 attempt = run.get("run_attempt", 1)。只有同时满足 attempt == 1、事件为 schedule、结论不是 cancelled、存在失败 job 四个条件才发起重跑,保证重跑不会循环(attempt 2 永远不触发),手动 dispatch 与 PR 事件不受影响。--dry-run 模式只打印 would rerun N failed jobs 并提前返回。

  2. 新增 GitHub API 方法:GitHub.rerun_failed_jobs(run_id) 以 POST 请求调用 /repos/{repo}/actions/runs/{id}/rerun-failed-jobs,要求返回 201;HTTPError / URLError 或非 201 都包装为 RuntimeError,错误正文截断 300 字节。成功打印 card follows attempt 2 后直接返回,不分析失败、不发布卡片。

  3. 第二次尝试的卡片渲染:attempt 2 完成再次触发 cmd_ci_status 时,用 gh.run_attempt_jobs(run_id, attempt - 1) 取 attempt 1 的失败集合,diff_attempts 按 job 名分成 fixed / still / new。render_ci_status 把原 Fixed by rerun 标题改为 Flaky, passed on rerun,标题与 job summary 追加 , N flaky,并删除 Rerun #N - 前缀(因为现在每次失败的 nightly 都是 rerun)。

  4. 拒跑兜底:若 POST 失败,rerun_error 被记录,仍然走完 attempt 1 的卡片发布(含 AI 分析),最后 raise rerun_error 让 notifier job 失败,使缺失的重跑对外可见。

  5. 权限与测试配套:.github/workflows/ci-lark-notify.yml 把 actions: read 改为 actions: write,仅用于 rerun-failed-jobs;AI 分析仍用只读 App token。tests/ci/test/test_lark_notify.py 新增 13 个用例,覆盖重跑触发、防循环、cancelled 与 manual 豁免、dry-run、拒跑兜底、HTTP 请求格式,并把 identity boundaries 断言从“禁止 actions: write”改为“允许 actions: write 但禁止 contents / pull-requests 写权限”。

文件 模块 状态 重要度
.github/workflows/scripts/lark_notify.py CI 通知 modified 5.72
tests/ci/test/test_lark_notify.py CI 通知 modified 6.86
.github/workflows/ci-lark-notify.yml CI 通知 modified 3.25

关键符号

GitHub.rerun_failed_jobs cmd_ci_status render_ci_status diff_attempts failed_job_names

关键源码片段

.github/workflows/scripts/lark_notify.py infrastructure

核心逻辑所在:新增 rerun_failed_jobs 方法、在 cmd_ci_status 中加入自动重跑决策与拒跑兜底、在 render_ci_status 中把 rerun 修复的 job 渲染为 flaky 并去掉 Rerun #N 前缀。

# GitHub API 客户端方法:请求重跑指定 run 的全部失败 job
def rerun_failed_jobs(self, run_id: int) -> None:
    # 只有返回 201 才认为重跑被接受,其余一律视为失败
    url = f"{GITHUB_API}/repos/{self.repo}/actions/runs/{run_id}/rerun-failed-jobs"
    req = urllib.request.Request(url, data=b"", method="POST")
    req.add_header("Authorization", f"Bearer {self.token}")
    req.add_header("Accept", "application/vnd.github+json")
    req.add_header("X-GitHub-Api-Version", "2022-11-28")
    try:
        with urllib.request.urlopen(req, timeout=self.timeout) as resp:
            status = resp.status
    except urllib.error.HTTPError as e:
        # 错误详情截断到 300 字节,避免响应体污染日志
        body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"POST {url} -> {e.code}: {body[:300]}") from e
    except urllib.error.URLError as e:
        raise RuntimeError(f"POST {url} failed: {e}") from e
    if status != 201:
        raise RuntimeError(f"POST {url} -> {status}")
​
​
# cmd_ci_status 中的自动重跑决策核心分支
failed = failed_job_names(jobs)
attempt = run.get("run_attempt", 1)
rerun_error = None
# 仅首次失败的 schedule run 自动重跑:attempt 1 防止循环,cancelled 不重跑
if attempt == 1 and failed and run["event"] == "schedule" and run.get("conclusion") != "cancelled":
    if args.dry_run:
        # dry-run 只预告行为,不真正发起重跑
        print(f"dry-run: would rerun {plural(len(failed), 'failed job')} of run {run['id']}")
        return
    try:
        gh.rerun_failed_jobs(run["id"])
    except RuntimeError as exc:
        # 拒跑不能牺牲报告:仍发 attempt 1 卡片,最后再把错误抛出让 notifier 可见
        rerun_error = exc
    else:
        # 重跑成功后不分析、不发卡,等 attempt 2 的 completed 事件再来打报告
        print(f"rerun requested for {plural(len(failed), 'failed job')} of run {run['id']}; card follows attempt 2")
        return
# render_ci_status 中的 flaky 计数与标题生成
# 第二次尝试才与 attempt 1 diff:被重跑修复的 job 记为 flaky
diff = diff_attempts(failed, prev_failed) if prev_failed is not None else None
flaky = diff["fixed"] if diff else []
flaky_note = f", {len(flaky)} flaky" if flaky else ""
​
if conclusion == "cancelled":
    title = f"{name}: CANCELLED"
    color = "grey"
else:
    # failed 只统计 attempt 2 仍在失败的 job,重跑修复的不算失败
    if failed:
        title = f"{name}: FAILED ({len(failed)} of {plural(len(counted), 'job')}{flaky_note})"
        color = "red"
    else:
        # 全部通过但曾有 flaky 时,绿卡也会带上 flaky 计数
        title = f"{name}: PASSED ({plural(len(counted), 'job')}{flaky_note})"
        color = "green"
tests/ci/test/test_lark_notify.py test-coverage

行为契约的全部 13 个新用例都集中在这里:重跑触发、防循环、cancelled / manual 豁免、dry-run、拒跑兜底、HTTP 请求格式,以及 flaky 计数的标题断言,是理解新状态机的唯一完整入口。

def test_first_failed_nightly_attempt_reruns_its_failed_jobs_instead_of_posting(monkeypatch):
    posted = []
    monkeypatch.setattr(HANDLER, "post_card", lambda *values: posted.append(values))
    # 重跑前不允许任何 AI 分析,attempt 1 唯一动作就是发起重跑
    monkeypatch.setattr(HANDLER, "analyze_failures", lambda **kwargs: pytest.fail("analysis before the rerun"))
    gh = FakeGitHub(run(), [job(), job(20, "pass", "success")])
    HANDLER.cmd_ci_status(args(), gh)
    # 关键契约:只请求重跑,不发布任何卡片
    assert gh.rerun_calls() == [("rerun_failed_jobs", 123)] and posted == []
​
​
def test_jobs_fixed_by_the_rerun_count_as_flaky_not_failed():
    previous = {"flaky": job(10, "flaky"), "still": job(19, "still")}
    current = [job(20, "still"), job(11, "flaky", "success")]
    card = HANDLER.render_ci_status(run(run_attempt=2), current, previous)
    # 标题只统计 attempt 2 仍失败的 job,被重跑修复的单独记为 flaky
    assert card["card"]["header"]["title"]["content"] == "Nightly Test: FAILED (1 of 2 jobs, 1 flaky)"
    content = markdown(card)
    assert "**Flaky, passed on rerun (1)**" in content and "**Still failing (1)**" in content

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 未验证的事件触发路径(核心风险):workflow_run: completed 是否对 job token 发起的 rerun attempt 触发,仓库内没有实证。若触发失败,卡片永远不会发布,nightly 失败将静默丢失。作者计划在第一次失败 nightly 后检查 PR Test 是否出现两个 attempt 且 notifier 只跑一次。

  2. 权限面扩大:ci-lark-notify.yml 把 actions: read 提升为 actions: write,notifier job 的 token 现在具备发起 rerun-failed-jobs 的能力;虽然测试断言了这是唯一写权限(contents / pull-requests 仍为 read),但 token 泄露或脚本注入的影响面变大了。

  3. 运行成本与延迟翻倍:失败 job 和 check-all-jobs 会运行两次(第一次失败 + 重跑),卡片到达时间从 cron 后约 2 小时推迟到 attempt 2 全部完成之后;周六的 weekly 同样是 scheduled run,也会被纳入自动重跑,成本随之增加。

  4. flaky 判定依赖 job 名匹配:diff 基于 failed_job_names 在 attempt 1 / attempt 2 之间做 job 名匹配,若 job 名因环境差异变化或 attempt 2 新增 job(由重跑依赖解锁),会被归入 new failures 而非 flaky,统计口径依赖既有 diff_attempts 的语义。

  5. 新路径缺少线上实证:dry-run 只在真实 run 上打印预告,真正 rerun 请求的端到端行为要等 main 上第一次失败 nightly 才能确认,属于上线的固有观察期。

影响范围集中在 CI 通知链路(.github/workflows/scripts/lark_notify.py、ci-lark-notify.yml 及对应测试),不涉及训练、推理或 rollout 代码。对团队的日常影响:nightly / weekly 红卡更可信,flaky 与真实失败分开呈现,减少人工重跑与手动核对;失败 job 的总执行次数增加,GPU / CPU 测试资源消耗相应上升。对通知系统的语义是一次契约变更:“失败”从“一次尝试失败”变成“重跑后仍失败”,所有依赖旧卡片语义的脚本或人工习惯需要跟随调整。

权限提升 actions: write workflow_run 触发路径未验证 失败 job 成本翻倍 CI 报告路径变更 新路径缺少线上实证

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论