Inkstone 是一个 Astro 静态博客,文章通过 Sveltia CMS 写入 GitHub 仓库,再由 Cloudflare Pages 自动构建和发布。CMS 里有一个很普通的字段:
draft: true它表达的不是缓存策略,而是发布状态:这篇文章不应该再出现在公开站点上。
这次问题的起点也不是 Cloudflare 缓存,而是一个更直接的现象:一篇文章已经在 CMS 里从发布状态改成 draft,但打开线上 URL 时仍然能看到旧页面。
这个问题一开始看起来像是应用代码没有正确过滤 draft。但查完代码和部署后发现,仓库里的 draft 逻辑是对的,最新 Cloudflare Pages 部署也是对的。真正麻烦的地方在后面:我们尝试用 Cloudflare Dashboard 的 Purge Everything 和 API purge 清缓存,结果旧页面仍然存在。
所以这次排查的主线其实是:
文章被 draft 后仍可见 -> 先确认 CMS / Astro / GitHub / Pages 部署是否正确 -> 怀疑 Cloudflare 缓存,尝试 Purge Everything -> 发现 purge 也清不掉某个历史页面 -> 重新定义问题:draft 隐藏不能依赖 cache purge -> 把隐藏规则放回仓库和构建产物用图看会更清楚:这不是一个单点缓存问题,而是一次从内容状态到部署语义的收敛过程。
问题演进:先确认内容和部署,再处理 Cloudflare Pages 历史资源保留带来的可见性问题。
最终的结论是:缓存清理只是发布后的辅助动作,不能承担“文章是否公开”的语义。draft 可见性必须由仓库里的内容状态和构建产物保证。
第一阶段:先确认是不是站点代码的问题
出现问题的文章已经有:
draft: true先查 Astro 路由。博客详情页的静态路径生成只读取非 draft 文章:
const posts = await getCollection('blog', ({ data }) => !data.draft);博客列表、分类、RSS 也都遵循同样的过滤逻辑。也就是说,只要最新构建真的跑到了这份代码,draft 文章就不应该再生成公开 HTML。
接着对比多个 URL 形态:
https://example.com/blog/<slug>/ 200,旧 HTMLhttps://example.com/blog/<slug> 404https://example.com/blog/<slug>/index.html 404https://project.pages.dev/blog/<slug>/ 404这个结果很重要。它说明:
- 最新 Pages 默认域名已经是 404。
- 无尾斜杠 URL 和
index.htmlURL 已经是 404。 - 只有 custom domain 上的尾斜杠 URL 还能看到旧 HTML。
这时就不能再简单归因成“CMS 没保存”或“Astro 没过滤 draft”。源码和最新部署大概率是正确的,问题集中在 Cloudflare 对某个历史 URL 的服务行为上。
这一步的排查决策可以简化成下面这棵树:
排查决策树:先确认内容状态、应用过滤和最新部署,再判断是否进入 Cloudflare Pages 服务层。
第二阶段:尝试 Purge Everything,但问题没有消失
接下来很自然会想到 Cloudflare 缓存。
当时做了两类清理:
- 在 Cloudflare Dashboard 里执行
Purge Everything。 - 通过 GitHub Actions 调用 Cloudflare Cache Purge API,既做
purge_everything,也按文章 slug 清理精确 URL。
为了避免 URL 形态没命中,精确清理还覆盖了:
/blog/<slug>/blog/<slug>//blog/<slug>/index.html但结果仍然不对:Cloudflare 返回 purge 成功,旧页面却还在。
这一步是整个问题的转折点。因为如果 Purge Everything 都没清掉,就说明我们不能继续把问题理解成“再把缓存清干净一点”。要回到证据本身,看这个旧响应到底像不像普通 zone cache。
旧响应里有几组关键 header:
cache-control: public, s-maxage=604800age: 持续增长cf-cache-status: DYNAMICx-robots-tag: noindex其中几个信号很明确:
s-maxage=604800是一周。age持续增长,说明响应确实来自某个缓存或保留层。cf-cache-status: DYNAMIC又不像普通 Cloudflare zone cache 命中。x-robots-tag: noindex更接近 Cloudflare Pages preview 或历史 Pages asset 的行为。
Cloudflare Pages 文档提到,Pages 会有静态 assets 的保留和缓存策略;旧 asset 可能在某些数据中心继续存在一段时间。Cloudflare Cache 的 Purge Everything 文档则主要针对 CDN cache。把这些现象放在一起看,更合理的判断是:这个旧 HTML 很可能落在 Pages 底层的 asset retention / 历史部署资源路径里,而不是普通 zone cache 对象。
社区里也有类似案例:HTML 已经从最新构建输出中删除,但 custom domain 仍然能访问旧页面,purge、development mode、redeploy 都无效。这和本次的表现高度相似。
第三阶段:临时止血,但不能把它当最终方案
为了先避免 draft 内容继续可见,当时临时加了一条 Cloudflare Dashboard Redirect Rule:
https://example.com/blog/<slug>/ -> https://example.com/blog/<slug>因为无尾斜杠 URL 已经是 404,所以这个 rule 可以快速挡住尾斜杠 URL 上的旧 HTML。
这一步作为止血是有效的,但它不是最终设计。它把一篇文章的发布状态拆成了两个地方:
- CMS / GitHub 里有
draft: true - Cloudflare Dashboard 里有一条手工维护的单篇 Redirect Rule
这会带来一个很差的后续操作:如果以后要重新公开这篇文章,除了改 CMS,还必须记得去 Cloudflare 删除那条 rule。这个状态不在仓库里,不在 code review 里,不随构建自动变化,也不容易被测试覆盖。
用户指出这一点是对的:draft 状态应该在 CMS 或 GitHub 仓库里闭环,不应该额外引入一个 Cloudflare Dashboard 手工状态。
重新定义目标
经过上面的排查,目标从“清掉缓存”改成了“让 draft 语义不依赖缓存是否被清掉”。
最终目标是:
- CMS / Markdown 是唯一内容状态源。
draft: true后,常见 URL 形态都不能显示旧页面。- 重新公开时,只需要把
draft改回false或删除字段。 - Cloudflare Dashboard 不维护单篇文章状态。
- 这个机制能被本地测试和构建验证。
这也是静态站点里一个通用原则:如果某个 URL 不应该被看到,就不要把保障寄托在 cache purge 上,而要让部署产物明确表达这个状态。
最终方案:构建时生成 Cloudflare Pages _redirects
Cloudflare Pages 支持在部署产物里放 _redirects 文件。这个文件可以和站点一起被构建、提交、部署,比 Dashboard Redirect Rule 更适合表达由内容状态派生出的规则。
最终方案分两层。
整体链路是:内容状态先进入仓库,再由构建脚本生成 Cloudflare Pages 能执行的 _redirects,最后由 Pages 部署产物统一决定 URL 行为。
最终方案:draft 不是靠 purge 隐藏,而是在构建产物中显式表达。
第一层,Astro 继续过滤 draft,保证最新构建不会生成 draft 页面、列表入口和 RSS 入口。
第二层,在构建前扫描 src/content/blog,给每篇 draft 文章生成 Pages redirect:
/blog/<slug> /.draft-hidden/blog/<slug> 302/blog/<slug>/ /.draft-hidden/blog/<slug> 302/blog/<slug>/index.html /.draft-hidden/blog/<slug> 302目标地址 /.draft-hidden/... 是仓库里不存在的路径,因此最终会返回 404。这里使用 302,是因为 draft 是可逆状态:文章重新公开后,下一次构建会自动移除对应规则。
为什么要覆盖三种 URL 形态?因为这次问题恰好只出现在尾斜杠路径上,而 Cloudflare Pages 对目录页、无扩展路径、index.html 会有自己的匹配和跳转行为。既然目标是“不能显示旧内容”,就应该把这些常见形态都覆盖掉。
实现
新增生成脚本:
const draftTargetPrefix = '/.draft-hidden/blog';
const redirectLines = draftSlugs.flatMap((slug) => { const target = `${draftTargetPrefix}/${slug}`; return [ `/blog/${slug} ${target} 302`, `/blog/${slug}/ ${target} 302`, `/blog/${slug}/index.html ${target} 302`, ];});脚本的职责很窄:
- 递归扫描
src/content/blog。 - 支持
.md和.mdx。 - 只读取 frontmatter 里的
draft: true。 - 把文件路径转换成 blog slug。
- 写入
public/_redirects。
生成结果类似:
# Generated by scripts/generate-draft-redirects.mjs.# Cloudflare Pages reads this file from the build output.
/blog/<slug> /.draft-hidden/blog/<slug> 302/blog/<slug>/ /.draft-hidden/blog/<slug> 302/blog/<slug>/index.html /.draft-hidden/blog/<slug> 302然后把生成步骤接进 package.json:
{ "scripts": { "redirects:generate": "node scripts/generate-draft-redirects.mjs", "test": "npm run redirects:generate && node scripts/validate-site.mjs", "build": "npm run redirects:generate && astro check && astro build && pagefind --site dist" }}这里选择提交 public/_redirects,虽然它是生成文件。原因是它不是临时 build cache,而是部署语义的一部分。提交后可以 code review,也可以让验证脚本检查它是否和当前 draft 状态一致。
测试与上线验证
这次先补了验证脚本,而不是直接写实现。
红灯阶段,npm test 失败:
Missing required files:- scripts/generate-draft-redirects.mjs- public/_redirects实现后,本地验证:
npm testnpm run build额外检查:
sed -n '1,120p' dist/_redirectstest ! -e dist/blog/<slug>/index.htmlgit diff --check上线后,再验证线上行为:
GET /blog/<slug>/
HTTP/2 302location: /.draft-hidden/blog/<slug>
HTTP/2 404cache-control: no-store同时检查一篇正常公开文章仍然返回 200,确认 draft redirect 没有误伤其他页面。
最后删除 Cloudflare Dashboard 里的临时单篇 Redirect Rule。Dashboard 只保留站点级的 www -> apex 规则,文章公开状态重新回到 CMS / GitHub / 构建产物这条链路里。
重新公开时会发生什么
现在重新公开文章只需要改内容源:
draft: false或者删除 draft 字段。下一次构建会:
扫描 src/content/blog -> 发现该文章不再是 draft -> public/_redirects 中移除对应规则 -> Astro 重新生成文章 HTML -> Cloudflare Pages 发布新产物这才是合理的状态模型:文章是否公开只由仓库内容决定。
取舍
这个方案没有继续追求“彻底清掉 Cloudflare 内部旧 asset”。原因很实际:Cloudflare Pages 的 asset retention 是平台行为,我们能做的是理解它、绕开它,而不是把发布状态建立在 purge 是否命中某个内部缓存层上。
它也没有上 Cloudflare Pages Functions。Functions 可以直接对 draft URL 返回 404,语义更直接,但会引入运行时代码和更多部署配置。对于一个静态博客,构建期 _redirects 已经足够,而且更符合当前项目的静态发布模型。
这次最大的经验是:缓存清理是性能和一致性的辅助,不是权限、发布状态或内容可见性的最终保障。 凡是涉及“这个 URL 不应该被看到”的语义,都应该进入可版本化、可测试、可部署回滚的代码或构建产物。
检查清单
以后遇到“静态页面已经删除或 draft,但线上仍可访问”的问题,可以按这个顺序查:
- 先确认 CMS / Markdown 状态是否真的改了。
- 确认最新部署输出里是否还存在对应 HTML。
- 对比 custom domain、
pages.dev默认域名、无斜杠、尾斜杠、index.html。 - 看响应头里的
cache-control、age、cf-cache-status、x-robots-tag。 - 可以尝试 purge,但不要把 purge 当最终保障。
- 如果隐藏是发布语义,把它做进仓库:
_redirects、_headers、Functions 或构建脚本。 - 给这些规则加测试,避免 CMS 改了状态但部署产物没有跟上。
Comments
Quiet notes for this article.