#!/usr/bin/env python3 """Executable form of the builder contract (references/contract.md). Checks a project's Makefile against the contract by probing make itself with dry runs (`make -n`) instead of parsing Makefile text: includes, conditionals, and variable expansion are resolved by make, so behavior is what gets judged. Usage: python3 -I -S check.py [--build] Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error. Change the contract here first, then mirror the change into contract.md. """ from __future__ import annotations import argparse import hashlib import re import shutil import subprocess import sys from pathlib import Path ARCH_VALUES = ("amd64", "arm64") REQUIRED_TARGETS = ("help", "version", "clean", "build") UPLOAD_TOKENS = ( "curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ", "docker push", "buildx build --push", "buildx --push", "upload_deb.sh", "publish_docker.sh", ) SECRET_PATTERNS = ( re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE), re.compile(r"\b[A-Za-z0-9_]*token[A-Za-z0-9_]*\s*[:?]?=\s*['\"]?[A-Za-z0-9._\-]{16,}", re.IGNORECASE), ) FLOATING_TAGS = (":latest", ":stable") DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$") VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh") PASS = "PASS" FAIL = "FAIL" SKIP = "SKIP" class Report: def __init__(self) -> None: self.failures = 0 self.skips = 0 def add(self, status: str, number: int, title: str, detail: str) -> None: print(f"[{status}] {number}. {title}") for line in detail.splitlines(): print(f" {line}") if status == FAIL: self.failures += 1 elif status == SKIP: self.skips += 0 if self.skips else 1 def run_make(project: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess[str]: return subprocess.run( ["make", "-C", str(project), "-n", *args], capture_output=True, text=True, timeout=timeout, check=False, ) def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool: return result.returncode != 0 and ( "No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower() ) BANNER_RE = re.compile(r"^make(?:\[[0-9]+\])?: (进入|离开|Entering|Leaving)") def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]: """Drop make directory banners and dry-run command echoes, keep real output.""" lines = [] for line in result.stdout.splitlines(): if BANNER_RE.match(line.strip()): continue stripped = line.lstrip() if stripped.startswith(("echo ", "echo\t", "printf ")): continue lines.append(line) return lines def check_required_targets(report: Report, project: Path) -> dict[str, bool]: present: dict[str, bool] = {} lines = [] for target in REQUIRED_TARGETS: result = run_make(project, target) ok = result.returncode == 0 present[target] = ok lines.append(f"{target}: {'found' if ok else 'missing'}") report.add(PASS if all(present.values()) else FAIL, 1, "必备目标存在(help/version/clean/build)", "\n".join(lines)) return present def check_arch_guard(report: Report, project: Path) -> None: bad = run_make(project, "build", "ARCH=loongarch") guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr) default_ok = run_make(project, "build").returncode == 0 lines = [ f"invalid ARCH rejected: {'yes' if guard_ok else 'NO'}", f"default ARCH works: {'yes' if default_ok else 'no'}", ] hint = "" if guard_ok else "\n Hint: add `$(error ARCH must be amd64 or arm64)` guarded by an ifneq filter." if guard_ok and default_ok: report.add(PASS, 2, "ARCH 守卫与缺省值", "\n".join(lines + hint.splitlines())) else: report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint) def check_version_output(report: Report, project: Path) -> None: result = run_make(project, "version") # Dry run: the echoed `@echo ` line IS the would-be output. out_lines = [ln.lstrip()[5:] for ln in result.stdout.splitlines() if ln.lstrip().startswith("echo ")] out = "\n".join(out_lines).strip() single = len(out.splitlines()) == 1 and out != "" no_v = single and not out.startswith("v") detail = f"stdout={out!r}" if single and not no_v: detail += "\ncanonical version must not start with 'v'" report.add( PASS if no_v else FAIL, 3, "version 输出一行非空规范版本(无 v 前缀)", detail, ) def check_build_has_no_upload(report: Report, project: Path) -> None: result = run_make(project, "build") text = chr(10).join(clean_make_output(result)) hits = [token for token in UPLOAD_TOKENS if token in text] report.add( PASS if not hits else FAIL, 4, "build 不含上传动作", "clean" if not hits else "found upload commands in build recipe:\n " + ", ".join(hits), ) def detect_deb_project(recipe_all: str, project: Path) -> bool: return ".deb" in recipe_all or "dpkg-deb" in recipe_all or "debuild" in recipe_all or any(project.glob("debian/*")) def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> None: dry = run_make(project, "deb") text = chr(10).join(clean_make_output(dry)) problems = [] if dry.returncode != 0: problems.append(f"`make -n deb` failed: {dry.stderr.strip() or 'unknown error'}") else: if "dist/" not in text and "$(DIST_DIR)" not in text: problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location") hits = [token for token in UPLOAD_TOKENS if token in text] if hits: problems.append("recipe contains upload commands: " + ", ".join(hits)) if "rm -rf /" in text or "rm -rf ~" in text: problems.append("recipe contains unrestricted rm -rf") if built_deb is not None: shape_ok = DEB_SHAPE.match(built_deb.name) is not None if not shape_ok: problems.append(f"artifact name does not match __.deb: {built_deb.name}") dpkg = shutil.which("dpkg-deb") if dpkg: info = subprocess.run([dpkg, "--field", str(built_deb), "Package"], capture_output=True, text=True, check=False) if info.returncode != 0 or not info.stdout.strip(): problems.append(f"dpkg-deb --info failed on {built_deb.name}") else: problems.append("dpkg-deb unavailable; metadata not verified (--build)") if problems: report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems)) else: extra = f"\nartifact: {built_deb.name}" if built_deb else "\n(static recipe check only; run --build to verify real artifact)" report.add(PASS, 5, "deb 目标产物形状与纯构建", extra.lstrip("\n")) def detect_docker_project(project: Path) -> bool: return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists() def check_docker_recipe(report: Report, project: Path) -> None: dry = run_make(project, "docker") text = chr(10).join(clean_make_output(dry)) if has_no_rule(dry): report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no docker target)") return problems = [] if "--push" in text or " docker push" in text or "docker push\n" in text: problems.append("make docker must be local-only; pushing belongs to publish_docker.sh") if "--platform" in text and "," in text.split("--platform")[1][:80].split()[0]: problems.append("make docker must stay single-platform; multi-platform belongs to publish_docker.sh") report.add(FAIL if problems else PASS, 6, "docker 目标为本地单平台构建", "\n".join(problems) or "local single-platform build") SCRIPT_RESOLVE_SNIPPETS = tuple( f"{prefix}{name}" for prefix in ( "$$BUILDER_SKILL_DIR", "$BUILDER_SKILL_DIR", "$$HOME/.pouch/skills/builder/scripts", "$HOME/.pouch/skills/builder/scripts", "~/.pouch/skills/builder/scripts", "$$HOME/.skills/skills/builder/scripts", "$HOME/.skills/skills/builder/scripts", "~/.skills/skills/builder/scripts", ) for name in VALID_SCRIPT_NAMES ) def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None: targets = ("push-deb", "push-docker") if dual_artifact else ("push",) missing = [] inline = [] thin = [] for target in targets: dry = run_make(project, target) if has_no_rule(dry): missing.append(target) continue text = chr(10).join(clean_make_output(dry)) bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text] if bad_tokens: inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})") elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \ and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \ and "publish_docker.sh" not in text: inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)") else: thin.append(target) problems = [] if missing: problems.append("missing targets: " + ", ".join(missing)) problems.extend(inline) status = PASS if not problems else FAIL detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin) report.add(status, 7, "push 仅委托 builder 脚本(薄包装)", detail) def check_secrets_and_tags(report: Report, project: Path) -> None: makefile = project / "Makefile" included_text = "" problems = [] files = [makefile] if makefile.exists(): for match in re.finditer(r"^include\s+(.+)$", makefile.read_text(encoding="utf-8"), re.MULTILINE): inc = (project / match.group(1).strip()).resolve() if inc.is_file(): files.append(inc) for file in files: text = file.read_text(encoding="utf-8") rel = file.relative_to(project) if file.is_relative_to(project) else file for pattern in SECRET_PATTERNS: for hit in pattern.finditer(text): problems.append(f"{rel}: possible hardcoded secret near `{hit.group(0)[:40]}...`") for tag in FLOATING_TAGS: for line in text.splitlines(): stripped = line.split("#", 1)[0] if tag in stripped: problems.append(f"{rel}: implicit floating tag `{tag}` in: {stripped.strip()[:70]}") report.add(FAIL if problems else PASS, 8, "无内联机密、无隐式 latest/stable", "\n".join(problems) or "clean") def check_script_paths(report: Report) -> None: import os candidates = [] env_dir = os.environ.get("BUILDER_SKILL_DIR") if env_dir: candidates.append(Path(env_dir) / "scripts") home = Path(os.environ.get("HOME", "")) candidates.append(home / ".pouch" / "skills" / "builder" / "scripts") candidates.append(home / ".skills" / "skills" / "builder" / "scripts") found = next((c for c in candidates if c.is_dir() and any((c / n).is_file() for n in VALID_SCRIPT_NAMES)), None) if found: report.add(PASS, 9, "builder 脚本路径可达", str(found)) else: report.add(FAIL, 9, "builder 脚本路径可达", "\n".join([ "none of these resolve to scripts/upload_deb.sh:", *(f" {c}" for c in candidates), "Fix: set BUILDER_SKILL_DIR, or clone the pouch repo to ~/.pouch.", ])) def build_project(project: Path) -> Path | None: """Run `make deb` for real and return the produced .deb, or None.""" result = subprocess.run(["make", "-C", str(project), "deb"], capture_output=True, text=True, timeout=1800, check=False) if result.returncode != 0: print(f"--build: `make deb` failed:\n{result.stderr[-2000:]}", file=sys.stderr) return None debs = sorted((p for p in (project / "dist").glob("*.deb") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True) return debs[0] if debs else None def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("project", type=Path, help="project directory containing the Makefile") parser.add_argument("--build", action="store_true", help="actually run `make deb` and verify the artifact") args = parser.parse_args(argv) project = args.project.resolve() makefile = project / "Makefile" if not makefile.is_file(): print(f"Error: no Makefile in {project}", file=sys.stderr) return 2 if shutil.which("make") is None: print("Error: make is required.", file=sys.stderr) return 2 report = Report() # Gather every recipe once via dry-running all known targets (best effort). recipe_all_parts = [] for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"): result = run_make(project, target) if result.returncode == 0: recipe_all_parts.append(result.stdout) recipe_all = "\n".join(recipe_all_parts) present = check_required_targets(report, project) built_deb: Path | None = None deb_project = detect_deb_project(recipe_all, project) docker_project = detect_docker_project(project) if present["build"]: check_arch_guard(report, project) check_version_output(report, project) check_build_has_no_upload(report, project) else: report.add(SKIP, 2, "ARCH 守卫与缺省值", "(build target missing)") report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)") report.add(SKIP, 4, "build 不含上传动作", "(build target missing)") if deb_project: if args.build: print("--build: running `make deb` ...") built_deb = build_project(project) if built_deb is None: print("--build: no .deb produced; artifact checks degrade to recipe-only.", file=sys.stderr) check_deb_recipe(report, project, built_deb) else: report.add(SKIP, 5, "deb 目标产物形状与纯构建", "(not a DEB project)") if docker_project: check_docker_recipe(report, project) else: report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)") dual = deb_project and docker_project check_push_delegates(report, project, dual) check_secrets_and_tags(report, project) check_script_paths(report) total_fail = report.failures print() if total_fail: print(f"RESULT: FAILED ({total_fail} check(s) failed)") return 1 print("RESULT: PASSED") return 0 if __name__ == "__main__": raise SystemExit(main())