Files
t 950c1b429b feat(release): 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)+ 1.0.0 前置(稳定契约承诺)
原来预检只"打印破坏性条数",抬哪一档靠人自觉 ⇒ 改成硬门禁:

- **抬档门禁**:破坏性条数取自**目标提交里候选版本那一段**(发版事实看提交,不看工作区)。
  `0.x` 阶段有破坏性 ⇒ 必须抬**次版本**(或主版本),只抬修订 ⇒ 阻断并报出该抬到哪一档;
  `≥1.0.0` 之后有破坏性 ⇒ 必须抬**主版本**,抬次/修订都阻断。
  另加"候选号必须大于上一个正式版本"(版本只增不减)。
- **1.0.0 前置**:上一版本是 `0.x` 时,候选 `1.0.0` = 对外承诺契约稳定 ⇒ 必须先有写实的
  《稳定契约承诺》(覆盖 接口 / 数据模型 / 配置 / CLI 输出与退出码,不留 `待填`/`TODO`/`TBD`),
  **且候选发布记录里链接它**,缺任一条 ⇒ 阻断;`1.x → 2.0.0` 不重复要求。
- 预检新增"版本走向"事实行(上一版本 → 候选 / 抬哪一档 / 破坏性 N 条 ⇒ 要求哪一档 ✅⛔);
  `docs/RELEASE_RUNBOOK.md` 新增"抬哪一档"对照表与《稳定契约承诺》段(故意留占位)。

验证:release_preflight --self-test 32 分支全过(含新增 9 条);反向夹具新增 §24 六条真实临时仓库用例
(0.x 只抬修订 ⇒ 阻断、0.x 抬次 ⇒ 放行、≥1.0.0 抬次 ⇒ 阻断、1.0.0 留占位 ⇒ 阻断、写实+链接 ⇒ 放行、
写实但没链接 ⇒ 仍阻断)⇒ 夹具 91/0;make verify 15/15。
于是 0.1.0 之后的第一个提交(本提交)开发号 = 0.1.1。
2026-10-13 17:13:44 +08:00

