Files
knowledge-governance/scripts/ci_negative_fixtures.sh
T
姚章浩 164dc2f72c test(ci): 反向夹具扩到 54 条,含预览发布与合并调用链负例
新增 14~19 节:假 bridge 数 publish 请求(坏报告 0 次/好报告 1 次)、--verify-only 单跑核对、检出协议保留、合并调用链(假 Gitea 跑真实 merge_pr,13 分支)、生成器落齐新脚本与空策略文件。首轮 1 处失败暴露真实缺陷:无远端引用时 --not --remotes 退化成整部历史(把 Initial commit 算进来)⇒ 改为只核 HEAD 并写明。
2026-10-12 11:15:18 +08:00

343 lines
19 KiB
Bash
Raw 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')
# 造一个"没有日期"的已发布版本标题(结构自检必须抓住)
old = '## [0.1.0] - 2026-10-10'
assert t.count(old) == 1, f'夹具锚点不唯一({t.count(old)} 处)—— 别静默跳过'
p.write_text(t.replace(old, '## [9.9.9]', 1), encoding='utf-8')
PY
expect 1 "变更记录里的已发布版本缺日期 ⇒ contracts 红" -- "$PY" scripts/ci_checks.py --only contracts
cp /tmp/kg-changelog.bak CHANGELOG.md
# 变异测试:把**契约那一处**的注入目标路径写错 ⇒ 版本用例会 SKIP ⇒ 契约必须红(跳过不算通过)
# ⚠️ 必须只改契约那一处:ci_checks.py 里还有一处同名的注入是给 `build-go` 用的,
# 改错地方就会出现"夹具没生效、但看起来通过了"的假结论(这一版夹具第一稿就是这么错的)。
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 = '-ldflags=-X knowledge-governance/internal/version.Number={version}'
assert t.count(needle) == 1, f'契约注入点不唯一({t.count(needle)} 处),夹具失效'
p.write_text(t.replace(needle, '-ldflags=-X knowledge-governance/internal/version.Nope={version}', 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
expect 0 "还原后 contracts 恢复绿" -- "$PY" scripts/ci_checks.py --only contracts
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
printf '\n\033[1m════ 反向验收汇总 ════\033[0m\n ✅ 通过 %d | ❌ 失败 %d\n' "$pass" "$fail"
[ "$fail" -eq 0 ] || exit 1