diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 74fc560c..a70ae342 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -186,6 +186,9 @@ jobs: # 缺服务时失败而非跳过(make test-backend-pg 硬校验环境变量); # 组件版本随 job 输出固定,供验收记录对齐(方案 KTD12/U2)。 test-pg: + # 分级门禁:base 非 main 的 PR(L2 child → L1 initiative)只跑轻量 CI(test 作业); + # 进入 main 的 PR 与 main 推送跑全量门禁。 + if: ${{ github.event_name != 'pull_request' || github.base_ref == 'main' }} runs-on: ubuntu-latest env: GOPROXY: https://goproxy.cn,direct @@ -479,6 +482,9 @@ jobs: # identity 领养/verify、release 切换、伴生工作台、RDM 全轨部署。缺件即失败, # 不静默跳过(SVC_TEST_REAL=1 硬开关)。 svc-realcombo: + # 分级门禁:base 非 main 的 PR(L2 child → L1 initiative)只跑轻量 CI(test 作业); + # 进入 main 的 PR 与 main 推送跑全量门禁。 + if: ${{ github.event_name != 'pull_request' || github.base_ref == 'main' }} runs-on: ubuntu-latest env: GOPROXY: https://goproxy.cn,direct diff --git a/Makefile b/Makefile index f63b68b2..66ef7dea 100644 --- a/Makefile +++ b/Makefile @@ -205,7 +205,11 @@ plugin-new: @test -n "$(NAME)" || (echo "用法:make plugin-new NAME="; exit 2) bash scripts/plugin-new.sh "$(NAME)" -.PHONY: test test-governance test-worktree contracts +.PHONY: test test-governance test-worktree test-affected contracts + +# host 最小直接检查:按 BASE(缺省为卡片 immediate_target 或 origin/main)以来的改动选 make 目标;DRY=1 只打印。 +test-affected: + python3 scripts/test_affected.py $(if $(BASE),--base $(BASE)) $(if $(DRY),--dry-run) test-governance: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v diff --git a/scripts/ci_wait.py b/scripts/ci_wait.py index 6d4b712d..813cda27 100644 --- a/scripts/ci_wait.py +++ b/scripts/ci_wait.py @@ -6,7 +6,7 @@ 轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status: - 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL `,立即退出 1; - - 成功须同时满足:至少一条状态、全部 success、每个 --contexts 名称是某个 context 的子串, + - 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串, 且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿); - 超时:打印最后观察到的状态,退出 2; - 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试, @@ -30,6 +30,8 @@ EXIT_TIMEOUT = 2 EXIT_USAGE = 2 SHA_RE = re.compile(r"^[0-9a-f]{40}$") FAILING = ("failure", "error") +# skipped:分级门禁下 base 非 main 的 PR 跳过全量作业;与 warning 一样是非失败终态。 +DONE_OK = ("success", "skipped", "warning") FATAL_HTTP = (400, 401, 403, 404, 410) @@ -79,8 +81,8 @@ def describe(rows): def green_key(rows, contexts): - """满足全绿与 --contexts 时返回可比较的集合键,否则 None。""" - if not rows or any(state != "success" for _, state, _ in rows): + """满足全绿(success/skipped/warning)与 --contexts 时返回可比较的集合键,否则 None。""" + if not rows or any(state not in DONE_OK for _, state, _ in rows): return None if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts): return None diff --git a/scripts/test_affected.py b/scripts/test_affected.py new file mode 100644 index 00000000..e1567813 --- /dev/null +++ b/scripts/test_affected.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""按分支改动选择最小直接检查:host 实施中的 make 目标子集(完整套件仍以 CI 为准)。 + +用法:test_affected.py [--base REF] [--dry-run] + base:--base,否则环境变量 BASE,否则当前分支卡片的 immediate_target(task_pr_check 助手), + 再否则 origin/main;裸分支名在 origin/ 存在时解析为 origin/。 + +改动路径 = `git diff ...HEAD` ∪ 未提交的已跟踪改动(相对 HEAD)。路径到目标: + scripts/** tests/** .gitea/** policy/** Makefile AGENTS.md AGENT-EXECUTION.md PROJECT.md → test-governance + modules/{core,backend,frontend,ui} → test-{core,backend,frontend,ui};deps/agent-command → test-agentcmd; + deps/agent-{intent,native,squad} → test-libs;gitlink 本身或 .gitmodules → test-pins; + modules/** 或 scripts/lint-boundaries.sh → test-boundaries;其余(.task/**、docs/**、其他 *.md)不选。 +选中任一目标时先跑 test-worktree;单次 `make ` 执行,退出码随 make。 + +退出码:make 的退出码;无目标或 --dry-run 为 0;用法/ref 错误为 2。仅使用标准库。 +""" +import argparse +import os +import subprocess +import sys + +DEFAULT_BASE = "origin/main" +EXIT_USAGE = 2 +GITLINK_MODE = "160000" +ORDER = ("test-governance", "test-boundaries", "test-pins", "test-libs", "test-core", + "test-backend", "test-frontend", "test-ui", "test-agentcmd") +GOVERNANCE_DIRS = ("scripts/", "tests/", ".gitea/", "policy/") +GOVERNANCE_FILES = ("Makefile", "AGENTS.md", "AGENT-EXECUTION.md", "PROJECT.md") +COMPONENTS = { + "modules/core": "test-core", + "modules/backend": "test-backend", + "modules/frontend": "test-frontend", + "modules/ui": "test-ui", + "deps/agent-command": "test-agentcmd", + "deps/agent-intent": "test-libs", + "deps/agent-native": "test-libs", + "deps/agent-squad": "test-libs", +} + + +class Stop(Exception): + pass + + +def git(*args): + proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace") + if proc.returncode != 0: + raise Stop(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.returncode}") + return proc.stdout + + +def ref_exists(ref): + return subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"], + capture_output=True).returncode == 0 + + +def card_target(): + """当前分支卡片的 immediate_target;无分支/无助手/无卡时返回 None。""" + try: + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import task_pr_check + meta, text = task_pr_check.branch_meta(task_pr_check.current_branch(), "main") + except Exception: + return None + return meta.get("immediate_target") if text is not None else None + + +def resolve_base(requested): + name = (requested or os.environ.get("BASE", "")).strip() or card_target() or DEFAULT_BASE + if not name.startswith("origin/") and ref_exists(f"refs/remotes/origin/{name}"): + name = f"origin/{name}" + if not ref_exists(name): + raise Stop(f"base {name} does not resolve to a commit") + return name + + +def raw_changes(*diff_args): + """{path: is_gitlink},取自 `git diff --raw -z --no-renames`。""" + out = git("diff", "--raw", "-z", "--no-renames", *diff_args) + fields = out.split("\0") + changes = {} + for meta, path in zip(fields[0::2], fields[1::2]): + modes = meta.lstrip(":").split()[:2] + changes[path] = changes.get(path, False) or GITLINK_MODE in modes + return changes + + +def changed_paths(base): + merge_base = git("merge-base", base, "HEAD").strip() + changes = raw_changes(merge_base, "HEAD") + for path, gitlink in raw_changes("HEAD").items(): + changes[path] = changes.get(path, False) or gitlink + return changes + + +def targets_for(path, gitlink): + found = set() + if path.startswith(GOVERNANCE_DIRS) or path in GOVERNANCE_FILES: + found.add("test-governance") + for prefix, target in COMPONENTS.items(): + if path == prefix or path.startswith(prefix + "/"): + found.add(target) + if gitlink or path == ".gitmodules": + found.add("test-pins") + if path.startswith("modules/") or path == "scripts/lint-boundaries.sh": + found.add("test-boundaries") + return found + + +def select(changes): + found = set() + for path, gitlink in changes.items(): + found |= targets_for(path, gitlink) + return ["test-worktree", *(t for t in ORDER if t in found)] if found else [] + + +def main(argv=None): + parser = argparse.ArgumentParser(prog="test_affected.py", description="按分支改动运行最小直接检查") + parser.add_argument("--base") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + try: + os.chdir(git("rev-parse", "--show-toplevel").strip()) + base = resolve_base(args.base) + changes = changed_paths(base) + except Stop as stop: + print(f"[affected] ERROR {stop}") + return EXIT_USAGE + targets = select(changes) + print(f"[affected] base={base} changed={len(changes)} targets={','.join(targets) or 'none'}", flush=True) + if not targets: + print("[affected] nothing to test") + return 0 + if args.dry_run: + print(f"[affected] dry-run: make {' '.join(targets)}") + return 0 + return subprocess.run(["make", *targets]).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_ci_wait.py b/tests/test_ci_wait.py index d6fe4b4b..79c5275d 100644 --- a/tests/test_ci_wait.py +++ b/tests/test_ci_wait.py @@ -177,6 +177,22 @@ class CiWaitTests(unittest.TestCase): self.assertIn("missing contexts: e2e", out) self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out) + def test_skipped_and_warning_contexts_count_as_done_not_failed(self): + self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")], + [status("ci/test", "success"), status("ci/test-pg", "skipped"), + status("ci/svc-realcombo", "warning")]] + code, out, _ = self.run_wait("--contexts", "test-pg", "--timeout", "300") + self.assertEqual(code, 0, out) + self.assertEqual(len(self.gitea.requests), 3, out) + self.assertNotIn("FAIL", out) + self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out) + + def test_skipped_does_not_mask_a_pending_context(self): + self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")]] + code, out, _ = self.run_wait("--interval", "5", "--timeout", "30") + self.assertEqual(code, 2, out) + self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out) + def test_context_names_match_as_substrings(self): self.gitea.script = [[status("CI / test (pull_request)", "success")]] code, out, _ = self.run_wait("--contexts", "test", "--timeout", "60") diff --git a/tests/test_test_affected.py b/tests/test_test_affected.py new file mode 100644 index 00000000..7eceb319 --- /dev/null +++ b/tests/test_test_affected.py @@ -0,0 +1,150 @@ +"""按改动选检查(scripts/test_affected.py):临时 Git 仓库 + 本地裸 origin,全部离线。""" +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[1] +SCRIPT = SOURCE / "scripts" / "test_affected.py" + + +class TestAffectedTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="test-affected-") + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.repo = root / "repo" + self.env = {k: v for k, v in os.environ.items() + if not k.startswith(("GIT_", "MAKE")) and k not in {"BASE", "TASK_TARGET_BRANCH"}} + self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", + "GIT_TERMINAL_PROMPT": "0", "HOME": str(root / "no-home"), + "GIT_AUTHOR_NAME": "Affected Test", "GIT_AUTHOR_EMAIL": "affected@example.invalid", + "GIT_COMMITTER_NAME": "Affected Test", + "GIT_COMMITTER_EMAIL": "affected@example.invalid"}) + self.git(root, "init", "-q", "--bare", "--initial-branch=main", str(root / "origin.git")) + self.git(root, "init", "-q", "--initial-branch=main", str(self.repo)) + self.git(self.repo, "remote", "add", "origin", str(root / "origin.git")) + self.write("README.md", "fixture\n") + self.write("modules/ui/notes.txt", "ui\n") + self.commit("Create fixture") + self.git(self.repo, "push", "-q", "origin", "main") + self.git(self.repo, "checkout", "-q", "-b", "feature/work") + + def git(self, cwd, *args): + return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True, + check=True).stdout.strip() + + def write(self, rel, text): + path = self.repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + def commit(self, message, gitlinks=()): + self.git(self.repo, "add", "-A") + sha = self.git(self.repo, "rev-parse", "HEAD") if gitlinks else None + for path in gitlinks: + self.git(self.repo, "update-index", "--add", "--cacheinfo", f"160000,{sha},{path}") + self.git(self.repo, "commit", "-q", "-m", message) + + def run_affected(self, *args, base=None): + env = self.env | ({"BASE": base} if base is not None else {}) + result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo, env=env, + capture_output=True, text=True, timeout=60) + return result.returncode, result.stdout + result.stderr + + def summary(self, out): + lines = [line for line in out.splitlines() if line.startswith("[affected] base=")] + self.assertEqual(len(lines), 1, out) + return dict(part.split("=", 1) for part in lines[0].split()[1:]) + + def test_mapping_dry_run_orders_and_dedupes_targets(self): + self.write("scripts/tool.py", "print('x')\n") + self.write("tests/test_tool.py", "\n") + self.write("modules/core/a.go", "package a\n") + self.write("deps/agent-native/x.go", "package x\n") + self.write("deps/agent-squad/y.go", "package y\n") + self.write("docs/guide.md", "doc\n") + self.write(".task/card.md", "card\n") + self.commit("Branch work", gitlinks=["deps/agent-command"]) + self.write("modules/ui/notes.txt", "uncommitted tracked change\n") + code, out = self.run_affected("--dry-run") + self.assertEqual(code, 0, out) + info = self.summary(out) + self.assertEqual(info["base"], "origin/main") + self.assertEqual(info["changed"], "9", out) + self.assertEqual(info["targets"], "test-worktree,test-governance,test-boundaries,test-pins,test-libs," + "test-core,test-ui,test-agentcmd", out) + self.assertIn("[affected] dry-run: make test-worktree test-governance", out) + + def test_gitlink_and_gitmodules_select_pins_and_module_target(self): + self.write(".gitmodules", "[submodule \"modules/backend\"]\n\tpath = modules/backend\n") + self.commit("Bump backend", gitlinks=["modules/backend"]) + code, out = self.run_affected("--dry-run") + self.assertEqual(code, 0, out) + self.assertEqual(self.summary(out)["targets"], + "test-worktree,test-boundaries,test-pins,test-backend", out) + + def test_lint_boundaries_and_governance_docs_select_targets(self): + self.write("scripts/lint-boundaries.sh", "#!/bin/sh\n") + self.write("AGENT-EXECUTION.md", "x\n") + self.commit("Lint") + code, out = self.run_affected("--dry-run") + self.assertEqual(code, 0, out) + self.assertEqual(self.summary(out)["targets"], "test-worktree,test-governance,test-boundaries", out) + + def test_docs_only_change_runs_nothing(self): + self.write("docs/guide.md", "doc\n") + self.write(".task/card.md", "card\n") + self.write("NOTES.md", "notes\n") + self.commit("Docs only") + self.write("README.md", "edited\n") + code, out = self.run_affected() + self.assertEqual(code, 0, out) + info = self.summary(out) + self.assertEqual((info["changed"], info["targets"]), ("4", "none"), out) + self.assertIn("[affected] nothing to test", out) + self.assertNotIn("dry-run", out) + + def test_base_env_resolves_bare_branch_to_origin(self): + self.write("scripts/tool.py", "x\n") + self.commit("Tool") + code, out = self.run_affected("--dry-run", base="main") + self.assertEqual(code, 0, out) + self.assertEqual(self.summary(out)["base"], "origin/main") + + def test_base_env_local_branch_limits_changes_since_that_base(self): + self.write("modules/core/a.go", "package a\n") + self.commit("Core") + self.git(self.repo, "branch", "local-base") + self.write("docs/later.md", "later\n") + self.commit("Later docs") + code, out = self.run_affected("--dry-run", base="local-base") + self.assertEqual(code, 0, out) + info = self.summary(out) + self.assertEqual((info["base"], info["changed"], info["targets"]), ("local-base", "1", "none"), out) + code, out = self.run_affected("--dry-run", "--base", "origin/main", base="local-base") + self.assertEqual(self.summary(out)["base"], "origin/main", out) + + def test_unresolvable_base_is_a_usage_error(self): + code, out = self.run_affected("--dry-run", base="no-such-branch") + self.assertEqual(code, 2, out) + self.assertIn("[affected] ERROR base no-such-branch does not resolve", out) + + def test_default_base_is_card_immediate_target(self): + self.git(self.repo, "push", "-q", "origin", "main:feature/init") + self.git(self.repo, "fetch", "-q", "origin") + self.write(".task/child.md", "# child\n- 工作区:/tmp/x;分支:feature/work\n" + "- 分支类型:child;initiative_id:init;root_target:main;immediate_target:feature/init\n") + self.commit("Card") + code, out = self.run_affected("--dry-run") + self.assertEqual(code, 0, out) + self.assertEqual(self.summary(out)["base"], "origin/feature/init", out) + self.git(self.repo, "checkout", "-q", "--detach") + code, out = self.run_affected("--dry-run") + self.assertEqual(self.summary(out)["base"], "origin/main", out) + + +if __name__ == "__main__": + unittest.main()