feat: add skill init/check and isolate builder makefile
Give ack, builder, and deployer an explicit init/check mode that reports missing project config instead of failing mid-work. Point builder at makefile.builder so its contract targets do not collide with an existing Makefile.
This commit is contained in:
+186
-20
@@ -1,14 +1,18 @@
|
||||
#!/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.
|
||||
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 <project-dir> [--build]
|
||||
python3 -I -S check.py <project-dir> [--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.
|
||||
"""
|
||||
@@ -17,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -37,6 +42,7 @@ SECRET_PATTERNS = (
|
||||
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"
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
@@ -58,10 +64,15 @@ class Report:
|
||||
self.skips += 0 if self.skips else 1
|
||||
|
||||
|
||||
def run_make(project: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess[str]:
|
||||
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(
|
||||
["make", "-C", str(project), "-n", *args],
|
||||
capture_output=True, text=True, timeout=timeout, check=False,
|
||||
cmd, capture_output=True, text=True, timeout=timeout, check=False
|
||||
)
|
||||
|
||||
|
||||
@@ -244,8 +255,7 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
|
||||
|
||||
|
||||
def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||
makefile = project / "Makefile"
|
||||
included_text = ""
|
||||
makefile = project / BUILDER_MAKEFILE
|
||||
problems = []
|
||||
files = [makefile]
|
||||
if makefile.exists():
|
||||
@@ -267,9 +277,104 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||
report.add(FAIL if problems else PASS, 8, "无内联机密、无隐式 latest/stable", "\n".join(problems) or "clean")
|
||||
|
||||
|
||||
def check_script_paths(report: Report) -> None:
|
||||
import os
|
||||
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 key names defined in project `.env`. Never return or print values."""
|
||||
path = project / ".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 match:
|
||||
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(
|
||||
[
|
||||
"",
|
||||
"blocks publish, not build. Put keys in the environment or project `.env`:",
|
||||
*[f" {key}=" for key in missing],
|
||||
"Do not commit `.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:
|
||||
@@ -289,26 +394,80 @@ def check_script_paths(report: Report) -> None:
|
||||
|
||||
|
||||
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)
|
||||
"""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 deb` failed:\n{result.stderr[-2000:]}", file=sys.stderr)
|
||||
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 <builder-skill>/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 the Makefile")
|
||||
parser.add_argument("--build", action="store_true", help="actually run `make deb` and verify the artifact")
|
||||
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 / "Makefile"
|
||||
makefile = project / BUILDER_MAKEFILE
|
||||
if not makefile.is_file():
|
||||
print(f"Error: no Makefile in {project}", file=sys.stderr)
|
||||
return 2
|
||||
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
|
||||
@@ -340,7 +499,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
if deb_project:
|
||||
if args.build:
|
||||
print("--build: running `make deb` ...")
|
||||
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)
|
||||
@@ -357,6 +516,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user