#!/usr/bin/env bash # 反向验收夹具:**只在隔离副本里跑**。它故意弄坏东西,断言门禁会红;再恢复,断言会绿。 # # 为什么要有它:一个"永远返回 0 的校验器"比没有校验更糟 —— 它给人一种已经检查过的错觉 # (正本 §0-2)。所以每条判据都要有反例,而且反例要在**隔离副本**里做, # 绝不往日常 main 上推故意失败的提交(正本 §10)。 # # 用法(在副本里): # cp -a /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