feat(builder): declare multi-arch builds with ARCHES

Project architecture lists belong in makefile.builder, not .env.builder.
deb/push-deb fan out when ARCH is omitted; Docker platforms derive from
ARCHES. Legacy makefiles without ARCHES keep the old single-arch behavior.
This commit is contained in:
2026-09-08 16:31:53 +08:00
parent c248c3f0e3
commit aa7a265e76
9 changed files with 433 additions and 36 deletions
+117 -12
View File
@@ -78,8 +78,9 @@ def run_make(
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
err = result.stderr.lower()
return result.returncode != 0 and (
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower()
"no rule to make target" in err or "没有规则可制作目标" in result.stderr
)
@@ -99,6 +100,33 @@ def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
return lines
def parse_dry_echo(result: subprocess.CompletedProcess[str]) -> str:
"""Recover `@echo …` payload from `make -n` output."""
out_lines = [
ln.lstrip()[5:]
for ln in result.stdout.splitlines()
if ln.lstrip().startswith("echo ")
]
return "\n".join(out_lines).strip()
def read_arches(project: Path) -> list[str] | None:
"""Return ARCHES if the `arches` target exists.
None means a legacy makefile without the target. An empty list means the
target exists but printed nothing usable.
"""
result = run_make(project, "arches")
if has_no_rule(result):
return None
if result.returncode != 0:
return []
raw = parse_dry_echo(result)
if not raw:
return []
return raw.split()
def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
present: dict[str, bool] = {}
lines = []
@@ -112,25 +140,68 @@ def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
def check_arch_guard(report: Report, project: Path) -> None:
title = "ARCH / ARCHES 守卫与缺省值"
problems: list[str] = []
bad = run_make(project, "build", "ARCH=loongarch")
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr)
guard_ok = bad.returncode != 0 and (
"amd64" in bad.stderr or "arm64" in bad.stderr or "ARCH" 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()))
if not guard_ok:
problems.append(
"Hint: reject unknown ARCH with `$(error)` naming allowed values "
"(amd64/arm64, or the project's ARCHES)."
)
if not default_ok:
problems.append("`make -n build` without ARCH= must succeed")
arches_make = run_make(project, "arches")
if has_no_rule(arches_make):
lines.append("arches target: absent (legacy; implicit ARCHES = default ARCH)")
elif arches_make.returncode != 0:
problems.append(
f"`make -n arches` failed: {arches_make.stderr.strip() or 'unknown error'}"
)
else:
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint)
raw = parse_dry_echo(arches_make)
arches = raw.split()
lines.append(f"ARCHES: {raw!r}")
if not arches:
problems.append("arches target printed an empty list")
if "," in raw:
problems.append("ARCHES must be space-separated, not comma-separated")
unknown = [a for a in arches if a not in ARCH_VALUES]
if unknown:
problems.append(f"ARCHES entries must be amd64 or arm64: {unknown}")
if len(arches) != len(set(arches)):
problems.append("ARCHES must not contain duplicates")
outsider = next((a for a in ARCH_VALUES if a not in arches), None)
if outsider:
extra = run_make(project, "build", f"ARCH={outsider}")
extra_ok = extra.returncode != 0
lines.append(
f"ARCH={outsider} rejected (not in ARCHES): "
f"{'yes' if extra_ok else 'NO'}"
)
if not extra_ok:
problems.append(
f"ARCH={outsider} must fail when it is not listed in ARCHES"
)
if problems:
report.add(FAIL, 2, title, "\n".join(lines + [""] + problems))
else:
report.add(PASS, 2, title, "\n".join(lines))
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()
out = parse_dry_echo(result)
single = len(out.splitlines()) == 1 and out != ""
no_v = single and not out.startswith("v")
detail = f"stdout={out!r}"
@@ -185,6 +256,24 @@ def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> N
problems.append(f"dpkg-deb --info failed on {built_deb.name}")
else:
problems.append("dpkg-deb unavailable; metadata not verified (--build)")
arches = read_arches(project)
if arches and len(arches) > 1 and dry.returncode == 0:
blob = dry.stdout + dry.stderr
missing = [
a for a in arches if f"ARCH={a}" not in blob and f"_{a}.deb" not in blob
]
if missing:
problems.append(
"ARCHES has multiple values but `make -n deb` does not fan out to: "
+ ", ".join(missing)
)
pinned = arches[-1]
one = run_make(project, "deb", f"ARCH={pinned}")
one_blob = one.stdout + one.stderr
if f"_{pinned}.deb" not in one_blob and f"ARCH={pinned}" not in one_blob:
problems.append(
f"`make -n deb ARCH={pinned}` does not build that single arch"
)
if problems:
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
else:
@@ -226,7 +315,9 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
)
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None:
def check_push_delegates(
report: Report, project: Path, dual_artifact: bool, deb_project: bool
) -> None:
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
missing = []
inline = []
@@ -250,6 +341,20 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
if missing:
problems.append("missing targets: " + ", ".join(missing))
problems.extend(inline)
if deb_project:
push_deb_target = "push-deb" if dual_artifact else "push"
if push_deb_target not in missing:
arches = read_arches(project)
if arches and len(arches) > 1:
dry = run_make(project, push_deb_target)
if not has_no_rule(dry):
text = chr(10).join(clean_make_output(dry))
absent = [a for a in arches if f"_{a}.deb" not in text]
if absent:
problems.append(
f"ARCHES has multiple values but `make -n {push_deb_target}` "
"does not upload: " + ", ".join(absent)
)
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)
@@ -453,7 +558,7 @@ def main(argv: list[str] | None = None) -> int:
)
skip_detail = f"(no {BUILDER_MAKEFILE})"
for number, title, detail in (
(2, "ARCH 守卫与缺省值", skip_detail),
(2, "ARCH / ARCHES 守卫与缺省值", skip_detail),
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
(4, "build 不含上传动作", skip_detail),
(5, "deb 目标产物形状与纯构建", skip_detail),
@@ -497,7 +602,7 @@ def main(argv: list[str] | None = None) -> int:
check_version_output(report, project)
check_build_has_no_upload(report, project)
else:
report.add(SKIP, 2, "ARCH 守卫与缺省值", "(build target missing)")
report.add(SKIP, 2, "ARCH / ARCHES 守卫与缺省值", "(build target missing)")
report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)")
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
@@ -517,7 +622,7 @@ def main(argv: list[str] | None = None) -> int:
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
dual = deb_project and docker_project
check_push_delegates(report, project, dual)
check_push_delegates(report, project, dual, deb_project)
check_secrets_and_tags(report, project)
check_script_paths(report)
if args.ready: