Files
blog/.cnb.yml
T
zqlit 7a6a3a5383 fix(ci): 全量刷新判定改为「非纯内容改动即全量」+ 刷新分批(又拍云 50/次)
上一版用「themes/layouts/assets 等 dirs 白名单」判全局,实测被绕过:
build 2 的 diff 只有 scripts/purge_list.js(主题改动在上一个 commit),
判成非全局 → 只刷了 10 条固定入口页 → 文章页仍引用已被 --delete 删掉的
旧 page-only.min.1e47477b…(靠 CDN 缓存才 200,缓存一过期即 404)。

改法:
- 判定反转:diff 只含 content/** 才算「纯内容改动」(走精细刷新),
  其它一律全量刷 public 下所有 .html。
- 拿不到基线时:push 事件按全量(宁多刷不漏刷),定时任务只刷固定入口页。
- 输出一行 stderr 诊断(base/变更数/纯内容/全量/清单条数)便于事后核对。
- .cnb.yml:清单按 50 条/批 + 批间 6s 调用 upx purge(又拍云限制
  单次 ≤50、每分钟 ≤600),并逐批打日志;不再一次性提交 1300+ 条。
2026-10-05 17:57:51 +08:00

332 lines
17 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =====================================================================
# CNB 流水线 | 由 .github/workflows/deploy.yml 迁移(2026-10-04)
# =====================================================================
# 为什么迁过来反而更简单:
# 1. 构建节点在国内(腾讯云)→ 又拍云「境内直传」恢复。
# 原版因 runner 在境外(Gitea 在境外 VPS),直传又拍云恒失败被 if:false
# 停用,改用「COS 中转 + 广州机 sync.sh 每分钟轮询」兜底。CNB 让这条
# 整链失去存在理由(见 deploy.yml 第 290 行那段注释的「复活条件」)。
# 2. 不需要 artifact 传递:同一 pipeline 的 stages 顺序执行、共享工作目录,
# public/ 天然传给后续 stage —— 连带 upload/download-artifact 在 Gitea 上
# 那两个坑(v4 不支持 / v3 finalize 500)一起消失。
# 3. 不需要 github.server_url 那 8 处兼容分支。
# 4. 未迁「图片优化 + 反向 commit」:原版每轮都白跑(15 张老图处理不掉),
# 还会把优化结果反写回仓库。如需保留,照搬 scripts/optimize_images.js。
# 5. 通知只保留邮件:原版三套冗余(TG / 飞书 / 邮件),现收敛成邮件一套。
# 成功 → stages 最后一个任务;失败 → failStages。
# 发信由 scripts/send_mail.js 零依赖手写(net/tls 直连 SMTP,465 隐式 TLS),
# 对应原版的 dawidd6/action-send-mail,两者都走 QQ 邮箱。
# 注意原版**失败通知没有邮件**(只有 TG/飞书),失败邮件是这次补上的。
# 6. 又拍云保持「非阻断但如实上报」:原版是 continue-on-error: true,
# 国内线路直传失败不得拖垮境外线路。这里用 allowFailure + .ci_status
# 落盘记录,邮件里逐条如实显示,不做假绿。
#
# ⚠️ 落地前必做
# 1. 建一个 CNB「密钥仓库」(新建仓库时类型选「密钥仓库」;它禁止 clone、
# 只能在 Web 界面编辑,所以必须手动建)。命名 zqlit/blog-secrets,
# 在 Web 界面新建 secrets.yml,内容照抄本仓库根目录的 cnb-secrets.yml
# (该文件已 gitignore,只作粘贴草稿,绝不入库)。需注入 10 项:
# UPYUN_SERVICE / UPYUN_OPERATOR / UPYUN_PASSWORD
# DOGECLOUD_ACCESS_KEY / DOGECLOUD_SECRET_KEY / CDN_URL_LIST
# EDGEONE_API_TOKEN / RSS_API_TOKEN
# MAIL_USERNAME / MAIL_PASSWORD ← QQ 邮箱 + SMTP 授权码
# ⚠️ 又拍云服务名在旧 Gitea 里存的 secret 名是 UPYUN_BUCKET,
# 迁到 CNB 必须改叫 UPYUN_SERVICE(imports 注入的就是变量名本身)。
# 2. 密钥仓库若与本仓库不在同一组织,定时任务可能被跨仓管控拦下,
# 需在「组织设置 → 仓库管控」放行。首跑留意 cron 那条有没有报 imports 403。
# 3. 首次跑重点观察两个未知数:
# a) 又拍云 v0 接口从 CNB 出口的真实表现(本方案的立论基础)
# b) EdgeOne 从国内上传 ~372MB 的耗时(原为境外同区上传)
# 建议首跑把这两步暂时注释掉,只验「Hugo 构建」跑通,再逐步放开。
# 4. 邮件默认 smtp.qq.com:465;要换服务商就加 env(SMTP_HOST / SMTP_PORT /
# SMTP_FROM_NAME),脚本自带 QQ 的默认值。
# =====================================================================
# 公用 stage 片段:push 与定时任务共用(顶层以 . 开头的 key 不会被当成分支名)
.common-stages: &common-stages
- name: Hugo 构建
timeout: 15m
script: |
set -eu
# 用一份落盘的状态文件在各 stage 之间传话(原版靠 needs.*.outputs),
# 邮件通知最后统一读它。同一 pipeline 的 stages 共享工作目录。
CI_STATUS="$PWD/.ci_status"
: > "$CI_STATUS"
hugo version
# ── 构建期数据快照(评论 conf / 友链 / 友圈)────────────────────
# ★ 这里必须用「先 curl 落盘、模板再读本地文件」,**不许**模板里
# 用 resources.GetRemote:那是「渲染过程中发网络请求」,footer 每页
# 都要走一遍,构建节点到 Cloudflare 一抖动就会重试到撞穿 Hugo 的
# partial 30s 超时 → 整站构建失败(2026-10-04 实测白等 14.5 分钟)。
# 改成快照后最坏只是数据为空,构建永远不挂。
# 详见 scripts/fetch_snapshots.sh 顶部注释。
# 脚本内部对每个快照都做了「拉失败就删文件」的降级,且恒 exit 0。
bash scripts/fetch_snapshots.sh
python3 scripts/add_draft_to_hidden.py
rm -rf public resources/_gen
hugo --minify --gc
BUILD_HASH=$(find public -type f -print0 | sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')
echo "$BUILD_HASH" > public/.build_hash
echo "$BUILD_HASH" > public/build_hash.txt
echo "$BUILD_HASH" > .build_hash.out
echo "build=ok" >> "$CI_STATUS"
echo "✅ 构建完成 hash=$(printf '%.12s' "$BUILD_HASH") 文件数=$(find public -type f | wc -l | tr -d ' ')"
- name: 同步到又拍云(境内源站)
timeout: 30m
# 对应原版的 continue-on-error: true —— 国内线路直传失败不该拖垮境外线路,
# 但失败必须被如实记录(写进 .ci_status,邮件里会显示 ❌)。
allowFailure: true
script: |
set -eu
CI_STATUS="$PWD/.ci_status"
ST=ok
record() {
rc=$?
if [ "$rc" -ne 0 ]; then ST=fail; fi
echo "upyun_sync=$ST" >> "$CI_STATUS"
exit "$rc"
}
trap record EXIT
# 原版这里是 if:false(境外直传必挂)。CNB 节点在国内,直接恢复。
# 首次仍保守用 -w 5 且不加 --strong,稳定后再考虑调高并发。
upx login "$UPYUN_SERVICE" "$UPYUN_OPERATOR" "$UPYUN_PASSWORD"
upx sync public/ / -w 5 --delete
upx put public/.build_hash /__build_hash || true
echo "✅ 又拍云同步完成"
- name: 刷新又拍云 CDN
allowFailure: true
script: |
set -eu
CI_STATUS="$PWD/.ci_status"
ST=ok
record() {
rc=$?
if [ "$rc" -ne 0 ]; then ST=fail; fi
echo "upyun_purge=$ST" >> "$CI_STATUS"
exit "$rc"
}
trap record EXIT
# 刷新清单 = 固定入口页 + (纯内容改动 ? 改动内容页 : 全站 HTML),
# 由 scripts/purge_list.js 生成。判定理由见脚本头部注释:只要产物里的资源
# 文件名可能变(非纯内容改动),就必须全量刷,否则旧 HTML 会引用被
# `upx sync --delete` 删掉的旧指纹资源而断链。
#
# ★ 为什么必须刷固定入口页:只刷首页会让 sitemap.xml / rss.xml /
# archives.html / posts.html 一直发 CDN 上的旧副本(2026-10-01 实测复现:
# 源站 22812B、CDN 仍是 7/24 的 22592B,手动 purge 后立刻变新)。
# ★ 为什么要动态部分:又拍云对 HTML 的 max-age 是 691200(8 天),
# **不在清单里的页面即使源站已更新,CDN 也会发最多 8 天的旧副本**
# (2026-10-05 实测:about.html 源站 47935B 已含新板块,CDN 仍发 41454B 旧版)。
# 脚本靠 `git diff BASE HEAD` 找出改动文件:只含 content/ 时按 slug/url/
# 文件名推出改动内容页并校验 public/ 下确实存在;否则(含主题/脚本等)
# 全量输出 public 下所有 .html。
# 基线取 HEAD~1,按 push / 定时任务分别兜底(见脚本头部注释)。
node scripts/purge_list.js > /tmp/purge.txt || true
# 兜底:脚本要是整个失败,至少保证固定入口页仍被刷
[ -s /tmp/purge.txt ] || printf 'https://usj.cc/\n' > /tmp/purge.txt
echo "刷新清单 $(wc -l < /tmp/purge.txt | tr -d ' ') 条"
# ★ 分批:又拍云 URL 刷新「单次 ≤50 个、每分钟 ≤600 个」。清单在全量刷新时
# 可达 1300+ 条,一次性提交会被拒(且 allowFailure 下静默失败)——2026-10-05
# 实测踩到过。按 50 条/批 + 批间 6s(≈500 条/分钟)稳妥推进。
TOTAL=$(wc -l < /tmp/purge.txt | tr -d ' ')
awk -v n=50 'NR % n == 1 { c++ } { print > sprintf("/tmp/pchunk_%03d", c) }' /tmp/purge.txt
n=0
for f in /tmp/pchunk_*; do
n=$((n + 1))
echo " → 批次 $n($(wc -l < "$f" | tr -d ' ') 条)"
upx purge --list "$f" || echo " ⚠️ 批次 $n 刷新失败(不阻断流水线)"
sleep 6
done
echo "✅ 又拍云 CDN 刷新完成:$TOTAL 条 / $n 批"
- name: 刷新多吉云 CDN
allowFailure: true
script: |
set -eu
CI_STATUS="$PWD/.ci_status"
ST=ok
record() {
rc=$?
if [ "$rc" -ne 0 ]; then ST=fail; fi
echo "dogecloud=$ST" >> "$CI_STATUS"
exit "$rc"
}
trap record EXIT
# 复用仓库里的 scripts/refresh_cdn.js(零外部依赖,仅用 node 内置模块)
node scripts/refresh_cdn.js
- name: 部署 EdgeOne(境外线路)
timeout: 20m
script: |
set -eu
CI_STATUS="$PWD/.ci_status"
ST=ok
record() {
rc=$?
if [ "$rc" -ne 0 ]; then ST=fail; fi
echo "edgeone=$ST" >> "$CI_STATUS"
exit "$rc"
}
trap record EXIT
test -n "$EDGEONE_API_TOKEN" || { echo "❌ 缺 EDGEONE_API_TOKEN"; exit 1; }
# ★ 重试 3 次:EdgeOne CLI 第一步要访问 api.edgeone.ai 做 region 检查,
# 构建节点到该域名的偶发超时(实测 15s AxiosError timeout)会让 CLI 直接
# 报「Invalid EDGEONE_PAGES_API_TOKEN」—— 令牌其实没坏,纯网络抖动。
# 2026-10-04 复现:同一令牌在本机部署秒过,CNB 节点那一次 15s 超时误报。
# 失败重试比让整条流水线变红、白发一封失败邮件划算得多。
ok_deploy=0
for i in 1 2 3; do
if npx edgeone pages deploy ./public -n hugo-blog -t "$EDGEONE_API_TOKEN" --area overseas; then
ok_deploy=1
break
fi
echo "⚠️ EdgeOne 第 $i 次部署失败(多为构建节点网络抖动),20s 后重试…"
sleep 20
done
test "$ok_deploy" = 1 || { echo "❌ EdgeOne 连续 3 次部署失败"; exit 1; }
echo "✅ EdgeOne 部署完成"
- name: 上报部署状态
allowFailure: true
script: |
set -eu
H=$(cat .build_hash.out)
curl -s --max-time 20 -X POST \
"https://api.200181.xyz/api/deploy-status?token=${RSS_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"hash\":\"$H\",\"phase\":\"built\"}" || true
echo "ℹ️ 已上报 built: $(printf '%.12s' "$H")"
# ── 成功通知(放在 stages 末尾)────────────────────────────────────
# 任一前置 stage 真失败会直接跳 failStages,这个 stage 就不会跑,
# 所以「成功邮件」只在真的全绿(含又拍云)时发出。
- name: 发送邮件通知(成功)
allowFailure: true
script: |
set -eu
CI_STATUS="$PWD/.ci_status"
st() { case "${1:-}" in ok) printf '✅ 成功';; fail) printf '❌ 失败';; *) printf '⏭️ 未执行';; esac; }
stv() { sed -n "s/^$1=//p" "$CI_STATUS" 2>/dev/null | tail -1; }
H=$(cat .build_hash.out 2>/dev/null || echo unknown)
if [ "${CNB_IS_CRONEVENT:-false}" = "true" ]; then TRIGGER=定时任务; else TRIGGER=代码推送; fi
{
printf '🎉 部署成功\n'
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
printf '📦 项目信息\n'
printf ' 项目: %s\n' "${CNB_REPO_SLUG:-zqlit/blog}"
printf ' 分支: %s\n' "${CNB_BRANCH:-main}"
printf ' Commit: %s\n' "${CNB_COMMIT_SHORT:-unknown}"
printf ' ⏰ 时间: %s\n' "$(date +'%Y-%m-%d %H:%M:%S')"
printf ' 🚀 触发: %s\n\n' "$TRIGGER"
printf '📊 构建统计\n'
printf ' 文件总数: %s\n' "$(find public -type f 2>/dev/null | wc -l | tr -d ' ')"
printf ' 构建哈希: %s\n\n' "$(printf '%.12s' "$H")"
printf '📌 各线路状态\n'
printf ' ☁️ 又拍云源站(境内直传) %s\n' "$(st "$(stv upyun_sync)")"
printf ' 🔄 又拍云 CDN 刷新 %s\n' "$(st "$(stv upyun_purge)")"
printf ' 🔄 多吉云 CDN %s\n' "$(st "$(stv dogecloud)")"
printf ' 🌐 EdgeOne Pages(境外) %s\n\n' "$(st "$(stv edgeone)")"
printf '🔗 构建日志\n %s\n\n' "${CNB_BUILD_WEB_URL:-(无)}"
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
} > /tmp/mail_body.txt
MAIL_SUBJECT="🎉 部署成功 - ${CNB_REPO_SLUG:-blog} - $(date +'%Y-%m-%d %H:%M')" \
MAIL_BODY_FILE=/tmp/mail_body.txt \
node scripts/send_mail.js
# 失败通知:stages 里任一**阻断型** stage 失败时执行
.common-fail-stages: &common-fail-stages
- name: 发送邮件通知(失败)
allowFailure: true
script: |
set -eu
CI_STATUS="$PWD/.ci_status"
st() { case "${1:-}" in ok) printf '✅ 成功';; fail) printf '❌ 失败';; *) printf '⏭️ 未执行';; esac; }
stv() { sed -n "s/^$1=//p" "$CI_STATUS" 2>/dev/null | tail -1; }
if [ "${CNB_IS_CRONEVENT:-false}" = "true" ]; then TRIGGER=定时任务; else TRIGGER=代码推送; fi
{
printf '💥 部署失败\n'
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
printf '📦 项目信息\n'
printf ' 项目: %s\n' "${CNB_REPO_SLUG:-zqlit/blog}"
printf ' 分支: %s\n' "${CNB_BRANCH:-main}"
printf ' Commit: %s\n' "${CNB_COMMIT_SHORT:-unknown}"
printf ' ⏰ 时间: %s\n' "$(date +'%Y-%m-%d %H:%M:%S')"
printf ' 🚀 触发: %s\n\n' "$TRIGGER"
printf '⚠️ 失败阶段\n %s\n\n' "${CNB_BUILD_FAILED_STAGE_NAME:-未知}"
printf '📋 失败原因\n'
printf '%s\n\n' "${CNB_BUILD_FAILED_MSG:-(未提供)}"
printf '📌 已执行到的步骤\n'
printf ' ☁️ 又拍云源站(境内直传) %s\n' "$(st "$(stv upyun_sync)")"
printf ' 🔄 又拍云 CDN 刷新 %s\n' "$(st "$(stv upyun_purge)")"
printf ' 🔄 多吉云 CDN %s\n' "$(st "$(stv dogecloud)")"
printf ' 🌐 EdgeOne Pages(境外) %s\n\n' "$(st "$(stv edgeone)")"
printf '🔗 构建日志\n %s\n\n' "${CNB_BUILD_WEB_URL:-(无)}"
printf '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
} > /tmp/mail_body.txt
MAIL_SUBJECT="💥 部署失败 - ${CNB_REPO_SLUG:-blog} - $(date +'%Y-%m-%d %H:%M')" \
MAIL_BODY_FILE=/tmp/mail_body.txt \
node scripts/send_mail.js
# =====================================================================
# 触发 1:推送到 main
# =====================================================================
main:
push:
- name: 构建并发布(境内外双线路)
runner:
cpus: 4 # 4 核 8G;160 核时/月 → 约 240 次
# ★ CNB 的 docker build 上下文「只含 Dockerfile + by 列出的文件」,
# 未列入的一律视为不存在(官方原文)。Dockerfile 要 COPY bin/linux/hugo,
# 故必须写进 by。漏掉会报:
# failed to compute cache key ... "bin/linux/hugo": not found
docker:
build:
dockerfile: deploy/Dockerfile
by: [bin/linux/hugo]
versionBy: [bin/linux/hugo]
imports:
- https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml
lock: # 对应原 concurrency.cancel-in-progress
key: deploy-main
cancel-in-progress: true
stages: *common-stages
failStages: *common-fail-stages
# =====================================================================
# 触发 2:定时构建(原 schedule '0 1 * * *' UTC = 北京 09:00;
# CNB 的 cron 时区就是 Asia/Shanghai,故直接写 09:00)
# =====================================================================
"crontab: 0 9 * * *":
- name: 定时构建(每天 09:00)
runner:
cpus: 4
# ★ CNB 的 docker build 上下文「只含 Dockerfile + by 列出的文件」,
# 未列入的一律视为不存在(官方原文)。Dockerfile 要 COPY bin/linux/hugo,
# 故必须写进 by。漏掉会报:
# failed to compute cache key ... "bin/linux/hugo": not found
docker:
build:
dockerfile: deploy/Dockerfile
by: [bin/linux/hugo]
versionBy: [bin/linux/hugo]
imports:
- https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml
lock:
key: deploy-main
cancel-in-progress: true
stages: *common-stages
failStages: *common-fail-stages