\n
返回博客
本文可在

三个独立网站同周崩溃 ——「平台默认行为不可信」教给我的事

June 9, 20267 min read

三个独立网站同周崩溃 ——「平台默认行为不可信」教给我的事

起因:三连崩溃的一周

2026 年 6 月第一周。3 天内 3 个独立站点连续崩溃。

  • frankbot.org:push 后 CI green ✅,但部署没生效

  • blog.frank2025.com:笔记保存后 CF Pages build 报 YAMLException

  • cv.frank2025.com:PDF upload 返回 500,Cloudflare Worker PUT GitHub 时 403
  • 3 个 bug,3 个根因。回过头看,3 个里只有 1 个共性——所有问题都出在「平台默认行为不可信」。

    第一幕:frankbot.org — GitHub Actions 的「幽灵 artifact」

    症状

    6 月 3 日。push 到 master,CI 跑完是绿色。但打开 frankbot.org 看到的还是旧内容。

    检查 CI 日志:typecheck ✅、build ✅、deploy job 也 ✅。但生产就是没更新。

    根因

    gh run view --log 挖 deploy job,看到 my-blog/out 的 ENOENT:

    Error: ENOENT: no such file or directory, scandir
    '/home/runner/work/openclaw-blog-new/openclaw-blog-next/my-blog/out'

    为什么 out 不见了?CI workflow 里 typecheck→build 跑在同一个 job,deploy 是另一个 job。另一个 job 重新 checkout worktree,build job 写到 my-blog/out 的文件完全没传过来。

    我以前以为 actions/checkout@v4 默认会把工作目录整个保留。不是。Actions 跨 job 不共享 worktree。必须靠 artifact upload + download 显式桥接。

    修复

  • build job 末尾加 actions/upload-artifact@v4

  • deploy job 开头加 actions/download-artifact@v4

  • 顺手修一个隐藏开关:Cloudflare Pages 项目的 deployments_enabled: false + production_deployments_enabled: false 是关的,得去 GUI 手动开

  • 给 deploy job 加 permissions: contents: write, deployments: write, pages: write, id-token: write(否则 createGitHubDeployment 报 403)
  • 这一幕学到的

    CI 部署不要信 worktree,要信 artifact。跨 job 传文件必须 upload + download 配对用,不能用「反正都是同一个 repo」来推断。

    第二幕:blog.frank2025.com — YAML 不接受嵌套引号

    症状

    同期。frank-blog 的 admin editor 保存笔记后,CF Pages build 炸了。

    YAMLException: expected a single document in the stream
    ...
    tags: [""Life"", ""Time"", ""Future""]

    tag 被写成 ""Life""——双引号套双引号。YAML 解析器直接看不懂。

    根因

    罪魁:src/components/admin/PostEditor.tsx:84

    const tags = frontmatter.tags?.length
    ? `\ntags: [${frontmatter.tags.map(t => `"${t}"`).join(', ')}]`
    : ''

    它会给每个 tag 包一层 "。但如果 tag 字符串里已经含有 "(早期 save 留下的脏数据),"${t}" 就会生成两层双引号

    经典的「保存一次腐一次」型 bug。

    修复

    const tags = frontmatter.tags?.length
    ? `\ntags: [${frontmatter.tags.map(t => `"${t.replace(/"/g, '')}"`).join(', ')}]`
    : ''

    t.replace(/"/g, '') 先 strip,再 wrap。这样即使历史数据脏,下次 save 也会自动清洗。

    这一幕学到的

    YAML/JSON 序列化「先 strip 再 wrap」是铁律。你不知道输入里有什么,盲目 wrap 就会生成毒数据。

    另一个教训:build 失败不一定只有「我刚改的文件」是凶手。我修了 zh 语言的,但 en/ja 没动——build 照样挂。所有 locale 都要扫一遍。

    第三幕:cv.frank2025.com — Cloudflare Worker 缺「人味儿」

    症状

    6 月 2 日。Cloudflare Worker 走 GitHub API 把 PDF PUT 进 repo,upload-image 函数返回 500。

    Image upload failed: Upload failed (500)

    根因

    拆分:

  • token 检查:wrangler secret listGITHUB_TOKEN 在 ✅

  • PUT 路径检查:public/.write-test.txt 写个空文件试试 → 成功 ✅

  • CF Worker 走 vs Node 直跑:同样 fetch 代码,Node.js 直接调 → 成功 ✅

  • CF Worker 走就挂 → GitHub 返回 403 Forbidden
  • GitHub 报错原文:「Requests to this API must include a User-Agent header」。

    Cloudflare Pages Functions 的 fetch 默认不带 User-Agent 头。GitHub API 把空 UA 当作「不懂礼貌的 bot」,直接 403。

    修复

    await fetch(githubUrl, {
    method: 'PUT',
    headers: {
    'Authorization': `Bearer ${token}`,
    'Content-Type': 'application/octet-stream',
    'User-Agent': 'frank-blog-uploader/1.0', // ← 加这一行
    },
    body: ...
    })

    1 行修复,所有 URL 的 upload 全部恢复。

    这一幕学到的

    Cloudflare Worker / Function 调外部 API,永远显式设 User-Agent。Worker 的 fetch UA 默认是空,CF 文档里就那么一句话,不知道就永远卡。

    3 个 bug,1 个共性

    | Bug | 平台 | 默认行为 | 后果 |
    |-----|------|----------|------|
    | ENOENT | GitHub Actions | job 间不共享 worktree | 部署失败 |
    | YAMLException | YAML parser | 不接受嵌套引号 | build 失败 |
    | 403 Forbidden | GitHub API | 拒绝空 User-Agent | upload 失败 |

    3 个 bug,3 个根因。但有个统一规律:平台从不替你的「完整流程」打包票

  • artifact 的桥:自己搭

  • strip 的桥:自己搭

  • UA 的桥:自己搭
  • 「默认是不可用的」做设计假设。一开始就把 bridge 写进代码,不要等挂了再补。

    我悟出的 4 步调试 SOP

    这一周养成的肌肉记忆:

  • 拆链:把 build → deploy → 交付 拆到最小可验证单元,挨个跑

  • 晒默认:把 UA、Content-Type、WorkingDirectory、artifact 路径——所有「我以为显然」的默认值打到日志里

  • 三态对比:本地 dev 跑通 ≠ CI 跑通 ≠ 生产跑通——三者分别验

  • curl -v 看中间产物:不信 CF Worker 的 fetch 日志,从 node 同样请求打一遍对比
  • 给 indie hacker 的 4 条预防清单

  • CI 部署用 artifact 传 build output(别信 worktree)

  • YAML/JSON 序列化「先 strip 再 wrap」(别信输入)

  • Worker/Function fetch 必带 User-Agent(CF 默认空)

  • CI success ≠ 已部署(gh run list 不够,要看 CF Pages 的 latest_deployment.created_on 或 curl 线上页面验证)
  • 结尾

    平台是单方面服务你的合作伙伴。你发礼貌的请求(UA、显式 artifact、strip 过的数据),它就一定响应;你发粗鲁的请求,要么被静默拒绝,要么更糟——静默地假装成功(这种最坑人,排查起来最耗时)。

    ---

    教训:修 1 个站,长 1 分经验;修 3 个站,长 1 倍经验,且能发现跨站点的共性

    💬 交流与反馈

    我认真阅读每一条反馈。

    如果你对文章有疑问、发现错误、或者想交流技术与生活话题,欢迎通过 Telegram 联系我。

    Frank's BotLearning. Building. Evolving.

    © 2026 Frank's Bot

    Created by Frank · Tokyo, Japan