feat(prompts): 四道闸CI接线段A——离线版本升号闸 check_version_bump + 挂 pre-commit/contract-gates

闸0「改 prompt 正文必升 version」落成独立零依赖脚本(与 check_registry 同风格、各管一摊):
- 相对基线(默认 HEAD;CI 传 push 前提交)剔除 frontmatter version 行后比规范化正文——正文有实质改动而 version 未变即拒;
- 新增文件无基线不判、删除文件交 check_registry 报缺失;基线 ref 无效则跳过不误拦、不假绿;
- 挂 .githooks/pre-commit 门5(prompts 路径条件触发,基线 HEAD)+ .gitea/workflows/contract-gates.yml 服务端(checkout fetch-depth:0 供比对 push 前提交)。

自测:改 safety 正文不升版→FAIL exit1、同步升版→OK exit0、git checkout 恢复干净。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-04 08:58:57 -07:00
parent d5865f57cd
commit f1676a1d49
3 changed files with 278 additions and 1 deletions

View File

@ -14,6 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# 段 A 版本升号闸(check_version_bump)要比对 push 前提交,故拉全历史;
# 其余四门只读工作区、不依赖 git 历史,fetch-depth 变化对它们无影响。
fetch-depth: 0
# 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。
# 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。
@ -33,3 +37,17 @@ jobs:
- name: Prompt Registry 版本一致性
run: python3 contracts/prompts/check_registry.py
# 四道闸·闸0(段 A):改了 prompt 正文相对 push 前提交必须升 version。
# 基线优先取 push 事件的 before;取不到/无效回落 HEAD~1;再取不到(首 push/新分支)则跳过不误拦。
- name: Prompt 闸0 version 升号(改正文必升版)
run: |
BASE="${{ github.event.before }}"
if [ -z "$BASE" ] || ! git rev-parse --verify --quiet "$BASE^{commit}" >/dev/null 2>&1; then
BASE="$(git rev-parse --verify --quiet HEAD~1 2>/dev/null || true)"
fi
if [ -z "$BASE" ]; then
echo "无可比基线(首次提交/新分支),版本升号闸跳过(不误拦、不假绿)"
exit 0
fi
python3 contracts/prompts/check_version_bump.py --base "$BASE"

View File

@ -29,9 +29,12 @@ if printf '%s\n' "$STAGED" | grep -qE '(cheap-worker/fixtures/genre-rubrics/|\.a
python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; }
fi
# ── 门 5:Prompt Registry 版本一致性 —— 触发 = 触及 prompts 契约(registry.yaml 或任一 prompt .md) ──
# ── 门 5:Prompt 契约双闸 —— 触发 = 触及 prompts 契约(registry.yaml 或任一 prompt .md) ──
# 闸 a = 版本一致性(check_registry:registry.yaml↔.md frontmatter 的 version 必须相等);
# 闸 b = 四道闸·闸0 版本升号(check_version_bump:改了 prompt 正文相对 HEAD 必须升 version)。
if printf '%s\n' "$STAGED" | grep -qE '^contracts/prompts/'; then
python3 "$ROOT/contracts/prompts/check_registry.py" || { echo '✘ Prompt Registry 版本一致性未过'; fail=1; }
python3 "$ROOT/contracts/prompts/check_version_bump.py" || { echo '✘ Prompt 闸0 version 升号未过(改正文须升 version)'; fail=1; }
fi
if [ "$fail" -ne 0 ]; then

View File

