fix(branch): 分支与评审口径改回原稿 —— main 直推,PR 只是建议

问题:我把原稿 §1.1 的「**所有改动直接进 main**」丢掉了,另外加了三条原稿没有的
口径,还全程以"正本 §1.x"的名义引用,没标出这是我加的:
  ① §1.2「业务代码必须 PR、高风险路径必须异人评审」
  ② §1.3「main 必须设分支保护:禁直推 + 必须 PR + 必须状态检查通过」
  ③ 把分支保护列进 03 的 P0 并顺手在一个仓上开了
后果是实打实的:kg 的 main 被锁成禁直推,使用者"本地 main 攒一批再推"的习惯
被迫改成走 PR —— 被规范的名义逼着换了工作方式。

改法(按原稿口径恢复):
- §1.1 恢复「所有改动直接进 main」;需要评审时才开短命分支 + PR
- §1.2 改成「什么时候开 PR(**建议**,不是门禁)」,并写明早期版本这么错过,
  防止下一个人"顺手加严"
- §1.3 改成「分支保护:**默认不开**」,写清它容易被忽略的代价:
  「必须状态检查通过」只门控 PR 合并,**直推 main 时管不住**(CI 推完再跑)
- 03 第 1 项 → 「已定:默认不开」;05 应用机制 / 06 接入文档 / new-project.sh 同步
- scripts/adopt-existing.py:改成 **--with-protection 才动保护**(原来是 --no-protection
  才不动,默认开)——默认值反了这本身就是在替使用者做决定
- 技能 eng-standards 同步(代理真正读到的是它,不同步等于改了文档没用)

平台侧:kg 的分支保护已 DELETE;7 个仓 branch_protections 全部为 []

