十条可证伪的核心论断
先看结论。每一条都写了它服务的章节与证据强度;如果某条被推翻,对应章节的结论就要改写。证据强度标记:A 官方规范 B 一手实测 C 第三方线索,实测均为单次观测,不代表全局分位数。
| # | 论断 | 章节 | 证据强度 |
|---|---|---|---|
| 1 | GitHub Pages 是双平面系统:写路径(Git → Actions → artifact → Pages Deployments API)与读路径(DNS → CDN → 源站 → 文件)几乎不相交;线上没有常驻应用进程。 | 02 / 03 / 04 | A B |
| 2 | 请求时计算能力为零是安全与成本的主动取舍,不是功能缺失;所有"动态"只能前移到构建时、下放浏览器或外包给第三方。 | 02 | A C |
| 3 | 2022 年换代后,"发布"的语义变成调用 API 登记一个 gzip 包裹的 tar 文件,而不是往分支塞 HTML;连分支发布底层也跑 Actions。 | 01 / 03 | A |
| 4 | pages: write 与 id-token: write 分立的根因是"授权"与"身份证明"分属两个机制:前者给 GITHUB_TOKEN 调 API 的权限,后者换取含 ref claim 的 OIDC JWT 供服务端校验分支保护。 | 03 / 08 | A |
| 5 | 读路径是三层缓存;cache-control: max-age=600 约束的主要是浏览器,边缘可以缓存更久。实测 age=1537 仍 x-cache: HIT,且命中与回源耗时差约两个数量级。 | 05 | B |
| 6 | Pages 的 ETag 是"mtime 十六进制 - 文件字节数十六进制",不是内容哈希;实测可逐字段反推并与 last-modified、content-length 完全对齐。因此不可变部署必须由构建工具用文件名哈希提供。 | 05 | B |
| 7 | URL 到文件是纯目录映射加少量规范化,没有 rewrite 规则;无尾斜杠 301、目录取 index.html、路径大小写敏感、缺失落到 404.html。SPA 深链 404 是这条规则的必然后果。 | 06 | B C |
| 8 | 站点身份由 Host 加路径首段二级路由决定;自定义域绑在用户站点后会自动覆盖同账号所有项目站点,实测 github.io/kof-3d/ 返回 301 证实。 | 07 | A B |
| 9 | "免费、能扛流量"与"部署后陈旧、配额受限"是同一枚硬币:以写延迟换读扩展性;1 GB / 100 GB 月 / 10 分钟部署超时都是这个交换的边界条件。 | 05 / 09 | A |
| 10 | 安全边界不在代码执行(服务器根本不跑用户代码),而在身份与域名:站点永远公开、密钥不可进前端、悬空 DNS 会导致子域接管,域名验证是唯一硬闸。 | 08 | A C |
怎么读这份报告。想理解"为什么"读 01、02、09;想排查线上问题读 04、05、06、07;想要可直接抄的配置与检查清单跳到 11;想复核每个数字去 10,那里给了全部可复跑命令与反证据。
先确认它到底是什么:静态托管,不是 Web 服务器
官方文档的定义很克制:「GitHub Pages 是一个静态站点托管服务,直接取用仓库里的 HTML、CSS、JavaScript 文件,可选择性地让这些文件经过一次构建过程,然后发布成网站。」注意三个动词——取用、经过构建、发布。没有"运行",也没有"请求时处理"。
由此可以给出一条判别式,用来判断任意平台是不是同一类东西:
静态发布系统的三条充要特征
- 无请求时代码执行:服务器上不运行仓库里的任何语言运行时;一次 GET 的结果不依赖函数被调用。
- 无服务端状态:没有会话、没有数据库连接、没有跨请求的写入;响应是「URL × 当前已发布文件集合」的纯函数。
- 产物先于请求存在:所有昂贵计算必须在发布之前完成,且产物通常是一个整体替换的不可变版本。
这条判别式能立刻解释很多现象:为什么 cache-control 敢给 600 秒(响应只随版本变化)、为什么不能有 /api/*(没有路由到函数的机制)、为什么"部署"其实是一次版本指针切换(换的是整个文件集合)。
架构换代:不是一直长这样
今天看到的 Actions 部署链路是 2022 年之后才成为主线的。GitHub 在 2022 年 7 月发布自定义 Actions 工作流 beta,同年 8 月 Actions 成为 Pages 构建与部署的默认方式;2024 年 6 月 30 日彻底关停遗留的 pagesworker 架构。这一步的意义常被低估:构建能力从平台内置的一个受限 Jekyll 沙箱,迁移到了通用的 CI 运行时,才使得 Hugo、Astro、Vite、Next.js 静态导出等任意工具链成为可能。
四种容易混在一起的"发布方式"
| 方式 | 实际发生什么 | 构建在哪跑 | 需要 .nojekyll 吗 |
|---|---|---|---|
| 分支发布 + Jekyll | 推送 main 或 /docs,平台用内置 Jekyll 构建后发布 | GitHub Actions(2024 后) | 不需要;它触发的是构建 |
| 分支发布 + 预构建产物 | 外部 CI 把 dist/ 推到 gh-pages 分支,平台跳过构建只部署 | 外部 CI | 需要,用来跳过 Jekyll |
| 自定义 Actions 工作流 | 工作流自己构建,上传 artifact,调 Pages Deployments API 登记 | 自己的 workflow | 不需要(artifact 不经过 Jekyll) |
| 纯静态站点(Static 模板) | 无构建步骤,直接把仓库文件打包上传 | 无 | 不需要 |
三个反常识点,都有官方原文
① 分支发布底层也跑 Actions:官方明确写「你的 Pages 站点总是通过一次 GitHub Actions workflow run 部署」,即使你配置的是别的 CI;外部 CI 推的产物只是让 workflow 检测到"无需构建"从而只执行部署步骤。
② 仓库里的 CNAME 文件不会自动添加或移除自定义域,必须通过仓库设置或 API 配置——这与大量教程的写法相反。
③ 用 GITHUB_TOKEN 推送的提交不会触发 Pages 构建,这是 CI 里"构建成功但站点没更新"的常见根因之一。
来源:Configuring a publishing source · Sunsetting GitHub Pages' legacy worker · Custom GitHub Actions Workflows (beta) A 官方规范
为什么必须是这个形状:三条约束推出整个技术栈
"只发文件"不是功能没做完,而是在一个托管数千万个仓库、且仓库内容完全不可信的平台上,唯一可规模化的选择。把三条约束写清楚,前面那些工程细节就都能推出来。
| 约束 | 推导 | 落到工程上的具体动作 |
|---|---|---|
| 安全:多租户 + 不可信代码 | 若每个请求都能触发仓库内代码执行,等于把任意代码执行能力免费开放给数千万个账号;隔离、密钥、逃逸防护的成本无法收敛。唯一安全解是把执行从请求路径移到隔离的构建沙箱。 | 所有计算进 CI;产物只含 HTML/CSS/JS/资源;OIDC 校验部署来源;仓库内容永不进入线上运行时。 |
| 成本:读写比极端悬殊 | 一个开源项目可能每天被读几万次、每周只发布几次。为读请求常驻进程是浪费;把写路径做重(构建、校验、打包)、读路径做到极致轻(缓存命中即返回),单位成本最低。 | CDN 前置、整体替换式部署、文件名哈希做长期缓存、max-age 与 ETag 协商、不提供自定义 HTTP 头。 |
| 可复现:产物不可变 | 若响应依赖运行时环境,同一 commit 在不同时刻可能给出不同结果,回滚与审计都失去意义。让「commit → 唯一产物 → 唯一版本指针」成为函数关系,回滚就是指针回退。 | 仓库不提交构建产物;CI 从源码重建;artifact 整体替换;pages_build_version 绑定 commit SHA。 |
一个可以验证的推论:既然读路径被刻意做轻,那么"部署后不是立刻全量生效"就不是 bug,而是代价。缓存 TTL、边缘节点差异、浏览器私有缓存三者叠加,构成一个有限但非零的陈旧窗口。第 05 章用 age、x-cache、304 把它量化出来。
双平面:写路径与读路径几乎不相交
这是理解整套系统最有用的一张图。控制面在几秒到几分钟内执行一次,结束后 CI 容器即销毁;数据面在之后的所有时间里被反复读取,两者唯一的接触面就是那个不可变的产物。
"动态"去哪了:只有三个去处
| 去处 | 典型手段 | 代价 |
|---|---|---|
| 前移到构建时 | SSG 预渲染每条路由、CI 里抓数据生成 JSON 快照、把 JS/CSS 内联进单个 HTML | 写延迟高:内容变更必须重新构建;数据新鲜度取决于构建频率 |
| 下放到浏览器 | 客户端路由、localStorage / IndexedDB、Canvas / WebGL / WASM、直接调用第三方 API | 首屏体积变大、SEO 变难、密钥无处安放 |
| 外包给第三方 | 表单后端、鉴权、评论、搜索、Serverless 函数放在别的平台 | 需要处理 CORS、多一个供应商与一份账单 |
观点标注:"以写延迟换读扩展性"是本报告对架构取舍的归纳,属于工程判断而非官方表述;但 max-age=600、整体替换式 artifact、1 GB 与 100 GB/月配额这些可观测事实与它一致。第 10 章列出了不能证实或存在相反解释的项。
从一次 push 到"当前版本":部署其实是一次 API 登记
把 .github/workflows/pages.yml 当成标本逐段读,能看清控制面的全部零件。这不是某个框架的约定,而是 Pages Deployments API 的使用范式。
# .github/workflows/pages.yml(节选,行号对应仓库实际文件)
10 permissions:
11 contents: read # 读源码
12 pages: write # ① 允许 GITHUB_TOKEN 创建 Pages deployment
13 id-token: write # ② 允许请求 OIDC JWT
16 concurrency:
17 group: pages
18 cancel-in-progress: false # 不打断进行中的部署,避免站点短暂空档
29 - run: npm ci
30 - run: npm test
31 - run: npm run typecheck
32 - run: npm run build
35-39 # 组装 site/:index.html + 同名下载副本 + .nojekyll + build.txt
40 - uses: actions/configure-pages@v5
41 - uses: actions/upload-pages-artifact@v3 # 打包上传
45-53 # 独立 deploy job:needs: build + actions/deploy-pages@v4
关键在第 41 与第 53 行:upload-pages-artifact 并不"发布",它只是把目录打包成一个 Actions artifact;真正改变线上状态的是 deploy-pages 调用 POST /repos/{owner}/{repo}/pages/deployments。构建与部署被拆成两个 job,用 needs: build 串起来, artifact 是两者之间唯一的交接物。
artifact 是一份有严格契约的压缩包
官方对 upload-pages-artifact 的约束写得非常具体,这些约束的存在本身就在说明服务端要做什么:它必须能安全解压一个来自不可信仓库的文件包。
| 约束 | 官方要求 | 为什么 |
|---|---|---|
| 命名 | artifact 必须叫 github-pages | 部署端按名定位,不依赖 workflow 传参 |
| 格式 | 单个 gzip 压缩包,内部只含一个 tar 文件 | 格式固定才能流式解压、避免压缩包套娃 |
| 体积 | tar 必须小于 10 GB(官方推荐 < 1 GB),站点硬上限 1 GB | 超过后极易触发 10 分钟部署超时 |
| 内容 | 只含普通文件与目录,不含符号链接与硬链接 | 防止解压时逃出目标目录或指向源站敏感路径 |
| 隐藏文件 | include-hidden-files 默认 false;.git 与 .github 恒排除 | 避免把仓库元数据发布到公网 |
本仓库的一个细节:workflow 第 38 行 touch site/.nojekyll,但 Actions 发布根本不经过 Jekyll,且隐藏文件默认被排除——它实际不会进入 artifact。这是"防御性冗余":万一将来把发布源切回分支模式,站点不会因为 _ 开头目录被吞掉而残废。属于好习惯,但不是必需。
OIDC:为什么必须同时给两个权限
这是整条链路里最容易被当作"照抄配置"的地方。actions/deploy-pages 的官方 README 给了机制解释:
| 权限 | 作用对象 | 服务端拿它做什么 |
|---|---|---|
pages: write | GITHUB_TOKEN | 让这个 token 有资格调用"创建 Pages deployment"的 API。它是授权。 |
id-token: write | Actions OIDC provider 签发的 JWT | 每个 job 一个唯一 JWT,含 ref / 分支 claim;随请求体一起交给 Pages API,服务端解码后校验这次工作流是否被允许部署(分支保护规则)。它是身份证明。 |
换句话说:只给 pages: write,API 知道"有人想部署"却无法可靠判断"来自哪个分支、是否绕过保护";只给 id-token: write,连创建 deployment 的资格都没有。两者分立不是冗余,而是把"能调用"与"能证明来源"拆开。
API 请求体与确定性
| 字段 | 必填 | 含义 |
|---|---|---|
artifact_id / artifact_url | 二选一 | 指向包含 .zip 或 .tar 静态资源的仓库 artifact |
pages_build_version | 是 | 本次构建版本的唯一字符串;默认值是 GITHUB_SHA,因此天然把线上版本与 commit 绑定 |
oidc_token | 是 | 证明部署来源的 JWT |
environment | 否 | 默认 github-pages,对应环境保规则 |
成功返回 200,另有 400 / 404 / 422(校验失败或请求过频)。来源:REST API · Pages、actions/deploy-pages README、actions/upload-pages-artifact README A 官方规范
控制面的三个反模式
① 把 dist/ 或构建后的 HTML 提交进仓库:破坏"commit → 唯一产物"的函数关系,也让审计失去意义。② 在 Actions 部署里把 .nojekyll 当成必需配置:它只作用于 Jekyll 路径。③ cancel-in-progress: true:新部署打断旧部署,可能造成站点短暂无内容;本仓库显式设为 false 是对的。
一次 GET 的完整旅程:响应头把每一层都暴露了
控制面结束后,CI 容器已经消失。剩下的所有时间里,系统只需要回答一个问题:把某个 URL 映射成某个文件的字节。以下是 2026-11-06 对 wangjunjian.com/kof-3d/ 的真实请求路径。
VE5 与 VE313 取自两次真实响应的 x-timer 尾段,属单次观测,只用于说明量级差异,不是性能基准。把响应头当成分层探针
静态托管的好处是:链路上的每一层都会诚实地留下自己的名字。下面全部是本次实测抓到的原值,可用第 10 章的命令复跑。
| 响应头 | 实测值 | 揭示了什么 |
|---|---|---|
server | GitHub.com | 源站标识;不是 nginx / Apache,是 GitHub 自己的 Pages 服务层 |
via | 1.1 varnish | 边缘缓存软件;Varnish 是 Fastly 的技术底座 |
x-fastly-request-id | 359b5d82…54375b6 | CDN 侧请求追踪 ID,向支持方报障时用它 |
x-served-by | cache-fra-etou8220191-FRA | 具体边缘节点;fra = 法兰克福,同一 URL 两次请求落在不同机器 |
x-github-edge-region | fra | 边缘区域,与 x-served-by 相互印证 |
x-cache / x-cache-hits | HIT / 1;MISS / 0 | 是否命中,以及该对象在边缘被命中过几次 |
age | 0、55、210、1537 | 对象在缓存中已存活的秒数;注意它可以是 max-age 的两倍以上 |
x-timer | S…,VS0,VE5 / VE313 | 边缘侧耗时;命中 5、回源 313,量级差约 60 倍 |
cache-control / expires | max-age=600 | 浏览器与共享缓存的新鲜期 10 分钟 |
etag / last-modified | "6aed7e66-c3c92" / 07:14:46 GMT | 协商缓存的版本指纹;第 05 章会把它逐字节拆开 |
content-encoding / vary | gzip / Accept-Encoding | 按扩展名与协商做压缩:801938 B → 211412 B,3.79 倍,省 73.6% |
access-control-allow-origin | * | 静态资源默认开放跨域;也再次说明站点内容对全网公开 |
content-type | text/html; charset=utf-8、text/plain | MIME 由扩展名决定,官方支持 750+ 种,不能按文件自定义 |
x-github-request-id | E834:240945:…:6AED849C | 源站侧排障 ID,与 Fastly 的 ID 分属两层 |
接入层:DNS、Anycast 与 TLS
DNS 是四个固定 A 记录
dig +noall +answer wang-junjian.github.io 返回 185.199.108.153 / .109 / .110 / .111 四个地址。它们不是四台机器,而是通过 Anycast 在多个 PoP 广播的同一组地址,因此北京与纽约的请求会落到不同边缘节点。B 一手实测
证书由 SNI 决定
openssl s_client 实测:issuer Let's Encrypt CN=YR1,subject CN=wangjunjian.com,有效期 2026-10-30 至 2027-01-28(约 90 天自动续期)。同一 IP 托管无数域名,靠 TLS 握手的 SNI 选择对应证书。B 一手实测
把这些拼起来,数据面的全部"智慧"只有两处:按 Host 与路径首段定位到某个站点的文件集合,以及在这堆文件里做目录查找。没有模板引擎、没有路由表、没有数据库查询。第 06 章会把这套查找规则用实测逐条验出来。
三层缓存、一个可解码的 ETag,以及"部署后没更新"的真相
静态托管的性能几乎全部来自缓存,而它的一致性成本也全部来自缓存。把三层拆开,很多"玄学问题"就变成可以计算的窗口。
| 层 | 依据 | 失效方式 | 实测信号 |
|---|---|---|---|
| 浏览器私有缓存 | cache-control: max-age=600 + expires | 等 TTL 过期,或带 If-None-Match 协商 | 第二次请求收到 304 |
| Fastly 边缘缓存 | 服务端自有策略,不等于浏览器的 600 秒 | 部署触发的版本切换与边缘策略 | age、x-cache、x-cache-hits |
| Pages 源站 | 不可变文件集合,整体替换 | 新 deployment 登记为新版本 | etag、last-modified |
一个值得注意的观测:某次响应 age: 1537、x-cache: HIT,但 cache-control 仍是 max-age=600。也就是说对象在缓存体系里已经存活超过 25 分钟,浏览器侧却被明确告知 10 分钟后要重新验证。合理解释是边缘/中间层缓存时长长于面向浏览器的 TTL,或 age 累计了多层缓存的停留时间——这是推断,不是官方说明;但它足以否定"改完立刻全量生效"的直觉。
把 ETag 逐字节拆开
本次响应的 ETag 是 "6aed7e66-c3c92"。它看起来像内容哈希,其实不是。用一行 Python 就能反推:
python3 -c "print(int('6aed7e66',16), int('c3c92',16))"
# 1793949286 801938
# 1793949286 → 2026-11-06T07:14:46+00:00 == last-modified
# 801938 == content-length(未压缩字节数)
即格式为 "<mtime 十六进制>-<文件字节数十六进制>",与 last-modified 及 content-length 三者完全自洽。这个结论有明确的工程含义:
推论:Pages 不提供内容寻址
ETag 反映的是时间与长度,不是内容的密码学摘要。同一个 URL 重新部署后,即使内容相同,只要 mtime 变了 ETag 就会变;反过来也不能靠 ETag 判断"字节是否完全一致"。因此要做真正的不变式部署(immutable deployment),必须由构建工具给资源文件名加内容哈希,再对 HTML 保留短 TTL。Pages 不会替你做这件事——本仓库的单文件 HTML 恰好绕开了多资源场景,但换成分 chunk 的 Vite 项目就必须处理。
条件请求实测:带 If-None-Match: "6aed7e66-c3c92" 再请求,返回 HTTP/2 304、无响应体,仅保留 cache-control 与 etag。这就是 801938 字节的页面在回访时几乎不耗带宽的原因。B 一手实测
x-timer 的 VE 段,属单次观测;统一刻度 0—350 ms,不做截断或视觉放大。一致性是"有界陈旧",不是"瞬时生效"
把发布后的可见性拆成四段:CI 构建 → API 登记版本 → 边缘节点拿到新版本 → 浏览器私有缓存过期。任何一段没走完,用户都可能看到旧页。由此得到三条可操作规则:HTML 保持短 TTL(平台已固定 600 秒)、静态资源用内容哈希文件名做长缓存、发布验证不要只看 Actions 绿灯,还要用 curl -I 看 etag / last-modified 是否变化,必要时用无痕窗口或带随机查询串排除浏览器缓存。
反证据与边界:本次没有做跨地域、跨节点的传播延迟测量,因此"部署后最长多久生效"只有一个理论窗口,没有实测分布;第 10 章把它列为待验证项。部分二手资料称 Pages 对带哈希资源给一年长缓存,本次未在官方规范中找到对应表述,故不采信。
URL 到文件是纯目录映射:SPA 深链 404 的根因
平台没有 rewrite 规则,也没有前端控制器。请求路径被当作文件系统路径处理,加上少量规范化。这一章的结论全部来自一组可复跑的 curl。
| 请求路径 | 状态 | 长度 / 类型 | 说明 |
|---|---|---|---|
/kof-3d | 301 | 162 B | location: …/kof-3d/,目录缺尾斜杠自动补 |
/kof-3d/ | 200 | 801938 B · html | 目录映射到 index.html |
/kof-3d/index.html | 200 | 801938 B · html | 直接返回,不 301 到目录;与部分二手教程说法相反 |
/kof-3d/kof-3d.html | 200 | 801938 B · html | CI 复制的同名下载副本,与首页字节数一致 |
/kof-3d/build.txt | 200 | 66 B · text/plain | MIME 由扩展名查表得到 |
/kof-3d/NO-SUCH.js | 404 | 9379 B · html | 落到平台默认 404 页 |
/kof-3d/KOF-3D.html | 404 | 9379 B · html | 路径大小写敏感,换大小写即找不到文件 |
那个 9379 字节的 404 页标题是 Page not found · GitHub Pages,来自平台而不是仓库——因为本项目的 site/ 里没有 404.html。这恰好演示了回退规则:有自定义 404.html 就返回它,没有则返回平台默认页,但 HTTP 状态码始终是 404。
SPA 深链为什么必然 404,以及三种解法
客户端路由器(History API)产生的 /orders/42 在服务器上并不存在对应文件。Pages 不会把它重写到 index.html,于是直接落到 404 分支。三种解法各自有代价:
| 解法 | 做法 | 代价 |
|---|---|---|
| 预渲染每路由 | 构建时为每条路由生成目录 + index.html(SSG 默认形态) | 内容必须构建时已知;首屏与 SEO 最好 |
| 复制 404.html | 把 index.html 复制成 404.html,让客户端路由接管 | HTTP 状态仍是 404,爬虫可能跳过;仅适合内部工具 |
| Hash 路由 | 改用 /#/orders/42,井号后不发给服务器 | URL 观感差,没有服务端语义 |
另外别忘了 base:项目站点挂在 /<repo>/ 下,绝对路径资源会指向域名根。Vite 需设 base: '/<repo>/',或像本仓库那样用 base: './' 相对路径并把 JS/CSS 全部内联,从根上消除路径依赖。
一个 IP 托管无数站点:Host 加路径首段的二级路由
同一组 Anycast IP 后面是数千万个站点,源站如何知道该取谁的文件?答案是两级:Host 头定位账号/站点,URL 路径首段定位仓库。官方文档把站点分成用户/组织站点(仓库名必须是 <owner>.github.io,挂在域名根)与项目站点(挂在该仓库同名的子路径下),每个账号只能有一个用户站点,每个仓库只能有一个项目站点。
用户 / 组织站点
仓库 wang-junjian/wang-junjian.github.io → wang-junjian.github.io/。这个仓库绑定的自定义域,会成为整个账号的域名基线。
项目站点
仓库 kof-3d → wang-junjian.github.io/kof-3d/。没有单独配置域名时,会自动继承账号级自定义域,落到 wangjunjian.com/kof-3d/。
官方原文说得很直白:如果用户站点设置了自定义域,该账号下所有项目站点默认都会使用同一个自定义域,除非给单个仓库单独指定。这正是本次实测的 301 来源:
curl -sSI https://wang-junjian.github.io/kof-3d/
HTTP/2 301
location: https://wangjunjian.com/kof-3d/
server: GitHub.com
age: 1537 · x-cache: HIT
# 连不存在的深链也先 301,说明域名规范化发生在文件查找之前
curl -sSI https://wang-junjian.github.io/kof-3d/does-not-exist
HTTP/2 301 → https://wangjunjian.com/kof-3d/does-not-exist
# 到自定义域上才真正判 404
| 域名层级 | DNS 配置 | 要点 |
|---|---|---|
子域 docs.example.com | CNAME 指向 <owner>.github.io | 官方称 www 子域最稳定,不受 GitHub 服务器 IP 变更影响 |
Apex example.com | A / ALIAS / ANAME 指向四个官方地址 | 185.199.108.153 等四个 Anycast 地址,固定不变 |
| HTTPS 证书 | 平台自动签发与续期 | 实测 Let's Encrypt、约 90 天有效期;TLS 握手的 SNI 决定用哪张证书 |
| 域名验证 | _github-pages-challenge TXT 记录 | 官方建议在绑定仓库前先验证,防止他人抢注你的子域 |
关于 CNAME 文件的一个流行误解
很多教程说"在发布目录放一个 CNAME 文件就能绑定域名"。官方文档明确否定:「仓库中的 CNAME 文件不会自动添加或移除自定义域,必须通过仓库设置或 API 配置」。分支发布时代它曾被构建流程读取,但在 Actions 部署链路上,域名配置存在于站点设置里,与产物文件无关。A 官方规范
边界不在代码执行,而在身份与域名
因为服务器上根本不跑用户代码,Pages 天然免疫一整类服务端漏洞(命令注入、模板注入、反序列化)。代价是安全重心整体转移到另外三处。
一、公开性是默认值
官方在发布源文档里用 Warning 强调:即使仓库是私有的,Pages 站点依然对全网公开。叠加实测的 access-control-allow-origin: *,可以推出两条硬规则:产物里不能有任何秘密;前端 JS 里写死的 API key 等于已泄露。需要鉴权的场景只能把后端放到别处,且要自己处理 CORS。
二、部署身份由 OIDC 与环境规则把关
第 03 章的 OIDC JWT 含 ref claim,服务端解码后校验工作流是否来自允许的分支;官方还建议给 github-pages 环境加部署保护规则,只允许默认分支部署。这构成"谁能改线上版本"的完整链条:pages: write(授权)→ OIDC ref claim(来源证明)→ 环境保护规则(策略)→ pages_build_version(版本与 commit 绑定,可审计可回滚)。
三、悬空 DNS 与子域接管
这是 Pages 相关最常见、也最容易被忽略的真实漏洞。OWASP WSTG-CONF-10 描述的模式是:某个子域的 DNS 记录仍指向 Pages,但对应站点已被删除或禁用;由于平台按 Host 认领域名,攻击者可以在自己的仓库里配置同一域名把内容"接管"过去。检测指纹之一正是页面里的 There isn't a GitHub Pages site here.。接管后浏览器把这个子域当作你的站点:作用域为父域的 Cookie、*.example.com 的 CORS 与 CSP 允许列表、OAuth 回调白名单都会连带失守。
可执行的防御顺序
① 先在账号设置里验证域名(TXT 记录),再绑定到仓库;② 不要用通配 *.example.com 指向 Pages,它会让验证形同虚设;③ 下线站点时先删 DNS 记录、等 TTL 过期,再释放资源,顺序反了就留下悬空窗口;④ 定期用证书透明日志与 DNS 导出核对子域清单;⑤ 会话 Cookie 尽量 host-only(__Host- 前缀)。
四、使用条款也是边界
官方限制页明确:Pages 不得用作在线业务、电商网站或以促成商业交易 / 提供 SaaS 为主的免费托管,也不应用于传递密码、信用卡号等敏感事务。这不是技术限制,而是产品定位——它解释了这个平台为什么长期保持极简,也解释了什么时候必须迁移。A 官方规范
来源:OWASP WSTG-CONF-10、About custom domains and GitHub Pages、GitHub Pages 限制
为什么能免费,以及什么时候该走
把配额表和第 02 章的约束并排看,会发现它们不是"抠门",而是同一套设计的边界条件:写路径被限量、产物被限体积、读路径被软限流量,换来的是不需要为每个站点常驻进程。
| 限制 | 官方数值 | 工程含义 |
|---|---|---|
| 源仓库 / 已发布站点体积 | 建议 ≤ 1 GB / 硬上限 1 GB | 大素材应走 Release 或外部对象存储,不要塞进产物 |
| 带宽 | 软限 100 GB / 月 | 超限可能收到建议迁移的邮件或被限速(HTTP 429) |
| 部署超时 | 10 分钟 | artifact 越大越容易超时;这是"推荐 < 1 GB"的直接原因 |
| 构建频率 | 软限 10 次 / 小时 | 自定义 Actions 工作流不受此限——又一条支持用 Actions 部署的理由 |
| MIME / HTTP 头 | 不可自定义 | 没有 _headers、没有重定向规则、没有按路径设缓存 |
全部取自 官方限制页 A 官方规范
和其他静态平台比,差的是"能力"不是"速度"
下表只列结构性差异。具体的免费额度、构建分钟数、计费模型变化很快,第三方汇总仅作线索,决策前请以各家官方 limits 页为准。
| 能力 | GitHub Pages | Netlify / Vercel | Cloudflare Pages / Workers |
|---|---|---|---|
| 请求时运行时 | 无 | Serverless 函数、SSR | Workers / V8 isolate |
| PR 预览环境 | 需自行搭 | 内建、自动 | 内建、自动 |
| 自定义 HTTP 头 / 重定向 | 不支持 | _headers / 配置文件 | 配置文件或 Worker |
| 与代码仓库的距离 | 最近:同一仓库、同一权限模型 | Git 集成 | Git 集成 |
| 运维与凭据 | 最少:平台账号即全部 | 多一个供应商 | 多一个供应商 |
C 第三方线索 结构性对比参考 2026 年多篇平台横评;表中不含任何可能过期的具体数字。
迁移信号:开始需要自定义响应头、需要 PR 预览、需要服务端鉴权、产物逼近 1 GB、或流量接近软限时,就该换平台了。这些都不是"Pages 变差了",而是需求越过了第 01 章那条判别式。
每条数据怎么来的,以及哪些结论其实还不确定
证据口径
| 级别 | 定义 | 本报告中的用途 |
|---|---|---|
| A 官方规范 | GitHub Docs、REST API 文档、官方 Action README、官方 changelog | 发布源、artifact 契约、OIDC、配额、站点类型、自定义域、公开性 |
| B 一手实测 | 2026-11-06 对线上站点的 curl / dig / openssl 观测,可复跑 | 全部响应头、缓存行为、ETag 解码、URL 映射、DNS、证书 |
| C 第三方线索 | 技术博客、平台横评、OWASP 指南 | 只用于发现线索与交叉验证,不采信其中的具体数字 |
所有 B 级数据都是单次观测,来自单一客户端与 fra 边缘区域;它们证明"存在这种行为",不证明分位数或全局一致性。建模值(如压缩比 3.79 倍)与实测值在本章分开列出。
可复跑命令集
# 1. 响应头全貌(分层探针)
curl -sSD - -o /dev/null https://wangjunjian.com/kof-3d/
# 2. 连续两次请求,看 age 递增与 x-cache 命中
for i in 1 2; do curl -sSI https://wangjunjian.com/kof-3d/ | grep -Ei 'age:|x-cache:|x-timer:'; done
# 3. 条件请求 → 304
curl -sSD - -o /dev/null -H 'If-None-Match: "6aed7e66-c3c92"' https://wangjunjian.com/kof-3d/
# 4. ETag 解码:mtime 与文件字节数
python3 -c "import datetime; t=int('6aed7e66',16); s=int('c3c92',16); print(s, datetime.datetime.fromtimestamp(t, datetime.timezone.utc).isoformat())"
# 5. DNS:四个官方 Anycast 地址
dig +noall +answer wang-junjian.github.io
# 6. TLS 证书签发者与有效期
echo | openssl s_client -connect wangjunjian.com:443 -servername wangjunjian.com 2>/dev/null | openssl x509 -noout -issuer -subject -dates
# 7. 压缩协商
curl -sSD - -o /dev/null -H 'Accept-Encoding: gzip, br' https://wangjunjian.com/kof-3d/
# 8. URL 映射规则(注意基准域名不要重复拼路径)
for p in /kof-3d /kof-3d/ /kof-3d/index.html /kof-3d/build.txt /kof-3d/NO-SUCH.js /kof-3d/KOF-3D.html; do
printf '%-30s ' "$p"; curl -sS -o /dev/null -w '%{http_code} %{size_download}\n' "https://wangjunjian.com$p"
done
# 9. 账号级自定义域导致的 301
curl -sSI https://wang-junjian.github.io/kof-3d/
数量级自洽性反推
| 观测 | 独立复核 | 结论 |
|---|---|---|
etag 尾段 c3c92 | 0xc3c92 = 801938 = content-length | 自洽 ✓ 尾段是文件字节数 |
etag 首段 6aed7e66 | 0x6aed7e66 = 1793949286 = 2026-11-06 07:14:46 UTC = last-modified | 自洽 ✓ 首段是 mtime |
| gzip 后 211412 B | 801938 / 211412 = 3.79;节省 73.6% | HTML 内联 JS 的压缩比合理 ✓ |
VE5 对 VE313 | 同一 URL 命中与回源两次观测,比值约 60 | 量级合理 ✓ 但不作为基准 |
age: 1537 与 max-age=600 | 1537 > 600,却仍 x-cache: HIT | 不矛盾:边缘 TTL 与浏览器 TTL 不是同一个值 |
反证据、被推翻的说法与待验证项
| 条目 | 处置 | 说明 |
|---|---|---|
二手教程称访问 /index.html 会 301 回目录 | 实测推翻 | 本次返回 200 且字节数与首页一致,故未采信 |
| 二手资料称 Pages 对带哈希资源给一年长缓存 | 未采信 | 官方规范中未找到依据,报告只写可实测的 max-age=600 |
age 超过 max-age 的确切机制 | 推断 | 可能是多层缓存累计或边缘策略更长,官方未公开,已明确标注为推断 |
x-timer 的 VE 段单位 | 按惯例 | 按 Fastly 通行读法当作毫秒处理,仅用于量级对比 |
| 部署后的跨地域传播延迟分布 | 待验证 | 本次只在单一边缘区域观测,没有分位数数据 |
| Fastly 内部缓存层级与 purge 策略 | 黑盒 | 只能从响应头反推,报告中一律写成"观测到"而非"平台实现是" |
为什么专门留一节写不确定性。静态托管最容易出错的地方,就是把一次观测当成平台契约。把"官方明说的"、"实测看到的"、"自己推的"分开,读者才知道哪些能直接照抄,哪些换台机器可能不一样。
检查清单、排障表与一份最小可用工作流
发布前检查清单
| ✓ | 检查项 | 依据 |
|---|---|---|
| □ | 仓库不提交任何构建产物,线上页面由 CI 从源码重建 | 第 02 章可复现约束;本仓库 .gitignore 与 README 硬约定 |
| □ | 构建工具设置了正确的 base(项目站点子路径)或改用相对路径 | 第 06 章路径规则 |
| □ | SPA 已处理深链:预渲染 / 404.html / hash 路由三选一 | 第 06 章;平台无 rewrite |
| □ | 资源文件名带内容哈希,HTML 保持短 TTL | 第 05 章;ETag 不是内容摘要 |
| □ | permissions 同时含 pages: write 与 id-token: write | 第 03 章 OIDC 双权限 |
| □ | 产物里没有任何密钥、私有数据或仓库元数据 | 第 08 章;站点永远公开 |
| □ | 产物体积远小于 1 GB;concurrency 不打断进行中部署 | 第 09 章配额、第 03 章 workflow |
| □ | 自定义域已先在账号级验证,DNS 台账有记录,未使用通配解析 | 第 07、08 章域名接管 |
排障:先按信号定位层,再改东西
| 观测信号 | 定位层 | 下一步 |
|---|---|---|
etag 与 last-modified 没变 | 控制面 | 查 Actions 是否真的执行了 deploy job、是否被环境规则拦下 |
etag 变了但浏览器仍是旧页 | 浏览器缓存 | 无痕窗口、带随机查询串、检查 Service Worker |
x-cache: MISS 且 VE 偏大 | 边缘冷缓存 | 再请求一次看是否转 HIT;新部署后短暂 MISS 属正常 |
| 返回 301 | 域名 / 路径规范化 | 用 curl -L 跟随,检查自定义域与尾斜杠 |
| 404 且响应体约 9 KB、标题为平台默认页 | 文件查找 | 确认产物里确有该文件、路径大小写、是否需要 404.html |
| 样式与脚本全 404,本地却正常 | 构建配置 | 十有八九是 base 写成根路径 |
反模式速查
| 反模式 | 症状 | 修正 |
|---|---|---|
手工往仓库或 gh-pages 分支塞 HTML | 版本与 commit 脱钩,无法回滚 | 删掉产物,改由 Actions 从源码重建 |
把 .nojekyll 当 Actions 部署的必需品 | 配置越来越多却没人说得清作用 | 只在分支发布且需跳过 Jekyll 时使用 |
只靠 CNAME 文件绑定域名 | 域名配置不生效或漂移 | 在仓库设置或 API 配置并验证域名 |
用 GITHUB_TOKEN 推送静态产物触发构建 | CI 显示成功但站点不更新 | 改用 upload-pages-artifact + deploy-pages |
| 把 API key 放进前端环境变量 | 构建产物里明文可见 | 密钥留在外部服务,前端只拿公开令牌 |
最小可用工作流骨架
name: Deploy to Pages
on:
push: { branches: [main] }
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22', cache: npm }
- run: npm ci
- run: npm run build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with: { path: dist }
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
骨架与 .github/workflows/pages.yml 的差别:本项目额外加了单测、类型检查、单文件组装与 build.txt 版本标记,并在站点目录放了 .nojekyll 作防御性冗余。
成熟度阶梯
| 层级 | 特征 | 典型缺口 |
|---|---|---|
| L1 能跑 | 分支发布或 Static 模板,页面能打开 | 产物来源不清、改了什么无法追溯 |
| L2 可复现 | Actions 从源码重建、测试门禁、产物不入库 | 缓存策略、发布后验证、域名治理仍缺 |
| L3 可运维 | 环境保规则 + OIDC、资源内容哈希、发布后 curl -I 校验 etag、域名验证与 DNS 台账 | 跨地域传播监控、配额告警 |
本仓库的 pages.yml 已具备 L2 与 L3 的大部分要素;最明显的剩余缺口是资源内容哈希(因采用单文件内联而不适用)与发布后的自动 etag 校验。
结论
属于共识、可直接引用的部分:GitHub Pages 是静态站点托管;2022 年后构建与部署统一走 GitHub Actions;部署通过 Pages Deployments API 登记 artifact,需要 pages: write 与 id-token: write 双权限;站点即使来自私有仓库也公开;配额为 1 GB / 100 GB 月 / 10 分钟部署超时;URL 到文件是目录映射,没有 rewrite;自定义域必须在设置或 API 中配置。
属于本报告观点、不是官方表述的部分:把系统归纳为"控制面 / 数据面双平面";把配额与缓存陈旧统一解释为"以写延迟换读扩展性";由 ETag 的非哈希结构推出"不可变部署必须由构建工具提供";以及用判别式而不是框架名称来做选型判断。这些观点与全部实测数据相容,但换一种叙事也成立。
收束成一句话
GitHub Pages 把"运行一个网站"这件事,彻底改写成了"编译一个版本,然后让全世界的缓存替你分发它"。理解这一点之后,base 路径、SPA 深链、10 分钟缓存、OIDC 双权限、1 GB 上限、以及"为什么它免费",就不再是六条互不相干的知识点,而是同一条设计决定的六个投影。
参考来源
一手 · 官方规范与 API 文档(A 级)
- What is GitHub Pages? — 静态托管定义、用户站点与项目站点类型
- GitHub Pages 限制 — 1 GB / 100 GB 月 / 10 分钟 / 10 次每小时及 Actions 豁免、禁止用途
- Configuring a publishing source — 分支发布与自定义工作流、Jekyll、CNAME 文件说明、公开性警告
- About custom domains and GitHub Pages — 站点类型、域名继承、DNS 类型、验证与接管风险
- REST API endpoints for Pages — Create a GitHub Pages deployment 的请求体与状态码
- actions/deploy-pages README — OIDC 机制、双权限解释、环境与超时默认值
- actions/upload-pages-artifact README — artifact 命名、gzip 包 tar、体积与链接约束、隐藏文件默认值
一手 · 官方变更记录(A 级)
- Custom GitHub Actions Workflows (beta),2022-07-27 — 可自带任意构建框架
- Sunsetting GitHub Pages' legacy worker,2024-07-08 — 2022-08 起 Actions 成为默认、2024-06-30 关停遗留架构
- OWASP WSTG-CONF-10 · Subdomain Takeover — 接管原理、GitHub Pages 检测指纹、验证与修复顺序
一手 · 本地实证材料(B 级)
.github/workflows/pages.yml、vite.config.ts、scripts/inline.mjs、.gitignore、README.md(工作区kof-3d)- 2026-11-06 线上站点 curl / dig / openssl 原始观测,命令见第 10 章
第三方 · 仅作线索,未采信其数据(C 级):Fastly/Varnish 分层解读、静态托管平台横评、部署缓存问题讨论。凡涉及具体 TTL、额度与内部实现,本报告一律以官方文档或本次实测为准。