feat: add skill init/check and isolate builder makefile

Give ack, builder, and deployer an explicit init/check mode that reports
missing project config instead of failing mid-work. Point builder at
makefile.builder so its contract targets do not collide with an existing
Makefile.
This commit is contained in:
2026-08-25 16:49:28 +08:00
parent e9d2b5fde6
commit 10d8800f07
21 changed files with 1129 additions and 95 deletions
+2
View File
@@ -35,6 +35,8 @@ class AckSkillContentTests(unittest.TestCase):
"运行版本发布",
"运行回归",
"via: deployer",
"## ack 初始化:完成 | 部分完成 | 阻塞",
"加载 deployer skill 的「初始化」",
):
self.assertIn(expected, content)
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import tempfile
import unittest
from io import StringIO
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts"
CHECK_PY = SCRIPTS / "check.py"
TEMPLATE = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.builder"
spec = importlib.util.spec_from_file_location("builder_check", CHECK_PY)
assert spec is not None and spec.loader is not None
builder_check = importlib.util.module_from_spec(spec)
spec.loader.exec_module(builder_check)
def git(cwd: Path, *args: str) -> None:
result = subprocess.run(
["git", *args],
cwd=cwd,
text=True,
capture_output=True,
check=False,
env={
**os.environ,
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
},
)
if result.returncode != 0:
raise AssertionError(f"git {args} failed: {result.stderr}")
def init_repo(path: Path) -> None:
git(path, "init", "-b", "main")
git(path, "config", "user.email", "test@example.com")
git(path, "config", "user.name", "Test")
(path / "README").write_text("x\n", encoding="utf-8")
git(path, "add", "README")
git(path, "commit", "-m", "init")
def write_contract_makefile(project: Path) -> None:
text = TEMPLATE.read_text(encoding="utf-8")
text = text.replace(
"include $(HOME)/.pouch/skills/builder/scripts/version.mk",
f"include {SCRIPTS / 'version.mk'}",
)
(project / "makefile.builder").write_text(text, encoding="utf-8")
def run_check(
project: Path,
*flags: str,
env: dict[str, str] | None = None,
which: dict[str, str | None] | None = None,
) -> tuple[int, str]:
merged = os.environ.copy()
if env:
merged.update(env)
merged["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent)
stdout = StringIO()
stderr = StringIO()
real_which = shutil.which
def fake_which(name: str, *args: object, **kwargs: object) -> str | None:
if which is not None and name in which:
return which[name]
return real_which(name)
with mock.patch.dict(os.environ, merged, clear=True):
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
with mock.patch.object(builder_check.shutil, "which", side_effect=fake_which):
code = builder_check.main([str(project), *flags])
return code, stdout.getvalue() + stderr.getvalue()
class BuilderCheckTests(unittest.TestCase):
def test_skill_documents_init_and_does_not_call_create_makefile(self) -> None:
skill = (REPO_ROOT / "skills" / "builder" / "SKILL.md").read_text(encoding="utf-8")
contract = (
REPO_ROOT / "skills" / "builder" / "references" / "contract.md"
).read_text(encoding="utf-8")
self.assertIn("## 初始化", skill)
self.assertIn("check.py", skill)
self.assertIn("--ready", skill)
self.assertIn("makefile.builder", skill)
self.assertIn("不要改用户的 `Makefile`", skill)
self.assertIn("不要调用 create-makefile", skill)
self.assertIn("不要用 create-makefile", contract)
self.assertIn("makefile.builder", contract)
def test_no_makefile_without_ready_is_usage_error(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
code, text = run_check(project)
self.assertEqual(code, 2)
self.assertIn("no makefile.builder", text)
def test_ready_without_makefile_fails_with_repair_hint(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
code, text = run_check(project, "--ready")
self.assertEqual(code, 1)
self.assertIn("[FAIL] 1. makefile.builder 存在", text)
self.assertIn("makefile.builder", text)
self.assertIn("RESULT: FAILED", text)
def test_contract_makefile_without_env_passes_build_and_skips_publish_keys(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("RESULT: PASSED", text)
self.assertIn("[SKIP] 11. 发布环境变量键名", text)
self.assertIn("DEB_SERVER_URL: MISSING", text)
self.assertIn("blocks publish, not build", text)
self.assertNotRegex(text, r"DEB_TOKEN: (?!MISSING|present).+")
def test_env_keys_present_without_printing_values(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / ".env").write_text(
"DEB_SERVER_URL=https://secret.example.com\n"
"DEB_TOKEN=super-secret-token-value\n"
"DEB_REPOSITORY=main\n",
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("DEB_SERVER_URL: present", text)
self.assertIn("DEB_TOKEN: present", text)
self.assertNotIn("super-secret-token-value", text)
self.assertNotIn("https://secret.example.com", text)
def test_ready_fails_when_docker_track_missing_docker(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
code, text = run_check(
project,
"--ready",
which={"docker": None, "dpkg-deb": "/usr/bin/dpkg-deb"},
)
self.assertEqual(code, 1, text)
self.assertIn("[FAIL] 10. 轨道工具链", text)
self.assertIn("docker: MISSING", text)
def test_user_makefile_does_not_satisfy_contract(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
(project / "Makefile").write_text(
"help:\n\t@echo user\nbuild:\n\t@echo user-build\n",
encoding="utf-8",
)
code, text = run_check(project, "--ready")
self.assertEqual(code, 1, text)
self.assertIn("no makefile.builder", text)
def test_user_makefile_is_ignored_when_builder_file_exists(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / "Makefile").write_text(
"TOKEN=super-secret-user-makefile-token\n"
"help:\n\t@echo hijacked\n"
"docker:\n\tdocker push example:latest\n",
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertNotIn("super-secret-user-makefile-token", text)
self.assertNotIn("hijacked", text)
self.assertNotIn(":latest", text)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import importlib.util
import os
import tempfile
import unittest
from io import StringIO
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
CHECK_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "check.py"
LIB_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "lib.py"
lib_spec = importlib.util.spec_from_file_location("deployer_lib", LIB_PY)
assert lib_spec is not None and lib_spec.loader is not None
deployer_lib = importlib.util.module_from_spec(lib_spec)
lib_spec.loader.exec_module(deployer_lib)
check_spec = importlib.util.spec_from_file_location("deployer_check", CHECK_PY)
assert check_spec is not None and check_spec.loader is not None
deployer_check = importlib.util.module_from_spec(check_spec)
check_spec.loader.exec_module(deployer_check)
def run_check(project: Path, hosts: set[str] | None = None) -> tuple[int, str]:
stdout = StringIO()
stderr = StringIO()
deployer_lib.PROJECT_ROOT = None
deployer_lib._SSH_HOSTS = None
deployer_check.lib.PROJECT_ROOT = None
deployer_check.lib._SSH_HOSTS = None
patched_hosts = hosts if hosts is not None else set()
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
with mock.patch.object(deployer_check.lib, "ssh_config_hosts", return_value=patched_hosts):
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop("DEPLOYER_ROOT", None)
code = deployer_check.main(["--project", str(project)])
return code, stdout.getvalue() + stderr.getvalue()
class DeployerCheckTests(unittest.TestCase):
def tearDown(self) -> None:
deployer_lib.PROJECT_ROOT = None
deployer_lib._SSH_HOSTS = None
deployer_check.lib.PROJECT_ROOT = None
deployer_check.lib._SSH_HOSTS = None
def test_skill_documents_init_and_check_script(self) -> None:
skill = (REPO_ROOT / "skills" / "deployer" / "SKILL.md").read_text(
encoding="utf-8"
)
self.assertIn("## 初始化", skill)
self.assertIn("scripts/deploy/check.py", skill)
self.assertIn("不要写假 node", skill)
def test_missing_layout_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
code, text = run_check(project)
self.assertEqual(code, 1, text)
self.assertIn("[FAIL] 1. 部署根存在", text)
self.assertIn("test/compose.yaml", text)
def test_compose_without_node_is_partial_failure(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
env = project / ".pouch" / "deployer" / "test"
env.mkdir(parents=True)
(env / "compose.yaml").write_text("services:\n web:\n image: nginx\n", encoding="utf-8")
code, text = run_check(project, hosts={"my-vps"})
self.assertEqual(code, 1, text)
self.assertIn("[PASS] 1. 部署根存在", text)
self.assertIn("[PASS] 4. 至少有一个 compose.yaml", text)
self.assertIn("[FAIL] 5. 每个服务能解析 node", text)
self.assertIn("MISSING node", text)
def test_node_missing_from_ssh_config_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
root = project / ".pouch" / "deployer"
env = root / "test"
env.mkdir(parents=True)
(root / "_config.yaml").write_text("node: my-vps\n", encoding="utf-8")
(env / "compose.yaml").write_text("services:\n web:\n image: nginx\n", encoding="utf-8")
code, text = run_check(project, hosts=set())
self.assertEqual(code, 1, text)
self.assertIn("[PASS] 5. 每个服务能解析 node", text)
self.assertIn("[FAIL] 6. node 出现在 SSH config", text)
self.assertIn("my-vps NOT in ~/.ssh/config", text)
def test_ready_when_node_and_compose_present(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
root = project / ".pouch" / "deployer"
env = root / "test"
env.mkdir(parents=True)
(root / "_config.yaml").write_text("node: my-vps\nbase_path: /opt/app\n", encoding="utf-8")
(env / "compose.yaml").write_text("services:\n web:\n image: nginx\n", encoding="utf-8")
code, text = run_check(project, hosts={"my-vps"})
self.assertEqual(code, 0, text)
self.assertIn("RESULT: PASSED", text)
self.assertIn("[PASS] 7. list 可发现服务", text)
def test_argocd_only_passes_compose_as_skip(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
root = project / ".pouch" / "deployer"
root.mkdir(parents=True)
(root / "argocd.yaml").write_text(
"repo: git@git.example.com:org/infra-gitops.git\n",
encoding="utf-8",
)
code, text = run_check(project)
self.assertEqual(code, 0, text)
self.assertIn("[PASS] 3. Argo CD 指针", text)
self.assertIn("[SKIP] 4. 至少有一个 compose.yaml", text)
if __name__ == "__main__":
unittest.main()