工程原理 · 深度研究报告

GitHub Pages 背后的技术

不是"怎么部署",而是"为什么它必须是这个形状":从 git push 到 OIDC 签发的部署令牌,从 Anycast DNS 到 Fastly 边缘的 x-timer,从 max-age=600 到 ETag 里藏着的文件大小。全部结论配一条可复跑命令或一份官方规范。

一句话论断

GitHub Pages 不是 Web 服务器,而是控制面与数据面完全分离的静态发布系统:写路径把源码编译成一个不可变的文件包并通过带 OIDC 身份断言的 API 登记为"当前版本";读路径只做 DNS、TLS、缓存与目录查找,服务器上永远不执行用户代码。所谓技术栈的形状、缓存的陈旧窗口、SPA 深链 404、以及"免费"这件事本身,都是这一条设计决定的推论。

实测对象:wang-junjian.github.io/kof-3d/ 与其自定义域 wangjunjian.com/kof-3d/(2026-11-06,法兰克福边缘节点 fra)
证据口径:A 官方规范 / B 一手实测 / C 第三方线索;共 10 条核心论断、13 节(00—12)、6 张手写图。
本报告零外部依赖、单文件、可离线打开;不重复既有《一个 URL 背后的三层机制》一文的链接选型讨论。

00 / 摘要

十条可证伪的核心论断

先看结论。每一条都写了它服务的章节与证据强度;如果某条被推翻,对应章节的结论就要改写。证据强度标记:A 官方规范 B 一手实测 C 第三方线索,实测均为单次观测,不代表全局分位数。

#论断章节证据强度
1GitHub Pages 是双平面系统:写路径(Git → Actions → artifact → Pages Deployments API)与读路径(DNS → CDN → 源站 → 文件)几乎不相交;线上没有常驻应用进程。02 / 03 / 04A B
2请求时计算能力为零是安全与成本的主动取舍,不是功能缺失;所有"动态"只能前移到构建时、下放浏览器或外包给第三方。02A C
32022 年换代后,"发布"的语义变成调用 API 登记一个 gzip 包裹的 tar 文件,而不是往分支塞 HTML;连分支发布底层也跑 Actions。01 / 03A
4pages: write 与 id-token: write 分立的根因是"授权"与"身份证明"分属两个机制:前者给 GITHUB_TOKEN 调 API 的权限,后者换取含 ref claim 的 OIDC JWT 供服务端校验分支保护。03 / 08A
5读路径是三层缓存;cache-control: max-age=600 约束的主要是浏览器,边缘可以缓存更久。实测 age=1537 仍 x-cache: HIT,且命中与回源耗时差约两个数量级。05B
6Pages 的 ETag 是"mtime 十六进制 - 文件字节数十六进制",不是内容哈希;实测可逐字段反推并与 last-modified、content-length 完全对齐。因此不可变部署必须由构建工具用文件名哈希提供。05B
7URL 到文件是纯目录映射加少量规范化,没有 rewrite 规则;无尾斜杠 301、目录取 index.html、路径大小写敏感、缺失落到 404.html。SPA 深链 404 是这条规则的必然后果。06B C
8站点身份由 Host 加路径首段二级路由决定;自定义域绑在用户站点后会自动覆盖同账号所有项目站点,实测 github.io/kof-3d/ 返回 301 证实。07A B
9"免费、能扛流量"与"部署后陈旧、配额受限"是同一枚硬币:以写延迟换读扩展性;1 GB / 100 GB 月 / 10 分钟部署超时都是这个交换的边界条件。05 / 09A
10安全边界不在代码执行(服务器根本不跑用户代码),而在身份与域名:站点永远公开、密钥不可进前端、悬空 DNS 会导致子域接管,域名验证是唯一硬闸。08A C

怎么读这份报告。想理解"为什么"读 01、02、09;想排查线上问题读 04、05、06、07;想要可直接抄的配置与检查清单跳到 11;想复核每个数字去 10,那里给了全部可复跑命令与反证据。

01 / 范式判定

先确认它到底是什么:静态托管,不是 Web 服务器

官方文档的定义很克制:「GitHub Pages 是一个静态站点托管服务,直接取用仓库里的 HTML、CSS、JavaScript 文件,可选择性地让这些文件经过一次构建过程,然后发布成网站。」注意三个动词——取用、经过构建、发布。没有"运行",也没有"请求时处理"。

由此可以给出一条判别式,用来判断任意平台是不是同一类东西:

