feat(builder): query CI via project status script
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.
This commit is contained in:
@@ -112,6 +112,9 @@ class BuilderCheckTests(unittest.TestCase):
|
||||
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:
|
||||
@@ -213,28 +216,62 @@ class BuilderCheckTests(unittest.TestCase):
|
||||
self.assertIn("[FAIL] 10. 轨道工具链", text)
|
||||
self.assertIn("docker: MISSING", text)
|
||||
|
||||
def test_ready_uses_curl_for_gitea_actions(self) -> None:
|
||||
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")
|
||||
git(project, "remote", "add", "origin", "https://gitea.example.test/acme/widget.git")
|
||||
code, text = run_check(
|
||||
project,
|
||||
"--ready",
|
||||
env={"BUILD_VENUE": "ci"},
|
||||
which={
|
||||
"docker": "/usr/bin/docker",
|
||||
"curl": "/usr/bin/curl",
|
||||
"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("curl: found", text)
|
||||
self.assertIn("Gitea API: /api/v1", text)
|
||||
self.assertNotIn("glab: ", 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:
|
||||
|
||||
+127
-155
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -9,7 +8,10 @@ from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = REPO_ROOT / "skills" / "builder" / "scripts" / "wait_ci.sh"
|
||||
SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts"
|
||||
WAIT_CI = SCRIPTS / "wait_ci.sh"
|
||||
INIT_CI = SCRIPTS / "init_ci_status.sh"
|
||||
TEMPLATES = REPO_ROOT / "skills" / "builder" / "templates" / "ci-status"
|
||||
|
||||
|
||||
def git(cwd: Path, *args: str) -> str:
|
||||
@@ -33,7 +35,7 @@ def git(cwd: Path, *args: str) -> str:
|
||||
|
||||
|
||||
class BuilderCiTests(unittest.TestCase):
|
||||
def make_repo(self, root: Path, origin: str) -> str:
|
||||
def make_repo(self, root: Path, origin: str = "https://gitea.example.test/acme/widget.git") -> str:
|
||||
git(root, "init", "-b", "main")
|
||||
git(root, "config", "user.email", "test@example.com")
|
||||
git(root, "config", "user.name", "Test")
|
||||
@@ -43,6 +45,27 @@ class BuilderCiTests(unittest.TestCase):
|
||||
git(root, "remote", "add", "origin", origin)
|
||||
return git(root, "rev-parse", "HEAD")
|
||||
|
||||
def write_makefile(self, root: Path, venue: str = "ci") -> None:
|
||||
(root / "makefile.builder").write_text(
|
||||
"BUILD_VENUE ?= "
|
||||
+ venue
|
||||
+ "\n"
|
||||
"CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh\n"
|
||||
"print-%:\n"
|
||||
"\t@echo $($*)\n"
|
||||
"venue:\n"
|
||||
"\t@echo $(BUILD_VENUE)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def write_status_script(self, root: Path, body: str, executable: bool = True) -> Path:
|
||||
path = root / ".pouch" / "builder" / "ci-status.sh"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
|
||||
if executable:
|
||||
path.chmod(0o755)
|
||||
return path
|
||||
|
||||
def fake_command(self, bin_dir: Path, name: str, body: str) -> None:
|
||||
command = bin_dir / name
|
||||
command.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8")
|
||||
@@ -52,20 +75,22 @@ class BuilderCiTests(unittest.TestCase):
|
||||
self,
|
||||
root: Path,
|
||||
sha: str,
|
||||
bin_dir: Path,
|
||||
bin_dir: Path | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
path = os.environ["PATH"]
|
||||
if bin_dir is not None:
|
||||
path = f"{bin_dir}{os.pathsep}{path}"
|
||||
env = {
|
||||
**os.environ,
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"PATH": path,
|
||||
"BUILDER_CI_POLL_SEC": "0",
|
||||
"BUILDER_CI_APPEAR_SEC": "0",
|
||||
"BUILDER_CI_FORGE": "gitea",
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
return subprocess.run(
|
||||
["bash", str(SCRIPT), "--sha", sha, "--root", str(root)],
|
||||
["bash", str(WAIT_CI), "--sha", sha, "--root", str(root)],
|
||||
cwd=root,
|
||||
env=env,
|
||||
text=True,
|
||||
@@ -73,176 +98,123 @@ class BuilderCiTests(unittest.TestCase):
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_gitea_success_reads_exact_run_and_jobs(self) -> None:
|
||||
def test_init_copies_each_kind_template(self) -> None:
|
||||
for kind in ("tea", "glab", "gh", "curl"):
|
||||
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
result = subprocess.run(
|
||||
["bash", str(INIT_CI), "--root", str(root), "--kind", kind],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
dest = root / ".pouch" / "builder" / "ci-status.sh"
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(result.stdout.strip(), str(dest))
|
||||
self.assertTrue(dest.is_file())
|
||||
self.assertTrue(os.access(dest, os.X_OK))
|
||||
self.assertEqual(
|
||||
dest.read_text(encoding="utf-8"),
|
||||
(TEMPLATES / f"{kind}.sh").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_init_rejects_unknown_kind(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
result = subprocess.run(
|
||||
["bash", str(INIT_CI), "--root", temp, "--kind", "jenkins"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("--kind must be tea, glab, gh, or curl", result.stderr)
|
||||
|
||||
def test_success_reads_project_script_not_forge_cli(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root, "https://gitea.example.test/acme/widget.git")
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
self.write_status_script(
|
||||
root,
|
||||
'echo "STATUS=success ID=12 PIPELINE=https://gitea.example.test/run/12 JOBS=build=success"',
|
||||
)
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
self.fake_command(
|
||||
bin_dir,
|
||||
"curl",
|
||||
"""
|
||||
case "$*" in
|
||||
*"/jobs?"*) cat "$BUILDER_CI_FIXTURE/jobs.json" ;;
|
||||
*"/actions/runs?"*) cat "$BUILDER_CI_FIXTURE/runs.json" ;;
|
||||
*) exit 9 ;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
fixture = root / "fixture"
|
||||
fixture.mkdir()
|
||||
(fixture / "runs.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"workflow_runs": [
|
||||
{
|
||||
"id": 9,
|
||||
"head_sha": "0" * 40,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"html_url": "https://gitea.example.test/wrong",
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"head_sha": sha,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
"html_url": "https://gitea.example.test/run/12",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(fixture / "jobs.json").write_text(
|
||||
json.dumps(
|
||||
{"jobs": [{"id": 1, "name": "build", "head_sha": sha, "status": "completed", "conclusion": "success"}]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_wait(
|
||||
root,
|
||||
sha,
|
||||
bin_dir,
|
||||
{"BUILDER_CI_FIXTURE": str(fixture)},
|
||||
)
|
||||
for name in ("curl", "tea", "glab", "gh"):
|
||||
self.fake_command(bin_dir, name, "exit 9")
|
||||
result = self.run_wait(root, sha, bin_dir)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("OK pipeline=https://gitea.example.test/run/12", result.stdout)
|
||||
self.assertIn("id=12", result.stdout)
|
||||
self.assertIn("jobs=build=success", result.stdout)
|
||||
self.assertNotIn("wrong", result.stdout + result.stderr)
|
||||
|
||||
def test_gitea_failed_job_is_failure(self) -> None:
|
||||
def test_failed_status_is_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root, "git@gitea.example.test:acme/widget.git")
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
fixture = root / "fixture"
|
||||
fixture.mkdir()
|
||||
self.fake_command(
|
||||
bin_dir,
|
||||
"curl",
|
||||
"""
|
||||
case "$*" in
|
||||
*"/jobs?"*) cat "$BUILDER_CI_FIXTURE/jobs.json" ;;
|
||||
*"/actions/runs?"*) cat "$BUILDER_CI_FIXTURE/runs.json" ;;
|
||||
*) exit 9 ;;
|
||||
esac
|
||||
""",
|
||||
)
|
||||
(fixture / "runs.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"workflow_runs": [
|
||||
{
|
||||
"id": 21,
|
||||
"head_sha": sha,
|
||||
"status": "completed",
|
||||
"conclusion": "failure",
|
||||
"html_url": "https://gitea.example.test/run/21",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(fixture / "jobs.json").write_text(
|
||||
json.dumps(
|
||||
{"jobs": [{"id": 2, "name": "test", "head_sha": sha, "status": "completed", "conclusion": "failure"}]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_wait(
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
self.write_status_script(
|
||||
root,
|
||||
sha,
|
||||
bin_dir,
|
||||
{"BUILDER_CI_FIXTURE": str(fixture)},
|
||||
'echo "STATUS=failed ID=21 PIPELINE=https://gitea.example.test/run/21 JOBS=test=failure"',
|
||||
)
|
||||
result = self.run_wait(root, sha)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("reason=job_failed", result.stderr)
|
||||
self.assertIn("test=failure", result.stderr)
|
||||
|
||||
def test_gitea_does_not_treat_other_sha_as_pipeline(self) -> None:
|
||||
def test_empty_status_is_no_pipeline(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root, "https://gitea.example.test/acme/widget.git")
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
fixture = root / "fixture"
|
||||
fixture.mkdir()
|
||||
self.fake_command(
|
||||
bin_dir,
|
||||
"curl",
|
||||
'cat "$BUILDER_CI_FIXTURE/runs.json"',
|
||||
)
|
||||
(fixture / "runs.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"workflow_runs": [
|
||||
{
|
||||
"id": 99,
|
||||
"head_sha": "f" * 40,
|
||||
"status": "completed",
|
||||
"conclusion": "success",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_wait(
|
||||
root,
|
||||
sha,
|
||||
bin_dir,
|
||||
{"BUILDER_CI_FIXTURE": str(fixture)},
|
||||
)
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
self.write_status_script(root, 'echo "STATUS=empty"')
|
||||
result = self.run_wait(root, sha)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("reason=no_pipeline", result.stderr)
|
||||
|
||||
def test_github_and_gitlab_branches_still_use_their_cli(self) -> None:
|
||||
for forge, command, output in (
|
||||
("github", "gh", "printf '%s\\n' '[]'"),
|
||||
("gitlab", "glab", "printf '%s\\n' '[]'"),
|
||||
):
|
||||
with self.subTest(forge=forge), tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root, f"https://{forge}.example.test/acme/widget.git")
|
||||
bin_dir = root / "bin"
|
||||
bin_dir.mkdir()
|
||||
self.fake_command(bin_dir, command, output)
|
||||
result = self.run_wait(
|
||||
root,
|
||||
sha,
|
||||
bin_dir,
|
||||
{
|
||||
"BUILDER_CI_FORGE": forge,
|
||||
"BUILDER_CI_APPEAR_SEC": "0",
|
||||
},
|
||||
)
|
||||
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
|
||||
self.assertIn("reason=no_pipeline", result.stderr)
|
||||
def test_missing_script_is_usage_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
result = self.run_wait(root, sha)
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
self.assertIn("CI status script missing", result.stderr)
|
||||
|
||||
def test_non_executable_script_is_usage_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
self.write_status_script(root, 'echo "STATUS=success"', executable=False)
|
||||
result = self.run_wait(root, sha)
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
self.assertIn("not executable", result.stderr)
|
||||
|
||||
def test_relative_script_outside_builder_dir_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
rogue = root / "ci-status.sh"
|
||||
rogue.write_text('#!/bin/sh\necho STATUS=success\n', encoding="utf-8")
|
||||
rogue.chmod(0o755)
|
||||
result = self.run_wait(
|
||||
root, sha, extra_env={"CI_STATUS_SCRIPT": "ci-status.sh"}
|
||||
)
|
||||
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
|
||||
self.assertIn("must be under .pouch/builder/", result.stderr)
|
||||
|
||||
def test_script_exit_3_is_cannot_query(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
sha = self.make_repo(root)
|
||||
self.write_makefile(root)
|
||||
self.write_status_script(root, 'echo "Error: not logged in" >&2; exit 3')
|
||||
result = self.run_wait(root, sha)
|
||||
self.assertEqual(result.returncode, 3, result.stdout + result.stderr)
|
||||
self.assertIn("CI status script exited 3", result.stderr)
|
||||
self.assertIn("not logged in", result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user