9ba9b3da0f
When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only polls that script and no longer guesses forge CLIs.
530 lines
21 KiB
Python
530 lines
21 KiB
Python
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(".env.builder", skill)
|
|
self.assertIn("不要改用户的 `Makefile`", skill)
|
|
self.assertIn("不要调用 create-makefile", skill)
|
|
self.assertIn("不要用 create-makefile", contract)
|
|
self.assertIn("makefile.builder", contract)
|
|
self.assertIn(".env.builder", contract)
|
|
self.assertIn("不要读取或改写用户 `.env`", contract)
|
|
self.assertIn("ARCHES", contract)
|
|
readme = (REPO_ROOT / "skills" / "builder" / "README.md").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("ARCHES", readme)
|
|
self.assertIn("amd64 arm64", readme)
|
|
self.assertIn("多架构", readme)
|
|
self.assertIn("copy_artifact.sh", skill)
|
|
self.assertIn("android", skill.lower())
|
|
self.assertIn("COPY_DEST", contract)
|
|
self.assertIn("init_ci_status.sh", skill)
|
|
self.assertIn("CI_STATUS_SCRIPT", contract)
|
|
self.assertIn("ci-status.md", skill)
|
|
|
|
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.builder").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_user_dotenv_is_ignored(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://user.example.com\n"
|
|
"DEB_TOKEN=user-env-secret-token\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: MISSING", text)
|
|
self.assertNotIn("user-env-secret-token", text)
|
|
self.assertNotIn("https://user.example.com", text)
|
|
|
|
def test_empty_env_builder_values_count_as_missing(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
write_contract_makefile(project)
|
|
(project / ".env.builder").write_text(
|
|
"DEB_SERVER_URL=\nDEB_TOKEN=\nDEB_REPOSITORY=\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_TOKEN: MISSING", 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_ready_ci_requires_status_script(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",
|
|
env={"BUILD_VENUE": "ci"},
|
|
which={
|
|
"docker": "/usr/bin/docker",
|
|
"curl": None,
|
|
"gh": None,
|
|
"glab": None,
|
|
"tea": None,
|
|
},
|
|
)
|
|
self.assertEqual(code, 1, text)
|
|
self.assertIn("[FAIL] 15. CI_STATUS_SCRIPT 可执行", text)
|
|
self.assertIn("does not point to a file", text)
|
|
self.assertIn("CI status: project CI_STATUS_SCRIPT", text)
|
|
self.assertNotIn("curl: found", text)
|
|
self.assertNotIn("Gitea API", text)
|
|
|
|
def test_ready_ci_passes_with_executable_status_script(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")
|
|
script = project / ".pouch" / "builder" / "ci-status.sh"
|
|
script.parent.mkdir(parents=True)
|
|
script.write_text("#!/bin/sh\necho STATUS=empty\n", encoding="utf-8")
|
|
script.chmod(0o755)
|
|
code, text = run_check(
|
|
project,
|
|
"--ready",
|
|
env={"BUILD_VENUE": "ci"},
|
|
which={"docker": "/usr/bin/docker", "curl": None, "gh": None, "glab": None},
|
|
)
|
|
self.assertEqual(code, 0, text)
|
|
self.assertIn("[PASS] 15. CI_STATUS_SCRIPT 可执行", text)
|
|
self.assertIn("CI status: project CI_STATUS_SCRIPT", text)
|
|
|
|
def test_ready_local_skips_status_script(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("[SKIP] 15. CI_STATUS_SCRIPT 可执行", text)
|
|
self.assertIn("BUILD_VENUE is not ci", 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)
|
|
|
|
def test_default_template_rejects_arch_outside_arches(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
write_contract_makefile(project)
|
|
result = subprocess.run(
|
|
["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertNotEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("amd64", result.stderr)
|
|
|
|
def test_multi_arches_deb_and_push_fan_out(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
write_contract_makefile(project)
|
|
makefile = project / "makefile.builder"
|
|
makefile.write_text(
|
|
makefile.read_text(encoding="utf-8").replace(
|
|
"ARCHES ?= amd64", "ARCHES ?= amd64 arm64"
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
code, text = run_check(
|
|
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
|
|
)
|
|
self.assertEqual(code, 0, text)
|
|
self.assertIn("ARCHES: 'amd64 arm64'", text)
|
|
deb = subprocess.run(
|
|
["make", "-C", str(project), "-f", "makefile.builder", "-n", "deb"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(deb.returncode, 0, deb.stderr)
|
|
combined = deb.stdout + deb.stderr
|
|
self.assertIn("ARCH=amd64", combined)
|
|
self.assertIn("ARCH=arm64", combined)
|
|
pinned = subprocess.run(
|
|
[
|
|
"make",
|
|
"-C",
|
|
str(project),
|
|
"-f",
|
|
"makefile.builder",
|
|
"-n",
|
|
"deb",
|
|
"ARCH=arm64",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(pinned.returncode, 0, pinned.stderr)
|
|
self.assertIn("_arm64.deb", pinned.stdout)
|
|
self.assertNotIn("_amd64.deb", pinned.stdout)
|
|
|
|
def test_legacy_makefile_without_arches_still_passes(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
(project / "makefile.builder").write_text(
|
|
"\n".join(
|
|
[
|
|
"ARCH ?= amd64",
|
|
"ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))",
|
|
"$(error ARCH must be amd64 or arm64)",
|
|
"endif",
|
|
"PROJECT_NAME ?= $(notdir $(CURDIR))",
|
|
"DIST_DIR ?= dist",
|
|
f"include {SCRIPTS / 'version.mk'}",
|
|
f"BUILDER_SCRIPTS := {SCRIPTS}",
|
|
".PHONY: help version clean build deb push",
|
|
"help:",
|
|
"\t@echo $(PROJECT_NAME) $(VERSION)",
|
|
"version:",
|
|
"\t@echo $(VERSION)",
|
|
"clean:",
|
|
"\trm -rf $(DIST_DIR)",
|
|
"build:",
|
|
"\tmkdir -p $(DIST_DIR)",
|
|
"\t@echo build linux/$(ARCH)",
|
|
"deb: build",
|
|
"\tmkdir -p $(DIST_DIR)",
|
|
"\tdpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb",
|
|
"push:",
|
|
"\t$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb",
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
code, text = run_check(
|
|
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
|
|
)
|
|
self.assertEqual(code, 0, text)
|
|
self.assertIn("arches target: absent", text)
|
|
result = subprocess.run(
|
|
["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
def test_publish_docker_derives_platforms_from_arches(self) -> None:
|
|
script = SCRIPTS / "publish_docker.sh"
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
write_contract_makefile(project)
|
|
makefile = project / "makefile.builder"
|
|
makefile.write_text(
|
|
makefile.read_text(encoding="utf-8").replace(
|
|
"ARCHES ?= amd64", "ARCHES ?= amd64 arm64"
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
|
|
env = os.environ.copy()
|
|
env["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent)
|
|
env.pop("PLATFORMS", None)
|
|
derived = subprocess.run(
|
|
[
|
|
str(script),
|
|
"--registry",
|
|
"example.com",
|
|
"--repository",
|
|
"ns/foo",
|
|
"--tag",
|
|
"dry-run-tag",
|
|
"--dry-run",
|
|
],
|
|
cwd=project,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
env=env,
|
|
)
|
|
self.assertEqual(derived.returncode, 0, derived.stdout + derived.stderr)
|
|
self.assertIn("Platform: linux/amd64,linux/arm64", derived.stdout)
|
|
env["PLATFORMS"] = "linux/amd64"
|
|
overridden = subprocess.run(
|
|
[
|
|
str(script),
|
|
"--registry",
|
|
"example.com",
|
|
"--repository",
|
|
"ns/foo",
|
|
"--tag",
|
|
"dry-run-tag",
|
|
"--dry-run",
|
|
],
|
|
cwd=project,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
env=env,
|
|
)
|
|
self.assertEqual(overridden.returncode, 0, overridden.stdout + overridden.stderr)
|
|
self.assertIn("Platform: linux/amd64", overridden.stdout)
|
|
self.assertNotIn("linux/arm64", overridden.stdout)
|
|
|
|
def test_android_copy_makefile_skips_missing_copy_dest(self) -> None:
|
|
template = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.android.builder"
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
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")
|
|
code, out = run_check(project, "--ready")
|
|
self.assertEqual(code, 0, out)
|
|
self.assertIn("[PASS] 12. android 目标产物形状与纯构建", out)
|
|
self.assertIn("[SKIP] 13. ios 目标产物形状与纯构建", out)
|
|
self.assertIn("COPY_DEST: MISSING", out)
|
|
self.assertIn("blocks publish, not build", out)
|
|
|
|
def test_inline_docker_cp_in_push_fails(self) -> None:
|
|
with tempfile.TemporaryDirectory() as temp:
|
|
project = Path(temp)
|
|
init_repo(project)
|
|
(project / "makefile.builder").write_text(
|
|
"\n".join(
|
|
[
|
|
"ARCHES ?= amd64",
|
|
"ARCH ?= amd64",
|
|
"ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))",
|
|
"$(error ARCH must be one of: $(ARCHES))",
|
|
"endif",
|
|
"DIST_DIR ?= dist",
|
|
f"include {SCRIPTS / 'version.mk'}",
|
|
".PHONY: help version arches clean build push",
|
|
"help:",
|
|
"\t@echo app $(VERSION)",
|
|
"version:",
|
|
"\t@echo $(VERSION)",
|
|
"arches:",
|
|
"\t@echo $(ARCHES)",
|
|
"clean:",
|
|
"\trm -rf $(DIST_DIR)",
|
|
"build:",
|
|
"\tmkdir -p $(DIST_DIR)/$(ARCH).apk",
|
|
"push:",
|
|
"\tdocker cp dist/app.apk file-server:/srv/app.apk",
|
|
"",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
code, out = run_check(project, "--ready")
|
|
self.assertEqual(code, 1, out)
|
|
self.assertIn("[FAIL] 7. push 仅委托 builder 脚本", out)
|
|
self.assertIn("docker cp", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|