Files
.pouch/skills/builder/scripts/check.py
T

372 lines
15 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <project-dir> [--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 <version>` 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 <name>_<version>_<arch>.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())