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:
parent
d5865f57cd
commit
f1676a1d49
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
256
contracts/prompts/check_version_bump.py
Executable file
256
contracts/prompts/check_version_bump.py
Executable file
@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
check_version_bump.py — Prompt 四道闸 · 闸 0「version 升号强制」离线闸(段 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(默认 HEAD;CI 传 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()
|
||||
Loading…
x
Reference in New Issue
Block a user