让 CI 读 PR 描述:验证、风险、回滚缺一不可
用一个小脚本拦下缺段和只写占位符的描述,并说明为什么推送前要先在本地跑一遍。
9 月 17 日,我在公司仓库里加了一个 CI 检查。它不跑测试,也不看代码,只读 PR 描述:没有「验证」「风险」「回滚」这三段,就标红。
同一天的提交说明里,最后一行是这么写的:
暂未进 required checks(Actions 账单停跑)
也就是说,这个检查上线那天,CI 正停着,它在 GitHub 上根本跑不起来。
为什么要读 PR 描述
agent 提 PR 很勤快,描述也写得很长,改了哪些文件、加了哪些函数,一条条列得清清楚楚。可我审 PR 的时候,最想知道的是另外三件事:你是怎么确认它能用的,它可能弄坏什么,弄坏了怎么撤回来。
这三件事,「变更」那一节里一件都没有。不写,我就得自己去翻代码、自己去跑一遍,那 PR 描述写那么长,对我来说没省下什么。
规则文件里当然可以写「PR 描述必须有验证、风险、回滚三段」,可规则靠的是 agent 记得,忘一次就漏一次;每段只填一个「无」,字面上也算照做了。这种事交给脚本最合适:它不会忘,也不会看在描述很长的份上放过去。
脚本只管两件事
一是三段都得在。标题里出现「验证」「测试」「Validation」中的任意一个就算验证段,风险和回滚同理;一个标题写成「风险与回滚」,两段都算。
二是不能只写占位符。「无」「N/A」「TODO」「同上」这种,删掉标点以后如果整段只剩它们,就算没写。模板里那些 <!-- 写怎么验证的 --> 的填写提示也不算内容,不然 agent 原样留着模板,检查也能过。
写不出内容的时候怎么办?我在规则文件里写的是:别删小节,把「为什么不适用」写进去;没做真机验证,就在验证段里如实写没做。
比如这个站的一个 PR,改的全是文案,风险段只写了一句:
低。只改文字,删了一个可选导语
这句话很短,但它是一个判断,读的人看完知道风险在哪、有多大。「无」就不是判断。
推送前,先在本地跑一遍
回到开头那件事。CI 停跑,后来查清楚了不是欠费,是那个月的免费额度用完了,而 Actions 的预算设的是 0 美元,额度一用完所有任务直接被拦。报错写的是付款失败,看着像欠费,其实一分钱都没欠。
10 月初额度重置,CI 恢复了。10 月 12 日,额度又用完了,又停了几天,直到我把预算从 0 调到 20 美元。
所以那条规则从第一天起就是这么写的:提 PR 前,本地先跑一遍。CI 停着的那段时间,本地这一遍是唯一一道闸。到现在这个检查也还没设成合并前的必过项,红了照样合得进去。本地这一遍,仍然是第一道闸。
就算 CI 一直好好的,本地先跑也划算。agent 在执行 gh pr create 之前跑一下,马上就知道缺了什么,当场补上;推上去再等 CI 变红,就是一来一回好几分钟。
那次提交里还有两个细节值得一说。PR 描述是谁都能写的文本,在 GitHub Actions 里别把它直接拼进 shell 命令,要通过环境变量传进脚本,不然有人在描述里写一段命令就能注入。另外,这种以后要设成必过项的检查,别用 workflow 级的 paths 过滤,改动没命中过滤条件的 PR,检查不会运行,就会一直挂着等待,永远合不进去。
可以直接抄的版本
脚本只用标准库,放进仓库的 scripts/pr_body_lint.py:
#!/usr/bin/env python3
"""PR 描述必须有 验证 / 风险 / 回滚 三段,且不能只写占位符。"""
import os
import re
import sys
SECTIONS = {
"验证": ("validation", "验证", "测试"),
"风险": ("risk", "风险"),
"回滚": ("rollback", "回滚", "回退"),
}
PLACEHOLDERS = {"n/a", "na", "none", "todo", "tbd", "无", "暂无", "略", "同上", "待补"}
HEADING = re.compile(r"^#{1,6}\s+(.+?)\s*$")
def split_sections(body):
body = re.sub(r"<!--.*?-->", "", body, flags=re.S) # 模板里的填写提示不算内容
sections, in_fence = [], False
for line in body.splitlines():
if line.lstrip().startswith("```"):
in_fence = not in_fence
match = None if in_fence else HEADING.match(line)
if match:
sections.append((match.group(1).lower(), []))
elif sections:
sections[-1][1].append(line)
return sections
def is_placeholder(lines):
words = [re.sub(r"[\s\-*>`.。::]+", "", line).lower() for line in lines]
return all(word in PLACEHOLDERS for word in words if word)
def main():
if len(sys.argv) > 1:
body = open(sys.argv[1], encoding="utf-8").read()
else:
body = os.environ.get("PR_BODY", "")
sections = split_sections(body)
errors = []
for name, aliases in SECTIONS.items():
found = [lines for title, lines in sections if any(a in title for a in aliases)]
if not found:
errors.append(f"缺少「{name}」一节")
elif all(is_placeholder(lines) for lines in found):
errors.append(f"「{name}」只写了占位符;不适用就写清为什么不适用")
for error in errors:
print(error)
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())
CI 里这样接,描述走环境变量,编辑描述也会重新检查:
name: pr-body
on:
pull_request:
types: [opened, edited, synchronize, reopened]
jobs:
pr-body:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python3 scripts/pr_body_lint.py
env:
PR_BODY: ${{ github.event.pull_request.body }}
再往规则文件里加一句,让 agent 在本地先跑:
- PR 描述必须有「验证」「风险」「回滚」三段;写不出内容就写清为什么不适用,
没做的验证如实写没做
- 提 PR 前先跑 `python3 scripts/pr_body_lint.py body.md`,通过了再 `gh pr create`
脚本能管的只是格式。验证段里写了「测试全过」,它分不出是真跑过还是随口一说,这得靠别的规矩,比如收尾汇报里要求测试失败就贴输出。
那个上线当天跑不起来的检查,现在 GitHub 上的 PR 都会跑,只是红了还拦不住合并。现在真正能拦在合并前面的,还是本地那一遍。