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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# 段 A 版本升号闸(check_version_bump)要比对 push 前提交,故拉全历史;
|
||||||
|
# 其余四门只读工作区、不依赖 git 历史,fetch-depth 变化对它们无影响。
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
# 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。
|
# 判例库 python-gates 段需 pytest;check-undef 段需 esbuild(随 node_modules)。
|
||||||
# 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。
|
# 尽力预装让四段全量真跑;装不上则相应段 run.mjs 会诚实 SKIP。
|
||||||
@ -33,3 +37,17 @@ jobs:
|
|||||||
|
|
||||||
- name: Prompt Registry 版本一致性
|
- name: Prompt Registry 版本一致性
|
||||||
run: python3 contracts/prompts/check_registry.py
|
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; }
|
python3 "$ROOT/.agents/tools/rubric-sync-gate.py" || { echo '✘ rubric 双写对账未过'; fail=1; }
|
||||||
fi
|
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
|
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_registry.py" || { echo '✘ Prompt Registry 版本一致性未过'; fail=1; }
|
||||||
|
python3 "$ROOT/contracts/prompts/check_version_bump.py" || { echo '✘ Prompt 闸0 version 升号未过(改正文须升 version)'; fail=1; }
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$fail" -ne 0 ]; then
|
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