我们流水线压到 7 分钟之后,我统计了最近 300 次构建:31% 的绿色是重跑出来的。红灯点了重跑,绿了就合并,检查等于没做。
两个月前开始治理,没加任何新工具,改了 5 个配置。现在重跑率 6%,变更失败率从 12% 掉到 4%。下面按顺序贴出来,都能直接抄。
第一步:先把重跑率变成数字
治理之前得先能看见。用 gh api 拉最近 100 次构建,数 run_attempt 大于 1 的有多少:
bash
#!/usr/bin/env bash
# rerun_rate.sh
REPO="your-org/your-repo"
gh api "repos/${REPO}/actions/runs?per_page=100" \
--jq '{total: (.workflow_runs | length),
rerun: ([.workflow_runs[] | select(.run_attempt > 1)] | length)}'
输出类似 {"total":100,"rerun":31}。我把它挂成每周一早上跑的定时任务,结果直接进周复盘。
有个细节:per_page=100 拉的是最近 100 次,不管时间跨度。发版频率低的仓库,改成按 created 时间过滤更准。
第二步:flaky 测试隔离,不是消灭
先说结论:别指望一次消灭 flaky,先隔离,再逐个修。
重试从全局改成显式标记。装 pytest-rerunfailures,不配全局 --reruns,只在确认过失败原因是环境类的测试上打标记:
python
import pytest
@pytest.mark.flaky(reruns=2, reruns_delay=3)
def test_query_order_status():
...
隔离集用 marker 管理,先在 pyproject.toml 里注册:
toml
[tool.pytest.ini_options]
markers = ["quarantine: flaky 候补,PR 流水线不跑"]
CI 侧分两条线。PR 流水线跑排除隔离集的用例;另建一个 nightly 工作流,凌晨跑全量,不带任何重试:
yaml
# .github/workflows/nightly.yml
on:
schedule:
- cron: "0 18 * * *" # UTC,北京时间凌晨 2 点,避开白天构建高峰
jobs:
strict-full:
runs-on: ubuntu-latest
steps:
- run: pytest
nightly 挂了谁,谁就是 flaky 候补(也可能是真回归,人工判断一下),第二天修。修一个,从 quarantine 放出来一个。两个月我们隔离了 14 个,修掉 11 个,PR 流水线基本不再重跑。
第三步:门禁分级,阻断只留机器能判定的
之前 23 个门禁全卡合并,后来砍到 9 个。原则一句话:能百分之百机器判定的才阻断,其余全改成报告。
yaml
jobs:
# 阻断级:必须绿才能合并
block-unit:
steps:
- run: pytest tests/unit
- run: gitleaks detect --no-banner --redact
# 报告级:挂了不挡合并,评论到 PR 让人自己看
report-sonar:
steps:
- run: sonar-scanner
- if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment ${{ github.event.number }} \
--body "SonarQube 有新增问题,详见 Action 日志,合不合并你自己定。"
配套改分支保护规则:Required checks 里只勾 block- 开头的 job。report 级的 job 挂了在 PR 上照样显示红叉,但不拦人。
SonarQube 严重问题、覆盖率阈值、许可证检查,我们全挪到了报告级。挪过去之后没有任何一个变成没人看------红叉还在,只是不再拿十几个检查消耗人的判断力。
第四步:依赖缓存留痕,出事两分钟定位
缓存翻过车的人才知道这个的价值。我们之前 pip 缓存里躺了个过旧的 patch 版本,本地和 CI 行为对不上,查了两天。
现在缓存照用,但每次构建把依赖清单存档:
yaml
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.lock') }}
- run: pip freeze > deps-${{ github.sha }}.txt
- uses: actions/upload-artifact@v4
with:
name: dependency-snapshot
path: deps-*.txt
retention-days: 90
key 里带 hashFiles,锁文件变了缓存自动失效。真出"本地好好的 CI 坏了"这种事,下载 90 天内对应那次构建的 artifact 和本地 diff,两分钟出结论。
不拦,只留证据。比每次重建缓存省的那一分钟值。
第五步:阶段开关化,给"敢删"一个试用期
流水线里有不少"当年觉得有用"的阶段。现在每个非核心阶段都挂开关:
yaml
e2e-smoke:
if: vars.PIPELINE_E2E_SMOKE != 'off'
steps: ...
vars 是仓库级变量,Settings → Variables 里改,不动代码、不用提 PR。
玩法:每个季度挑一个最可疑的阶段,开关拨到 off,观察两周。没出事,下个 PR 直接把 job 删掉。到现在我们删了 5 个阶段,没有一个加回来。
开关不是长期留着用的,是给删除一个安全的试用期。
前后数据
| 指标 | 治理前 | 治理后 |
|---|---|---|
| 重跑率(最近 100 次构建) | 31% | 6% |
| 变更失败率 | 12% | 4% |
| 平均恢复时间 | 3 小时 | 40 分钟 |
| 流水线时长 | 7 分钟 | 7 分钟(没变快) |
注意最后一行,流水线没变快。这套东西治的不是速度,是绿色里掺了多少次"再来一次"。
注意事项
- 重跑率统计要排除手动运维场景,我们按 run_attempt 统计前会过滤掉 event 为 workflow_dispatch 的构建,不然数字虚高。
- flaky 标记别滥用,只打在确认过是环境依赖类失败上的(超时、端口占用)。断言错了还打重试,等于把 bug 藏起来。
- 报告级门禁的 PR 评论要控制条数,同一个 PR 用固定评论更新,别一次失败刷一条。
先从第一步开始,今天就统计一下你们组的重跑率。统计完这个数,你大概率就知道该先动哪一步了。
你们流水线的重跑率是多少?评论区报个数。后面我会写那 11 个 flaky 具体怎么修的案例,想看的点个关注。
声明:本文选题与实操经验出自我本人,AI 用于资料整理与文字校对。