文档站和静态站点是 CI/CD 里"看起来最简单、实际最容易出细节问题"的一类。构建本身通常几十秒就完成,真正的难点在于:构建产物如何可靠地传到部署目标、预览环境如何自动生成、失效链接和性能回归如何被发现、以及发布后如何快速回滚。
本文以"构建 → 产物 → 部署"三段式为主线,覆盖 Hugo、Docusaurus、MkDocs 等常见生成器,以及 GitHub Pages、Cloudflare Pages、Vercel、S3+CDN 等部署目标,给出可以直接复用的 workflow 片段。
一、静态站点发布的产物模型
1.1 三段式流水线
源码(Markdown / MDX / 组件)
│ build(Hugo / Docusaurus / MkDocs)
▼
静态产物目录(public/ dist/ site/)
│ artifact 上传
▼
部署目标(Pages / CDN / 对象存储)
把"构建"与"部署"拆成两个 job 的价值在于:构建可以在 PR 上跑(做校验),部署只在合并后跑。构建产物通过 upload-artifact / download-artifact 传递,避免重复构建。产物传递的细节可参考 /github-actions-artifacts-custom-actions/。
1.2 触发策略
| 事件 | 构建 | 部署 |
|---|---|---|
pull_request | 是(校验) | 否(或部署预览) |
push to main | 是 | 是 |
release | 是 | 是(版本化快照) |
schedule | 是 | 否(链接巡检) |
1.3 权限最小化
部署 Pages 需要 pages: write 与 id-token: write,其余一律 read:
permissions:
contents: read
pages: write
id-token: write
id-token: write 用于 OIDC 部署令牌,避免使用长期有效的部署密钥。
二、构建阶段
2.1 通用骨架
name: Docs
on:
push:
branches: [main]
paths:
- "docs/**"
- "content/**"
- "package.json"
pull_request:
paths:
- "docs/**"
- "content/**"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Hugo 需要完整历史计算 .Lastmod
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run docs:build
- uses: actions/upload-artifact@v4
with:
name: site
path: dist/
paths 过滤让文档站只在相关内容变更时构建,避免每次改后端代码都触发一遍。
2.2 Hugo 构建
Hugo 是单二进制,构建极快,但有两个坑:
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.128.0"
extended: true
- name: Build
run: hugo --minify --gc
env:
HUGO_ENVIRONMENT: production
HUGO_ENV: production
坑一:Hugo 版本必须锁定。不同大版本的模板行为可能不同,latest 会在某天突然构建失败。坑二:fetch-depth: 0。如果主题用 .Lastmod 或 .GitInfo,浅克隆会导致日期全变成构建时间。
--minify 会去掉 HTML 中不必要的空白与属性引号,--gc 清理未使用的缓存条目。
2.3 Docusaurus / Next.js 构建
- run: npm ci
- run: npm run build
env:
NEXT_TELEMETRY_DISABLED: "1"
- uses: actions/cache@v4
with:
path: .next/cache
key: next-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: next-${{ runner.os }}-
把框架的增量构建缓存(.next/cache)持久化,第二次构建能快很多。注意缓存键要绑定 lockfile。
2.4 MkDocs 构建
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt
- run: mkdocs build --strict
--strict 让 MkDocs 把警告升级为错误——文档站里最常见的警告就是"引用了不存在的页面",用严格模式可以在 CI 阶段直接拦下。
2.5 构建缓存
不同生成器的缓存位置不同:
| 生成器 | 缓存目录 |
|---|---|
| Hugo | resources/_gen/ |
| Docusaurus | node_modules/.cache |
| Next.js | .next/cache |
| MkDocs | ~/.cache/pip |
统一用 actions/cache 按生成器配置,能显著缩短构建时间。
三、部署目标与策略
3.1 目标对比
| 目标 | 部署方式 | 预览环境 | 成本 |
|---|---|---|---|
| GitHub Pages | actions/deploy-pages | 无(需自建) | 免费 |
| Cloudflare Pages | wrangler pages deploy | 内置分支预览 | 免费额度大 |
| Vercel | CLI 或 Git 集成 | 内置 | 免费额度 |
| Netlify | CLI 或 Git 集成 | 内置 | 免费额度 |
| S3 + CloudFront | aws s3 sync + 失效 | 需自建 | 按量 |
选型核心:如果只要"发布一个站",Pages 最省心;如果要每个 PR 一个预览 URL,Cloudflare Pages / Vercel 开箱即用。
3.2 GitHub Pages 部署
这是最标准的一条路,用官方三个 Action 串联:
jobs:
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
permissions:
pages: write
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: site
path: dist
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: dist
- id: deployment
uses: actions/deploy-pages@v4
三个关键点:
environment: github-pages必须在 job 上声明,否则部署 URL 无法正确回填;upload-pages-artifact与deploy-pages必须成对使用,中间不要插入其他上传;permissions必须包含pages: write与id-token: write。
3.3 并发控制
文档站不需要并发部署。加 concurrency 防止多次 push 时后发先至、旧版本覆盖新版本:
concurrency:
group: pages-deploy
cancel-in-progress: false # 让进行中的部署跑完,避免半成品
cancel-in-progress: false 是刻意的:部署是"非幂等且不可中断"的操作,中途取消可能留下不完整站点。
3.4 Cloudflare Pages 部署
- name: Publish to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=my-docs --branch=${{ github.head_ref || github.ref_name }}
--branch 决定是生产部署还是预览部署:分支为 main 时是生产,其他分支自动生成预览 URL。完整的 Hugo + Cloudflare Pages 组合可参考 Cloudflare Pages 与 Hugo 部署
。
3.5 Vercel 部署
- run: npm i -g vercel@latest
- run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
- run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
- run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}
用 vercel build --prebuilt 可以在 CI 里完成构建,避免 Vercel 侧重复构建。Astro/Svelte 这类框架的部署细节可参考 Vercel 部署 Astro 与 Svelte
。更系统的 Vercel 集成可参考 /github-actions-deploy-vercel/。
3.6 S3 + CDN 部署
- name: Sync to S3
run: |
aws s3 sync dist/ s3://my-docs-bucket/ \
--delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html" \
--exclude "*.xml"
aws s3 sync dist/ s3://my-docs-bucket/ \
--cache-control "public, max-age=0, must-revalidate" \
--exclude "*" --include "*.html" --include "*.xml"
这段配置体现了静态站点缓存的核心原则:带哈希的静态资源长期缓存,HTML 短缓存。HTML 引用的是带哈希的资源名,所以 HTML 必须每次都校验,而资源可以永久缓存。
四、预览环境与 PR Preview
4.1 为什么需要预览
文档改动最需要"所见即所得"的评审。预览环境让评审者在合并前就能看到渲染效果,而不是靠脑补 Markdown。
4.2 在 PR 中回链预览 URL
- name: Comment preview URL
uses: actions/github-script@v7
with:
script: |
const url = "${{ steps.deploy.outputs.url }}";
const marker = "<!-- docs-preview -->";
const body = `${marker}\n📄 文档预览:${url}`;
const { data: comments } = await github.rest.issues.listComments({
...context.repo, issue_number: context.issue.number,
});
const prev = comments.find(c => c.body.includes(marker));
if (prev) {
await github.rest.issues.updateComment({ ...context.repo, comment_id: prev.id, body });
} else {
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number, body });
}
用标记注释实现幂等更新,避免每次 push 都追加一条新评论。
4.3 清理旧预览
预览部署会累积。定时任务里清理已合并/已关闭 PR 对应的预览,避免配额被占满。
五、质量门禁
5.1 失效链接检查
文档站最影响体验的问题就是死链。用 link checker 在 CI 中拦截:
- name: Check links
uses: lycheeverse/lychee-action@v2
with:
args: --no-progress --max-retries 2 --accept 200,206 "dist/**/*.html"
fail: true
对内部链接(相对路径)可以严格失败,对外部链接建议容忍偶发超时(--max-retries)。
5.2 构建告警即失败
hugo --minify 遇到模板错误会返回非零,但 REF_NOT_FOUND 这类警告默认不会让构建失败。要把它变成硬错误:
- run: hugo --minify --gc --panicOnWarning
--panicOnWarning 让任何警告都变成 panic,从而让 CI 失败。这能在合并前抓出"引用了不存在的页面"。
5.3 性能预算
用 Lighthouse CI 守住性能预算:
- name: Lighthouse CI
run: |
npm i -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.json
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }]
}
}
}
}
5.4 拼写与风格检查
文档站可以加拼写检查(如 cspell)和 Markdown lint,把低级错误挡在合并前。
5.5 门禁分级
不是所有检查都该阻断。建议分级:
| 检查 | 级别 | 理由 |
|---|---|---|
| 构建失败 | 阻断 | 产物不可用 |
| 内部死链 | 阻断 | 用户必然踩到 |
| 外部死链 | 仅警告 | 对方站点可能临时故障 |
| 性能预算 | 阻断(阈值宽松) | 防止明显回归 |
| 拼写 | 仅警告 | 误报多 |
门禁的松紧需要按"误报成本"与"漏报成本"权衡。一条经常误报的硬门禁,很快会被团队用 continue-on-error 绕过,反而失去意义。
六、缓存、失效与回滚
6.1 缓存失效策略
| 资源类型 | Cache-Control | 原因 |
|---|---|---|
| 带哈希的 JS/CSS | max-age=31536000, immutable | 内容变了文件名也变 |
| HTML | max-age=0, must-revalidate | 引用关系会变 |
| 图片(无哈希) | max-age=86400 | 折中 |
| sitemap / RSS | max-age=3600 | 更新频率中等 |
6.2 CDN 失效
改动了无哈希的资源(如 logo.png),需要主动失效 CDN:
aws cloudfront create-invalidation \
--distribution-id ABCDEF123456 \
--paths "/logo.png" "/favicon.ico"
失效是按路径计费的,尽量精确到变更的文件而非 /*。
6.3 版本化与回滚
两种版本化方式:
- 路径版本化:
/v1.2/、/latest/,适合产品文档; - 部署版本化:保留最近 N 次部署产物,回滚时重新部署旧产物。
- uses: actions/upload-artifact@v4
with:
name: site-${{ github.sha }}
path: dist/
retention-days: 90
回滚时用 gh run download 取回旧产物,重新执行部署 job。保留 90 天足够覆盖绝大多数"发错了要回退"的场景。
七、常见问题
Q:Pages 部署报 Not Found 或 404?
检查 configure-pages 是否在 upload-pages-artifact 之前执行,以及 environment 是否声明。
Q:Hugo 构建出的日期全一样?fetch-depth: 0 缺失,浅克隆下 .GitInfo 拿不到提交时间。
Q:预览 URL 每次都变,评论刷屏?
用标记注释 + updateComment 实现幂等(见 4.2)。
Q:链接检查总是因为外链超时失败?
对外链设 --max-retries,或只对内部链接开启 fail: true。
总结
静态站点发布流水线的成熟度,体现在三个细节上:构建产物与部署解耦(PR 校验、合并部署)、缓存策略分资源类型(哈希资源永久缓存、HTML 短缓存)、质量门禁前置(死链、构建警告、性能预算都在合并前拦下)。Pages 适合追求简单,Cloudflare/Vercel 适合要预览环境,S3+CDN 适合要完全掌控缓存与成本。把版本化产物保留下来,回滚就从"紧急救火"变成"一条命令"。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。