验证:py_compile 通过;DRY-RUN 默认输出"默认不开";加 --with-protection 才出现保护计划;
正本仓已无"必须走 PR / 禁止直接推送 / 必须设分支保护"等旧措辞。
This commit is contained in:
姚章浩
2026-10-10 18:04:21 +08:00
parent c26288b108
commit b87a102e34
6 changed files with 67 additions and 41 deletions
+25 -11
View File
@@ -23,30 +23,44 @@
### 1.1 分支模型
- **单主干**:默认分支 `main`;需要评审时用**短命分支 + PR**,合完即删。**禁止长期分支**。
- **单主干**:默认分支 `main`,**所有改动直接进 `main`**(原稿口径)。本地只保留 `main` 这一条长期分支。
- **需要评审时**用**短命分支 + PR**,合完即删。**禁止长期分支**。
- 现状欠账:`Weknora/Enterprise-Knowledge-Base` 有 7 条分支(含 `release/*`、`develop/*`),属于"长期分支"反模式,见 `03-待配置事项.md`。
- **禁止重写 `main` 历史**(`rebase` / `push --force`)。理由:CI 以 `main` 的推送为部署触发,"线上跑的是哪个提交"这个问题靠 `main` 的历史回答;历史一改,答案就没了。
- **一切"退路"都必须是远端有的东西**:本地分支、本地 tag 都不算退路。真正的退路是"远端有那个提交、且能重跑那条流水线"。
### 1.2 评审(按风险分级,不搞一刀切)
### 1.2 什么时候开 PR(**建议**,不是门禁)
本环境是 3 人小团队 + AI 代理,多加一道"人人评审"会直接卡死流程;所以按**改动路径**分级:
**默认直推 `main`。** 原稿 §1.1 的口径就是"所有改动直接进 `main`,需要评审时才用短命分支 + MR"。
下面这张表是**建议** —— 判断依据是"错了代价高不高、需不需要留下可追溯的记录":
| 改动落在 | 评审要求 |
| 改动落在 | 建议 |
|---|---|
| `deploy/`、`.gitea/`、`db/migrations/`、生产配置模板、`Dockerfile` | **必须**异人评审(不得自评合并),必须在 PR 描述里写"怎么验的" |
| 业务代码(`internal/`、`src/`、`web/`) | **必须** PR;自评合并允许,但 PR 描述必须含"改动范围 + 已跑的门禁 + 未验证项" |
| 纯文档 / 文案 / 注释 | 可直接提交,**不**要求 PR |
| `deploy/`、`.gitea/`、`db/migrations/`、生产配置模板、`Dockerfile` | **建议**开 PR(这类改动错了会动到线上),描述里写清"怎么验的" |
| 业务代码(`internal/`、`src/`、`web/`) | 可直接提交;改动大、或想让别人能回看时开 PR |
| 纯文档 / 文案 / 注释 | 直接提交 |
⚠️ **这是建议,不是"必须"。** 把建议当强制会把 3 人小团队卡在流程上 ——
本规范早期版本正是这么错的(曾写成"业务代码必须 PR、main 禁止直接推送"),已按原稿口径改回。
上一行这句留着,是为了让下一个人不再"顺手加严"。
- PR 描述是**交付契约**,不是说明文。`gitea-workflows` 技能要求:写清 **已实现 / 安全与数据边界 / 真跑过的测试 / 尚未验证 / "未合并、未部署"**。
- 合并前必须读到**权威读回**:`GET /pulls/{n}` → `state=closed`、`merged=true`、`merge_commit_sha` 非空。
⚠️ Gitea 的合并接口**成功时可能返回空 body**,客户端解析 JSON 会误报失败 —— **永远不要重试合并**,改为读回。
- ⚠️ `mergeable=true` 只表示**没有冲突**,不代表测试、评审、迁移演练、部署门禁已满足。
### 1.3 分支保护(Gitea 侧)
### 1.3 分支保护:**默认不开**
`main` **必须**设分支保护,至少勾:禁止直接推送、必须走 PR、必须 CI 状态通过。
现状:**四个仓库全部没有分支保护**(`GET /repos/{r}/branch_protections` 返回 `[]`),见 `03-待配置事项.md` 第 1 项。
原稿的默认是 `main` **不受保护**(它 §8 甚至写明"不要勾 Protected"),本规范沿用。
- **默认不开**。它有一个容易忽略的代价:**"必须状态检查通过"只门控 PR 合并 ——
直推 `main` 时管不住**(CI 是"推完再跑",红了也拦不住那次推送)。
既然本环境以直推为主,开保护的收益小于它带来的流程成本。
- **什么时候值得开**:某段时间改动都要评审,或需要"任何人都不能直推"(交接期、多人并行、外部协作者)。
开的时候按需勾"禁止直接推送 + 必须走 PR",**不勾强制状态检查** ——
除非该仓工作流覆盖所有分支(判据见 `06-接入既有工程.md` §2)。
- 现状:`Weknora/knowledge-governance` 曾开过一次,已按本节口径**关掉**;7 个仓现在
`GET /repos/{r}/branch_protections` 全为 `[]`。
### 1.4 `.gitignore` 是契约(五条)
@@ -429,7 +443,7 @@ flock -n 9 || { echo "❌ 另一个部署正在进行,退出"; exit 1; }
- [ ] **4. CI 骨架**:复制 `templates/gitea-build.yml` → `.gitea/workflows/build.yml`;随仓带 `.gitea/scripts/{ci_gate.py,ci_report.py}`(**逐字节复制**已验证的版本,不要重写)。
- [ ] **5. 仓库级 Runner**:`bash /srv/aiball/runner/attach_repo.sh <owner/repo> <runner-name>`(幂等;注册令牌走文件挂载,不落容器 env)。
- [ ] **6. bridge 变量/密钥**:`CI_BRIDGE_URL`(variable)、`CI_BRIDGE_TOKEN`(secret),由上面的脚本写入;写完 `GET` 读回。
- [ ] **7. 分支保护**:`main` 禁止直接推送 + 必须 PR + 必须 CI 通过。
- [ ] **7. 分支保护(可选,默认不开)**:见 §1.3。只在"需要任何人都不许直推"时开,且**不要**勾强制状态检查(除非该仓工作流覆盖所有分支)。
- [ ] **8. `AGENTS.md` 指针**:仓库 `AGENTS.md` 写一行指回本规范;陷阱库拆 `AGENTS.md`(索引)+ `docs/agents/traps.md`(全文)。
- [ ] **9. 自检一遍**:推一个**故意违规**的提交,确认 `gate` 红且 `build` 没跑(反向自检,§3.2-3)。
- [ ] **10. 首次推 `main` 因为缺变量而红 —— 这是设计**:变量检查在任何上传动作**之前**,目标机一个字节都不会被碰。
+7 -8
View File
@@ -10,15 +10,14 @@
## P0
### 1. 分支保护(试点 1/7 完成)
### 1. 分支保护 —— **已定:默认不开**(不需要动)
- **现状**:`Weknora/knowledge-governance` **已接入**(`enable_push=false` + 必须 PR + 强制 `build / build (push)`,见 `06-接入既有工程.md` §3 试点记录)。其余 6 个仓当前**无保护**(期中一次全量铺开已按指示回退)。
- **要做什么**:逐个跑 `python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo> --apply`(先 DRY-RUN 看计划)。
⚠️ **顺序有讲究**:先补 CI,再开强制状态检查 —— 反过来会把 PR 锁死(判据见 `06` §2)。
- **为什么**:没有保护,"提交规范 + 评审分级"全是自觉;`main` 被 force-push 一次,"线上跑的是哪个提交"就永久失去答案。
- **怎么验证**:`GET /repos/{r}/branch_protections` 非空且 `enable_push=false`;**直推被服务端拒绝**(真跑一次,见 `06` §5)。
- **谁来做**:开发(脚本可代做);**凭据/授权环节仍由本人把关**。
⚠️ 分支保护涉及平台设置,属可逆操作(网页可随时关掉),所以锁死风险低;但"PR 等待不存在的检查"这个坑必须靠 §2 的体检避开。
- **决定**:按原稿口径,`main` **不受保护**,所有改动直接进 `main`(正本 §1.1 / §1.3)。
- **现状**:曾给 `Weknora/knowledge-governance` 开过一次(禁直推 + 强制 `build / build (push)`),
**已关掉**;7 个仓现在 `GET /repos/{r}/branch_protections` 全为 `[]`。
- **这条知识别丢**:将来真需要"谁都不能直推"时,开保护**前**必须做 `06-接入既有工程.md` §2 的体检 ——
**盲目勾"必须状态检查通过"会让 PR 永远等一个不会出现的检查**。判据仍然有效,只是默认不用它。
- **可逆**:开/关都是一次 API 调用或网页点一下,不涉及数据。
### 2. `knowledge-adapter-service` 的工作流不在 `main` 上
@@ -52,7 +52,7 @@ Hermes 注入项目上下文的机制(官方 reference `project-context-files.
| 项 | 为什么要 | 谁来做 |
|---|---|---|
| 各仓 `AGENTS.md` 指针 | 渠道 B;同时让 Codex/Claude Code 也读到 | 跨 6 个仓,属独立动作 —— 等指示 |
| `main` 分支保护 | 平台层,唯一"不依赖自觉"的一层 | **网页操作**,只有你能点 |
| `main` 分支保护 | **默认不开**(正本 §1.3)。真要"谁都不能直推"时才开 | 开发可代做;开了之后记得它的代价:状态检查管不住直推 |
| 守门脚本(Gitea 版 spec-check) | 渠道 C 的另一半:让"没接规范的仓"在 CI 里红 | 我可以写(原稿 `spec-check.mjs` 是 GitLab 口径,直接跑会**假红**) |
| 模板漂移检查 | `templates/ci_gate.py` 与各仓 `.gitea/scripts/ci_gate.py` 会漂移;漂移要有人被吵醒 | 我可以写(逐字节比对 + 反向自检) |
@@ -77,7 +77,7 @@ EOF
git remote add origin https://gitea.zheromiku.top/<owner>/<repo>.git && git push -u origin main
bash /srv/aiball/runner/attach_repo.sh <owner>/<repo> <runner-name>
# ④ 网页:Settings → Branches → 给 main 设保护(禁直推 + 必须 PR + 必须 CI 通过)
# ④ (可选)需要"谁都不能直推"时才开分支保护;**默认不开**(正本 §1.3)
# ⑤ 反向自检:推一个故意违规的提交,确认 gate 红、且 build 没跑
```
@@ -115,7 +115,8 @@ cp ~/eng-standards/templates/{ci_gate.py,ci_report.py} .gitea/scripts/
# ③ 补指针:AGENTS.md 里加一行指向 zheromiku/eng-standards-main
```
收紧顺序建议:**先 B(指针)+ 平台层(分支保护)**,都是分钟级动作且立刻见效;
收紧顺序建议:**先补 CI,再谈分支保护** —— 顺序反了会把 PR 锁死;
而分支保护本身默认不开(正本 §1.3)。渠道 A/B 都是分钟级动作且立刻见效;
再补门禁与守门(决定它是建议还是门禁)。
## 8. 怎么证明它真的生效(这条别跳)
+11 -6
View File
@@ -12,8 +12,8 @@ python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo>
# ② 计划无误再执行
python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo> --apply
# 可选:只加指针、不动分支保护
python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo> --apply --no-protection
# 需要"谁都不能直推"时才开分支保护 —— **默认不开**(正本 §1.3:原稿口径是直推 main)
python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo> --apply --with-protection
```
工具只依赖 Python 标准库 + git;凭据运行时从 `~/.git-credentials` 读,**永不打印**。
@@ -25,7 +25,7 @@ python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo> --apply --no-prot
|---|---|---|
| 1 | 体检 | 读默认分支、工作流触发方式、runner(仓库级/组织级)、最新提交的 status context、已有保护 |
| 2 | 加指针 | 新起分支 → 写 `AGENTS.md` → 提交(过本机闸门)→ **PR** → 等 CI → 合并 → 删分支 |
| 3 | 开保护 | `POST/PATCH /branch_protections`:禁直推 + 禁 force push + 必须 PR;**状态检查按第 1 步判定** |
| 3 | 开保护(**仅 `--with-protection` 时**) | `POST/PATCH /branch_protections`:禁直推 + 必须 PR;**状态检查按第 1 步判定**。**默认不做** —— 原稿口径是直推 `main`(正本 §1.3) |
| 4 | 给自检命令 | 打印"直推必须被拒"的验证命令(**这步工具不代做,必须你跑**) |
⚠️ `main` 一旦被保护,**所有改动都得走 PR** —— 包括工具自己加 `AGENTS.md` 这一步。
@@ -61,6 +61,10 @@ python3 ~/eng-standards/scripts/adopt-existing.py <owner/repo> --apply --no-prot
## 3. 试点记录:`Weknora/knowledge-governance`(已跑通)
> ⚠️ **口径已更新**:下表里的"分支保护"作法**后来按原稿口径关掉了**(正本 §1.3:默认不开、直推 `main`)。
> 记录保留有两个用处:① 万一将来真要开保护,它证明"PR + 强制状态检查"是**能跑通**的;
> ② §2 那条体检判据是真的有用。其余部分(加 `AGENTS.md` 指针)不受影响。
执行前该仓状态:无 `AGENTS.md`、无分支保护;`build.yml` 是 `push: "**"`;仓库级 runner `runner-weknora-kg-01`。
| 步 | 实测结果 |
@@ -106,9 +110,10 @@ cd /tmp/t && git commit --allow-empty -m 'chore(probe): 不该进 main' && git p
## 5. 接入后的验收判据(缺一即不算接入)
1. `AGENTS.md`(或 `.hermes.md`)在默认分支上,且含指向 `zheromiku/eng-standards-main` 的路径。
2. `GET /repos/{r}/branch_protections` 非空,`enable_push=false`。
3. 直推默认分支**被服务端拒绝**(不是"我没敢推",是真被拒)。
4. PR 能正常合并(证明强制状态检查没有把 PR 锁死)—— **只有开了状态检查的仓需要这条**。
2. **(只在开了保护时)** `GET /repos/{r}/branch_protections` 非空、`enable_push=false`,且直推默认分支**被服务端拒绝**。
⚠️ 默认口径是**直推 `main`**,所以正常情况下这里应当是"没有保护" —— **别把"没保护"当成缺陷**。
3. 推一个故意违规的提交 → 本机 hook 红、CI 的 `gate` 红。**直推也拦得住**,因为 gate 在 CI 里(正本 §4.1)。
4. **(只在开了保护时)** PR 能正常合并 —— 证明强制状态检查没有把 PR 锁死。
5. 会话里问"这个仓提交信息什么规范",代理能**不靠你重复**答出来(说明指针真的被注入了)。
## 6. 会遇到的拒绝与处理
+19 -12
View File
@@ -1,11 +1,14 @@
#!/usr/bin/env python3
"""把一个**既有** Gitea 工程接入工程规范(正本 §8 的可执行版)。
python3 adopt-existing.py <owner/repo> [--apply] [--no-protection]
python3 adopt-existing.py <owner/repo> [--apply] [--with-protection]
默认**只体检 + 打印计划,不动任何东西**。加 --apply 才真的改。
为什么入口分成两边(体检 / 执行):
**分支保护默认不开**(原稿口径:所有改动直接进 main,正本 §1.3)。
只有加 --with-protection 才动它。
为什么开保护要单独一个开关、而且还得分两步入(体检 / 执行):
"强制状态检查"开错了会把 PR **永久锁死** —— 检查永远没人报,PR 永远合不了。
所以必须先体检判定该仓的工作流**能不能覆盖 PR 头提交**,判定通过才允许开。
@@ -262,9 +265,9 @@ def protection_payload(rep, allow, ctxs):
"enable_status_check": bool(allow), "status_check_contexts": ctxs if allow else []}
def set_protection(g: Gitea, repo: str, rep: dict, apply: bool, allow: bool, ctxs, no_protection: bool):
if no_protection:
return "按 --no-protection 跳过"
def set_protection(g: Gitea, repo: str, rep: dict, apply: bool, allow: bool, ctxs, with_protection: bool):
if not with_protection:
return "默认不开(原稿口径:直推 main,正本 §1.3);要开请加 --with-protection"
plan = f"保护 {rep['default_branch']}(禁直推 + 必须 PR;强制状态检查 {'开 ' + str(ctxs) if allow else '不开'})"
if not apply:
return "计划:" + plan
@@ -284,7 +287,7 @@ def main():
sys.exit(__doc__)
repo = args[0]
apply = "--apply" in sys.argv
no_protection = "--no-protection" in sys.argv
with_protection = "--with-protection" in sys.argv
g = Gitea()
print(f"Gitea: {g.base} 仓库: {repo} 模式: "
@@ -311,7 +314,7 @@ def main():
print("\n── 计划 ──")
print(" 1. AGENTS.md : " + add_pointer(g, repo, rep, False))
print(" 2. 分支保护 : " + set_protection(g, repo, rep, False, allow, ctxs, no_protection))
print(" 2. 分支保护 : " + set_protection(g, repo, rep, False, allow, ctxs, with_protection))
if not apply:
print("\n(DRY-RUN:什么都没改。确认计划无误后加 --apply 重跑)")
@@ -319,17 +322,21 @@ def main():
print("\n── 执行 ──")
print(" AGENTS.md : " + add_pointer(g, repo, rep, True))
print(" 分支保护 : " + set_protection(g, repo, rep, True, allow, ctxs, no_protection))
print(" 分支保护 : " + set_protection(g, repo, rep, True, allow, ctxs, with_protection))
_, bp = g.api(f"/repos/{repo}/branch_protections")
for b in L(bp):
d = D(b)
print(f" 读回: {d.get('branch_name')} enable_push={d.get('enable_push')} "
f"status_check={d.get('enable_status_check')} ctx={d.get('status_check_contexts')}")
print("\n ⚠️ 还差一步反向自检(必须做,别跳):")
print(f" t=$(mktemp -d); git clone {g.base}/{repo}.git $t; cd $t \\")
print(f" && git commit --allow-empty -m 'chore(probe): 不该进 main' \\")
print(f" && git push origin {rep['default_branch']}")
print(" 判据:必须被拒 —— Not allowed to push to protected branch")
if with_protection:
print(f" t=$(mktemp -d); git clone {g.base}/{repo}.git $t; cd $t \\")
print(" && git commit --allow-empty -m 'chore(probe): 不该进 main' \\")
print(f" && git push origin {rep['default_branch']}")
print(" 判据:必须被拒 —— Not allowed to push to protected branch")
else:
print(" 推一个故意违规的提交 → 本机 hook 必须红、CI 的 gate 必须红")
print(" (直推也拦得住:gate 在 CI 里,不是靠 PR 才有的)")
if __name__ == "__main__":
+1 -1
View File
@@ -176,7 +176,7 @@ cat <<EOF
3) 挂仓库级 Runner + bridge 变量(幂等):
bash /srv/aiball/runner/attach_repo.sh $REPO $RUNNER
4) main 分支保护(网页:Settings → Branches):禁止直接推送 + 必须 PR + 必须 CI 通过
4) (可选)需要"谁都不能直推"时才开分支保护;**默认不开**(正本 §1.3)
5) **反向自检**(正本 §3.2-3):推一个故意违规的提交,确认 gate 红、且 build 没跑