From aa7a265e76aeddfa3bf16ecf58670cd7783be39d Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 8 Sep 2026 16:31:53 +0800 Subject: [PATCH] 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. --- skills/builder/README.md | 51 ++++++- skills/builder/SKILL.md | 10 +- skills/builder/references/contract.md | 23 ++- skills/builder/references/registry.md | 2 +- skills/builder/scripts/check.py | 129 ++++++++++++++-- skills/builder/scripts/publish_docker.sh | 28 +++- skills/builder/templates/env.builder | 2 +- skills/builder/templates/makefile.builder | 51 ++++++- tests/test_builder_check.py | 173 ++++++++++++++++++++++ 9 files changed, 433 insertions(+), 36 deletions(-) diff --git a/skills/builder/README.md b/skills/builder/README.md index 68e1841..a74b2af 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -38,8 +38,9 @@ pouch add builder -g # 全局 不改用户已有的 `Makefile` 或 `.env`。 1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`, - 变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`。 + 变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME`。 `VERSION` 通过 `include /scripts/version.mk` 从 Git 推导。 + 多架构清单是 `ARCHES`,见下方「多架构」。 2. 运行 `python3 -I -S /scripts/check.py . --ready` 直到构建项 PASS。 缺发布键只挡住上传,不挡住构建。 3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`): @@ -54,6 +55,51 @@ DOCKER_REGISTRY=registry.example.com 4. 日常发布就是两条命令: `make -f makefile.builder deb && make -f makefile.builder push-deb`、 `make -f makefile.builder push-docker`。 + 写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。 + +## 多架构 + +项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进 +`.env.builder`(那是密钥,换机器会丢)。 + +```makefile +ARCHES ?= amd64 arm64 +``` + +`ARCH` 是「这一次」:默认等于清单第一项。不传 `ARCH=` 时,`deb` / `push-deb` +按清单逐个做完;`push-docker` 把清单映射成 `linux/amd64,linux/arm64`。 +只做其中一个:`make -f makefile.builder deb ARCH=arm64`。 + +合法值只有 `amd64` 和 `arm64`(空格分隔)。不要默认写成双架构:本机编不出 +另一边时,只留一项。 + +```bash +make -s -f makefile.builder arches # 打印清单,例如 amd64 arm64 +make -f makefile.builder deb # 打出两个 .deb +make -f makefile.builder push-deb # 两个都上传(需你授权发布) +make -f makefile.builder push-docker # 一次 buildx 推两个平台 +``` + +产物: + +```text +dist/__amd64.deb +dist/__arm64.deb +``` + +`build` 配方必须用 `$(ARCH)`,否则两个包内容会一样。初始化时 Agent 按仓库里 +已有命令来写(Go 一般是 `GOOS=linux GOARCH=$(ARCH) go build …`),不另做一套 +运行时探测脚本。看不出交叉编法就不要写双架构。 + +`packaging/DEBIAN/control` 的 `Architecture:` 必须随 `$(ARCH)` 变,不要写死 +`amd64`。模板会先拷到 `dist/packaging-$(ARCH)` 再改这一行。 + +本机临时只推一个平台,可以在 `.env.builder` 设 `PLATFORMS=linux/amd64`,这是 +覆盖不是清单。项目清单始终以 `ARCHES` 为准。 + +存量 `makefile.builder` 没有 `ARCHES` 时行为与以前相同(默认 `amd64`, +`ARCH=arm64` 仍合法)。要改成双架构:加上面那一行,确认 `build` 吃 `$(ARCH)`, +再跑 `python3 -I -S /scripts/check.py . --ready`。 ## 版本号 @@ -119,11 +165,12 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` 用 builder 构建当前版本的 DEB 和镜像,先不要上传。 用 builder 把当前 commit 的产物发布到项目已配置的仓库。 用 builder 发布多平台 linux/amd64,linux/arm64 镜像。 +用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。 ``` ## Agent 会做什么 -1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,不改你已有的 `Makefile` 或 `.env`。 +1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 2. 检查:只跑 `scripts/check.py --ready`,不改文件。 3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。 4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 2861961..767c18c 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -27,8 +27,8 @@ description: >- 1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。 2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。 -3. 没有 `makefile.builder`:把 `/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它)。`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成 `push: push-deb push-docker`。不要改用户 Makefile。 -4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非用户明确要求按契约改。不要调用 create-makefile(版本规则冲突)。 +3. 没有 `makefile.builder`:把 `/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成 `push: push-deb push-docker`。不要改用户 Makefile。 +4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。 5. 没有 `.env.builder`:把 `/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。 6. 运行 `python3 -I -S /scripts/check.py --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。 @@ -59,7 +59,7 @@ DOCKER_REGISTRY=registry.example.com 1. 跑 `check.py `;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。 2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。 -3. 构建:`make -f makefile.builder build ARCH= VERSION=`;DEB 再 `make -f makefile.builder deb ARCH=<...>`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。 -4. DEB 上传前:`verify_deb.sh `。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。 -5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make。 +3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。 +4. DEB 上传前:对每个包 `verify_deb.sh `。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。 +5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。 6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 8d99f48..a89c26a 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -28,15 +28,16 @@ make -f makefile.builder | `help` | 分组列出全部目标;首屏含当前版本 | | `version` | 输出一行版本号,适合脚本消费 | | `clean` | 只删除明确、受限的构建产物目录 | -| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** | +| `build` | 编译/打包主产物;尊重 `ARCH`(单架构原语);**不得内含任何上传动作** | ### 条件目标 | 目标 | 适用 | 要求 | |------|------|------| -| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/__.deb`;只构建不上传 | +| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/__.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 | | `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** | -| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` | +| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 | +| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 | | `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` | | `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | @@ -48,17 +49,27 @@ make -f makefile.builder `push` 即可。 3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送" (`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。 + 未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为 + `linux/,…`。 4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。 +5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。 ## 2. 变量 | 变量 | 默认 | 说明 | |------|------|------| -| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 | +| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 | +| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 | | `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 | | `DIST_DIR` | `dist` | DEB 产物目录 | | `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 | +`ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。 + +`deb` 配方在打包前必须把 `packaging/DEBIAN/control`(或等价文件)的 `Architecture:` 写成当前 `$(ARCH)`,不要写死 `amd64`。推荐先拷到 `$(DIST_DIR)/packaging-$(ARCH)` 再改,避免弄脏源树。 + +`build` 配方必须使用 `$(ARCH)`(例如 `GOARCH=$(ARCH)`、`--platform=linux/$(ARCH)`)。配方若忽略 `ARCH`,双架构会打出两个文件名不同、内容却都是宿主机架构的包。Builder 不安装 qemu 或交叉编译器;看不出交叉编法时 `ARCHES` 只留本机一项。 + ### 版本号 `make -f makefile.builder version` 输出一行规范版本(无 `v` 前缀)。DEB 的 @@ -120,7 +131,9 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk | `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme | | `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 | | `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` | -| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` | +| `PLATFORMS` | 否 | 本机/本轮覆盖。未设置时由 `ARCHES` 映射为 `linux/,…`;再没有则 `linux/amd64`。不要把项目的架构清单只写在这里(`.env.builder` 不进 git) | + +`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。 配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。 `.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。 diff --git a/skills/builder/references/registry.md b/skills/builder/references/registry.md index fe36418..f5b627f 100644 --- a/skills/builder/references/registry.md +++ b/skills/builder/references/registry.md @@ -7,7 +7,7 @@ | Registry | 必须显式确定,例如 `registry.example.com` | | Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 | | Tag | 必须显式确定;优先使用版本号或 Git SHA | -| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` | +| Platform | 用户这轮显式值,否则 `.env.builder` 的 `PLATFORMS`,否则 `makefile.builder` 的 `ARCHES` 映射为 `linux/,…`,再否则 `linux/amd64` | | Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 | | Context | 默认当前项目根目录 | diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py index 8644cb0..6b8a411 100755 --- a/skills/builder/scripts/check.py +++ b/skills/builder/scripts/check.py @@ -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 ` 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: diff --git a/skills/builder/scripts/publish_docker.sh b/skills/builder/scripts/publish_docker.sh index 018db63..e7b2b99 100755 --- a/skills/builder/scripts/publish_docker.sh +++ b/skills/builder/scripts/publish_docker.sh @@ -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/,… ; 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 diff --git a/skills/builder/templates/env.builder b/skills/builder/templates/env.builder index 2c4d641..f7d5307 100644 --- a/skills/builder/templates/env.builder +++ b/skills/builder/templates/env.builder @@ -10,4 +10,4 @@ # DOCKER_REGISTRY= # DOCKER_REPOSITORY= # IMAGE_TAG= -# PLATFORMS= +# PLATFORMS= # optional override; project ARCHES lives in makefile.builder diff --git a/skills/builder/templates/makefile.builder b/skills/builder/templates/makefile.builder index ea7e137..8e5a98a 100644 --- a/skills/builder/templates/makefile.builder +++ b/skills/builder/templates/makefile.builder @@ -2,13 +2,26 @@ # 不要把这些目标写进用户的 Makefile / makefile。 # 初始化时按项目轨道删掉未使用的 deb / docker 段。 # 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。 +# build 必须尊重 $(ARCH)。要发双架构时把 arm64 写进 ARCHES,不要改 .env.builder。 # # include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。 -ARCH ?= amd64 -ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH)) -$(error ARCH must be amd64 or arm64) +ARCHES ?= amd64 +ARCH ?= $(firstword $(ARCHES)) +ifeq ($(strip $(ARCHES)),) +$(error ARCHES must not be empty) endif +ifneq ($(filter-out amd64 arm64,$(ARCHES)),) +$(error ARCHES entries must be amd64 or arm64) +endif +ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH)) +$(error ARCH must be one of: $(ARCHES)) +endif + +# Fan-out over ARCHES only when ARCH was not set on the CLI/environment. +_ARCH_FROM_FILE := $(filter file default undefined,$(origin ARCH)) +_MULTI_ARCH := $(and $(_ARCH_FROM_FILE),$(filter-out 1,$(words $(ARCHES)))) +_PUSH_DEB_ARCHES := $(if $(_ARCH_FROM_FILE),$(ARCHES),$(ARCH)) PROJECT_NAME ?= $(notdir $(CURDIR)) DIST_DIR ?= dist @@ -21,40 +34,62 @@ endif BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts -.PHONY: help version clean build deb docker push push-deb push-docker +.PHONY: help version arches clean build deb docker push push-deb push-docker help: @echo "$(PROJECT_NAME) $(VERSION)" + @echo " ARCHES=$(ARCHES) ARCH=$(ARCH)" @echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build" @echo " make -f makefile.builder version - 打印规范版本 / canonical version" + @echo " make -f makefile.builder arches - 打印架构清单 / print ARCHES" @echo " make -f makefile.builder clean - 删除 $(DIST_DIR)" - @echo " make -f makefile.builder deb - 打 DEB(只构建不上传)" + @echo " make -f makefile.builder deb - 打 DEB(按 ARCHES;只构建不上传)" @echo " make -f makefile.builder docker - 本地单平台镜像" @echo " make -f makefile.builder push / push-deb / push-docker" version: @echo $(VERSION) +arches: + @echo $(ARCHES) + clean: rm -rf $(DIST_DIR) -# 替换为项目真实构建命令。不得 curl/scp/docker push。 +# 替换为项目真实构建命令,且必须使用 $(ARCH)。不得 curl/scp/docker push。 build: mkdir -p $(DIST_DIR) @echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)" +ifdef _MULTI_ARCH +deb: + $(foreach a,$(ARCHES),$(MAKE) -f makefile.builder --no-print-directory deb ARCH=$(a) &&) true +else deb: build mkdir -p $(DIST_DIR) - dpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb + rm -rf $(DIST_DIR)/packaging-$(ARCH) + cp -a packaging $(DIST_DIR)/packaging-$(ARCH) + sed -e 's/^Architecture:.*/Architecture: $(ARCH)/' \ + $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control \ + > $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped + mv $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped \ + $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control + dpkg-deb --build $(DIST_DIR)/packaging-$(ARCH) $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb +endif docker: docker build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) . push-deb: - $(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb + $(BUILDER_SCRIPTS)/upload_deb.sh $(foreach a,$(_PUSH_DEB_ARCHES),$(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(a).deb) +ifdef _ARCH_FROM_FILE push-docker: $(BUILDER_SCRIPTS)/publish_docker.sh +else +push-docker: + PLATFORMS=linux/$(ARCH) $(BUILDER_SCRIPTS)/publish_docker.sh +endif # 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker push: push-deb diff --git a/tests/test_builder_check.py b/tests/test_builder_check.py index 74506c2..1b4d1fa 100644 --- a/tests/test_builder_check.py +++ b/tests/test_builder_check.py @@ -102,6 +102,13 @@ class BuilderCheckTests(unittest.TestCase): self.assertIn("makefile.builder", contract) self.assertIn(".env.builder", contract) self.assertIn("不要读取或改写用户 `.env`", contract) + self.assertIn("ARCHES", contract) + readme = (REPO_ROOT / "skills" / "builder" / "README.md").read_text( + encoding="utf-8" + ) + self.assertIn("ARCHES", readme) + self.assertIn("amd64 arm64", readme) + self.assertIn("多架构", readme) def test_no_makefile_without_ready_is_usage_error(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -233,6 +240,172 @@ class BuilderCheckTests(unittest.TestCase): self.assertNotIn("hijacked", text) self.assertNotIn(":latest", text) + def test_default_template_rejects_arch_outside_arches(self) -> None: + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + init_repo(project) + write_contract_makefile(project) + result = subprocess.run( + ["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"], + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0, result.stderr) + self.assertIn("amd64", result.stderr) + + def test_multi_arches_deb_and_push_fan_out(self) -> None: + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + init_repo(project) + write_contract_makefile(project) + makefile = project / "makefile.builder" + makefile.write_text( + makefile.read_text(encoding="utf-8").replace( + "ARCHES ?= amd64", "ARCHES ?= amd64 arm64" + ), + encoding="utf-8", + ) + code, text = run_check( + project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} + ) + self.assertEqual(code, 0, text) + self.assertIn("ARCHES: 'amd64 arm64'", text) + deb = subprocess.run( + ["make", "-C", str(project), "-f", "makefile.builder", "-n", "deb"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(deb.returncode, 0, deb.stderr) + combined = deb.stdout + deb.stderr + self.assertIn("ARCH=amd64", combined) + self.assertIn("ARCH=arm64", combined) + pinned = subprocess.run( + [ + "make", + "-C", + str(project), + "-f", + "makefile.builder", + "-n", + "deb", + "ARCH=arm64", + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(pinned.returncode, 0, pinned.stderr) + self.assertIn("_arm64.deb", pinned.stdout) + self.assertNotIn("_amd64.deb", pinned.stdout) + + def test_legacy_makefile_without_arches_still_passes(self) -> None: + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + init_repo(project) + (project / "makefile.builder").write_text( + "\n".join( + [ + "ARCH ?= amd64", + "ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))", + "$(error ARCH must be amd64 or arm64)", + "endif", + "PROJECT_NAME ?= $(notdir $(CURDIR))", + "DIST_DIR ?= dist", + f"include {SCRIPTS / 'version.mk'}", + f"BUILDER_SCRIPTS := {SCRIPTS}", + ".PHONY: help version clean build deb push", + "help:", + "\t@echo $(PROJECT_NAME) $(VERSION)", + "version:", + "\t@echo $(VERSION)", + "clean:", + "\trm -rf $(DIST_DIR)", + "build:", + "\tmkdir -p $(DIST_DIR)", + "\t@echo build linux/$(ARCH)", + "deb: build", + "\tmkdir -p $(DIST_DIR)", + "\tdpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb", + "push:", + "\t$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb", + "", + ] + ), + encoding="utf-8", + ) + code, text = run_check( + project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"} + ) + self.assertEqual(code, 0, text) + self.assertIn("arches target: absent", text) + result = subprocess.run( + ["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_publish_docker_derives_platforms_from_arches(self) -> None: + script = SCRIPTS / "publish_docker.sh" + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + init_repo(project) + write_contract_makefile(project) + makefile = project / "makefile.builder" + makefile.write_text( + makefile.read_text(encoding="utf-8").replace( + "ARCHES ?= amd64", "ARCHES ?= amd64 arm64" + ), + encoding="utf-8", + ) + (project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + env = os.environ.copy() + env["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent) + env.pop("PLATFORMS", None) + derived = subprocess.run( + [ + str(script), + "--registry", + "example.com", + "--repository", + "ns/foo", + "--tag", + "dry-run-tag", + "--dry-run", + ], + cwd=project, + capture_output=True, + text=True, + check=False, + env=env, + ) + self.assertEqual(derived.returncode, 0, derived.stdout + derived.stderr) + self.assertIn("Platform: linux/amd64,linux/arm64", derived.stdout) + env["PLATFORMS"] = "linux/amd64" + overridden = subprocess.run( + [ + str(script), + "--registry", + "example.com", + "--repository", + "ns/foo", + "--tag", + "dry-run-tag", + "--dry-run", + ], + cwd=project, + capture_output=True, + text=True, + check=False, + env=env, + ) + self.assertEqual(overridden.returncode, 0, overridden.stdout + overridden.stderr) + self.assertIn("Platform: linux/amd64", overridden.stdout) + self.assertNotIn("linux/arm64", overridden.stdout) + if __name__ == "__main__": unittest.main()