#!/usr/bin/env python3 """Executable form of the builder contract (references/contract.md). Checks a project's makefile.builder against the contract by probing make itself with dry runs (`make -f makefile.builder -n`) instead of parsing makefile text: includes, conditionals, and variable expansion are resolved by make, so behavior is what gets judged. The default Makefile/makefile is not read. Usage: python3 -I -S check.py [--build] [--ready] Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error. `--ready` 额外检查轨道工具链和发布环境变量键名(只看键是否存在,永不打印值)。 无 makefile.builder 时普通模式退出 2;`--ready` 输出结构化 FAIL 并继续工具链/发布键检查。 Change the contract here first, then mirror the change into contract.md. """ from __future__ import annotations import argparse import hashlib import os 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") BUILDER_MAKEFILE = "makefile.builder" BUILDER_ENV = ".env.builder" 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, dry_run: bool = True ) -> subprocess.CompletedProcess[str]: cmd = ["make", "-C", str(project), "-f", BUILDER_MAKEFILE] if dry_run: cmd.append("-n") cmd.extend(args) return subprocess.run( cmd, 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 / BUILDER_MAKEFILE 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") DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY") DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",) ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") def env_file_keys(project: Path) -> set[str]: """Return nonempty key names in `.env.builder`. Never return or print values.""" path = project / BUILDER_ENV keys: set[str] = set() if not path.is_file(): return keys try: text = path.read_text(encoding="utf-8") except OSError: return keys for raw in text.splitlines(): stripped = raw.strip() if not stripped or stripped.startswith("#"): continue match = ENV_KEY_LINE.match(stripped) if not match: continue value = match.group(2).strip().strip("'\"") if value: keys.add(match.group(1)) return keys def key_present(key: str, env_keys: set[str]) -> bool: return bool(os.environ.get(key)) or key in env_keys def check_ready_toolchain( report: Report, *, deb_project: bool, docker_project: bool ) -> None: lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"] problems = [] if docker_project: docker_ok = shutil.which("docker") is not None lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}") if not docker_ok: problems.append("install docker to build/publish images") else: lines.append("docker: skipped (no docker track)") if deb_project: dpkg_ok = shutil.which("dpkg-deb") is not None lines.append( f"dpkg-deb: {'found' if dpkg_ok else 'MISSING (blocks make deb / --build)'}" ) if not dpkg_ok: problems.append("install dpkg-dev (or equivalent) to build .deb packages") else: lines.append("dpkg-deb: skipped (no deb track)") report.add( FAIL if problems else PASS, 10, "轨道工具链", "\n".join(lines + ([""] + problems if problems else [])), ) def check_ready_env_keys( report: Report, project: Path, *, deb_project: bool, docker_project: bool ) -> None: env_keys = env_file_keys(project) lines = [] missing: list[str] = [] if not deb_project and not docker_project: report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track") return if deb_project: for key in DEB_ENV_KEYS: found = key_present(key, env_keys) lines.append(f"{key}: {'present' if found else 'MISSING'}") if not found: missing.append(key) else: lines.append("DEB_*: skipped (no deb track)") if docker_project: for key in DOCKER_ENV_KEYS: found = key_present(key, env_keys) lines.append(f"{key}: {'present' if found else 'MISSING'}") if not found: missing.append(key) else: lines.append("DOCKER_*: skipped (no docker track)") if missing: lines.extend( [ "", f"blocks publish, not build. Put keys in the environment or {BUILDER_ENV}:", *[f" {key}=" for key in missing], f"Do not commit {BUILDER_ENV}. Do not put these keys in `.env`. Never print values.", ] ) report.add(SKIP, 11, "发布环境变量键名(不读取值)", "\n".join(lines)) return report.add(PASS, 11, "发布环境变量键名(不读取值)", "\n".join(lines)) def check_script_paths(report: Report) -> None: 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 -f makefile.builder deb` for real and return the produced .deb, or None.""" result = run_make(project, "deb", timeout=1800, dry_run=False) if result.returncode != 0: print( f"--build: `make -f {BUILDER_MAKEFILE} 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 MAKEFILE_HINT = ( f"Fix: copy /templates/{BUILDER_MAKEFILE} to the project " f"root as {BUILDER_MAKEFILE}. Do not put builder targets in Makefile or " "makefile. Keep help/build/clean/version, include builder " "scripts/version.mk, and enable deb/docker/push* for the tracks this " "project actually uses. Then re-run check.py. Do not use create-makefile." ) 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 makefile.builder", ) parser.add_argument( "--build", action="store_true", help="actually run `make -f makefile.builder deb` and verify the artifact", ) parser.add_argument( "--ready", action="store_true", help="also check toolchain and publish env key names (init/check mode)", ) args = parser.parse_args(argv) project = args.project.resolve() makefile = project / BUILDER_MAKEFILE if not makefile.is_file(): if not args.ready: print(f"Error: no {BUILDER_MAKEFILE} in {project}", file=sys.stderr) return 2 report = Report() report.add( FAIL, 1, f"{BUILDER_MAKEFILE} 存在", f"no {BUILDER_MAKEFILE} in {project}\n{MAKEFILE_HINT}", ) skip_detail = f"(no {BUILDER_MAKEFILE})" for number, title, detail in ( (2, "ARCH 守卫与缺省值", skip_detail), (3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail), (4, "build 不含上传动作", skip_detail), (5, "deb 目标产物形状与纯构建", skip_detail), (6, "docker 目标为本地单平台构建", skip_detail), (7, "push 仅委托 builder 脚本(薄包装)", skip_detail), (8, "无内联机密、无隐式 latest/stable", skip_detail), (9, "builder 脚本路径可达", skip_detail), ): report.add(SKIP, number, title, detail) docker_project = detect_docker_project(project) deb_project = any(project.glob("debian/*")) check_ready_toolchain(report, deb_project=deb_project, docker_project=docker_project) check_ready_env_keys( report, project, deb_project=deb_project, docker_project=docker_project ) print() print(f"RESULT: FAILED ({report.failures} check(s) failed)") return 1 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(f"--build: running `make -f {BUILDER_MAKEFILE} 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) if args.ready: check_ready_toolchain( report, deb_project=deb_project, docker_project=docker_project ) check_ready_env_keys( report, project, deb_project=deb_project, docker_project=docker_project ) 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())