在开源社区里,GitHub 的 Pull Request 通常被用来合并代码改动。如果反过来做一层设计:网站内容本身就是仓库里的 Markdown 文件,把 PR 当作编辑入口,让通过自动校验的改动直接合并回主分支,就会得到一个“任何人可编辑,但编辑过程仍然可追溯、可阻断、可自动化”的网站。这正是Show HN: A website anyone can edit through auto-merged GitHub PRs这类项目展示的核心思路。
这个思路看起来简单,落地时却要处理几件具体的事:仓库怎么保护、自动合并怎么触达每一个新 PR、Actions 工作流怎么设计才不会把安全边界撕开、CI 失败时怎么排查。下面按“原理 -> 架构 -> GitHub 配置 -> 工作流实现 -> 验证 -> 排障 -> 最佳实践”的顺序,拆出一个可运行的最小系统。
1. 为什么把 PR 当编辑入口,而不是重新做一个后台
1.1 把 Git 仓库当 CMS:内容、版本、发布走同一条通道
传统网站后台通常由三部分组成:数据库存储内容、表单页面提供编辑、发布按钮触发部署。PR 编辑模型换了一种组合方式:
- Markdown 文件充当内容记录,文件的增删改在 Git 中天然留下完整历史。
- Fork、Clone、Pull Request 充当编辑表单和提交通道。
- GitHub Actions 充当审核人、校验器和合并执行者。
- 合并到
main分支后,静态站点生成器重新构建,完成发布。
这个模型最大的价值是“一套基础设施复用到底”。不需要额外维护用户注册、权限系统和内容管理界面,GitHub 本身已经提供了身份、权限、Issue 讨论、Review、CI 和审计日志。
1.2 这个模式适合什么站点,不适合什么站点
适合的项目通常具备三个特征:
| 特征 | 典型场景 |
|---|---|
| 内容以文本文件为主 | 技术文档、开源项目官网、Wiki、FAQ |
| 受众具备基本 Git 能力 | 开发者社区、开源贡献者、内部工程团队 |
| 内容被修改的影响范围可控 | 文档页、博客文章,不涉及核心业务配置 |
不适合的场景也很明显:需要敏感权限控制的内容、实时写入量很大的内容、编辑者完全不会使用 Git 的平台型应用。把评论系统、实时数据面板、订单数据搬进 Git 仓库是不合理的,因为 Git 的强项是版本历史而不是高频写入。
1.3 自动合并的核心收益与副作用
自动合并让“任何人可编辑”真正成立。如果每个 PR 都要维护者手动点击合并,低活跃项目很容易堆积大量过时 PR,贡献者也会因为等待过久失去继续编辑的意愿。
但副作用也直接:自动合并等于把“是否允许合入”的判断从人移交给了机器。机器判断依赖三样东西:
- 内容改动是否在限定的文件路径内。
- CI 是否通过。
- 分支保护规则是否满足。
只要这三点的定义有问题,攻击者或误操作就有可能把不该发布的内容合入主分支。所以在动手写工作流之前,必须先理解仓库侧的护栏如何配置。
2. 端到端架构:任何人改一个文件,背后发生了什么
2.1 角色和动作链路
整套流程可以拆成以下链路:
访问者 Fork 仓库 ↓ 在 content/ 或者 pages/ 下新增、修改 Markdown ↓ 向原仓库发起 Pull Request ↓ GitHub Actions 运行内容与构建校验 ↓ 自动校验通过后,受信任流程开启原生 Auto Merge ↓ 状态检查全部通过后,GitHub 自动合并 PR ↓ main 分支变更触发部署流水线 ↓ 站点重新构建并发布参与方不只是“编辑者”和“机器人”。维护者仍然要做两件事:定义规则、在异常 PR 出现时拦截。自动合并并不是放弃管理,而是把常规内容变更的管理成本压到最低。
2.2 仓库目录结构与技术选型
为了演示,这里选用 Hugo 作为静态站点生成器。换成 Astro、Next.js、Jekyll 或 MkDocs 也成立,核心流转逻辑一致:内容进入content/,代码与配置放入.github/,两者严格分开。
community-site/ ├── .github/ │ └── workflows/ │ ├── content-check.yml │ ├── pr-safe-check.yml │ └── deploy.yml ├── archetypes/ ├── assets/ ├── content/ │ └── posts/ │ └── hello.md ├── layouts/ ├── static/ ├── themes/ ├── hugo.toml └── README.md目录设计有两个关键点:
content/是允许外部贡献者修改的区域。.github/workflows/、hugo.toml、package-lock.json等文件必须禁止外部 PR 修改,否则攻击者可以通过替换工作流来自动执行恶意代码。
2.3 构建站点并推送初始内容
先安装 Hugo,再初始化仓库:
hugo new site community-site cd community-site git init git remote add origin git@github.com:yourname/community-site.git git branch -M main git add . git commit -m "chore: init site" git push -u origin main创建第一篇内容:
hugo new posts/hello.md编辑content/posts/hello.md,把draft改为false:
--- title: "Hello Community" date: 2025-01-01T10:00:00+08:00 draft: false --- 这是一个任何人都可以通过 GitHub PR 编辑的页面。本地预览:
hugo server -D确认页面正常后,再推送到远程仓库。到这里,站点只是普通的 Git 仓库,还没有任何自动化能力。
3. 仓库侧的护栏:分支保护与原生 Auto Merge
3.1 用分支保护固定“必须通过的检查”
默认情况下,有写权限的人可以直接 push 到main。这个权限要收回。进入仓库Settings -> Branches -> Add branch protection rule,把main设置为受保护分支。
推荐配置如下:
| 配置项 | 推荐值 | 作用 |
|---|---|---|
| Require a pull request before merging | 开启 | 禁止直接推送,统一走 PR |
| Require approvals | 1 | 至少一个有效审核 |
| Dismiss stale pull request approvals when new commits are pushed | 开启 | 新提交后需要重新验证 |
| Require status checks to pass before merging | 开启 | 未通过检查不允许合并 |
| Require branches to be up to date before merging | 可选 | 防止基于过期分支合并 |
| Allow force pushes | 关闭 | 防止历史被重写 |
| Allow deletions | 关闭 | 保留分支中的 Review 上下文 |
| Do not allow bypassing the above settings | 可开启 | 防止维护者绕过规则 |
状态检查这一项要在工作流定义好之后回填。先把规则保存下来,随后每一轮新增工作流,都需要回到这里把检查项补上。
3.2 原生 Auto Merge 和手工合并的区别
GitHub 提供“Enable auto-merge”能力:当一个 PR 的所有必需条件满足后,GitHub 会自动执行合并,不需要维护者每次点击。
原生 Auto Merge 与“在工作流里直接调用 merge API”是两个不同的方案,差异很重要:
| 维度 | 原生 Auto Merge | 工作流内直接 merge |
|---|---|---|
| 等待检查 | GitHub 原生等待全部必需检查 | 需要自己轮询或依赖 job 顺序 |
| 合并逻辑 | 由 GitHub 保证原子性 | 容易出现并发触发多次 |
| 权限边界 | 使用 PR 发起时携带的权限上下文 | 需要工作流具有写入权限 |
| 可观察性 | PR 页面显示 “Auto merge enabled” | 需要查看 Actions 日志 |
| 推荐程度 | 生产环境推荐 | 只建议在简单场景使用 |
因此后面实现的自动合并,核心是“自动开启 Auto Merge”,而不是直接替 GitHub 做合并。合并动作交给 GitHub 原生的合并逻辑完成。
3.3 为什么不建议只靠一个机器人无脑“点合并”
如果只是让机器人收到 PR 后直接合并,整个系统会非常脆。原因有三个:
- 机器人无法判断目标分支上是否还有尚未完成的事。
- 直接 merge 无法利用 GitHub 原生“自动合并队列”的等待与重试逻辑。
- 一旦工作流组合出现问题,机器人可能在没有检查通过的情况下强行触发合并。
正确姿势是:让工作流只负责“验证 + 批准 + 开启自动合并”,最终合并由 GitHub 在分支保护条件全部满足后执行。
4. 自动合并工作流:既要自动,也要防住脏 PR
4.1 先理解pull_request与pull_request_target的安全边界
这一步是整个系统最容易写错的地方,必须先解释清楚。
GitHub Actions 中有两个容易混淆的事件:
| 事件 | 运行上下文 | secrets | 典型用途 |
|---|---|---|---|
pull_request | 在 PR 的合并结果或 head 分支上运行 | 默认不暴露仓库 secrets | 执行构建、测试、Lint |
pull_request_target | 在目标仓库默认分支上运行 | 可访问仓库 secrets | 执行受信任的合并策略判断 |
如果直接使用pull_request_target并 checkout PR 的代码,攻击者提交的恶意代码会带着 secrets 运行。这是公开仓库上非常经典的攻击路径。
所以安全原则是:
绝不能在一个能访问 secrets 的
pull_request_target工作流中执行来自 fork 的不可信代码。
正确的做法是把工作流拆成两份:
pull_request工作流:执行真实构建、内容校验,不访问 secrets。pull_request_target工作流:只读取 PR 元数据和文件列表,做路径限制和自动合并策略,不执行 fork 代码。
4.2 工作流一:内容构建与校验
文件.github/workflows/content-check.yml:
name: content-check on: pull_request: types: [opened, synchronize, reopened] branches: [main] permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - name: Checkout pull request uses: actions/checkout@v4 with: ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: "0.120.0" extended: true - name: Build site run: hugo --minify - name: Check Markdown style run: | if [ -d "content" ]; then npx --yes markdownlint-cli2 "content/**/*.md" fi这个工作流的 job 名是build,后续分支保护里的状态检查需要引用它。
因为pull_request事件默认不暴露 secrets,即使 PR 里包含恶意代码,攻击者也拿不到仓库密钥。这一点非常重要,不能被简化掉。
4.3 工作流二:只做安全判断,开启原生 Auto Merge
文件.github/workflows/pr-safe-check.yml:
name: pr-safe-check on: pull_request_target: types: [opened, synchronize, reopened] branches: [main] permissions: contents: read pull-requests: write issues: write jobs: validate-and-enable-auto-merge: runs-on: ubuntu-latest steps: - name: Validate changed paths and enable auto merge uses: actions/github-script@v7 with: script: | const message = `你好,我是自动合并守卫。`; const files = await github.paginate( github.rest.pulls.listFiles, { owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number } ); const allowedPrefixes = ['content/', 'assets/']; const deniedFiles = files.filter((file) => { const name = file.filename; if ( name.startsWith('.github/') || name === 'hugo.toml' || name.endsWith('package-lock.json') || name.endsWith('package.json') ) { return true; } return !allowedPrefixes.some((prefix) => name.startsWith(prefix)); }); if (deniedFiles.length > 0) { await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, body: `自动合并已拒绝,原因:PR 修改了受限文件。\n\n受限文件列表:\n${deniedFiles.map((file) => `- ` + file.filename).join('\n')}` }); core.setFailed('PR contains changes outside allowed paths.'); return; } const review = await github.rest.pulls.createReview({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, event: 'APPROVE', body: '内容校验与路径校验通过,允许自动合并。' }); const pullRequestId = context.payload.pull_request.node_id; await github.graphql(` mutation enableAutoMerge($pullRequestId: ID!) { enablePullRequestAutoMerge( input: { pullRequestId: $pullRequestId mergeMethod: SQUASH } ) { pullRequest { number } } } `, { pullRequestId: pullRequestId });这个工作流没有 checkout 任何来自 fork 的代码。它只通过 GitHub API 读取 PR 文件列表,然后执行三个动作:
- 判断改动路径是否在允许范围内。
- 在 PR 上打上 APPROVE 审核。
- 调用 GraphQL mutation 开启原生 Auto Merge。
开启 Auto Merge 后,GitHub 会等待content-check的buildjob,以及本工作流的validate-and-enable-auto-mergejob 全部通过,再自动执行合并。任何一个检查失败,PR 都会停留在等待状态,不会合并。
4.4 用标签和路径限制自动合并范围
上面的工作流已经限制了文件路径,但还不够细。建议再做三件事:
- 只有带指定标签的 PR 才自动合并。
- 只有目标分支是
main的 PR 才自动合并。 - 把工作流自身的变更排除在自动合并范围外。
标签方案可以这样扩展:在pull_request_target的触发事件中加入labeled,然后在脚本里检查:
const labels = context.payload.pull_request.labels || []; const hasAutoMergeLabel = labels.some((label) => label.name === 'auto-merge'); if (!hasAutoMergeLabel) { core.setFailed('PR does not have the auto-merge label.'); return; }如果担心攻击者给自己打标签,需要利用 GitHub 的权限模型:fork 仓库的贡献者发起 PR 后,并不能直接给原仓库设置标签,除非维护者明确开启。这里推荐做法是让维护者通过配置好的工作流打标签,而不是让 PR 作者自己决定。
路径限制加标签策略之后,自动合并才具备可控的“白名单”边界。
5. 验证整条编辑链路:从一个真实 PR 开始
5.1 一次完整演示
创建一个测试账号,用它来验证外部贡献者流程更接近真实情况。这里以命令行方式演示。
先 Fork 目标仓库,然后把 fork 仓库克隆到本地:
git clone https://github.com/yourname/community-site.git cd community-site git remote add mine https://github.com/contributor/community-site.git创建新分支并修改内容:
git checkout -b add-project-intro hugo new posts/project-intro.md编辑content/posts/project-intro.md,写入一段介绍,并把draft置为false。提交并推送:
git add content/posts/project-intro.md git commit -m "docs: add project intro" git push -u mine add-project-intro发起 PR:
gh pr create --title "docs: add project intro" --body "为网站新增项目介绍页面"5.2 观察自动合并是否按预期工作
打开 PR 列表,可以看到两个检查项:
content-check / buildpr-safe-check / validate-and-enable-auto-merge
用命令行持续观察:
gh pr checks <PR_NUMBER> --watch正常流程中,两个检查会依次通过。当pr-safe-check开启 Auto Merge 后,PR 页面会出现 “Auto merge enabled” 的提示。一旦build也成功,GitHub 会自动执行 Squash 合并。
合并完成后,PR 状态会变成Merged,分支保护规则没有被绕过,main分支上新增了对应的内容。
5.3 部署出口怎么接
PR 自动合并之后,还需要一个部署流水线把新内容发布到线上。最简单的方式是在 GitHub Pages 上发布:
name: deploy on: push: branches: - main permissions: contents: write jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: "0.120.0" extended: true - name: Build run: hugo --minify - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./public部署工作流只监听main分支的 push,因此只有当内容通过 PR 合并后才会发布。学习环境可以把输出直接放到 GitHub Pages;生产环境可以继续接 Vercel、Netlify 或自建静态资源服务器,逻辑不变。
6. 常见问题与排障路径
6.1 状态检查名称对不上,PR 始终无法合并
现象:分支保护配置了必需状态检查,但 PR 上的检查名字和工作流里的 job 名不一致,导致 GitHub 一直提示Required status check ... was not set。
原因:GitHub 展示的状态检查名一般取自 job 名,但不同工作流、不同事件会产生不同前缀。配置分支保护时,直接手工填写 job 名容易拼错。
处理方式:
gh pr checks <PR_NUMBER>先看当前 PR 上实际存在的检查名,再把分支保护里的Require status checks to pass before merging按实际名字勾选。
6.2 PR 没有自动合并,也不报错
把问题按顺序排查:
| 排查步骤 | 检查方式 | 可能原因 |
|---|---|---|
| 1 | PR 页面是否出现 “Auto merge enabled” | pr-safe-check没有调用成功 |
| 2 | Actions 日志中是否有权限错误 | 工作流缺少pull-requests: write |
| 3 | 分支保护规则是否要求build | 状态检查没有在保护规则中登记 |
| 4 | 是否存在未解决的 review 评论 | 开启 conversation resolution 后需要手动处理 |
| 5 | 是否有人重置了 PR 分支 | 基于旧分支发起的新 PR 会被自动等待 |
如果 PR 没有进入 Auto Merge 状态,优先看 Actions 日志,而不是纠结分支保护规则。日志中通常会有Could not resolve to a PullRequest、Resource not accessible by integration等提示。
6.3 自动合并提示没有权限
现象:PR 是 fork 仓库发起的,pr-safe-checkjob 运行时报权限不足。
原因:GitHub Actions 的默认权限设置可能关闭了pull-requests: write。需要在工作流级重新声明:
permissions: contents: read pull-requests: write issues: write如果仓库级Settings -> Actions -> Workflow permissions设置为只读,工作流水位声明可以覆盖为更高的权限,但这会带来安全成本,需要确认仓库是公开还是私有,以及维护者是否信任工作流定义。
6.4 恶意或格式错误的 PR 如何被拦截
自动合并不等于不设防。恶意 PR 通常会在校验阶段暴露问题,拦截手段优先按顺序使用:
- 路径白名单:只能改
content/和assets/。 - 构建检查:坏 Markdown、模板语法错误会让
build失败。 - 内容格式校验:在
content-check.yml中加入 front matter 校验。 - 敏感信息扫描:在
pull_request工作流中执行gitleaks之类扫描。 - 人工复核:PR 修改了超过 20 个文件、删除大量内容,或者涉及核心配置,跳过自动合并。
再强调一次:不要在pull_request_target工作流中 checkout fork 的代码。即使路径校验通过,也可能会触发依赖安装、模板执行等不可信逻辑。安全设计上,自动合并脚本应该保持“只读 API 判断 + 调用原生合并”的形态。
7. 生产环境落地建议
7.1 上线前必须检查的清单
在把“任何人可编辑”的模式推到生产环境之前,逐项确认:
- [ ] 默认分支是
main,且已经开启分支保护。 - [ ]
main禁止直接 push,所有改动必须走 PR。 - [ ] 分支保护中已经登记
content-check / build和pr-safe-check / validate-and-enable-auto-merge两个必需状态检查。 - [ ]
pull_request_target工作流没有actions/checkout。 - [ ] PR 改动被限制在
content/与assets/白名单路径内。 - [ ] 构建阶段不依赖仓库 secrets。
- [ ] 部署流水线只监听
main分支 push。 - [ ] 已用测试 PR 完整验证过一次 fork -> PR -> 自动合并 -> 发布。
- [ ] 已确认 PR 作者无法给自己打
auto-merge标签。 - [ ] 已配置工作流失败通知,例如发送到指定 Telegram、Slack、飞书或邮件。
7.2 自动合并与人工放行的边界
自动合并的条件越宽,维护成本越低,但风险越高。实际项目建议分层处理:
| 变更类型 | 处理方式 |
|---|---|
| 新增或修改 Markdown 文档 | 全自动合并 |
| 修改静态资源 | 全自动合并 |
| 修改站点模板 | 必须人工 Review,禁止自动合并 |
| 修改工作流文件 | 必须人工 Review,禁止自动合并 |
| 依赖版本升级 | 走 Dependabot 或 Renovate,单独配置自动合并 |
| 删除文件 | 建议人工 Review,防止误删 |
一个更稳妥的策略是:完全自动合并只保留在“新增内容”场景,删除和重命名这类破坏性操作全部降级为人工审核。
7.3 可以在自动合并模型之上继续扩展什么
这套模型不是终点,而是基础设施。常见的扩展方向包括:
- 用 Issue 模板驱动“新建页面”的入口,让非 Git 用户在网页端补齐字段。
- 在内容校验中加入 front matter 的 JSON Schema 校验。
- 用合并后事件发送 Webhook,把内容变更同步到搜索索引或 CDN。
- 在
main分支上使用代码签名和作者验证,满足合规审计要求。 - 为高频贡献者配置协作者权限,减少 fork 流程的等待时间。
- 对低信用用户限制每日 PR 合并数量,避免批量垃圾内容挤占队列。
这套“内容进 Git、编辑走 PR、检查交给 Actions、合交给 GitHub”的模式,最适合技术文档和开源社区。初学者可以在一个自己的文档仓库里先跑通最小闭环,再逐步添加安全规则。最该记住的一点是:自动合并不是把审查删掉,而是把审查前移,移到机器可以可靠完成的位置。