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:
2026-08-25 16:49:28 +08:00
parent e9d2b5fde6
commit 10d8800f07
21 changed files with 1129 additions and 95 deletions
+186 -20
View File
@@ -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()