静态发布系统的三条充要特征

  1. 无请求时代码执行:服务器上不运行仓库里的任何语言运行时;一次 GET 的结果不依赖函数被调用。
  2. 无服务端状态:没有会话、没有数据库连接、没有跨请求的写入;响应是「URL × 当前已发布文件集合」的纯函数。
  3. 产物先于请求存在:所有昂贵计算必须在发布之前完成,且产物通常是一个整体替换的不可变版本。

这条判别式能立刻解释很多现象:为什么 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 静态导出等任意工具链成为可能。

GitHub Pages 架构换代时间轴 四个时间点展示构建能力从平台内置 Jekyll 沙箱迁移到通用 GitHub Actions 运行时的过程。 2022 · 07 2022 · 08 2024 · 06 2026 · 11 自定义工作流 beta 可自带任意构建框架 Actions 成为默认 构建与部署改走 CI 遗留 worker 关停 分支发布仍依赖 Actions 本报告实测状态 Fastly 边缘与 Pages API 「发布」的语义从「推送分支上的文件」变成「登记一个经过校验的构建产物」
图 1 · 架构换代时间轴。时间点均取自 GitHub 官方 changelog 与官方博客回顾;2026-11 为本报告的实测截面,非产品事件。

四种容易混在一起的"发布方式"

方式实际发生什么构建在哪跑需要 .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 官方规范

02 / 理论根基

为什么必须是这个形状:三条约束推出整个技术栈

"只发文件"不是功能没做完,而是在一个托管数千万个仓库、且仓库内容完全不可信的平台上,唯一可规模化的选择。把三条约束写清楚,前面那些工程细节就都能推出来。

约束推导落到工程上的具体动作
安全:多租户 + 不可信代码若每个请求都能触发仓库内代码执行,等于把任意代码执行能力免费开放给数千万个账号;隔离、密钥、逃逸防护的成本无法收敛。唯一安全解是把执行从请求路径移到隔离的构建沙箱。所有计算进 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 容器即销毁;数据面在之后的所有时间里被反复读取,两者唯一的接触面就是那个不可变的产物。

GitHub Pages 双平面架构 上半部为控制面写路径四步,下半部为数据面读路径四步,两步之间只有不可变产物这一处接触。 控制面 · 写路径 每次 push 执行一次,结束即销毁 git push 源码与 workflow Actions 构建 测试 / 类型检查 / 打包 artifact 单个 gzip 包内含 tar Pages API 登记 OIDC 校验来源分支 产物:整体替换的不可变文件集合,绑定 pages_build_version 两个平面唯一的接触面:已发布的文件集合 数据面 · 读路径 每次 GET 执行,无进程无数据库 DNS + Anycast 四个官方 A 记录 TLS / SNI 按域名选证书 Fastly 边缘缓存 命中即返回 源站目录查找 未命中才回源 响应:文件字节 + 缓存头;服务器上没有用户代码执行
图 2 · 双平面架构。控制面产物是一次性生成、整体替换的;数据面只读这个产物。图中"OIDC 校验"与"Fastly 边缘"分别在第 03、04 章展开。

"动态"去哪了:只有三个去处

去处典型手段代价
前移到构建时SSG 预渲染每条路由、CI 里抓数据生成 JSON 快照、把 JS/CSS 内联进单个 HTML写延迟高:内容变更必须重新构建;数据新鲜度取决于构建频率
下放到浏览器客户端路由、localStorage / IndexedDB、Canvas / WebGL / WASM、直接调用第三方 API首屏体积变大、SEO 变难、密钥无处安放
外包给第三方表单后端、鉴权、评论、搜索、Serverless 函数放在别的平台需要处理 CORS、多一个供应商与一份账单

观点标注:"以写延迟换读扩展性"是本报告对架构取舍的归纳,属于工程判断而非官方表述;但 max-age=600、整体替换式 artifact、1 GB 与 100 GB/月配额这些可观测事实与它一致。第 10 章列出了不能证实或存在相反解释的项。

03 / 控制面

从一次 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: writeGITHUB_TOKEN让这个 token 有资格调用"创建 Pages deployment"的 API。它是授权。
id-token: writeActions 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 是对的。

04 / 数据面

一次 GET 的完整旅程:响应头把每一层都暴露了

控制面结束后,CI 容器已经消失。剩下的所有时间里,系统只需要回答一个问题:把某个 URL 映射成某个文件的字节。以下是 2026-11-06 对 wangjunjian.com/kof-3d/ 的真实请求路径。