@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""
check_version_bump.py Prompt 四道闸 · 0version 升号强制离线闸 A
防什么改了一条 prompt 的正文模板体或 frontmatter 实质字段却没升 version
会让 id@version 加载回归比对基线全部失效审计追溯里两个不同文本挂同一版本号
线上换了行为却无人知晓本门在每次提交/CI 时对比基线逮住正文变了version 没升 exit 1 拦下
check_registry.py 的分工各管一摊互不等待
· check_registry.py 静态一致性registry.yaml version 必须与 .md frontmatter version 相等
· check_version_bump.py本脚本 变更纪律相对基线prompt 正文若有实质改动version 必须随之升
两门叠加才完整前者防两处版本号写歪后者防改了内容忘记升号
判定口径明确可解释
对每条 registry 登记的 prompt .md基线版本当前工作区版本各自算两样东西
规范化正文 = 全文剔除 frontmatter 里的 version 行之后的内容升版动作本身体现在 version
故比对时把它排除version 行的行内说明注释也随之排除改升版说明不强制再升号
version = frontmatter 顶层 version 字段剥行内注释与引号
规范化正文变了 version 值没变 违规改了正文没升号
新增文件基线不存在该路径不判首次入库无基线可比被删除的文件不判 check_registry 报缺失
基线怎么取
默认基线 = HEAD本地开发/提交前自查/pre-commit 场景工作区当前内容上次提交
CI 服务端可 --base <ref> 指定 push 前的提交 .gitea/workflows/contract-gates.yml
基线 ref 无效如首次 push 的全 0 SHA 打印说明并 exit 0无可比基线不误拦绝不假绿放行违规
真无基线时本就没有改了没升号这回事
用法
python3 contracts/prompts/check_version_bump.py # 基线 = HEAD
python3 contracts/prompts/check_version_bump.py --base <git-ref>
# exit 0 = 无违规含无基线跳过exit 1 = 有条目改了正文却没升 version
零三方依赖对齐 check_registry.py只用标准库 + git 命令
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
# 本文件所在目录 = contracts/prompts/
PROMPTS_DIR = Path(__file__).resolve().parent
REGISTRY_FILE = PROMPTS_DIR / "registry.yaml"
def _strip_inline_comment(value):
"""剥行内注释(# 后非引号内的内容去掉),再剥成对外层引号。与 check_registry.py 同逻辑(避免三方依赖)。"""
out = []
in_quote = None
for ch in value:
if in_quote:
if ch == in_quote:
in_quote = None
out.append(ch)
elif ch in ("'", '"'):
in_quote = ch
out.append(ch)
elif ch == '#':
break # 注释起点(不在引号内)
else:
out.append(ch)
s = "".join(out).strip()
if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
s = s[1:-1]
return s
def parse_registry_files(text):
"""从 registry.yaml 文本解析出每条 prompt 的 (id, file) —— 只取本门需要的两字段。
解析口径与 check_registry.py 一致最小 YAML 子集无三方依赖"""
entries = []
current = None
in_prompts = False
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if re.match(r'^prompts\s*:\s*(#.*)?$', line):
in_prompts = True
continue
if not in_prompts:
continue
m = re.match(r'^\s*-\s+id\s*:\s*(.+)$', line)
if m:
current = {'id': _strip_inline_comment(m.group(1))}
entries.append(current)
continue
m = re.match(r'^\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*)$', line)
if m and current is not None:
current[m.group(1)] = _strip_inline_comment(m.group(2))
continue
if re.match(r'^[A-Za-z_]', line):
in_prompts = False
current = None
return entries
def _frontmatter_bounds(text):
"""返回 frontmatter 的 (起始行号=0, 闭合 --- 行号) 或 None无合法 frontmatter"""
lines = text.split("\n")
if not lines or lines[0].strip() != "---":
return None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return (0, i)
return None
def canonical_content(text):
"""规范化正文:全文剔除 frontmatter 内的顶层 version 行后返回。
用于判定除升版动作外是否有实质改动version 含其行内说明注释被排除其余逐字节保留
frontmatter 原样返回整篇当正文比对"""
bounds = _frontmatter_bounds(text)
if bounds is None:
return text
_, close_idx = bounds
lines = text.split("\n")
kept = []
for i, line in enumerate(lines):
# 仅在 frontmatter 范围内1..close_idx-1剔除顶层 version 行
if 1 <= i < close_idx and re.match(r'^version\s*:', line):
continue
kept.append(line)
return "\n".join(kept)
def frontmatter_version(text):
"""取 frontmatter 顶层 version 值(剥行内注释/引号);无则返回 None。"""
bounds = _frontmatter_bounds(text)
if bounds is None:
return None
_, close_idx = bounds
for line in text.split("\n")[1:close_idx]:
m = re.match(r'^version\s*:\s*(.+)$', line)
if m:
return _strip_inline_comment(m.group(1))
return None
def git_repo_root():
"""仓库根目录worktree 亦可);失败返回 None。"""
try:
out = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
cwd=str(PROMPTS_DIR), capture_output=True, text=True, check=True,
)
return Path(out.stdout.strip())
except (subprocess.CalledProcessError, OSError):
return None
def git_show(root, ref, rel_path):
"""git show <ref>:<rel_path> 的内容;路径在该 ref 不存在(新文件)或出错 → 返回 None。"""
try:
out = subprocess.run(
["git", "show", f"{ref}:{rel_path}"],
cwd=str(root), capture_output=True, text=True,
)
if out.returncode != 0:
return None # 该 ref 下无此路径(新增文件)
return out.stdout
except OSError:
return None
def git_ref_valid(root, ref):
"""基线 ref 是否有效(可解析为一个 commit"""
try:
out = subprocess.run(
["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
cwd=str(root), capture_output=True, text=True,
)
return out.returncode == 0
except OSError:
return False
def main():
parser = argparse.ArgumentParser(description="Prompt 闸 0改正文必升 version离线版本闸")
parser.add_argument("--base", default="HEAD",
help="对比基线 git ref默认 HEADCI 传 push 前提交)")
args = parser.parse_args()
root = git_repo_root()
if root is None:
print("[跳过] 非 git 仓库或 git 不可用,版本闸无基线可比(不拦)。")
sys.exit(0)
if not git_ref_valid(root, args.base):
print(f"[跳过] 基线 ref 无效({args.base!r}),无可比基线,版本闸跳过(不误拦、不假绿)。")
sys.exit(0)
try:
registry_text = REGISTRY_FILE.read_text(encoding="utf-8")
except OSError as e:
print(f"[错误] 无法读取 registry.yaml{e}", file=sys.stderr)
sys.exit(1)
entries = parse_registry_files(registry_text)
if not entries:
print("[警告] registry.yaml 解析出 0 条 prompt 条目(格式异常?),退出码 1。", file=sys.stderr)
sys.exit(1)
violations = []
checked = 0
for entry in entries:
pid = entry.get('id', '(无 id)')
rel_file = (entry.get('file') or '').strip()
if not rel_file:
continue # 缺 file 字段交 check_registry 报,不在本门职责
cur_path = PROMPTS_DIR / rel_file
if not cur_path.exists():
continue # 工作区文件被删/缺失,交 check_registry 报缺失,本门不判
rel_from_root = f"contracts/prompts/{rel_file}"
base_text = git_show(root, args.base, rel_from_root)
if base_text is None:
continue # 基线不存在该文件(新增),无基线可比,跳过
try:
cur_text = cur_path.read_text(encoding="utf-8")
except OSError as e:
violations.append(f" [{pid}] 读工作区文件失败:{e}{rel_file}")
continue
checked += 1
base_canon = canonical_content(base_text)
cur_canon = canonical_content(cur_text)
if base_canon == cur_canon:
continue # 正文无实质改动(或仅改了 version 行/升版说明),放行
base_ver = frontmatter_version(base_text)
cur_ver = frontmatter_version(cur_text)
if base_ver == cur_ver:
violations.append(
f" [{pid}] 正文已改但 version 未升(仍为 {cur_ver!r}{rel_file}"
)
if violations:
print(f"[FAIL] Prompt 闸 0 未通过({len(violations)} 条改了正文却没升 version")
for v in violations:
print(v)
print()
print("修复方式:改一条 prompt 的正文(模板体或 frontmatter 实质字段),必须同步升 version"
".md frontmatter + registry.yaml 两处,语义化版本号,改一字也要升)。")
sys.exit(1)
else:
print(f"[OK] Prompt 闸 0 通过(相对基线 {args.base} 比对了 {checked} 条已存在的 prompt"
"改了正文的都已升 version")
sys.exit(0)
if __name__ == "__main__":
main()