/2,649 字/阅读约 10 分钟

从"记得跑"到"跑不掉":博客文章检查的 CI 门禁

博客的文章检查脚本经过几轮校准后已经很准,但一个问题把我问住了:"它以后每次都会跑吗?"本文记录把检查装进部署门禁的完整过程——为什么"红叉警告"不算强制、怎么按变更类型分流检查强度,以及如何用临时分支和负测试证明门禁"会拦、且拦得准"。

眠月眠月
joker1point

这个博客有一套上线前的文章检查:行尾必须 LF;正文里的裸标签会被 MDX 当成 JSX、直接炸掉整站构建;frontmatter 五项必须齐全;对外内容不能含服务器信息——外加一条图文密度(每 500 汉字至少一个视觉元素)。每一条都来自一次真实踩坑,脚本本身也校准过几轮:先是有文章开始用语义组件,触发了「疑似危险内容」的误报;后来又是 JSX 属性里的大括号被误报——两轮校准之后,规则和存量 30 多篇文章对到零误报,我一度觉得这件事已经做完了。

直到一个问题把我问住:这些校准后的规则,怎么保证对以后每一篇文章都生效?

那一下才意识到,我把两件事搞混了:规则「存在」和规则「生效」。规则写在脚本里,但脚本要有人跑——而「有人跑」这件事,当时没有任何东西可以保证。

"写进脚本"解决了什么,没解决什么

先说做对的部分。最早的检查规则散在文档和记忆里,每次写文章靠回忆;后来把它们收进一个脚本,规则有了单一来源——校准一次,所有跑过脚本的文章都受益。这一步很关键,但它解决的是「规则的一致性」,不是「规则的执行」。

执行侧当时长这样:脚本躺在工作区的一个工具目录里,三份文档写着「上线前跑一下」。也就是说,执行的保证是一句提醒,和一个记得住的作者。哪天没跑就推送,规则对那篇文章就等于不存在。

顺着查下去还有两个更隐蔽的缺口:脚本和它的校准历史不在版本控制里,改了什么只有注释里记着日期;它也只覆盖「知道这个流程的人」——未来如果有另一个协作者、另一个 agent 来写文章,根本不经过这道仪式。

最要命的发现来自一次对账。检查器里有一张「已注册组件」白名单——站点会把正文里的尖括号当 JSX 处理,这些已注册的组件是合法的,不算危险内容。这张白名单是手写的。而站点其实有一份真正的组件注册表。我把两份清单对了一遍:注册表里有 11 个「作者可写」的组件,白名单只有 9 个——缺了两个。这次没爆雷,纯粹因为恰好没人手写过它们。

靠对齐维护的两份清单,迟早对不上。 这是那天最大的收获。

方案 A:把检查装进部署门禁

这个博客部署在 GitHub Pages 上,推送 main 就走一条固定的工作流:构建、上传、部署。最自然的想法就是给这条流水线加一道前置关卡——文章检查不通过,就不进入构建,更不会部署。

为什么不做成独立检查任务

独立跑一个检查、失败了亮一个红叉,看起来更礼貌——但红叉的本质是「无人执行的建议」。部署门禁则是「不部署」这个物理事实:文章真有问题时,问题内容不会上线。规则要成为门禁,而不是告示牌。

发布流水线中的文章检查:push 后先做检查,失败则中止、不部署
图 1 · 门禁在流水线上的位置:检查不通过,构建与部署都不会发生

做成门禁,立刻撞上一个现实问题:检查不能对「所有文章」一刀切。图文密度是新立的规矩,存量 30 多篇里绝大多数不达标——如果全量强制执行,门禁会从第一天起常红,然后被所有人当背景噪音忽略。门禁得能常绿,才有意义。

所以按「变更类型」给检查分了强度:

变更类型机械五项图文密度后果
新增文章强制强制任一失败 → 拦截部署
修改旧文强制仅警告只有机械失败才拦截

机械五项对所有变更全查——旧文章也可能被改出 CRLF 和裸标签;密度则对新文章严格执行,对修改旧文只发一条 GitHub 警告:存量欠账不追溯,但改版时会被提醒。

按变更类型分流:新增文机械五项与密度都强制;修改旧文只有机械项强制、密度仅警告
图 2 · 分流口径:新增严格、修改宽松

另外两个细节值得一提:降级与逃生舱。拿不到变更基线时(比如手动触发部署),门禁降级为「全量机械检查、密度跳过」——保守,但不会因为信息缺失而误杀。紧急情况则需要一个显式的绕过开关:手动触发时勾选跳过检查。这个开关刻意做成「必须显式动作」——绕过门禁应该是一个决定,而不是一次疏忽。

