372 lines
15 KiB
Python
Executable File
372 lines
15 KiB
Python
Executable File
#!/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())
|