本文数据全部来自我自己的本机链路,命令可复现。涉及的组织、账号、凭据均已泛化。

我有一条本机自动化链:抓资讯 → 本地模型打分 → 生成站点 → 构建 → 推仓库 → 线上验收。写了三个月,跑通时一声不响,坏掉时也一声不响。

这篇把最近踩到的六个坑摊开说。三个在 Windows 脚本层,一个在部署时序,两个在 iOS 侧的平台约束。它们有一个共同点:没有一个抛出异常。退出码是 0,日志是干净的,命令是"成功"的。

这条链长什么样

四行命令,读起来毫无歧义:

1
2
3
4
powershell -ExecutionPolicy Bypass -File run.ps1 --hours 48 -l INFO
hugo --minify
git -c http.proxy=http://127.0.0.1:10809 push origin main
curl -s -o NUL -w "%{http_code}" https://www.example.com/posts/<slug>/

歧义不在命令里,在"成功"这个词的定义里。下面六个样本,每个都是一次定义错位。

样本一:时间戳写在未来,页面压根没生成

我在 frontmatter 里把 date 写成 2026-11-12T21:30:00+08:00,实际构建发生在 21:17。Hugo 的 buildFuture 默认是 false,未来日期的文章直接跳过,不报错、不警告、不出现在任何计数里。

我搭了个最小复现,两篇文章,一篇时间在过去、一篇在几小时后:

1
2
3
hugo --quiet
echo EXITCODE=%errorlevel%
if exist public\posts\future (echo future: EXISTS) else (echo future: MISSING)

输出:

E f X u I t T u C r O e D : E = M 0 I S S I N G

Pages │ 10 这个计数是正常的,构建产物目录结构和往常一模一样,只有那一篇不见了。我到线上看到 404,第一反应是"部署挂了",去翻推送记录、翻边缘节点缓存,折腾半小时才回到本地看一眼 public\posts\。

这类坑的本质是:构建器的"成功"只承诺"我把能建的都建了",不承诺"你想要的都被建了"。两者的差集,它不负责告诉你。

修法不是记住"别写未来时间",是发布前加一道断言:

1
2
3
4
$fm = Get-Content $post -TotalCount 10
$dateLine = $fm | Where-Object { $_ -match '^date:' } | Select-Object -First 1
$d = [datetime]::Parse($dateLine.Substring(5).Trim())
if ($d -gt (Get-Date)) { throw "frontmatter date 在未来:$d" }

跑一条 throw,比事后翻半小时日志便宜太多。

样本二:push 打出一条新 commit,线上还是 404

推送到主分支成功,返回 1cbfa1e..d13da64 main -> main。我拿着这个 commit hash 就去点开线上地址,404。

实际的轮询记录:

2 2 2 2 2 2 1 1 1 1 1 1 : : : : : : 2 2 2 2 2 3 8 8 9 9 9 0 : : : : : : 2 4 0 1 2 0 7 4 1 2 8 3 4 4 4 4 2 2 0 0 0 0 0 0 4 4 4 4 0 0 ( ( 4 4 6 6 , , 8 8 5 5 7 7 B B ) ) 复 测

从 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 秒被外部超时杀掉。

它被杀之前干了这些事,日志都在:

抓 A 筛 去 → 取 I 选 重 进 4 分 1 1 入 8 析 5 3 E i 4 i u n t 8 t n r e e i i m i m q c s t s u h e e i m n ( s g G i 阶 t 段 H u … b … 1 无 后 / 续 T e l e g r a m 1 7 / H a c k e r N e w s 9 / R S S 9 / R e d d i t 1 2 )

22:24 换 PID 34904 重跑,22:39 产物落地,29,920 B。

麻烦的地方在于:进程消失 和 进程正常结束 在日志里表现完全一致。脚本最后一行都没打印,退出码也没人接住(外部杀进程,没有退出码语义)。你只能靠"产物文件在不在、字节数对不对"来倒推它到底走到哪一步。

这类链路的改造只有一条路:让每个阶段自己留痕,别指望退出码。

1
2
3
def stage(name):
    with open(state_dir / "stage.txt", "w", encoding="utf-8") as f:
        f.write(f"{name}\t{datetime.now().isoformat(timespec='seconds')}\n")

crash 之后 cat stage.txt 就知道死在哪。这条链现在每一步都写 stage.txt,配合产物文件的 mtime,能不能续跑一目了然。

顺带说个设计上的取舍:600 秒的超时对"抓 48 条 + 本地模型逐条打分 + 内容富化"是不够的。要么把富化拆成独立的第二段,要么把超时提到 1800 秒并把进度写进状态文件。我选的是后者,因为拆段会让"一次调用拿到完整产物"这个语义丢掉,而下游全依赖这个语义。