908 lines
51 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 反向验收夹具:**只在隔离副本里跑**。它故意弄坏东西,断言门禁会红;再恢复,断言会绿。
#
# 为什么要有它:一个"永远返回 0 的校验器"比没有校验更糟 —— 它给人一种已经检查过的错觉
# (正本 §0-2)。所以每条判据都要有反例,而且反例要在**隔离副本**里做,
# 绝不往日常 main 上推故意失败的提交(正本 §10)。
#
# 用法(在副本里):
# cp -a <repo> /tmp/kg-neg && cd /tmp/kg-neg && CI_NEGATIVE_FIXTURE=1 bash scripts/ci_negative_fixtures.sh
#
# 两道保险,防止有人在本仓里直接跑:
# 1. 必须显式设 CI_NEGATIVE_FIXTURE=1
# 2. 本仓有 origin 指向真实 Gitea 时拒绝运行(副本里通常没有 origin)
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [ "${CI_NEGATIVE_FIXTURE:-}" != "1" ]; then
echo "❌ 拒绝运行:本脚本会故意弄坏文件。先设 CI_NEGATIVE_FIXTURE=1,并确认自己在隔离副本里。" >&2
exit 2
fi
if git remote get-url origin >/dev/null 2>&1; then
echo "❌ 拒绝运行:这个工作区有 origin 远端($(git remote get-url origin))—— 反向夹具只在无远端的副本里跑。" >&2
exit 2
fi
PY="${PYTHON:-python3}"
pass=0; fail=0
say() { printf '\n\033[1m== %s ==\033[0m\n' "$*"; }
ok() { pass=$((pass+1)); echo " ✅ $1"; }
no() { fail=$((fail+1)); echo " ❌ $1"; }
# expect <期望退出码> <名字> -- <命令...>
expect() {
local want="$1" name="$2"; shift 3
local out; out="$("$@" 2>&1)"; local got=$?
if [ "$got" = "$want" ]; then ok "$name(退出码 $got,符合预期)"
else no "$name:期望退出码 $want,实际 $got"; echo "$out" | tail -12 | sed 's/^/ /'; fi
}
[ -d web/node_modules ] || { echo "先装依赖:npm --prefix web ci" >&2; exit 2; }
say "0. 基线:现在应当全绿"
expect 0 "identity(事件 SHA = 当前 HEAD)" -- env GITHUB_SHA="$(git rev-parse HEAD)" "$PY" scripts/ci_checks.py --only identity
expect 0 "format" -- "$PY" scripts/ci_checks.py --only format
expect 0 "security-secrets" -- "$PY" scripts/ci_checks.py --only security-secrets
say "1. 格式错误必须红(Go / 前端各一条)"
printf 'package main\nfunc main( ){ println("x") }\n' > zz_fixture_bad.go
git add zz_fixture_bad.go
expect 1 "Go 未格式化 ⇒ format 红" -- "$PY" scripts/ci_checks.py --only format
git rm -q --cached zz_fixture_bad.go; rm -f zz_fixture_bad.go
printf 'export function f( x:number ){return x*2}\n' > web/src/zzFixture.ts
git -C "$ROOT" add web/src/zzFixture.ts 2>/dev/null || git add web/src/zzFixture.ts
expect 1 "前端未格式化(基线外的新文件)⇒ format 红" -- "$PY" scripts/ci_checks.py --only format
git rm -q --cached web/src/zzFixture.ts; rm -f web/src/zzFixture.ts
expect 0 "还原后 format 恢复绿" -- "$PY" scripts/ci_checks.py --only format
say "2. 前端测试失败必须让门禁红"
cat > web/src/zzFixture.test.ts <<'TS'
import { describe, expect, it } from 'vitest'
describe('fixture', () => {
it('故意失败', () => { expect(1).toBe(2) })
})
TS
expect 1 "vitest 有用例失败 ⇒ tests-web 红" -- "$PY" scripts/ci_checks.py --only tests-web
rm -f web/src/zzFixture.test.ts
expect 0 "删除后 tests-web 恢复绿" -- "$PY" scripts/ci_checks.py --only tests-web
say "3. 版本与变更记录契约"
cp CHANGELOG.md /tmp/kg-changelog.bak
python3 - <<'PY'
import pathlib
p = pathlib.Path('CHANGELOG.md')
t = p.read_text(encoding='utf-8').rstrip('\n')
# 追加一条"没有日期"的已发布版本标题(结构自检必须抓住)
p.write_text(t + '\n\n## [9.9.9]\n\n### Added\n\n- 没有日期的正式版本条目\n', encoding='utf-8')
PY
"$PY" scripts/ci_checks.py --only contracts > /tmp/kg-nodate.log 2>&1; rc=$?
if [ "$rc" -ne 0 ] && grep -q '缺少日期' /tmp/kg-nodate.log; then
ok "变更记录里的已发布版本缺日期 ⇒ contracts 红(原因就是缺日期)"
else
no "已发布版本缺日期没有判红,或红的原因不是缺日期(rc=$rc)"; tail -8 /tmp/kg-nodate.log | sed 's/^/ /'
fi
cp /tmp/kg-changelog.bak CHANGELOG.md
expect 0 "还原后 contracts 恢复绿" -- "$PY" scripts/ci_checks.py --only contracts
# 构建身份:本仓还没有正式发布过 —— 也必须能产出**开发**身份(不退化成"上一正式版本号")
pycheck() { # pycheck "<名字>" <<'PY' … PY
local name="$1"; local out; out="$("$PY" - 2>&1)"; local rc=$?
if [ "$rc" = 0 ]; then ok "$name"; else no "$name"; echo "$out" | tail -8 | sed 's/^/ /'; fi
}
pycheck "有正式基线但取不到 tag ⇒ 退回 <版本>-dev+g<短SHA>(不猜开发号)" <<'PY'
import json, re, subprocess
p = subprocess.run(["python3", "scripts/version.py", "--identity"], capture_output=True, text=True)
if p.returncode:
print("❌ --identity 失败:", (p.stderr or "").strip()[:200]); raise SystemExit(1)
d = json.loads(p.stdout)
rel = d["release_version"]
okv = (d["build_kind"] == "development" and len(d["commit_sha"]) == 40
and d["commits_since_release"] is None
and re.fullmatch(re.escape(rel) + r"-dev\+g[0-9a-f]{12}", d["display_version"] or ""))
print(("✅ " if okv else "❌ ") + f"显示版本 {d['display_version']}|release_version={rel}|提交数={d['commits_since_release']}")
raise SystemExit(0 if okv else 1)
PY
pycheck "没有已发布版本时不许声明发布构建(--identity --release ⇒ rc=2)" <<'PY'
import subprocess
p = subprocess.run(["python3", "scripts/version.py", "--identity", "--release"], capture_output=True, text=True)
okv = (p.returncode == 2 and "不标为正式版" in (p.stderr or "")
and '"build_kind": "development"' in (p.stdout or ""))
print(("✅ " if okv else "❌ ") + f"退出码 {p.returncode}|{(p.stderr or '').strip().splitlines()[:1]}")
raise SystemExit(0 if okv else 1)
PY
pycheck "构建身份自检:证据齐(注释 tag + 完整 SHA + 提交内版本一致)才叫正式构建" <<'PY'
import subprocess
p = subprocess.run(["python3", "scripts/version.py", "--self-test"], capture_output=True, text=True)
print(((p.stdout or "") + (p.stderr or "")).strip().splitlines()[-1][:200])
raise SystemExit(p.returncode)
PY
pycheck "正式构建要核证据:无 tag/tag 名不符/unknown SHA ⇒ 红;注释 tag 指向该提交 ⇒ 绿(临时仓库 + go test)" <<'PY'
import os, pathlib, shutil, subprocess, tempfile
GO = shutil.which("go") or "/home/zheromiku/.local/go/bin/go"
SRC = pathlib.Path("internal/version")
def seed(d):
(d / "internal" / "version").mkdir(parents=True)
for f in SRC.glob("*.go"):
shutil.copy(f, d / "internal" / "version" / f.name)
(d / "scripts").mkdir(exist_ok=True)
shutil.copy("scripts/version.py", d / "scripts" / "version.py")
(d / "go.mod").write_text("module knowledge-governance\n\ngo 1.21\n", encoding="utf-8")
(d / "CHANGELOG.md").write_text(
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n- x\n\n"
"## [0.2.0] - 2026-02-01\n\n### Fixed\n\n- y\n", encoding="utf-8")
(d / "no-hooks").mkdir(exist_ok=True)
os.environ.update({"GIT_AUTHOR_NAME": "f", "GIT_AUTHOR_EMAIL": "f@f",
"GIT_COMMITTER_NAME": "f", "GIT_COMMITTER_EMAIL": "f@f",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": str(d / "no-hooks"), "GOFLAGS": "-mod=mod"})
for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]):
subprocess.run(["git", *args], cwd=d, check=True, capture_output=True)
return subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
text=True).stdout.strip()
def run(d, kind, rel, sha):
import json as _json
n = ""
ident = subprocess.run(["python3", "scripts/version.py", "--identity"], cwd=d,
capture_output=True, text=True)
if ident.returncode == 0:
val = _json.loads(ident.stdout).get("commits_since_release")
n = "" if val is None else str(val)
ld = (f"-X knowledge-governance/internal/version.Number={rel or 'dev'} "
f"-X knowledge-governance/internal/version.ReleaseVersion={rel} "
f"-X knowledge-governance/internal/version.BuildKind={kind} "
f"-X knowledge-governance/internal/version.CommitsSinceRelease={n} "
f"-X knowledge-governance/internal/version.CommitSHA={sha}")
return subprocess.run([GO, "test", "-count=1", "-run", "Test", f"-ldflags={ld}",
"./internal/version/..."], cwd=d, capture_output=True, text=True)
cases = []
with tempfile.TemporaryDirectory() as tmp:
d = pathlib.Path(tmp) / "r"; d.mkdir(); sha = seed(d)
p = run(d, "release", "0.2.0", sha)
cases.append(("有版本但**没有 tag** ⇒ 正式构建必须红", p.returncode != 0, (p.stdout + p.stderr)))
subprocess.run(["git", "tag", "-a", "v0.2.0", "-m", "release 0.2.0"], cwd=d, check=True,
capture_output=True)
p = run(d, "release", "0.2.0", sha)
cases.append(("注释 tag 指向该提交 + 版本一致 ⇒ 正式构建绿", p.returncode == 0, (p.stdout + p.stderr)))
p = run(d, "release", "0.2.0", sha[:12])
cases.append(("短 SHA ⇒ 红(正式构建要完整 40 位)", p.returncode != 0, (p.stdout + p.stderr)))
subprocess.run(["git", "tag", "-d", "v0.2.0"], cwd=d, check=True, capture_output=True)
subprocess.run(["git", "tag", "-a", "v9.9.9", "-m", "x"], cwd=d, check=True, capture_output=True)
p = run(d, "release", "0.2.0", sha)
cases.append(("只有别的 tag ⇒ 红", p.returncode != 0, (p.stdout + p.stderr)))
subprocess.run(["git", "tag", "-d", "v9.9.9"], cwd=d, check=True, capture_output=True)
subprocess.run(["git", "tag", "v0.2.0"], cwd=d, check=True, capture_output=True) # 轻量 tag
p = run(d, "release", "0.2.0", sha)
cases.append(("轻量 tag ⇒ 红(不算发布证据)", p.returncode != 0, (p.stdout + p.stderr)))
# 开发构建:ReleaseVersion 要等于该提交里 CHANGELOG 顶部的版本(注入口径),显示带 dev 标识
p = run(d, "development", "0.2.0", sha)
cases.append(("开发构建(tag 就在该提交上)⇒ 绿(开发号 == 正式号 0.2.0)",
p.returncode == 0, (p.stdout + p.stderr)))
# tag 之后再落 2 个提交 ⇒ 开发号必须是 0.2.2(随提交递增)
for i in (1, 2):
(d / f"more{i}.txt").write_text("x", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=d, check=True, capture_output=True)
subprocess.run(["git", "commit", "-q", "-m", f"chore: 追加 {i}"], cwd=d, check=True,
capture_output=True)
sha2 = subprocess.run(["git", "rev-parse", "HEAD"], cwd=d, capture_output=True,
text=True).stdout.strip()
p = run(d, "development", "0.2.0", sha2)
got = subprocess.run(["python3", "scripts/version.py", "--identity"], cwd=d,
capture_output=True, text=True)
import json as _json
disp = _json.loads(got.stdout).get("display_version") if got.returncode == 0 else "?"
cases.append((f"tag 之后第 2 个提交 ⇒ 开发号 0.2.2(实际 {disp})",
p.returncode == 0 and disp == "0.2.2", (p.stdout + p.stderr)))
bad = [n for n, okv, _ in cases if not okv]
for n, okv, tail in cases:
print(("✅ " if okv else "❌ ") + n + ("" if okv else "|" + tail.strip()[-200:]))
raise SystemExit(1 if bad else 0)
PY
# 变异测试①:把契约那一处的注入**整个拿掉** ⇒ 版本用例会 SKIP ⇒ 契约必须红(跳过不算通过)
cp scripts/ci_checks.py /tmp/kg-cichecks.bak
python3 - <<'PY'
import pathlib
p = pathlib.Path('scripts/ci_checks.py')
t = p.read_text(encoding='utf-8')
needle = 'rc1, text = run(ldflags_for(ident))'
assert t.count(needle) == 1, f'契约注入锚点不唯一({t.count(needle)} 处),夹具失效'
p.write_text(t.replace(needle, 'rc1, text = run("-X knowledge-governance/internal/version.Nope=x")', 1),
encoding='utf-8')
PY
"$PY" scripts/ci_checks.py --only contracts > /tmp/kg-skip.log 2>&1; rc=$?
if [ "$rc" -ne 0 ] && grep -q 'SKIP' /tmp/kg-skip.log; then
ok "注入缺失 ⇒ 版本用例被 SKIP ⇒ contracts 红(确认走的是 SKIP 分支)"
else
no "注入缺失没有判红,或没走到 SKIP 分支(rc=$rc)"; tail -8 /tmp/kg-skip.log | sed 's/^/ /'
fi
cp /tmp/kg-cichecks.bak scripts/ci_checks.py
# 变异测试②:把"自称正式版 ⇒ 必须红"那条负例阉割成开发构建 ⇒ contracts 必须红(证明负例不是死的)
python3 - <<'PY'
import pathlib
p = pathlib.Path('scripts/ci_checks.py')
t = p.read_text(encoding='utf-8')
needle = 'rc2, _ = run(ldflags_for(ident, build_kind="release", release_version=bogus))'
assert t.count(needle) == 1, f'负例锚点不唯一({t.count(needle)} 处),夹具失效'
p.write_text(t.replace(needle, 'rc2, _ = run(ldflags_for(ident, build_kind="development"))', 1),
encoding='utf-8')
PY
expect 1 "负例被阉割(不再自称正式版)⇒ contracts 红(说明负例真的在起作用)" -- "$PY" scripts/ci_checks.py --only contracts
cp /tmp/kg-cichecks.bak scripts/ci_checks.py
expect 0 "还原后 contracts 恢复绿" -- "$PY" scripts/ci_checks.py --only contracts
rm -f /tmp/kg-changelog.bak
say "4. 依赖锁与例外文件"
mv web/package-lock.json /tmp/kg-lock.bak
expect 1 "缺 package-lock.json ⇒ deps-install 红(不退化无约束安装)" -- "$PY" scripts/ci_checks.py --only deps-install
mv /tmp/kg-lock.bak web/package-lock.json
mv security/dependency-exceptions.json /tmp/kg-exc.bak
expect 1 "例外登记表缺失 ⇒ security-deps 红(不静默放行)" -- "$PY" scripts/ci_checks.py --only security-deps
mv /tmp/kg-exc.bak security/dependency-exceptions.json
say "5. 合成密钥夹具(无真实秘密)必须被拦住"
python3 - <<'PY'
import pathlib
# 值在运行期拼出来:夹具文件里不留真命中文本
ak = "AKIA" + "Q7ZP2M4XR9TLB6VW"
pathlib.Path('zz_fixture_secret.txt').write_text(f"aws_access_key = \"{ak}\"\n", encoding='utf-8')
PY
git add zz_fixture_secret.txt
expect 1 "合成 AWS AK ⇒ security-secrets 红" -- "$PY" scripts/ci_checks.py --only security-secrets
git rm -q --cached zz_fixture_secret.txt; rm -f zz_fixture_secret.txt
say "6. 检出身份(事件 SHA 错配必须红)"
expect 1 "事件 SHA 与 HEAD 不一致 ⇒ identity 红" -- env GITHUB_SHA="0000000000000000000000000000000000000000" "$PY" scripts/ci_checks.py --only identity
expect 0 "事件 SHA 与 HEAD 一致 ⇒ identity 绿" -- env GITHUB_SHA="$(git rev-parse HEAD)" "$PY" scripts/ci_checks.py --only identity
say "7. 必需检查未运行不得报全绿(全量跑一次,故意让 format 红)"
printf 'package main\nfunc main( ){ println("x") }\n' > zz_fixture_bad.go
git add zz_fixture_bad.go
"$PY" scripts/ci_checks.py > /tmp/kg-neg-gate.log 2>&1; rc=$?
if [ "$rc" -ne 0 ]; then ok "全量门禁非 0(退出码 $rc)"; else no "全量门禁竟然返回 0"; fi
python3 - <<'PY' && ok "报告里后续必需项被记为 not_run,且总体为 failure" || no "报告的 not_run / overall 断言不成立"
import json, sys
d = json.load(open('ci-report.json'))
by = {c['id']: c['status'] for c in d['checks']}
assert by.get('format') == 'failure', by
later = [by.get(k) for k in ('tests-go', 'tests-web', 'contracts', 'security-deps')]
assert all(x == 'not_run' for x in later if x is not None), by
assert d['overall_status'] == 'failure', d['overall_status']
assert any('not_run' in p or '未运行' in p for p in d['problems']), d['problems']
PY
git rm -q --cached zz_fixture_bad.go; rm -f zz_fixture_bad.go
say "8. 上报:通知失败不改变质量结果;汇总不可用必须非 0"
python3 - "$(git rev-parse HEAD)" <<'PYPY'
import json, sys, pathlib
sys.path.insert(0, 'scripts')
from ci_checks import CHECK_MANIFEST, SUCCESS
sha = sys.argv[1]
# 夹具报告必须是**合规**的(真清单 + 完整 SHA + run_mode=full):上报脚本现在会独立核对,
# 不核对就会把"能跑通"误当成"结论可信"。
checks = [{"id": cid, "status": SUCCESS, "reason": "ok", "duration_s": 1, "required_in": list(req)}
for cid, req in CHECK_MANIFEST.items()]
rep = {"schema": "ci-report/1", "profile": "ci", "run_mode": "full", "commit_sha": sha,
"event_sha": sha, "overall_status": SUCCESS, "policy_revision": "fixture",
"manifest": {cid: list(req) for cid, req in CHECK_MANIFEST.items()},
"checks": checks, "duration_s": 2, "problems": []}
pathlib.Path("/tmp/kg-report-ok.json").write_text(json.dumps(rep, ensure_ascii=False), encoding="utf-8")
PYPY
CI_BRIDGE_RESOLVED="http://127.0.0.1:1" "$PY" .gitea/scripts/ci_report.py --report /tmp/kg-report-ok.json > /tmp/kg-notify.log 2>&1
rc=$?
if [ "$rc" = 0 ]; then ok "通知不可达时退出码仍是 0(质量结论不变)"; else no "通知失败把退出码带成了 $rc"; sed 's/^/ /' /tmp/kg-notify.log | tail -5; fi
if grep -q '"notification_status": "failed' /tmp/kg-report-ok.json; then ok "通知失败被单独标注(notification_status=failed)"; else no "没看到 notification_status 被标注"; fi
expect 1 "汇总报告缺失 ⇒ 上报脚本非 0(不能报绿)" -- "$PY" .gitea/scripts/ci_report.py --report /tmp/kg-does-not-exist.json
say "9. 必需/可选测试的区分(必需用例被跳过必须红)"
cp scripts/test-scope.json /tmp/kg-scope.bak
python3 - <<'PYPY'
import json, pathlib
p = pathlib.Path('scripts/test-scope.json')
d = json.loads(p.read_text(encoding='utf-8'))
# 删掉"整个 postgres 包可跳过"这条声明:该包里的 57 个跳过随即变成"必需用例被跳过"
d['optional'] = [e for e in d['optional']
if e.get('package') != 'knowledge-governance/internal/platform/postgres']
p.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding='utf-8')
PYPY
expect 1 "未声明的必需用例被跳过 ⇒ tests-go 红" -- "$PY" scripts/ci_checks.py --only tests-go
cp /tmp/kg-scope.bak scripts/test-scope.json
expect 0 "恢复声明后 tests-go 回到绿" -- "$PY" scripts/ci_checks.py --only tests-go
mv scripts/test-scope.json /tmp/kg-scope2.bak
expect 1 "测试范围声明文件缺失 ⇒ tests-go 红(不许默认放行)" -- "$PY" scripts/ci_checks.py --only tests-go
mv /tmp/kg-scope2.bak scripts/test-scope.json
say "10. 前端 pending/todo 必须红"
printf "import { describe, it } from 'vitest'\ndescribe('fixture', () => { it.todo('故意待办') })\n" > web/src/zzPending.test.ts
expect 1 "有 todo 待跑用例 ⇒ tests-web 红" -- "$PY" scripts/ci_checks.py --only tests-web
rm -f web/src/zzPending.test.ts
expect 0 "删除后恢复绿" -- "$PY" scripts/ci_checks.py --only tests-web
say "11. 上报前必须独立核对报告(不能只看 overall_status)"
python3 - "$(git rev-parse HEAD)" <<'PYPY'
import json, sys, pathlib
sys.path.insert(0, 'scripts')
from ci_checks import CHECK_MANIFEST, SUCCESS
sha = sys.argv[1]
# 合规夹具必须用**真清单**造:报告里的清单与实现不一致时,上报脚本本来就应该拦下来
checks = [{"id": cid, "status": SUCCESS, "reason": "ok", "duration_s": 1,
"required_in": list(req)} for cid, req in CHECK_MANIFEST.items()]
base = {"schema": "ci-report/1", "profile": "ci", "run_mode": "full", "commit_sha": sha,
"event_sha": sha, "overall_status": SUCCESS,
"manifest": {cid: list(req) for cid, req in CHECK_MANIFEST.items()}, "checks": checks}
cases = {
"ok": base,
"short-sha": {**base, "commit_sha": sha[:12]},
"missing-check": {**base, "checks": [c for c in checks if c["id"] != "tests-go"]},
"dup-check": {**base, "checks": checks + [checks[0]]},
"unknown-check": {**base, "checks": checks + [{"id": "zz", "status": SUCCESS}]},
"profile-lie": {**base, "profile": "local"},
"manifest-drift": {**base, "manifest": {k: list(v) for k, v in CHECK_MANIFEST.items() if k != "format"}},
}
for name, rep in cases.items():
pathlib.Path(f"/tmp/kg-rep-{name}.json").write_text(json.dumps(rep, ensure_ascii=False), encoding="utf-8")
PYPY
expect 0 "合规报告 ⇒ 上报脚本 0" -- env GITHUB_ACTIONS=1 GITHUB_SHA="$(git rev-parse HEAD)" "$PY" .gitea/scripts/ci_report.py --no-post --report /tmp/kg-rep-ok.json
for c in short-sha missing-check dup-check unknown-check profile-lie manifest-drift; do
expect 1 "报告缺陷 $c ⇒ 上报脚本非 0(不能报绿)" -- env GITHUB_ACTIONS=1 GITHUB_SHA="$(git rev-parse HEAD)" "$PY" .gitea/scripts/ci_report.py --no-post --report /tmp/kg-rep-$c.json
done
say "12. 提交闸门用「本次范围」,不用「最近 N 条」"
T="$(mktemp -d)"
(
cd "$T" || exit 1
git init -q -b main
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
mkdir -p "$T/no-hooks"
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.hooksPath GIT_CONFIG_VALUE_0="$T/no-hooks"
git commit -q --allow-empty -F - <<'M'
feat(a): 第一条合规
M
git commit -q --allow-empty -F - <<'M'
update stuff
M
BAD="$(git rev-parse HEAD)"
git commit -q --allow-empty -F - <<'M'
fix(b): 第三条合规
M
"$PY" "$ROOT/.gitea/scripts/ci_gate.py" --range "$BAD..HEAD" > /tmp/kg-range1.log 2>&1
echo "clean=$?"
"$PY" "$ROOT/.gitea/scripts/ci_gate.py" --range "HEAD~2..HEAD" > /tmp/kg-range2.log 2>&1
echo "dirty=$?"
) > /tmp/kg-range.log 2>&1
grep -q '^clean=0$' /tmp/kg-range.log && ok "干净范围通过(历史里有违规也不误伤 ⇒ 不是固定最近 N 条)" || { no "干净范围竟然判红"; sed 's/^/ /' /tmp/kg-range.log; }
grep -q '^dirty=1$' /tmp/kg-range.log && ok "范围含违规提交 ⇒ 判红" || { no "含违规的范围没有判红"; sed 's/^/ /' /tmp/kg-range.log; }
rm -rf "$T"
say "13. 通用模板/生成器:调用同一门禁入口、默认不生成 CD"
STD="${ENG_STANDARDS_DIR:-$HOME/eng-standards}"
GEN="$(mktemp -d)"
if [ -x "$STD/scripts/new-project.sh" ]; then
bash "$STD/scripts/new-project.sh" "$GEN/svc" Weknora/svc runner-x > /tmp/kg-gen.log 2>&1
if [ -f "$GEN/svc/.gitea/workflows/deploy-production.yml" ]; then no "生成器默认生成了 CD 工作流"; else ok "生成器默认不生成 CD 工作流"; fi
[ -f "$GEN/svc/scripts/ci_checks.py" ] && ok "生成器带了权威门禁实现" || no "生成器没带门禁实现"
[ -f "$GEN/svc/scripts/verify.sh" ] && ok "生成器带了门禁入口 shim" || no "生成器没带门禁入口"
if grep -q 'gofmt -l\|npm run build\|go vet' "$GEN/svc/.gitea/workflows/build.yml"; then
no "模板工作流里还留着内联的语言检查(第二套逻辑)"
else ok "模板工作流只调用门禁入口(没有内联第二套)"; fi
if grep -q '^ deploy' "$GEN/svc/.gitea/workflows/build.yml"; then no "build.yml 里还有 deploy 作业"; else ok "build.yml 里没有 deploy 作业"; fi
else
no "找不到规范仓的生成器(设 ENG_STANDARDS_DIR 指向它)"
fi
rm -rf "$GEN"
say "14. 第二轮修正:预览发布顺序(真实 ci_preview.py + 假 bridge 数请求)"
if python3 scripts/ci_preview_negative_test.py >/tmp/kg-preview-neg.log 2>&1; then
ok "预览:坏报告一个 publish 请求都不发、合规报告正好发 1 次(10 分支)"
else
no "预览发布负例失败,详见 /tmp/kg-preview-neg.log"
fi
say "15. 第二轮修正:提交范围取法(11 分支)+ 显示范围异常"
if python3 .gitea/scripts/ci_gate.py --self-test >/tmp/kg-scope.log 2>&1; then
ok "提交范围:截断/空范围/新分支/手工运行/shallow 全部分支符合预期"
else
no "提交范围自检失败,详见 /tmp/kg-scope.log"
fi
say "16. 第二轮修正:报告核对可单独调用(--verify-only)"
RD="$(mktemp -d)"
python3 - "$RD/rep.json" <<'PY'
import json, sys
sys.path.insert(0, "scripts")
import ci_checks
good = {"schema": "ci-report/1", "profile": "ci", "run_mode": "full",
"commit_sha": "a" * 40, "event_sha": "a" * 40,
"manifest": {c: list(r) for c, r in ci_checks.CHECK_MANIFEST.items()},
"checks": [{"id": c, "status": "success", "reason": ""} for c in ci_checks.CHECK_MANIFEST],
"overall_status": "success"}
json.dump(good, open(sys.argv[1], "w"))
json.dump({**good, "checks": [c for c in good["checks"] if c["id"] != "tests-go"]}, open(sys.argv[1] + ".missing", "w"))
PY
GITHUB_ACTIONS=true GITHUB_SHA=$(printf 'a%.0s' {1..40}) python3 .gitea/scripts/ci_report.py --verify-only --report "$RD/rep.json" >/dev/null 2>&1 \
&& ok "合规报告:--verify-only 退出 0(可采信)" || no "合规报告竟然核不过"
GITHUB_ACTIONS=true GITHUB_SHA=$(printf 'a%.0s' {1..40}) python3 .gitea/scripts/ci_report.py --verify-only --report "$RD/rep.json.missing" >/dev/null 2>&1 \
&& no "缺项报告:--verify-only 竟然退出 0" || ok "缺项报告:--verify-only 非 0(不可采信 ⇒ 预览不会发)"
rm -rf "$RD"
say "17. 第二轮修正:检出脚本的协议保留"
if [ "$(GITHUB_SERVER_URL='https://gitea.zheromiku.top' GITHUB_REPOSITORY='Weknora/knowledge-governance' bash .gitea/scripts/ci_checkout.sh --print-remote 2>/dev/null)" = "https://gitea.zheromiku.top/Weknora/knowledge-governance.git" ]; then
ok "https 服务端 ⇒ remote 仍是 https(没有降级)"
else
no "https 被降级或拼接不对"
fi
if GITHUB_SERVER_URL='ftp://nope' GITHUB_REPOSITORY='Weknora/knowledge-governance' bash .gitea/scripts/ci_checkout.sh --print-remote >/dev/null 2>&1; then
no "未知协议竟然被接受"
else
ok "未知协议被拒绝"
fi
say "18. 第二轮修正:合并只认明确策略(真实 merge_pr + 假 Gitea)"
if [ -f "$STD/scripts/adopt-existing.py" ]; then
if python3 "$STD/scripts/adopt-existing.py" --self-test-chain >/tmp/kg-merge-chain.log 2>&1; then
ok "合并调用链:13 分支里只有'策略齐+必需 context 全 success+完整 SHA+base/state 对'才发 merge"
else
no "合并调用链负例失败,详见 /tmp/kg-merge-chain.log"
fi
grep -q "required-contexts" "$STD/scripts/adopt-existing.py" \
&& ok "接入器只从明确策略取必需 context(不再用观察到的状态)" \
|| no "接入器里找不到策略来源"
else
no "找不到规范仓的接入器"
fi
say "19. 第二轮修正:生成器带齐新脚本与策略文件"
if [ -x "$STD/scripts/new-project.sh" ]; then
GEN2="$(mktemp -d)"
bash "$STD/scripts/new-project.sh" "$GEN2/svc" Weknora/svc2 runner-y >/tmp/kg-gen2.log 2>&1
for f in .gitea/scripts/ci_preview.py .gitea/scripts/ci_checkout.sh .gitea/required-contexts.txt scripts/check_secrets.py scripts/test-scope.json; do
[ -f "$GEN2/svc/$f" ] && ok "生成器落了 $f" || no "生成器没落 $f"
done
if grep -qE '^[[:space:]]*build / build' "$GEN2/svc/.gitea/required-contexts.txt"; then
no "生成器预填了猜出来的 context(会把没跑过必需检查的 PR 放过去)"
else
ok "策略文件故意留空(填之前合并会被拒)"
fi
rm -rf "$GEN2"
fi
say "20. 完整调用链:空工作区 → bootstrap → 仓内脚本 → 提交范围闸门"
if python3 scripts/ci_chain_fixture.py >/tmp/kg-chain.log 2>&1; then
ok "空工作区能启动:bootstrap 检出到事件完整 SHA、协议保留、脚本检出后才就位(12 项断言)"
else
no "完整调用链夹具失败,详见 /tmp/kg-chain.log"; tail -20 /tmp/kg-chain.log | sed 's/^/ /'
fi
if grep -q '没有那个文件或目录\|127' /tmp/kg-chain.log && grep -q '空工作区里调用仓内脚本返回 127' /tmp/kg-chain.log; then
ok "旧写法(第一步直接调用仓内脚本)的 127 已被复现并钉住"
fi
say "21. 通用模板:Python 必须锁定依赖 + 隔离环境"
# ① 自检必须覆盖"锁定/隔离"这一族判据(不是只看文本里有没有 --system —— 那会被说明文字误伤)
if python3 "$STD/templates/ci-gate.py" --self-test >/tmp/kg-pylock-self.log 2>&1 \
&& grep -q "Python 锁定/隔离" /tmp/kg-pylock-self.log; then
ok "通用门禁自检覆盖了「Python 锁定/隔离(缺锁失败、从不用 --system)」"
else
no "通用门禁自检没有覆盖 Python 锁定/隔离"; tail -3 /tmp/kg-pylock-self.log | sed 's/^/ /'
fi
# ② 真跑:只有 requirements.txt、没有 uv.lock ⇒ deps-install 红,并要求先生成锁文件
PYF="$(mktemp -d)"; mkdir -p "$PYF/scripts"
cp "$STD/templates/ci-gate.py" "$PYF/scripts/ci_checks.py"
cp "$STD/templates/check-secrets.py" "$PYF/scripts/check_secrets.py"
printf '[project]\nname = "fixture"\nversion = "0.1.0"\n' > "$PYF/pyproject.toml"
printf 'pytest==8.0.0\n' > "$PYF/requirements.txt"
printf '{"optional": []}' > "$PYF/scripts/test-scope.json"
( cd "$PYF" && git init -q -b main && env -u GITHUB_SHA python3 scripts/ci_checks.py --only deps-install ) >/tmp/kg-py-lock.log 2>&1
if [ $? -ne 0 ] && grep -q 'uv lock' /tmp/kg-py-lock.log; then
ok "只有 requirements.txt、没有 uv.lock ⇒ deps-install 红,并要求先生成锁文件"
else
no "缺锁时没有明确失败(或提示里没有'先生成锁文件')"; tail -5 /tmp/kg-py-lock.log | sed 's/^/ /'
fi
# ③ 真跑:生成 uv.lock 之后 ⇒ 绿,且环境是**项目内 .venv**(隔离,不是系统解释器)
if command -v uv >/dev/null 2>&1; then
( cd "$PYF" && uv lock >/dev/null 2>&1 )
( cd "$PYF" && env -u GITHUB_SHA python3 scripts/ci_checks.py --only deps-install ) >/tmp/kg-py-lock2.log 2>&1
# 断言看的是**这一项本身**(报告里 deps-install=success),不是进程总退出码 ——
# 夹具仓故意没有提交,总退出码会因为"commit_sha 不是完整 40 位"而红,那是另一回事。
if grep -q '按锁文件安装完成' /tmp/kg-py-lock2.log && grep -q 'deps-install=success' /tmp/kg-py-lock2.log; then
ok "有 uv.lock ⇒ 按锁文件安装成功(uv sync --frozen),deps-install=success"
else
no "有 uv.lock 时 deps-install 没通过"; tail -6 /tmp/kg-py-lock2.log | sed 's/^/ /'
fi
ISO="$( cd "$PYF" && uv run --frozen python -c "import sys; print(sys.prefix != sys.base_prefix)" 2>/dev/null )"
if [ "$ISO" = "True" ]; then
ok "依赖装在项目内 .venv(sys.prefix != sys.base_prefix ⇒ 没装进系统环境)"
else
no "Python 环境没有隔离(得到 $ISO)"
fi
else
no "本机没有 uv:无法验证锁定/隔离(不许当通过)"
fi
rm -rf "$PYF"
say "22. 版本记录:CHANGELOG 结构与正式 tag 一致性"
CLK="$PY scripts/check_changelog.py"
# ① 正例:本仓现在的 CHANGELOG 必须合规;没有正式 tag 也不判红
expect 0 "CHANGELOG 结构合规(四类小节、无空栏目)" -- $CLK
expect 0 "没有正式 tag ⇒ 不判红(开发版本不打 tag 是正常的)" -- $CLK --tags
# ② 检查器自检:非法日期/自创栏目/空栏目/重复/顺序倒置/前导零/破坏性无链接/轻量 tag/未核对远端 逐条会红
if $PY scripts/check_changelog.py --self-test >/tmp/kg-cl-self.log 2>&1; then
ok "CHANGELOG 检查的 --self-test 全过(夹具里逐条证明会红)"
else
no "CHANGELOG 检查自检失败"; tail -6 /tmp/kg-cl-self.log | sed 's/^/ /'
fi
# ③ 真改本仓 CHANGELOG(五种形状各判一次,改完立刻恢复)
cp CHANGELOG.md /tmp/kg-changelog.bak
mut() { # mut <名字> <期望 grep> <期望退出码>;变异脚本从 stdin 进
local name="$1" want="$2" want_rc="$3"
if ! "$PY" - >/tmp/kg-mut-py.log 2>&1; then
no "$name:变异脚本失败(锚点不在了?)"; tail -3 /tmp/kg-mut-py.log | sed 's/^/ /'
cp /tmp/kg-changelog.bak CHANGELOG.md; return
fi
$CLK >/tmp/kg-cl-mut.log 2>&1; local rc=$?
if [ "$rc" = "$want_rc" ] && grep -q "$want" /tmp/kg-cl-mut.log; then ok "$name"
else no "$name(rc=$rc,期望 $want_rc 且含 [$want])"; tail -6 /tmp/kg-cl-mut.log | sed 's/^/ /'; fi
cp /tmp/kg-changelog.bak CHANGELOG.md
}
mut "自创栏目(### 已知问题)⇒ 红" "不属于允许的四类" 1 <<'PY'
import pathlib, re
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
# ⚠️ 锚点必须是**行首真标题**:顶部说明里也出现过 "`### Added`" 这类行内引用,
# 用子串替换会改到说明文字(看起来"变异了"、结构没动)——夹具第一稿就这么假通过过。
assert len(re.findall(r"^### Fixed$", t, re.M)) == 1, "行首真标题 ### Fixed 不唯一"
t2 = re.sub(r"^### Fixed$", "### 已知问题", t, count=1, flags=re.M)
assert t2 != t, "没改成"
p.write_text(t2, encoding="utf-8")
PY
mut "空栏目(清空 Security 内容)⇒ 红" "是空栏目" 1 <<'PY'
import pathlib, re
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
assert len(re.findall(r"^### Security$", t, re.M)) == 1, "行首真标题 ### Security 不唯一"
t2 = re.sub(r"(^### Security\n(?:\n)?)(?:- [^\n]*\n)+", r"\1", t, count=1, flags=re.M)
assert t2 != t, "没清掉 Security 的内容"
p.write_text(t2, encoding="utf-8")
PY
mut "破坏性变化没带发布文档链接 ⇒ 红" "没有链接" 1 <<'PY'
import pathlib, re
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
assert len(re.findall(r"^### Changed$", t, re.M)) == 1, "行首真标题 ### Changed 不唯一"
t2 = re.sub(r"^### Changed$", "### Changed\n\n- **破坏性**:某接口下线,升级步骤见文档(故意不写链接)",
t, count=1, flags=re.M)
assert t2 != t, "没改成"
p.write_text(t2, encoding="utf-8")
PY
mut "破坏性变化链接的文档不存在 ⇒ 红" "链接的发布文档不存在" 1 <<'PY'
import pathlib, re
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8")
assert len(re.findall(r"^### Changed$", t, re.M)) == 1, "行首真标题 ### Changed 不唯一"
t2 = re.sub(r"^### Changed$", "### Changed\n\n- **破坏性**:某接口下线,见 [迁移步骤](docs/NO_SUCH_DOC.md)",
t, count=1, flags=re.M)
assert t2 != t, "没改成"
p.write_text(t2, encoding="utf-8")
PY
mut "版本号带前导零 ⇒ 红" "不合法" 1 <<'PY'
import pathlib
p = pathlib.Path("CHANGELOG.md"); t = p.read_text(encoding="utf-8").rstrip("\n")
p.write_text(t + "\n\n## [01.0.0] - 2026-03-02\n\n### Fixed\n\n- x\n", encoding="utf-8")
PY
rm -f /tmp/kg-changelog.bak
expect 0 "本仓 CHANGELOG 恢复后仍是绿的" -- $CLK
# ④ tag 一致性与远端核对:**只在临时仓库里造 tag**,绝不污染本仓(正本 §6.6 注)
pycheck "tag 一致性与远端核对(临时仓库内 6 个场景)" <<'PY'
import os, pathlib, shutil, subprocess, tempfile
SAMPLE = """# Changelog
## [Unreleased]
### Added
- 还没发布
## [0.1.0] - 2026-10-10
### Changed
- **破坏性**:接口 `/old` 下线,升级步骤见 [发布说明](docs/RELEASE_RUNBOOK.md)
### Fixed
- 修了 X
"""
def git(d, *args):
return subprocess.run(["git", *args], cwd=d, capture_output=True, text=True)
def seed(d, changelog=SAMPLE):
(d / "scripts").mkdir(parents=True)
(d / "docs").mkdir()
shutil.copy("scripts/check_changelog.py", d / "scripts/check_changelog.py")
(d / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
(d / "CHANGELOG.md").write_text(changelog, encoding="utf-8")
(d / "no-hooks").mkdir(exist_ok=True)
os.environ.update({"GIT_AUTHOR_NAME": "f", "GIT_AUTHOR_EMAIL": "f@f",
"GIT_COMMITTER_NAME": "f", "GIT_COMMITTER_EMAIL": "f@f",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")})
git(d, "init", "-q", "-b", "main")
git(d, "add", "-A")
git(d, "commit", "-q", "-m", "chore: 夹具提交")
git(d, "remote", "add", "origin", str(d / "nowhere.git")) # 没有远端 ⇒ 只能看本地 tag
def clk(d, *args):
return subprocess.run(["python3", str(d / "scripts/check_changelog.py"), *args],
cwd=d, capture_output=True, text=True)
cases = []
with tempfile.TemporaryDirectory() as tmp:
r = pathlib.Path(tmp) / "r"; r.mkdir(); seed(r)
cases.append(("无正式 tag ⇒ 绿", clk(r, "--tags").returncode == 0))
git(r, "tag", "-a", "v0.1.0", "-m", "release 0.1.0")
cases.append(("注释 tag 与提交内版本一致 ⇒ 绿", clk(r, "--tags").returncode == 0))
git(r, "tag", "-a", "v9.9.9", "-m", "release 9.9.9")
cases.append(("tag 与提交内版本不符 ⇒ 红", clk(r, "--tags").returncode == 1))
git(r, "tag", "-d", "v9.9.9")
git(r, "tag", "v0.2.0") # 轻量 tag
cases.append(("轻量 tag ⇒ 红", clk(r, "--tags").returncode == 1))
git(r, "tag", "-d", "v0.2.0")
p2 = clk(r, "--tags", "--require-remote")
cases.append(("拿不到远端清单且 --require-remote ⇒ 红(不许声称已核验)",
p2.returncode == 1 and "没核对远端" in (p2.stdout + p2.stderr)))
r2 = pathlib.Path(tmp) / "r2"; r2.mkdir()
seed(r2, SAMPLE.replace("## [Unreleased]", "## [Unreleased] - 2026-01-01"))
cases.append(("未发布段写了日期 ⇒ 红", clk(r2, "--tags").returncode == 1))
bad = [n for n, okv in cases if not okv]
for n, okv in cases:
print(("✅ " if okv else "❌ ") + n)
raise SystemExit(1 if bad else 0)
PY
say "23. 发版预检:只读、不给可发结论时不越界"
if $PY scripts/release_preflight.py --self-test >/tmp/kg-pf-self.log 2>&1; then
ok "预检自检全过(写类 git 命令与 POST/PUT/DELETE/PATCH 在预检路径上被拒)"
else
no "预检自检失败"; tail -6 /tmp/kg-pf-self.log | sed 's/^/ /'
fi
$PY scripts/release_preflight.py >/tmp/kg-pf.log 2>&1; rc=$?
if [ "$rc" -ne 0 ] && grep -q "现在\*\*不能\*\*发" /tmp/kg-pf.log; then
ok "工作区脏时预检**不给可发结论**(退出码 $rc)"
else
no "脏工作区下预检竟然给了可发结论(退出码 $rc)"; tail -6 /tmp/kg-pf.log | sed 's/^/ /'
fi
if grep -q "首个正式版本号必须由人显式指定" /tmp/kg-pf.log; then
no "本仓已有已发布版本(0.1.0),不该再报「首个版本号由人指定」"
else
ok "本仓已有已发布版本 ⇒ 不再报「首个版本号由人指定」(无基线时报的那条由 --self-test 覆盖)"
fi
if grep -q "开发号" /tmp/kg-pf.log; then
ok "预检会打印当前开发号($(grep -o "当前开发号 : .*" /tmp/kg-pf.log | head -1 | cut -c1-46))"
else
no "预检没有打印当前开发号"; tail -6 /tmp/kg-pf.log | sed 's/^/ /'
fi
$PY scripts/release_preflight.py --no-api >/tmp/kg-pf-noapi.log 2>&1; rc=$?
if [ "$rc" -ne 0 ] && grep -q "未核对" /tmp/kg-pf-noapi.log; then
ok "显式不核对远端(--no-api)⇒ 不给可发结论,且明说「未核对」(退出码 $rc)"
else
no "不核对远端却给了可发结论(退出码 $rc)"; tail -6 /tmp/kg-pf-noapi.log | sed 's/^/ /'
fi
pycheck "预检:候选版本**没进目标提交** ⇒ 不就绪;写进提交后该条阻塞消失(临时仓库,不打 tag、不推送、不查远端)" <<'PY'
import os, pathlib, subprocess, tempfile
def seed(d):
(d / "scripts").mkdir(parents=True)
(d / "docs").mkdir()
(d / ".gitea").mkdir()
for n in ("check_changelog.py", "release_preflight.py"):
(d / "scripts" / n).write_text(pathlib.Path("scripts") / n and
pathlib.Path("scripts", n).read_text(encoding="utf-8"),
encoding="utf-8")
(d / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
(d / ".gitea" / "required-contexts.txt").write_text("build / build (push)\n", encoding="utf-8")
(d / "CHANGELOG.md").write_text(
"# Changelog\n\n## [Unreleased]\n\n### Added\n\n- 待发的东西\n", encoding="utf-8")
(d / "no-hooks").mkdir(exist_ok=True)
os.environ.update({"GIT_AUTHOR_NAME": "f", "GIT_AUTHOR_EMAIL": "f@f",
"GIT_COMMITTER_NAME": "f", "GIT_COMMITTER_EMAIL": "f@f",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")})
for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]):
subprocess.run(["git", *args], cwd=d, check=True, capture_output=True)
def pre(d, *args):
p = subprocess.run(["python3", "scripts/release_preflight.py", *args, "--no-api"],
cwd=d, capture_output=True, text=True)
return p.returncode, (p.stdout + p.stderr)
cases = []
with tempfile.TemporaryDirectory() as tmp:
d = pathlib.Path(tmp) / "r"; d.mkdir(); seed(d)
rc1, t1 = pre(d, "--version", "0.2.0")
cases.append(("记录只在工作区(没提交)⇒ 明说『还没写进目标提交』且不就绪",
rc1 == 1 and "还没写进目标提交" in t1 and "现在**不能**发" in t1))
cases.append(("就绪判定里含『准备建议』与『发布就绪判定』两段(不是混在一起)",
"── 准备建议" in t1 and "── 发布就绪判定 ──" in t1))
cases.append(("不推荐自动发布路径(输出里没有 --execute 的建议行)",
"--execute" not in t1))
cases.append(("首发未显式指定版本时,把建议值当已发布是禁止的 —— 这里显式给了 --version,"
"所以不该报『首个正式版本号必须由人显式指定』", "首个正式版本号必须由人显式指定" not in t1))
# 把版本记录**写进提交**(首发:显式版本号 + 记录进提交)
(d / "CHANGELOG.md").write_text(
"# Changelog\n\n## [0.2.0] - 2026-02-01\n\n### Added\n\n- 待发的东西\n", encoding="utf-8")
subprocess.run(["git", "add", "-A"], cwd=d, check=True, capture_output=True)
subprocess.run(["git", "commit", "-q", "-m", "chore(release): 0.2.0 记录"], cwd=d, check=True,
capture_output=True)
rc2, t2 = pre(d, "--version", "0.2.0")
cases.append(("记录进了提交 ⇒ 『还没写进目标提交』这条阻塞**消失**",
"还没写进目标提交" not in t2))
cases.append(("仍然不就绪(没核对远端 / 没有 CI 证据)⇒ 不许给可发结论",
rc2 == 1 and "现在**不能**发" in t2))
cases.append(("候选记录存在 ≠ 已发布:不该出现『已存在』类误判",
"已存在" not in t2.replace("不存在(可发)", "")))
# 候选版本必须“**是**目标提交里的顶部版本”:只在历史里出现过不算
d2 = pathlib.Path(tmp) / "r2"; d2.mkdir()
(d2 / "scripts").mkdir(parents=True); (d2 / "docs").mkdir(); (d2 / ".gitea").mkdir()
for n in ("check_changelog.py", "release_preflight.py", "version.py"):
(d2 / "scripts" / n).write_text(pathlib.Path("scripts", n).read_text(encoding="utf-8"),
encoding="utf-8")
(d2 / "docs" / "RELEASE_RUNBOOK.md").write_text("# 发布说明\n", encoding="utf-8")
(d2 / ".gitea" / "required-contexts.txt").write_text("build / build (push)\n", encoding="utf-8")
(d2 / "CHANGELOG.md").write_text(
"# Changelog\n\n## [0.3.0] - 2026-03-01\n\n### Added\n\n- 新\n\n"
"## [0.2.0] - 2026-02-01\n\n### Fixed\n\n- 旧\n", encoding="utf-8")
(d2 / "no-hooks").mkdir(exist_ok=True)
for args in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]):
subprocess.run(["git", *args], cwd=d2, check=True, capture_output=True)
_, t3 = pre(d2, "--version", "0.2.0")
cases.append(("候选版本只在**历史**里(顶部是 0.3.0)⇒ 明确『还没写进目标提交』",
"还没写进目标提交" in t3))
_, t4 = pre(d2, "--version", "0.3.0")
cases.append(("候选版本就是该提交的**顶部**版本 ⇒ 该条阻塞消失", "还没写进目标提交" not in t4))
# 打上注释 tag 后:开发号 = 0.3.0(tag 就在该提交上)⇒ 候选 0.3.0 会被"开发号"判据拦住
subprocess.run(["git", "tag", "-a", "v0.3.0", "-m", "release 0.3.0"], cwd=d2, check=True,
capture_output=True)
_, t5 = pre(d2, "--version", "0.3.0")
cases.append(("候选正式版本不大于当前开发号 ⇒ 阻断(不许把开发号当正式号)",
"不大于当前开发号" in t5))
_, t6 = pre(d2, "--version", "0.4.0")
cases.append(("候选正式版本 > 开发号(次版本加一)⇒ 该条不拦", "不大于当前开发号" not in t6))
bad = [n for n, okv in cases if not okv]
for n, okv in cases:
print(("✅ " if okv else "❌ ") + n)
raise SystemExit(1 if bad else 0)
PY
pycheck "预检:--execute 自动发布路径已禁用(明确拒绝、rc=2、零写操作:跑完仓里 tag 不变)" <<'PY'
import subprocess
before = subprocess.run(["git", "tag", "-l"], capture_output=True, text=True).stdout.split()
p = subprocess.run(["python3", "scripts/release_preflight.py", "--execute",
"--approve-version", "9.9.9", "--approve-sha", "0" * 40],
capture_output=True, text=True)
after = subprocess.run(["git", "tag", "-l"], capture_output=True, text=True).stdout.split()
okv = (p.returncode == 2 and "已禁用" in (p.stderr or "") and not p.stdout.strip()
and before == after)
print(("✅ " if okv else "❌ ") + f"rc={p.returncode}|tag {before} → {after}|输出为空={not p.stdout.strip()}")
raise SystemExit(0 if okv else 1)
PY
if grep -qE "git tag -a|--data-binary|POST" /tmp/kg-pf.log; then
no "预检输出里出现了写操作迹象"
else
ok "预检输出里没有任何写操作(只列事实与阻塞项)"
fi
# 预检必须**真的没打过 tag**:跑完之后不该多出任何 v* tag
if [ -z "$(git tag -l 'v*')" ]; then
ok "跑完预检后本仓没有多出任何正式 tag(无副作用)"
else
no "预检留下了 tag:$(git tag -l 'v*' | tr '\n' ' ')"
fi
echo
echo "§24 抬档门禁(破坏性 ⇒ 0.x 抬次 / ≥1.0.0 抬主)与 1.0.0 前置(《稳定契约承诺》)"
cat >/tmp/kg_bump_lib.py <<'LIB'
import os, pathlib, shutil, subprocess, tempfile
def mk(prev, cand, breaking_in_changed, stab_section=None, link_in_record=False):
"""造临时仓库:CHANGELOG 顶部 = 候选版本,下面留上一版本;docs 里有/没有承诺段。"""
d = pathlib.Path(tempfile.mkdtemp(prefix="kg-bump-"))
(d / "scripts").mkdir()
for n in ("check_changelog.py", "release_preflight.py", "version.py"):
(d / "scripts" / n).write_text(pathlib.Path("scripts", n).read_text(encoding="utf-8"),
encoding="utf-8")
(d / "docs").mkdir()
doc = "# 发布运行手册\n"
if stab_section:
doc += "\n## 稳定契约承诺(占位)\n\n" + stab_section + "\n"
(d / "docs" / "RELEASE_RUNBOOK.md").write_text(doc, encoding="utf-8")
(d / ".gitea").mkdir()
(d / ".gitea" / "required-contexts.txt").write_text("build / build (push)\n", encoding="utf-8")
changed = ("\n### Changed\n\n- **破坏性**:接口下线,升级见 "
"[发布文档](docs/RELEASE_RUNBOOK.md)\n") if breaking_in_changed else ""
added = "- 新东西" + ("(稳定契约承诺见 [本文](docs/RELEASE_RUNBOOK.md))" if link_in_record else "")
(d / "CHANGELOG.md").write_text(
f"# Changelog\n\n## [{cand}] - 2026-10-13\n\n### Added\n\n{added}\n{changed}"
f"\n## [{prev}] - 2026-09-01\n\n### Fixed\n\n- 旧\n", encoding="utf-8")
(d / "no-hooks").mkdir()
env = {**os.environ, "GIT_AUTHOR_NAME": "f", "GIT_AUTHOR_EMAIL": "f@f",
"GIT_COMMITTER_NAME": "f", "GIT_COMMITTER_EMAIL": "f@f",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "core.hooksPath",
"GIT_CONFIG_VALUE_0": str(d / "no-hooks")}
for a in (["init", "-q", "-b", "main"], ["add", "-A"], ["commit", "-q", "-m", "chore: 夹具"]):
subprocess.run(["git", *a], cwd=d, env=env, check=True, capture_output=True)
return d
def pre(prev, cand, breaking, *args, **kw):
d = mk(prev, cand, breaking, **kw)
try:
r = subprocess.run(["python3", "scripts/release_preflight.py", "--version", cand, "--no-api"],
cwd=d, capture_output=True, text=True)
return r.stdout + r.stderr
finally:
shutil.rmtree(d, ignore_errors=True)
LIB
FULL="冻结 /api/v1;变更提前一个次版本通知。|表结构兼容窗口 2 个大版本。|配置项冻结,弃用先警告一个小版本。|0/1/2 语义固定。"
pycheck "破坏性 + 0.x 只抬修订(0.1.0→0.1.3)⇒ 阻断,并给出抬档结论" <<'PY'
import kg_bump_lib as L # noqa
out = L.pre("0.1.0", "0.1.3", True)
good = "必须抬**次版本**" in out and "⛔ 抬档不够" in out
print(("✅ " if good else "❌ ") + "版本走向行:" + next((l.strip() for l in out.splitlines()
if "版本走向" in l), "(没有这行)"))
raise SystemExit(0 if good else 1)
PY
pycheck "破坏性 + 0.x 抬次(0.1.0→0.2.0)⇒ 该条不拦(✅ 抬档够)" <<'PY'
import kg_bump_lib as L # noqa
out = L.pre("0.1.0", "0.2.0", True)
good = "必须抬**次版本**" not in out and "✅ 抬档够" in out
print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "版本走向" in l), ""))
raise SystemExit(0 if good else 1)
PY
pycheck "破坏性 + ≥1.0.0 只抬次(1.2.3→1.3.0)⇒ 阻断(必须抬主)" <<'PY'
import kg_bump_lib as L # noqa
out = L.pre("1.2.3", "1.3.0", True)
good = "必须抬**主版本**" in out
print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "版本走向" in l), ""))
raise SystemExit(0 if good else 1)
PY
pycheck "首发 1.0.0 但《稳定契约承诺》留占位 ⇒ 阻断(报出占位与缺项)" <<'PY'
import kg_bump_lib as L # noqa
out = L.pre("0.9.3", "1.0.0", False,
stab_section="- **接口**(待填)\n- **数据模型**(待填)\n")
good = "稳定契约承诺" in out and "占位" in out and "没覆盖「配置」" in out
print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "1.0.0 前置" in l), ""))
raise SystemExit(0 if good else 1)
PY
pycheck "1.0.0 + 承诺段写实 + 发布记录里链接 ⇒ 该条不拦" <<'PY'
import kg_bump_lib as L # noqa
sec = ("- **接口**:冻结 /api/v1;变更提前一个次版本通知。\n"
"- **数据模型**:表结构兼容窗口 2 个大版本。\n"
"- **配置**:配置项冻结,弃用先警告一个小版本。\n"
"- **CLI 输出与退出码**:0/1/2 语义固定。\n")
out = L.pre("0.9.3", "1.0.0", False, stab_section=sec, link_in_record=True)
good = "候选进入 1.0.0" not in out and "✅ 已写实" in out
print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "1.0.0 前置" in l), ""))
raise SystemExit(0 if good else 1)
PY
pycheck "1.0.0 + 承诺段写实但发布记录里**没链接** ⇒ 仍阻断" <<'PY'
import kg_bump_lib as L # noqa
sec = ("- **接口**:冻结 /api/v1。\n- **数据模型**:兼容 2 个大版本。\n"
"- **配置**:配置项冻结。\n- **CLI 输出与退出码**:0/1/2 固定。\n")
out = L.pre("0.9.3", "1.0.0", False, stab_section=sec, link_in_record=False)
good = "候选进入 1.0.0" in out and "没有链接" in out
print(("✅ " if good else "❌ ") + next((l.strip() for l in out.splitlines() if "1.0.0 前置" in l), ""))
raise SystemExit(0 if good else 1)
PY
printf '\n\033[1m════ 反向验收汇总 ════\033[0m\n ✅ 通过 %d | ❌ 失败 %d\n' "$pass" "$fail"
[ "$fail" -eq 0 ] || exit 1