一次 GET 的请求流水线与缓存分叉 请求依次经过浏览器、DNS 与 TLS、Fastly 边缘;边缘命中直接返回,未命中则回源到 Pages 源站做目录查找。 浏览器发起 GET /kof-3d/ HTTP/2 · Accept-Encoding: gzip, br DNS → Anycast → TLS / SNI 四个官方 A 记录 · 按域名选证书 Fastly 边缘节点 fra via: 1.1 varnish · x-served-by 命中:直接返回 200 x-cache: HIT · age 递增 · VE5 未命中:回源目录查找 server: GitHub.com · VE313
图 3 · 请求流水线与缓存分叉。VE5 与 VE313 取自两次真实响应的 x-timer 尾段,属单次观测,只用于说明量级差异,不是性能基准。

把响应头当成分层探针

静态托管的好处是:链路上的每一层都会诚实地留下自己的名字。下面全部是本次实测抓到的原值,可用第 10 章的命令复跑。

响应头实测值揭示了什么
serverGitHub.com源站标识;不是 nginx / Apache,是 GitHub 自己的 Pages 服务层
via1.1 varnish边缘缓存软件;Varnish 是 Fastly 的技术底座
x-fastly-request-id359b5d82…54375b6CDN 侧请求追踪 ID,向支持方报障时用它
x-served-bycache-fra-etou8220191-FRA具体边缘节点;fra = 法兰克福,同一 URL 两次请求落在不同机器
x-github-edge-regionfra边缘区域,与 x-served-by 相互印证
x-cache / x-cache-hitsHIT / 1;MISS / 0是否命中,以及该对象在边缘被命中过几次
age0、55、210、1537对象在缓存中已存活的秒数;注意它可以是 max-age 的两倍以上
x-timerS…,VS0,VE5 / VE313边缘侧耗时;命中 5、回源 313,量级差约 60 倍
cache-control / expiresmax-age=600浏览器与共享缓存的新鲜期 10 分钟
etag / last-modified"6aed7e66-c3c92" / 07:14:46 GMT协商缓存的版本指纹;第 05 章会把它逐字节拆开
content-encoding / varygzip / Accept-Encoding按扩展名与协商做压缩:801938 B → 211412 B,3.79 倍,省 73.6%
access-control-allow-origin*静态资源默认开放跨域;也再次说明站点内容对全网公开
content-typetext/html; charset=utf-8、text/plainMIME 由扩展名决定,官方支持 750+ 种,不能按文件自定义
x-github-request-idE834: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 章会把这套查找规则用实测逐条验出来。

05 / 缓存与一致性

三层缓存、一个可解码的 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 一手实测

三层缓存与共用刻度下的命中回源耗时 三层缓存自上而下依次为浏览器私有缓存、Fastly 边缘、Pages 源站;下方条形图统一使用 0 到 350 毫秒刻度对比两次观测的边缘处理耗时。 浏览器私有缓存 max-age=600 · 命中则不发请求 · 过期走 304 协商 Fastly 边缘缓存 age 可超 600 · x-cache: HIT / MISS · 命中即终结请求 Pages 源站文件集合 整体替换 · etag 与 last-modified · 未命中才回源 边缘处理耗时(毫秒,统一刻度 0—350,单次观测) 0 175 350 边缘命中 5 ms 回源查找 313 ms 两图共用同一刻度;命中柱子极矮正是要传达的信息,没有为了观感放大
图 4 · 三层缓存与命中/回源耗时。数值来自 x-timer 的 VE 段,属单次观测;统一刻度 0—350 ms,不做截断或视觉放大。

一致性是"有界陈旧",不是"瞬时生效"

把发布后的可见性拆成四段:CI 构建 → API 登记版本 → 边缘节点拿到新版本 → 浏览器私有缓存过期。任何一段没走完,用户都可能看到旧页。由此得到三条可操作规则:HTML 保持短 TTL(平台已固定 600 秒)、静态资源用内容哈希文件名做长缓存、发布验证不要只看 Actions 绿灯,还要用 curl -I 看 etag / last-modified 是否变化,必要时用无痕窗口或带随机查询串排除浏览器缓存。

反证据与边界:本次没有做跨地域、跨节点的传播延迟测量,因此"部署后最长多久生效"只有一个理论窗口,没有实测分布;第 10 章把它列为待验证项。部分二手资料称 Pages 对带哈希资源给一年长缓存,本次未在官方规范中找到对应表述,故不采信。

06 / URL 语义

URL 到文件是纯目录映射:SPA 深链 404 的根因

平台没有 rewrite 规则,也没有前端控制器。请求路径被当作文件系统路径处理,加上少量规范化。这一章的结论全部来自一组可复跑的 curl。