还有一件事得一起解决:重跑的安全边界。第一次被杀之后我是直接重跑的,因为那次输出目录还是空的。但如果它死在 95% 的位置,输出文件已经写了一半,第二次重跑就会撞上两个问题——旧文件被当成新结果,以及两个进程同时写同一个文件。前者让"到底哪次的结果"永远说不清,后者在 Windows 上会拿到 文件正被另一进程使用 的异常,而这个异常常常被上层 catch 吃掉,最后表现为"跑完了但产物不全"。

我现在的做法是三条:产物先写 .tmp 后缀,全部写完再 Move-Item 覆盖(同卷内的重命名是原子的);开跑前检查是否已有同任务的进程在跑;每次产物都带一个 run_id 写进文件头,事后能追到是哪一次跑的。这三个动作加起来不到二十行,但它们决定了"重跑"这个操作能不能放心按下去。

样本四:分号把第二条命令的变量吃了

这个坑小,但很隐蔽。我习惯把多条命令用 ; 串在一行 powershell -Command 里:

1
powershell -Command "$a=1; Write-Host $a; $b=2; Write-Host $b"

从第二条起,$b 被吞掉,报错是 =@ 不是 cmdlet——注意它报的不是"变量未定义",是一个看起来毫不相干的语法错。因为 cmd.exe 先把整行交给 PowerShell,中间的引号和分号在双层解析里被啃掉了。

修法简单粗暴:一条命令一个独立进程。批量跑就写个循环,别图省事。我当时的复算命令有五条,串在一行里跑,前两条对、后三条错,错的方式还不一样,排查时间远超省下来的那点敲键盘。

样本五:转义的竖线让判据永久为绿

这条最贵。一张自评记分卡,J2 那条判据卡面上写 0.6(6/10),我把卡面上的命令原样粘进终端跑,输出 6/0。

看清楚了:分母是 0,不是 10。6 除以 0,卡面上的 0.6 是早期手抄进去的,之后再没跟着命令更新。

根因是 markdown 转义。卡面里写的是 (答满\|半满\|接不住),这个 \| 在 markdown 表格里是"字面竖线",粘进 .NET 正则里也仍然是字面竖线——它匹配的是一整个字符串 答满|半满|接不住,永远不命中。所以分子 6、分母 0。

按当时文档里的提示"删掉反斜杠"去改,更糟:

4 4 0 / 4 4 0

空的分支在交替里匹配所有行,直接满分。一个分母为 0,一个分母为全部,两个都是假数字。

顺着查下去还发现更严重的事:J3、J4 两条命令的 \| 是同一个毛病,它们恒返回 0。而这两条判据的真值本来就是 0,所以回归测试、人工复核、月度比对,全都发现不了。这就是"永久绿"——判据失效了,但失效的样子和健康的样子一样。

改法是把字面管道符从命令里彻底排挤出去:

1
2
3
4
5
6
7
# 交替用字符码拼,不写字面竖线
$pat = '答满' , '半满' , '接不住' -join [char]124
# 管道操作符换成 foreach
foreach ($line in $lines) { ... }
# 判定列用固定索引取词,避开分数列里嵌判定的行
$cell = $line.Split([char]124)[4].Trim()
if ($cell.StartsWith('答满')) { $q++ }

第 4 列那个细节值得单说:表格结构是 | 题号 | 题目 | 分数 | 判定 | 缺口 |,但分数列里会出现 4.0(答满) 这种把判定词嵌进去的值。全表扫关键词就会把它算成命中。按列索引取、再用 StartsWith 匹配,才稳。

改完必须验证的不是“命令能跑通”,而是"命令能命中":

1
2
3
# 植入探针:造一条必然命中的数据,命令必须数出 1
'| Q1 | 探针 | 1.0 | 答满 | 无 |' | Set-Content probe.txt
# 期望输出 1,输出 0 说明命令仍然是瞎的

五条命令改完逐条独立进程重跑: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 且内容命中 看边缘构建日志

落地成一个脚本就是二十行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$url = "https://www.example.com/posts/$slug/"
for ($i = 0; $i -lt 20; $i++) {
    try {
        $r = Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 10
        if ($r.StatusCode -eq 200 -and $r.Content -match [regex]::Escape($keyword)) {
            "{0}  200  {1} B" -f (Get-Date -Format 'HH:mm:ss'), $r.RawContentLength
            exit 0
        }
    } catch { }
    Start-Sleep -Seconds 5
}
"FAIL: 20 次轮询未拿到 200+关键词"
exit 1

轮询 20 次、间隔 5 秒,覆盖样本二那 60 秒的构建滞后还有富余。拿到 200 并且内容命中才 exit 0,否则红着退出去。CI 里这一步红了,就是真没上线;它绿了,我才敢说"发了"。

写自动化的人容易犯一个错:把精力全花在让它"能跑",忘了让它"跑坏了会喊"。上面六个坑没有一个在"能跑"的阶段暴露,全在"跑坏了"的阶段才现形。所以这条链真正的工程量,不在那四行命令,在让这四行命令失败时能说清楚自己死在哪。