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+ 条。
This commit is contained in:
zqlit committed 2026-10-05 17:57:51 +08:00
1 parent 5a50df6e23
commit c1f9a0c6f0
2 files changed
+44 -18

No files matched your search

+22 -6
View File
@@ -113,7 +113,10 @@
}
trap record EXIT
# 刷新清单 = 固定入口页 + 本次改动过的内容页,由 scripts/purge_list.js 生成。
# 刷新清单 = 固定入口页 + (纯内容改动 ? 改动内容页 : 全站 HTML),
# 由 scripts/purge_list.js 生成。判定理由见脚本头部注释:只要产物里的资源
# 文件名可能变(非纯内容改动),就必须全量刷,否则旧 HTML 会引用被
# `upx sync --delete` 删掉的旧指纹资源而断链。
#
# ★ 为什么必须刷固定入口页:只刷首页会让 sitemap.xml / rss.xml /
# archives.html / posts.html 一直发 CDN 上的旧副本(2026-10-01 实测复现:
@@ -121,14 +124,27 @@
# ★ 为什么要动态部分:又拍云对 HTML 的 max-age 是 691200(8 天),
# **不在清单里的页面即使源站已更新,CDN 也会发最多 8 天的旧副本**
# (2026-10-05 实测:about.html 源站 47935B 已含新板块,CDN 仍发 41454B 旧版)。
# 脚本靠 `git diff BASE HEAD` 找出改动的 content/*.md,按 slug/url/文件名
# 推出输出页并校验 public/ 下确实存在,避免刷不存在的 URL。
# 基线取 HEAD~1;拿不到(浅克隆等)就自动降级为只剩固定入口页。
# 脚本靠 `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) 条"
upx purge --list /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
+22 -12
View File
@@ -9,11 +9,12 @@
* 卡在旧副本上(源站 47935B / CDN 41454B)。
*
* 策略:固定入口页(保底,逻辑不变)
* + 本次改动过的 content/*.md 对应的输出页
* + **全局性改动(themes/layouts/assets/static/data、hugo/config)时,追加全站所有 HTML**。
* + **非「纯内容改动」时,追加全站所有 HTML**
* + 纯内容改动(diff 只含 content/**)时,只追加改动内容对应的输出页
* - 输出页靠「frontmatter 的 url / slug / 目录名 / 文件名」推导,并**校验
* public/ 下确实存在该文件**才加入,避免刷不存在的 URL(刷了也无害,但脏)。
* - 拿不到 git 基线(单提交仓库 / 浅克隆)时自动降级:只输出固定入口页。
* - 拿不到 git 基线(单提交仓库 / 浅克隆)时:push 事件按「全量」处理(宁多刷
* 不漏刷,代价只是刷新耗时),定时任务只输出固定入口页。
*
* 用法:node scripts/purge_list.js [BASE_REF] BASE_REF 默认 HEAD~1
*/
@@ -69,15 +70,18 @@ function walkHtml(dir, out = []) {
const urls = new Set(FIXED.map((p) => SITE + '/' + p.replace(/^\//, '')));
// ★ 全局性改动 → 刷新**全部** HTML(2026-10-05 新增)
// 为什么必须这么做:模板用 resources.Fingerprint 给 JS/CSS 生成带 hash 的文件名,
// 而 upx sync 带 --delete(远端不存在的文件会被删)。主题一改,page-only / core 等
// 包的文件名就变了 → 老指纹文件被删。文章页的 HTML 若还留在 CDN(TTL 8 天),
// 它引用的就是**已被删掉的旧 JS** → 评论区/交互直接断链。
// 所以凡是会改变「全站 HTML 引用资源」的改动,都必须让所有 HTML 重新拉取。
const GLOBAL_DIRS = /^(themes|layouts|assets|static|data|archetypes)\//;
const GLOBAL_FILES = /^(hugo|config)\.(toml|yaml|yml|json)$/;
const isGlobal = changed.some((f) => GLOBAL_DIRS.test(f) || GLOBAL_FILES.test(f));
// ★ 全量刷新判定:**只有「纯内容改动」才走精细刷新,其它一律全量**
// 为什么必须这么严:模板用 resources.Fingerprint 给 JS/CSS 生成带 hash 的
// 文件名,而 upx sync 带 --delete(远端不存在的文件会被删)。只要产物的资源
// 文件名变了,CDN 上还在的旧 HTML(TTL 8 天)就会引用**已被删掉的旧文件**
// → 评论区/交互直接断链。所以凡是产物可能变的改动,都得让 HTML 重新拉取。
// 为什么不用「dirs 白名单」:2026-10-05 踩过 —— 主题改动与 purge 规则改动分成
// 两个 commit 时,后一个 commit 的 diff 里没有主题文件 → 被判成非全局 → 文章页
// 漏刷。改成「非 content 即全量」后,不再依赖改动被切进哪个 commit。
const isCron = process.env.CNB_IS_CRONEVENT === 'true';
const isContentOnly = changed.length > 0 && changed.every((f) => /^content\//.test(f));
const isGlobal = changed.length > 0 ? !isContentOnly : !isCron;
if (isGlobal) {
for (const p of walkHtml('public')) {
const rel = p.replace(/^public[\\/]/, '').split(path.sep).join('/');
@@ -85,6 +89,12 @@ if (isGlobal) {
}
}
// 诊断输出(进构建日志,便于事后核对判定是否正确)
process.stderr.write(
`[purge] base=${base} 变更=${changed.length} 条 纯内容=${isContentOnly} ` +
`全量=${isGlobal}${changed.length ? '' : '(无基线)'} → 清单=${urls.size} 条\n`
);
for (const f of changed) {
if (!/^content\/.*\.md$/.test(f) || !fs.existsSync(f)) continue;
const src = fs.readFileSync(f, 'utf8');