请求路径状态长度 / 类型说明
/kof-3d301162 Blocation: …/kof-3d/,目录缺尾斜杠自动补
/kof-3d/200801938 B · html目录映射到 index.html
/kof-3d/index.html200801938 B · html直接返回,不 301 到目录;与部分二手教程说法相反
/kof-3d/kof-3d.html200801938 B · htmlCI 复制的同名下载副本,与首页字节数一致
/kof-3d/build.txt20066 B · text/plainMIME 由扩展名查表得到
/kof-3d/NO-SUCH.js4049379 B · html落到平台默认 404 页
/kof-3d/KOF-3D.html4049379 B · html路径大小写敏感,换大小写即找不到文件

那个 9379 字节的 404 页标题是 Page not found · GitHub Pages,来自平台而不是仓库——因为本项目的 site/ 里没有 404.html。这恰好演示了回退规则:有自定义 404.html 就返回它,没有则返回平台默认页,但 HTTP 状态码始终是 404。

URL 到文件的映射决策树 四步判定依次为精确文件匹配、目录缺尾斜杠 301、目录内 index.html、以及 404 回退。 请求路径 P P 精确命中一个文件? 200 · 按扩展名定 MIME P 无尾斜杠且是目录? 301 · 补斜杠重定向 目录内有 index.html? 200 · 返回 index.html 全否 → 404.html(有则用)或平台默认 404 页 · 状态码始终是 404
图 5 · URL 映射决策树,规则顺序与第 06 章实测表一致。注意链路里没有任何"把未知路径交给前端路由"的环节。

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 全部内联,从根上消除路径依赖。

07 / 域名与身份路由

一个 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.comCNAME 指向 <owner>.github.io官方称 www 子域最稳定,不受 GitHub 服务器 IP 变更影响
Apex example.comA / ALIAS / ANAME 指向四个官方地址185.199.108.153 等四个 Anycast 地址,固定不变
HTTPS 证书平台自动签发与续期实测 Let's Encrypt、约 90 天有效期;TLS 握手的 SNI 决定用哪张证书
域名验证_github-pages-challenge TXT 记录官方建议在绑定仓库前先验证,防止他人抢注你的子域

关于 CNAME 文件的一个流行误解

很多教程说"在发布目录放一个 CNAME 文件就能绑定域名"。官方文档明确否定:「仓库中的 CNAME 文件不会自动添加或移除自定义域,必须通过仓库设置或 API 配置」。分支发布时代它曾被构建流程读取,但在 Actions 部署链路上,域名配置存在于站点设置里,与产物文件无关。A 官方规范

08 / 安全模型

边界不在代码执行,而在身份与域名

因为服务器上根本不跑用户代码,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 限制

09 / 配额与选型

为什么能免费,以及什么时候该走

把配额表和第 02 章的约束并排看,会发现它们不是"抠门",而是同一套设计的边界条件:写路径被限量、产物被限体积、读路径被软限流量,换来的是不需要为每个站点常驻进程。

限制官方数值工程含义
源仓库 / 已发布站点体积建议 ≤ 1 GB / 硬上限 1 GB大素材应走 Release 或外部对象存储,不要塞进产物
带宽软限 100 GB / 月超限可能收到建议迁移的邮件或被限速(HTTP 429)
部署超时10 分钟artifact 越大越容易超时;这是"推荐 < 1 GB"的直接原因
构建频率软限 10 次 / 小时自定义 Actions 工作流不受此限——又一条支持用 Actions 部署的理由
MIME / HTTP 头不可自定义没有 _headers、没有重定向规则、没有按路径设缓存

全部取自 官方限制页 A 官方规范

和其他静态平台比,差的是"能力"不是"速度"

下表只列结构性差异。具体的免费额度、构建分钟数、计费模型变化很快,第三方汇总仅作线索,决策前请以各家官方 limits 页为准。

能力GitHub PagesNetlify / VercelCloudflare Pages / Workers
请求时运行时无Serverless 函数、SSRWorkers / V8 isolate
PR 预览环境需自行搭内建、自动内建、自动
自定义 HTTP 头 / 重定向不支持_headers / 配置文件配置文件或 Worker
与代码仓库的距离最近:同一仓库、同一权限模型Git 集成Git 集成
运维与凭据最少:平台账号即全部多一个供应商多一个供应商

C 第三方线索 结构性对比参考 2026 年多篇平台横评;表中不含任何可能过期的具体数字。

