最近在 Go 多语言站点的正式工作流里,我遇到了一个看起来有点荒谬的问题:
中间命令明明已经失败,脚本最后却打印出了 RESULT=PASS。
而且这不是某个业务逻辑偷偷吞掉了错误。
真正的原因其实很朴素。
第一处错误是:
我调用了一个实际上不存在的命令:
generation-bundle check正确命令应该是:
generation-bundle current-check第二处错误更加关键:
为了让脚本在失败后提前停止,我在顶层子 Shell 中写了:
return但 Bash 的 return 只能用于函数,或者被 source 执行的脚本。
于是最后出现了一条很危险的执行链:
真正的检查命令不存在
→ 命令退出状态为 1
→ 脚本检测到失败
→ 打印 RESULT=FAILED
→ 执行 return
→ return 自己也失败
→ 后面的命令继续执行
→ 后续检查恰好成功
→ 最后再次打印 RESULT=PASS也就是说:
同一次脚本执行,同时出现了 FAILED 和 PASS。
如果只看终端最后几行,甚至可能只看到 PASS。
这件事让我再次确认:
无法确认时按失败处理,不只是业务验证器的责任。包裹这些验证器的 Shell 控制流本身,也必须做到失败后真正停止。
事情是怎么发生的?
当时我刚刚为简体中文首页建立了新的翻译生成批次:
chatgpt-zh-CN-059随后成功导出了正式 Generation Bundle:
zh-CN-chatgpt-zh-CN-059-generation.zip对应 SHA-256:
5b8e2944838ca12e5b79c4ed3fbe1ff49245c10026d176df79e2a73e360b8cb6Bundle 导出成功以后,还不能直接开始翻译生成。
因为“导出成功”只能证明:
当前已经成功生成了一份 ZIP。
它并不能证明:
这份 ZIP 相对于此刻的仓库状态仍然是当前有效的。
所以后面还必须做一次当前性检查。
问题就出在这里。
我错误地执行了:
generation-bundle check实际输出非常明确:
tour-i18n: unknown command "generation-bundle check"
exit status 1这时候,真正的当前性检查其实根本没有执行。
如果脚本到这里正确停止,问题并不大。
命令写错,查清楚以后改正就行。
真正危险的是外围 Shell 控制流没有停下来。
一个不能工作的 return
当时用于包装这组命令的结构,大致类似这样:
(
some-command
STATUS=$?
if [ "$STATUS" -ne 0 ]; then
printf 'RESULT=FAILED\n'
return
fi
do-more-work
printf 'RESULT=PASS\n'
)乍一看没有什么问题。
逻辑也很直观:
如果前面的命令失败,就打印失败,然后 return。
但这里有一个关键问题。
外层的:
(
...
)创建的是一个子 Shell。
它并不是 Shell 函数。
所以这里的:
return并不能退出当前子 Shell。
Bash 会直接报错:
bash: return: 只能在函数或者被 source 的脚本中使用 "return"而更容易被忽略的是:
return 自己失败,并不意味着后面的命令一定停止。
于是失败分支虽然打印了:
===== FINAL =====
RESULT=FAILED但实际上没有形成真正的停止边界。
后面的逻辑仍然继续执行。
为什么后面还能出现一个 PASS?
原脚本在当前性检查之后,还安排了其他步骤,例如:
- 查看 ZIP 内容;
- 统计文件数量;
- 检查是否包含 manifest;
- 执行
git diff --check。
这些步骤本身没有问题。
而且它们后来确实都成功了。
于是脚本继续向下走,最后又执行了正常成功分支中的:
===== FINAL =====
RESULT=PASS最终终端里出现了类似这样的结果:
===== FINAL =====
RESULT=FAILED
STAGE=site-v2-shell-homepage-generation-bundle-check
...后面又接着出现:
===== FINAL =====
RESULT=PASS
STAGE=site-v2-shell-homepage-generation-bundle
...同一次执行里居然出现两个互相矛盾的最终结果。
如果人工完整阅读日志,还能发现问题。
但如果某个后续脚本只是简单地:
取最后一个
RESULT
那它就很可能把真实失败错误地解释成成功。
为什么最后那个 PASS 不能相信?
这里有一个很重要的原则:
后面的成功,不能覆盖前面尚未闭合的必要检查失败。
当时前面已经有一个明确事实:
CHECK_STATUS=1而错误输出也明确说明:
unknown command "generation-bundle check"所以真实状态只能是:
当前性检查没有执行成功。
后面的检查即使全部通过,也不能替代它。
例如:
git diff --check = PASS只能证明:
当前 Git diff 没有空白格式问题。
它不能证明:
Generation Bundle 仍然是当前有效的。
同样:
ZIP SHA 匹配只能证明:
当前 ZIP 字节与之前记录的 ZIP 字节一致。
它也不能证明:
这份 ZIP 与当前仓库状态仍然一致。
每一道检查都有自己的职责。
一个检查通过,不能代替另一个尚未执行或者已经失败的检查。
“最后一个 FINAL”不是天然可信的
我在很多维护命令里都会统一打印:
===== FINAL =====
RESULT=PASS这样有一个很大的好处:
终端输出再长,也可以快速找到最终状态。
但这次事故让我更明确地认识到:
FINAL 只是摘要格式,不是权威来源本身。
它成立的前提是:
- 所有必要步骤都已经执行;
- 每个步骤的退出状态都符合预期;
- 失败分支真的能够停止;
- 不存在失败以后继续向下执行的路径;
- 最后的结果与前面所有必要检查一致。
如果这些条件没有满足,那么:
RESULT=PASS也不过是一行普通文本。
它自己并不能证明任何事情。
换句话说:
不是因为打印了 PASS,所以流程成功了;而是因为前面的所有条件都已经被证明成立,所以最后才有资格打印 PASS。
后来怎么确认真正的问题?
发现同一段输出里同时存在 FAILED 和 PASS 以后,我没有重新生成 Bundle。
也没有重新创建翻译批次。
第一步只做了一件事:
读取第一次执行留下来的错误日志。
结果非常清楚:
tour-i18n: unknown command "generation-bundle check"
exit status 1这一下就能区分两个完全不同的问题。
第一种情况是:
Bundle 本身当前性检查失败。
第二种情况是:
当前性检查命令根本就没有正确执行。
这两种情况的恢复方式完全不同。
如果 Bundle 真的已经失效,就需要继续检查:
- 当前仓库状态;
- 输入身份;
- 上游依赖;
- Bundle 与当前工作区之间是否发生变化。
但如果只是命令名称写错,就不应该重建任何已有产物。
正确做法只是:
保留原来的 Bundle,然后使用正式的当前性检查命令。
正确命令到底是什么?
后来直接读取当前 CLI 实现,确认正式入口是:
generation-bundle current-check它只接受一个必要参数:
--bundle <generation.zip>所以真正应该执行的是类似:
go run -mod=readonly ./cmd/tour-i18n \
generation-bundle current-check \
--bundle /tmp/site-v2-shell-homepage-generation-bundle-20261025/zh-CN-chatgpt-zh-CN-059-generation.zip最终得到:
Generation bundle: CURRENT这说明原来的 Bundle 从头到尾都是好的。
真正出错的是:
外围命令调用和 Shell 控制流。
为什么不能看到失败就直接重跑?
这一次出问题的是只读当前性检查。
即使重新执行,实际风险也不算高。
但整个项目里还有很多命令会真正修改正式状态,例如:
apply
record
import
promotion
activation如果养成一种习惯:
失败了就再跑一次看看。
很快就会遇到更危险的问题。
尤其是在出现:
mutation=unknown的时候。
它表达的不是:
一定没有发生状态变化。
而是:
当前无法可靠证明刚才是否已经发生了状态变化。
例如可能出现:
- 正式文件已经写入;
- current pointer 已经替换;
- receipt 已经创建;
- 但 stdout 没有成功返回;
- 或者进程在最后阶段异常终止。
这时候如果直接重跑原来的 mutation 命令,就可能造成:
- 重复执行;
- 使用已经失效的前置 SHA;
- CAS 冲突;
- pending marker 混乱;
- 历史来源无法解释。
因此我的恢复原则一直是:
失败以后先判断有没有发生状态变化,再决定能不能重新执行。
这次虽然不是正式 mutation 失败,但同样采用了这个原则。
我没有重新导出 Bundle。
没有重新创建 chatgpt-zh-CN-059。
而是先读取第一次执行留下来的证据。
确认只是命令名称错误以后,再执行正确的只读检查。
命令名称也不能靠经验猜
这次还有一个很直接的教训。
当时已经存在:
generation-bundle export而很多其他命令又都有类似:
xxx check这样的结构。
于是很自然地写成了:
generation-bundle check但真实 CLI 并不是这样设计的。
实际相关命令包括:
generation-bundle export
generation-bundle current-check
generation-bundle archive-imported
generation-bundle retry-handoff
generation-bundle locale-export
generation-bundle locale-check可以看到:
TranslationUnit Generation Bundle 使用的是:
current-check而 locale Generation Bundle 使用的是:
locale-check它们并没有完全统一。
如果只是日常手工操作,命令写错以后再查一下帮助,问题不大。
但在正式工作流里,这种“根据同类命令猜一个名字”的做法风险更高。
尤其当命令可能:
- 创建正式产物;
- 修改状态;
- 记录审核结果;
- 切换当前权威状态。
这时候 CLI 名称、参数、输入类型和失败语义都应该来自当前正式契约,而不是来自经验推断。
为什么我没有简单改成 set -e?
看到这里,一个很自然的想法是:
那就在 Shell 最前面写:
set -e命令失败以后不就自动停止了吗?
我没有采用这种方式作为主要解决方案。
原因是正式工作流里,我经常需要主动捕获退出状态:
some-command
STATUS=$?然后根据失败类型决定:
- 保存哪些证据;
- 输出什么停止原因;
- 当前状态变更是
none、known还是unknown; - 是否允许重新执行;
- 是否应该保留 pending marker;
- 是否必须停止后续步骤。
而 set -e 在不同 Shell 结构中的行为并没有想象中那么直观。
例如它会受到:
if&&||- pipeline
- 子 Shell
- 命令替换
等语法环境影响。
所以我现在更倾向于明确写出成功路径和失败路径。
例如:
if some-command; then
printf 'RESULT=PASS\n'
else
printf 'RESULT=FAILED\n'
fi或者:
if some-command; then
do-next-step
else
preserve-evidence
fi这样最重要的性质是:
失败分支结束以后,代码结构上就不会再落入成功分支。
而不是依赖某个可能失效的:
return去阻止后续执行。
Shell 包装脚本也属于工作流正确性的一部分
以前很容易把 Go 程序看成“正式实现”,而把外层 Shell 命令理解成临时辅助。
但这次事故说明:
只要 Shell 在承担这些职责:
- 串联多个正式命令;
- 读取退出状态;
- 传递 SHA;
- 传递前置状态;
- 判断是否继续;
- 保存日志;
- 输出最终状态;
- 控制失败恢复;
它就已经是正式工作流的一部分。
即使这段 Shell 没有长期保存在仓库里,它仍然会直接影响:
一次正式操作到底是继续,还是停止。
所以失败路径同样值得检查。
至少应该问几个问题:
- 第一条命令失败以后,后面还会不会执行?
- 文件不存在以后,是否仍然可能进入成功分支?
- SHA 不匹配以后,会不会继续?
- 当前性检查失败以后,会不会继续?
- 同一次执行是否可能同时打印 FAILED 和 PASS?
mutation=unknown以后,是否还会执行下一步?
成功路径通常比较容易写。
真正决定自动化是否安全的,往往是失败路径。
长日志为什么应该默认保存到文件?
这次还顺带暴露了另一个问题。
最开始,我让完整 JSON 和检查结果直接打印到终端。
结果真正执行以后,输出太长,很难完整复制回来。
这时如果为了获得更短的输出,再执行一次正式命令,就可能产生不必要的重复执行风险。
后来我把处理方式调整成:
完整 JSON → evidence 文件
完整 stderr → evidence 文件
终端 → 只打印摘要、SHA、身份标识、异常和 FINAL例如终端只保留:
CHECK_STATUS=0
BUNDLE_SHA256=...
MUTATION=none
RESULT=PASS而完整内容继续保存在 /tmp 下。
这样做有几个明显好处。
第一,完整证据不会丢失。
第二,终端输出更短,更容易发现异常。
第三,不会因为“输出太长无法复制”而产生重新运行正式命令的诱惑。
第四,发生问题以后,可以直接读取第一次执行时留下来的原始结果。
这次最后能够安全恢复,靠的就是这一点。
没有重新创建批次。
没有重新导出 Bundle。
只是读取原来的:
export log
check stderr
ZIP SHA然后继续诊断。
RESULT=PASS 应该是证明的结论,而不是控制流的默认终点
这次问题以后,我开始更加警惕一种脚本写法:
do-step-1
do-step-2
do-step-3
printf 'RESULT=PASS\n'如果三个步骤中的任何一个失败后仍然存在穿透路径,最后这个 PASS 就很危险。
更可靠的思路应该是:
只有在所有必要条件都明确满足以后,才进入唯一的成功分支。
也就是说,PASS 不应该只是:
脚本终于执行到了最后一行。
它应该意味着:
所有必须证明的条件都已经被逐项证明成立。
这两者看起来很像,实际上差别非常大。
前者属于流程位置。
后者属于状态证明。
这次事故留下的几条规则
经过这次问题以后,我给 Shell 自动化又补充了几条规则。
第一:
正式 CLI 的名称和参数不靠相似命令推断。
第二:
失败分支必须真正停止,不能只是打印 FAILED。
第三:
后面的 PASS 不能覆盖前面尚未闭合的必要检查失败。
第四:
FINAL 是状态摘要,不是状态权威本身。
第五:
长日志默认保存到证据文件,不为了获得短输出重新执行正式 mutation。
第六:
失败以后优先读取第一次执行留下来的证据,而不是直接重跑。
第七:
Shell 控制流本身也是工作流正确性的一部分。
最后,也是这次最直观的一句话:
一个系统最危险的状态,不一定是明确告诉你“失败了”,而是它其实已经失败,却仍然存在一条路径可以走到最后,再打印一个 PASS。
真正的 fail-closed,不只是能够发现错误。
而是:
一旦某个必要条件无法被证明成立,后面的成功路径在结构上就已经走不通。
Go 学习与文档多语言本地化项目
本系列完整记录 Go 学习与文档多语言本地化项目从 A Tour of Go 起步,逐步扩展到站点首页、项目说明页、学习内容、文档以及未来内容包的实际开发过程,包括架构设计、统一术语治理、整页翻译、结构保护、自动校验、独立质量审核、生产发布与长期维护。
项目源码:
✅ GitHub:shuijingwan/go-tour-i18n
语言版本:
当前跨项目目标语言池共包含 66 个语言版本。Go 项目本身目前提供 65 个社区 Production 语言版本,下面每一门 Go 语言版本都直接链接到其当前公开站点。语言列表沿用项目首页现有的展示顺序。
英语(美国)仅作为跨项目目标语言纳入列表,并不是单独的 Go Production 语言版本。当前 Go 的英文源内容仍为官方 generic English,因此英语(美国)链接到 Go 官方站点,而不是不存在的本地社区部署。
- 阿姆哈拉语
- 阿拉伯语
- 孟加拉语
- 葡萄牙语(巴西)
- 保加利亚语
- 加泰罗尼亚语
- 克罗地亚语
- 捷克语
- 丹麦语
- 荷兰语
- 英语(美国)(Go 官方英文站点)
- 英语(澳大利亚)
- 英语(加拿大)
- 英语(印度)
- 英语(新加坡)
- 英语(南非)
- 英语(英国)
- 爱沙尼亚语
- 菲律宾语
- 芬兰语
- 法语(法国)
- 法语(加拿大)
- 德语(德国)
- 德语(奥地利)
- 德语(瑞士)
- 希腊语
- 古吉拉特语
- 希伯来语
- 印地语
- 匈牙利语
- 印度尼西亚语
- 意大利语
- 日语
- 卡纳达语
- 哈萨克语
- 韩语
- 西班牙语(拉丁美洲)
- 拉脱维亚语
- 立陶宛语
- 马来语
- 马拉雅拉姆语
- 马拉地语
- 书面挪威语(Bokmål)
- 波斯语
- 波兰语
- 葡萄牙语(葡萄牙)
- 旁遮普语(果鲁穆奇文,印度)
- 罗马尼亚语
- 俄语
- 塞尔维亚语(西里尔文)
- 简体中文(中国大陆)
- 简体中文(新加坡)
- 斯洛伐克语
- 斯洛文尼亚语
- 西班牙语(西班牙)
- 斯瓦希里语(坦桑尼亚)
- 瑞典语
- 泰米尔语
- 泰卢固语
- 泰语
- 繁体中文(台湾)
- 繁体中文(香港)
- 土耳其语
- 乌克兰语
- 乌尔都语
- 越南语
项目正在持续扩展新的 Go 学习与文档内容,并长期维护各语言版本的术语一致性、翻译质量、独立审核、生产发布与后续更新。
本项目属于非官方社区多语言本地化项目,与 Go 官方无隶属、授权或背书关系。

