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:
+117
-12
@@ -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:
|
||||
|
||||
@@ -17,7 +17,8 @@ usage() {
|
||||
# DOCKER_REGISTRY Required (or --registry)
|
||||
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
|
||||
# IMAGE_TAG Optional, default: version.sh --docker (or --tag)
|
||||
# PLATFORMS Optional, default: linux/amd64 (or --platform)
|
||||
# PLATFORMS Optional. Else makefile.builder ARCHES mapped to
|
||||
# linux/<arch>,… ; else linux/amd64 (or --platform)
|
||||
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
|
||||
# DOCKER_CONTEXT Optional, default: . (--context)
|
||||
# DOCKER_BUILDER Optional buildx builder name (--builder)
|
||||
@@ -53,7 +54,7 @@ fi
|
||||
registry=${DOCKER_REGISTRY:-}
|
||||
repository=${DOCKER_REPOSITORY:-$git_repo_name}
|
||||
tag=${IMAGE_TAG:-}
|
||||
platform=${PLATFORMS:-linux/amd64}
|
||||
platform=${PLATFORMS:-}
|
||||
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
|
||||
build_context=${DOCKER_CONTEXT:-.}
|
||||
builder=${DOCKER_BUILDER:-}
|
||||
@@ -126,6 +127,29 @@ if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
|
||||
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ -z "$platform" ]]; then
|
||||
if [[ -n "$project_root" && -f "$project_root/makefile.builder" ]]; then
|
||||
arches=$(make -C "$project_root" -f makefile.builder -s --no-print-directory arches 2>/dev/null || true)
|
||||
mapped=
|
||||
for arch in $arches; do
|
||||
case "$arch" in
|
||||
amd64|arm64)
|
||||
if [[ -n "$mapped" ]]; then
|
||||
mapped="$mapped,linux/$arch"
|
||||
else
|
||||
mapped="linux/$arch"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Error: unsupported ARCHES entry from makefile.builder: $arch" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
platform=$mapped
|
||||
fi
|
||||
platform=${platform:-linux/amd64}
|
||||
fi
|
||||
if [[ "$mode" == load && "$platform" == *,* ]]; then
|
||||
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
|
||||
exit 2
|
||||
|
||||
Reference in New Issue
Block a user