GitHub Pages 适用性决策树 两道判定依次为是否需要服务端计算或状态、是否涉及官方禁止的商用或敏感交易用途;两项都否则适合 Pages。 请求时需要服务端计算或状态? 是:选带运行时的平台 否 涉及电商、SaaS 或敏感交易? 是:官方明确禁止 否 适合 Pages:文档 / 开源 Demo / 博客 / 作品集 前提:产物不超过 1 GB、流量在软限内、内容与代码同仓 落地:Vite / Astro / Hugo + Actions artifact + 资源内容哈希 判据不是「框架是什么」,而是「计算发生在哪一侧」
图 6 · 适用性决策树。注意判定依据是计算发生的位置,而不是前端框架的名字:同一个 Next.js 项目,静态导出可以上,用了 SSR 就不行。

迁移信号:开始需要自定义响应头、需要 PR 预览、需要服务端鉴权、产物逼近 1 GB、或流量接近软限时,就该换平台了。这些都不是"Pages 变差了",而是需求越过了第 01 章那条判别式。

10 / 证据全景

每条数据怎么来的,以及哪些结论其实还不确定

证据口径

级别定义本报告中的用途
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 尾段 c3c920xc3c92 = 801938 = content-length自洽 ✓ 尾段是文件字节数
etag 首段 6aed7e660x6aed7e66 = 1793949286 = 2026-11-06 07:14:46 UTC = last-modified自洽 ✓ 首段是 mtime
gzip 后 211412 B801938 / 211412 = 3.79;节省 73.6%HTML 内联 JS 的压缩比合理 ✓
VE5 对 VE313同一 URL 命中与回源两次观测,比值约 60量级合理 ✓ 但不作为基准
age: 1537 与 max-age=6001537 > 600,却仍 x-cache: HIT不矛盾:边缘 TTL 与浏览器 TTL 不是同一个值

反证据、被推翻的说法与待验证项

条目处置说明
二手教程称访问 /index.html 会 301 回目录实测推翻本次返回 200 且字节数与首页一致,故未采信
二手资料称 Pages 对带哈希资源给一年长缓存未采信官方规范中未找到依据,报告只写可实测的 max-age=600
age 超过 max-age 的确切机制推断可能是多层缓存累计或边缘策略更长,官方未公开,已明确标注为推断
x-timer 的 VE 段单位按惯例按 Fastly 通行读法当作毫秒处理,仅用于量级对比
部署后的跨地域传播延迟分布待验证本次只在单一边缘区域观测,没有分位数数据
Fastly 内部缓存层级与 purge 策略黑盒只能从响应头反推,报告中一律写成"观测到"而非"平台实现是"

为什么专门留一节写不确定性。静态托管最容易出错的地方,就是把一次观测当成平台契约。把"官方明说的"、"实测看到的"、"自己推的"分开,读者才知道哪些能直接照抄,哪些换台机器可能不一样。

11 / 可执行物

检查清单、排障表与一份最小可用工作流

发布前检查清单

✓检查项依据
□仓库不提交任何构建产物,线上页面由 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 校验。

12 / 结论与参考

结论

属于共识、可直接引用的部分: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 级)

  1. What is GitHub Pages? — 静态托管定义、用户站点与项目站点类型
  2. GitHub Pages 限制 — 1 GB / 100 GB 月 / 10 分钟 / 10 次每小时及 Actions 豁免、禁止用途
  3. Configuring a publishing source — 分支发布与自定义工作流、Jekyll、CNAME 文件说明、公开性警告
  4. About custom domains and GitHub Pages — 站点类型、域名继承、DNS 类型、验证与接管风险
  5. REST API endpoints for Pages — Create a GitHub Pages deployment 的请求体与状态码
  6. actions/deploy-pages README — OIDC 机制、双权限解释、环境与超时默认值
  7. actions/upload-pages-artifact README — artifact 命名、gzip 包 tar、体积与链接约束、隐藏文件默认值

一手 · 官方变更记录(A 级)

  1. Custom GitHub Actions Workflows (beta),2022-07-27 — 可自带任意构建框架
  2. Sunsetting GitHub Pages' legacy worker,2024-07-08 — 2022-08 起 Actions 成为默认、2024-06-30 关停遗留架构
  3. OWASP WSTG-CONF-10 · Subdomain Takeover — 接管原理、GitHub Pages 检测指纹、验证与修复顺序

一手 · 本地实证材料(B 级)

  1. .github/workflows/pages.yml、vite.config.ts、scripts/inline.mjs、.gitignore、README.md(工作区 kof-3d)
  2. 2026-11-06 线上站点 curl / dig / openssl 原始观测,命令见第 10 章

第三方 · 仅作线索,未采信其数据(C 级):Fastly/Varnish 分层解读、静态托管平台横评、部署缓存问题讨论。凡涉及具体 TTL、额度与内部实现,本报告一律以官方文档或本次实测为准。