本文数据全部来自我自己的本机链路,命令可复现。涉及的组织、账号、凭据均已泛化。
我有一条本机自动化链:抓资讯 → 本地模型打分 → 生成站点 → 构建 → 推仓库 → 线上验收。写了三个月,跑通时一声不响,坏掉时也一声不响。
这篇把最近踩到的六个坑摊开说。三个在 Windows 脚本层,一个在部署时序,两个在 iOS 侧的平台约束。它们有一个共同点:没有一个抛出异常。退出码是 0,日志是干净的,命令是"成功"的。
这条链长什么样
四行命令,读起来毫无歧义:
|
|
歧义不在命令里,在"成功"这个词的定义里。下面六个样本,每个都是一次定义错位。
样本一:时间戳写在未来,页面压根没生成
我在 frontmatter 里把 date 写成 2026-11-12T21:30:00+08:00,实际构建发生在 21:17。Hugo 的 buildFuture 默认是 false,未来日期的文章直接跳过,不报错、不警告、不出现在任何计数里。
我搭了个最小复现,两篇文章,一篇时间在过去、一篇在几小时后:
|
|
输出:
Pages │ 10 这个计数是正常的,构建产物目录结构和往常一模一样,只有那一篇不见了。我到线上看到 404,第一反应是"部署挂了",去翻推送记录、翻边缘节点缓存,折腾半小时才回到本地看一眼 public\posts\。
这类坑的本质是:构建器的"成功"只承诺"我把能建的都建了",不承诺"你想要的都被建了"。两者的差集,它不负责告诉你。
修法不是记住"别写未来时间",是发布前加一道断言:
|
|
跑一条 throw,比事后翻半小时日志便宜太多。
样本二:push 打出一条新 commit,线上还是 404
推送到主分支成功,返回 1cbfa1e..d13da64 main -> main。我拿着这个 commit hash 就去点开线上地址,404。
实际的轮询记录:
从 push 完成到首个 200,中间隔了大约一分钟。静态站点走的是边缘节点拉取源码后重新构建,构建是异步的,push 的返回值只覆盖"代码到了远端",不覆盖"站点重建完成"。
问题在于,这一分钟里所有信号都是绿的:git 成功、构建成功、远端有新 commit、本地 public/ 里有那个 HTML。只有一个信号是红的,就是那个 200 没来。而如果你不做这一步,你永远不会知道。
所以我后来把验收物钉死成一句话:交付物是那个 HTTP 200,不是 commit hash,也不是本地文件的字节数。本地 47,370 B 的 HTML 和线上 46,857 B 的响应不是一回事,中间隔着构建、压缩、CDN。
顺手记一个诊断细节:光看状态码不够,还得看内容对不对。软 404(返回 200 但内容是首页或空壳)在这种链路上很常见。我的检查是三件套——状态码 200、响应体里命中标题关键词、<h1> 只出现一次。
样本三:被杀掉的进程,和跑完的进程,日志里长得一样
run.ps1 --hours 48 第一次跑,22:14 启动,PID 5836。跑到 600 秒被外部超时杀掉。
它被杀之前干了这些事,日志都在:
22:24 换 PID 34904 重跑,22:39 产物落地,29,920 B。
麻烦的地方在于:进程消失 和 进程正常结束 在日志里表现完全一致。脚本最后一行都没打印,退出码也没人接住(外部杀进程,没有退出码语义)。你只能靠"产物文件在不在、字节数对不对"来倒推它到底走到哪一步。
这类链路的改造只有一条路:让每个阶段自己留痕,别指望退出码。
|
|
crash 之后 cat stage.txt 就知道死在哪。这条链现在每一步都写 stage.txt,配合产物文件的 mtime,能不能续跑一目了然。
顺带说个设计上的取舍:600 秒的超时对"抓 48 条 + 本地模型逐条打分 + 内容富化"是不够的。要么把富化拆成独立的第二段,要么把超时提到 1800 秒并把进度写进状态文件。我选的是后者,因为拆段会让"一次调用拿到完整产物"这个语义丢掉,而下游全依赖这个语义。
还有一件事得一起解决:重跑的安全边界。第一次被杀之后我是直接重跑的,因为那次输出目录还是空的。但如果它死在 95% 的位置,输出文件已经写了一半,第二次重跑就会撞上两个问题——旧文件被当成新结果,以及两个进程同时写同一个文件。前者让"到底哪次的结果"永远说不清,后者在 Windows 上会拿到 文件正被另一进程使用 的异常,而这个异常常常被上层 catch 吃掉,最后表现为"跑完了但产物不全"。
我现在的做法是三条:产物先写 .tmp 后缀,全部写完再 Move-Item 覆盖(同卷内的重命名是原子的);开跑前检查是否已有同任务的进程在跑;每次产物都带一个 run_id 写进文件头,事后能追到是哪一次跑的。这三个动作加起来不到二十行,但它们决定了"重跑"这个操作能不能放心按下去。
样本四:分号把第二条命令的变量吃了
这个坑小,但很隐蔽。我习惯把多条命令用 ; 串在一行 powershell -Command 里:
|
|
从第二条起,$b 被吞掉,报错是 =@ 不是 cmdlet——注意它报的不是"变量未定义",是一个看起来毫不相干的语法错。因为 cmd.exe 先把整行交给 PowerShell,中间的引号和分号在双层解析里被啃掉了。
修法简单粗暴:一条命令一个独立进程。批量跑就写个循环,别图省事。我当时的复算命令有五条,串在一行里跑,前两条对、后三条错,错的方式还不一样,排查时间远超省下来的那点敲键盘。
样本五:转义的竖线让判据永久为绿
这条最贵。一张自评记分卡,J2 那条判据卡面上写 0.6(6/10),我把卡面上的命令原样粘进终端跑,输出 6/0。
看清楚了:分母是 0,不是 10。6 除以 0,卡面上的 0.6 是早期手抄进去的,之后再没跟着命令更新。
根因是 markdown 转义。卡面里写的是 (答满\|半满\|接不住),这个 \| 在 markdown 表格里是"字面竖线",粘进 .NET 正则里也仍然是字面竖线——它匹配的是一整个字符串 答满|半满|接不住,永远不命中。所以分子 6、分母 0。
按当时文档里的提示"删掉反斜杠"去改,更糟:
空的分支在交替里匹配所有行,直接满分。一个分母为 0,一个分母为全部,两个都是假数字。
顺着查下去还发现更严重的事:J3、J4 两条命令的 \| 是同一个毛病,它们恒返回 0。而这两条判据的真值本来就是 0,所以回归测试、人工复核、月度比对,全都发现不了。这就是"永久绿"——判据失效了,但失效的样子和健康的样子一样。
改法是把字面管道符从命令里彻底排挤出去:
|
|
第 4 列那个细节值得单说:表格结构是 | 题号 | 题目 | 分数 | 判定 | 缺口 |,但分数列里会出现 4.0(答满) 这种把判定词嵌进去的值。全表扫关键词就会把它算成命中。按列索引取、再用 StartsWith 匹配,才稳。
改完必须验证的不是“命令能跑通”,而是"命令能命中":
|
|
五条命令改完逐条独立进程重跑:J1=60 / J2=6/10 / J3=0 / J4=0 / J5=5,探针各 2/2 命中。J3、J4 的真值仍然是 0——但这次是"测过了,确实是 0",不是"从来没测到过"。
样本六:iOS 侧,一个勾选框让扩展静默消失
前五个都在 Windows 上,第六个在手机上,性质完全不同:它不是 bug,是平台强加的边界,只能写进约束,不能修掉。
同一个 Flutter 工程,Windows 侧跑 WebView2,iOS 侧靠 Share Extension 接系统分享菜单。iOS 的安装方式是未签名 IPA 自己签名侧载,流程里有个选项叫「Remove app extensions」。勾上它,主 App 装得好好的,一切正常,只有分享菜单里那个入口不见了。
因为主 App 和 Share Extension 是两个独立的 bundle,主 App 能启动不代表扩展被注册。你在设置里翻不到、在日志里看不到、App 也不报错——功能就是不存在。
第二个边界更硬:免费开发者账号签的包 7 天过期。也就是说"自动化"这件事在 iOS 上每 7 天断一次,而且断的方式是 App 打不开,不是同步失败。
第三个是小坑:Windows 侧依赖 WebView2 Runtime。Win10/11 一般自带,但在干净环境里跑首次会白屏,白屏不带任何错误信息。
这三条我最后都没写进代码,写进了一张约束表:
| 约束 | 触发条件 | 表现 | 处理 |
|---|---|---|---|
| 扩展未注册 | 签名时勾了 Remove app extensions | 主 App 正常,分享菜单无入口 | 重签,不勾该项 |
| 签名过期 | 免费账号满 7 天 | App 无法启动 | 定期重签,写进日历 |
| WebView2 缺失 | 干净系统首次运行 | 白屏无报错 | 启动时探测并提示安装 |
区分"能修的 bug"和"必须接受的边界",比修 bug 本身更省时间。前者改代码,后者改预期——把后者当 bug 修,会修到怀疑人生。
收口:把"成功"拆成可观测的四件事
六个样本摊完,剩下的是一套检查顺序。我现在不信任何单一信号,只信这四件事:
一、退出码 0 不是证据。 构建器、打包器、推送工具,返回 0 的语义都是"我没崩",不是"你要的东西存在"。要用产物本身验证产物:文件在不在、字节数对不对、里面有没有该有的字符串。
二、进程消失和正常退出必须可区分。 每个阶段写状态文件,别把判断依据押在退出码上。被外部杀掉的那次和跑完的那次,日志里长得一样,唯一能分开它们的是落盘的阶段标记和产物 mtime。
三、每条判据配一条植入探针。 判据返回 0 的时候,你分不清是"确实没有"还是"根本没在测"。造一条必然命中的数据,让命令数出 1,才算这条判据活着。这条纪律救回来的不止一处。
四、验收物要外部可达。 本地的 HTML、远端的 commit、构建成功的日志,全是中间态。真正的验收物只有一个:别人在浏览器里打开那个地址,看到 200 和正确的内容。
发布前能三十秒过一遍的清单,就这五行:
| 检查 | 命令/动作 | 不过怎么办 |
|---|---|---|
| 时间戳不在未来 | 读 frontmatter 的 date 与当前时间比 |
改小,别改构建参数 |
| 产物真的存在 | public\posts\<slug>\index.html 有字节数 |
查 draft / buildFuture |
| 判据是活的 | 跑一遍植入探针 | 命令重写,别信旧结果 |
| 推到对的分支 | git rev-parse --abbrev-ref HEAD 是 main |
切回来,别推 master |
| 线上 200 | 轮询到 200 且内容命中 | 看边缘构建日志 |
落地成一个脚本就是二十行:
|
|
轮询 20 次、间隔 5 秒,覆盖样本二那 60 秒的构建滞后还有富余。拿到 200 并且内容命中才 exit 0,否则红着退出去。CI 里这一步红了,就是真没上线;它绿了,我才敢说"发了"。
写自动化的人容易犯一个错:把精力全花在让它"能跑",忘了让它"跑坏了会喊"。上面六个坑没有一个在"能跑"的阶段暴露,全在"跑坏了"的阶段才现形。所以这条链真正的工程量,不在那四行命令,在让这四行命令失败时能说清楚自己死在哪。