方案 B:把白名单还给注册表

「两份清单」的根治方案很直接:让检查器的白名单从组件注册表动态解析。加新组件时检查器自动跟随,不再需要「记得同步」。解析失败时退回一份内嵌快照,并打一条警告——宁可降级,不静默失准。

怎么证明门禁真的会拦?

门禁写完了,但「它绿了」证明不了什么——绿可能是检查真的通过,也可能是检查根本没跑。所以验收要同时回答两个方向:该拦的拦得住吗?不该拦的会误杀吗?

第一组证据是三个负测试,专门造出来的坏样本:

  • 一个含 CustomLink 组件的正常文件——预期放行(顺带验证动态白名单:手写白名单时代它会被误报)
  • 一个正文裸写 <p> 标签的文件——预期拦下
  • 一个 CRLF 行尾的文件——预期拦下

结果:放行的放行,拦下的拦下,三个退出码和预期完全一致。

负测试输出:组件文件通过(exit 0),裸标签与 CRLF 文件被拦(exit 1)
图 3 · 负测试三连:该放行的放行,该拦下的拦下

第二组证据更接近实战:用临时分支完整模拟一次 CI。关键是用门禁将执行的同一条命令——只是 before/after 指向本地分支——跑两种场景:

  1. 只修改一篇旧文(密度欠账)→ 只出一条警告,检查通过
  2. 同一分支再新增一篇含裸标签的文章 → 失败项 2,拦截
临时分支演练输出:修改旧文只警告放行(exit 0);新增违规文被拦(exit 1)
图 4 · 两种分流在同一分支上的实测

这组演练里还顺出一个哭笑不得的发现。我用工具写测试用的 .mdx 文件,三个文件全是 CRLF——也就是说,我自己写作链路上的工具,正是「制造问题文件」的源头之一。这从反面确认了门禁的必要性:与其指望流程上的每个人(包括我)都不犯错,不如让犯错的文件进不了部署。

最后补了一次全量基线:门禁所用的检查脚本对存量 32 篇跑了一遍,零问题——确保它不会拿旧文章当误伤对象。

本地自查输出:机械五项全部通过,图文密度 3/3 达标
图 5 · 自查命令的常态输出:机械五项 + 图文密度,一条命令跑完

上线:推送那一刻才是第一次真跑

本地演练得再全,门禁的第一次真正执行还是在推送那一刻。这次推送本身就是个特殊样本:变更集里没有任何文章文件(改的是工作流、脚本和文档),门禁应该走一条最安静的路径——直接放行,然后照常构建部署。

跑完去看结果,有一个容易被跳过的验收动作值得单独说:不要只看绿了。 整条工作流是绿的,可能是检查步骤在正常工作,也可能是那个步骤因为条件写错被整个跳过了——两种情况的界面颜色完全一样。所以要展开步骤列表,确认那一行真的存在、真的执行了。

GitHub Actions 页面:工作流 success,步骤列表里包含文章检查步骤
图 6 · 上线验收:不仅要看绿,还要确认检查真的跑了

结论:工作流成功,总时长 1 分 30 秒;步骤列表里「文章检查」一步在列、状态成功,构建与部署照常完成。门禁自这一刻起生效。

这次的账

6
文件改动
3 个新脚本 + 工作流 + 文档
+352
代码净增行
32
全量基线篇数
零问题
3
负测试
两个方向都验证
2
演练场景
放行 / 拦截
1m30s
CI 总时长

诚实起见,把边界也列清楚:

  • 「拦截」在 CI 侧的首次实测还没发生。本地用的是完全同一条命令、同样的场景,但真实门禁「红掉一次问题提交」,要等下一次有人(很可能就是我)犯错。届时它该红就红。
  • 机械检查管不了内容质量。规则能挡裸标签和 CRLF,挡不了「这段写得很没意思」。格式归机器,判断归人。
  • 门禁有它的代价:万一规则本身误报,它会卡住部署。缓解手段是「检查项只收零误报的规则」加上显式绕过开关——但代价不会消失,这是「强制」这个词的价格。

写在最后

回头看,一条规则的生命周期其实分三段:写下来只是出生,校准让它变准,而装进门禁才让它成真。前两步早就做过,唯独第三步——把「应该跑」变成「跑不掉」——是这次才补上的。

规则不是写完就生效的。它需要一道每次都会合上的门。

— 2026-10 博客工程复盘