diff --git a/skills/builder/README.md b/skills/builder/README.md index a74b2af..e110f39 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -1,14 +1,15 @@ # builder -按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见 +按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布, +或把产物拷到目录 / 容器。规范本体见 [references/contract.md](references/contract.md),`scripts/check.py` 是契约的 可执行校验器。 ## 什么时候使用 - "用 builder 初始化这个项目" -- "帮我构建这个项目的 DEB / Docker 镜像" -- "把 1.2.3 发布到包仓库 / 镜像仓库" +- "帮我构建这个项目的 DEB / Docker 镜像 / APK" +- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser" - "检查这个项目的 makefile.builder 是否符合 builder 契约" - "看看项目现在的发布流程" @@ -50,6 +51,8 @@ DEB_SERVER_URL=https://deb.example.com DEB_REPOSITORY=main DEB_TOKEN= # 只放 .env.builder 或密钥系统,不进 git DOCKER_REGISTRY=registry.example.com +COPY_DEST=file-server:/srv +COPY_AS=latest.apk ``` 4. 日常发布就是两条命令: @@ -166,11 +169,12 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` 用 builder 把当前 commit 的产物发布到项目已配置的仓库。 用 builder 发布多平台 linux/amd64,linux/arm64 镜像。 用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。 +用 builder 打一份测试 APK 并拷到 File Browser。 ``` ## Agent 会做什么 -1. 初始化:探测 DEB / Docker 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 +1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 2. 检查:只跑 `scripts/check.py --ready`,不改文件。 3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。 4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。 @@ -196,6 +200,9 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回 | `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) | | `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 | | `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 | +| `scripts/copy_artifact.sh` | 把产物拷到 `COPY_DEST`(本地目录或 `container:/dir`) | +| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 | +| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 | 环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、 脏工作树策略等完整规则见 contract.md。 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 767c18c..00b492a 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -1,12 +1,13 @@ --- name: builder description: >- - 初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化 - builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker - 镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。 + 初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA, + 或把产物拷到目录/容器。触发词:初始化 builder、检查 makefile.builder、构建/发布 + deb、推送 apt、构建/发布 Docker 镜像、打 APK/IPA、拷到 File Browser、make push。 + 未授权不上传。Docker 仅用户点名镜像时才走。 --- -# Builder:DEB / Docker 构建发布 +# Builder:DEB / Docker / Android / iOS 构建发布 **make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。** 项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建 @@ -20,14 +21,14 @@ description: >- - 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。 - 检查契约或发布配置:执行「检查」。 - 构建、发布、上传:执行「工作流」。不要静默初始化。 -- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。 +- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。 - 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。 ## 初始化 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` 或递归调用它),且配方必须使用 `$(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。 +2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。 +3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [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 push-copy` 的子集。不要改用户 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 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。 @@ -47,6 +48,8 @@ DEB_SERVER_URL=https://deb.example.com DEB_REPOSITORY=main DEB_TOKEN= DOCKER_REGISTRY=registry.example.com +COPY_DEST=file-server:/srv +COPY_AS=latest.apk ``` ## 检查 @@ -61,5 +64,5 @@ DOCKER_REGISTRY=registry.example.com 2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。 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`。 +5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道要 `COPY_DEST`(本地目录或 `container:/dir`);`COPY_AS` 给第一份文件额外名字。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。 6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index a89c26a..feff94a 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -36,23 +36,29 @@ make -f makefile.builder |------|------|------| | `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/__.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 | | `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** | +| `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/__.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 | +| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/__.ipa`;只构建不上传 | | `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 | -| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 | -| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` | -| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | +| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 | +| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` | +| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh` | +| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | 规则: 1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。 有镜像的判据:项目根存在 `Dockerfile`。 -2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个 - `push` 即可。 + Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。 + 拷贝发布判据:配方调用 `copy_artifact.sh`。 +2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合; + 单轨道项目一个 `push` 即可。 3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送" (`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。 未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为 `linux/,…`。 -4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。 +4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点/`docker cp`。 5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。 + `build` 禁止 `docker cp`、`copy_artifact.sh`、curl/scp。 ## 2. 变量 @@ -61,7 +67,7 @@ make -f makefile.builder | `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 | | `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 | | `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 | -| `DIST_DIR` | `dist` | DEB 产物目录 | +| `DIST_DIR` | `dist` | 产物目录(deb / apk / ipa) | | `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 | `ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。 @@ -124,6 +130,15 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk | `DEB_REPOSITORY` | 是 | 目标仓库名 | | `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` | +### 拷贝轨道 + +| 变量 | 必填 | 说明 | +|---|---|---| +| `COPY_DEST` | 是 | 本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) | +| `COPY_AS` | 否 | 第一份文件的额外 basename,逗号或空格分隔 | + +`copy_artifact.sh FILE [FILE ...]` 把已构建文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。不读 `.env`。脏工作树默认拒绝,`ALLOW_UNCOMMITTED=1` 放行。 + ### Docker 轨道 | 变量 | 必填 | 说明 | diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py index 6b8a411..ae7477a 100755 --- a/skills/builder/scripts/check.py +++ b/skills/builder/scripts/check.py @@ -32,8 +32,8 @@ ARCH_VALUES = ("amd64", "arm64") REQUIRED_TARGETS = ("help", "version", "clean", "build") UPLOAD_TOKENS = ( "curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ", - "docker push", "buildx build --push", "buildx --push", "upload_deb.sh", - "publish_docker.sh", + "docker push", "docker cp", "buildx build --push", "buildx --push", + "upload_deb.sh", "publish_docker.sh", "copy_artifact.sh", ) SECRET_PATTERNS = ( re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE), @@ -41,7 +41,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") +VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh") BUILDER_MAKEFILE = "makefile.builder" BUILDER_ENV = ".env.builder" @@ -285,6 +285,45 @@ def detect_docker_project(project: Path) -> bool: return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists() +def detect_android_project(recipe_all: str) -> bool: + return ".apk" in recipe_all or ".aab" in recipe_all + + +def detect_ios_project(recipe_all: str) -> bool: + return ".ipa" in recipe_all + + +def detect_copy_project(recipe_all: str) -> bool: + return "copy_artifact.sh" in recipe_all + + +def check_mobile_recipe( + report: Report, project: Path, *, kind: str, number: int, ext: str +) -> None: + target = "build" + dry = run_make(project, "build") + text = chr(10).join(clean_make_output(dry)) + problems = [] + if dry.returncode != 0: + problems.append(f"`make -n {target}` failed: {dry.stderr.strip() or 'unknown error'}") + else: + if "dist/" not in text and "$(DIST_DIR)" not in text: + problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location") + wanted = (".apk", ".aab") if kind == "android" else (ext,) + if not any(item in text for item in wanted): + problems.append(f"recipe does not mention {' or '.join(wanted)}") + hits = [token for token in UPLOAD_TOKENS if token in text] + if hits: + problems.append("recipe contains upload commands: " + ", ".join(hits)) + if "rm -rf /" in text or "rm -rf ~" in text: + problems.append("recipe contains unrestricted rm -rf") + title = f"{kind} 目标产物形状与纯构建" + if problems: + report.add(FAIL, number, title, "\n".join(problems)) + else: + report.add(PASS, number, title, f"{target} writes {ext} under dist/") + + def check_docker_recipe(report: Report, project: Path) -> None: dry = run_make(project, "docker") text = chr(10).join(clean_make_output(dry)) @@ -316,24 +355,42 @@ SCRIPT_RESOLVE_SNIPPETS = tuple( def check_push_delegates( - report: Report, project: Path, dual_artifact: bool, deb_project: bool + report: Report, + project: Path, + *, + deb_project: bool, + docker_project: bool, + copy_project: bool, ) -> None: - targets = ("push-deb", "push-docker") if dual_artifact else ("push",) + tracks = [] + if deb_project: + tracks.append("deb") + if docker_project: + tracks.append("docker") + if copy_project: + tracks.append("copy") + multi = len(tracks) > 1 + targets = tuple(f"push-{t}" for t in tracks) if multi else ("push",) missing = [] inline = [] thin = [] + builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh") for target in targets: dry = run_make(project, target) if has_no_rule(dry): missing.append(target) continue text = chr(10).join(clean_make_output(dry)) - bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text] + bad_tokens = [ + token + for token in ("curl ", "scp ", "aptly ", "reprepro ", "docker cp") + if token in text + ] if bad_tokens: inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})") elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \ - and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \ - and "publish_docker.sh" not in text: + and "$(BUILDER_SCRIPT)" not in text \ + and not any(name in text for name in builder_names): inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)") else: thin.append(target) @@ -342,7 +399,7 @@ def check_push_delegates( problems.append("missing targets: " + ", ".join(missing)) problems.extend(inline) if deb_project: - push_deb_target = "push-deb" if dual_artifact else "push" + push_deb_target = "push-deb" if multi else "push" if push_deb_target not in missing: arches = read_arches(project) if arches and len(arches) > 1: @@ -385,6 +442,7 @@ def check_secrets_and_tags(report: Report, project: Path) -> None: DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY") DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",) +COPY_ENV_KEYS = ("COPY_DEST",) ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") @@ -416,7 +474,7 @@ def key_present(key: str, env_keys: set[str]) -> bool: def check_ready_toolchain( - report: Report, *, deb_project: bool, docker_project: bool + report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False ) -> None: lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"] problems = [] @@ -436,6 +494,11 @@ def check_ready_toolchain( problems.append("install dpkg-dev (or equivalent) to build .deb packages") else: lines.append("dpkg-deb: skipped (no deb track)") + if copy_project: + docker_ok = shutil.which("docker") is not None + lines.append( + f"docker (for container COPY_DEST): {'found' if docker_ok else 'optional; needed only for container:/dir dest'}" + ) report.add( FAIL if problems else PASS, 10, @@ -445,13 +508,18 @@ def check_ready_toolchain( def check_ready_env_keys( - report: Report, project: Path, *, deb_project: bool, docker_project: bool + report: Report, + project: Path, + *, + deb_project: bool, + docker_project: bool, + copy_project: bool = False, ) -> 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") + if not deb_project and not docker_project and not copy_project: + report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track") return if deb_project: for key in DEB_ENV_KEYS: @@ -469,6 +537,14 @@ def check_ready_env_keys( missing.append(key) else: lines.append("DOCKER_*: skipped (no docker track)") + if copy_project: + for key in COPY_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("COPY_*: skipped (no copy track)") if missing: lines.extend( [ @@ -585,7 +661,17 @@ def main(argv: list[str] | None = None) -> int: # Gather every recipe once via dry-running all known targets (best effort). recipe_all_parts = [] - for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"): + for target in ( + *REQUIRED_TARGETS, + "deb", + "docker", + "android", + "ios", + "push", + "push-deb", + "push-docker", + "push-copy", + ): result = run_make(project, target) if result.returncode == 0: recipe_all_parts.append(result.stdout) @@ -596,6 +682,9 @@ def main(argv: list[str] | None = None) -> int: built_deb: Path | None = None deb_project = detect_deb_project(recipe_all, project) docker_project = detect_docker_project(project) + android_project = detect_android_project(recipe_all) + ios_project = detect_ios_project(recipe_all) + copy_project = detect_copy_project(recipe_all) if present["build"]: check_arch_guard(report, project) @@ -621,16 +710,38 @@ def main(argv: list[str] | None = None) -> int: else: report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)") - dual = deb_project and docker_project - check_push_delegates(report, project, dual, deb_project) + if android_project: + check_mobile_recipe(report, project, kind="android", number=12, ext=".apk") + else: + report.add(SKIP, 12, "android 目标产物形状与纯构建", "(not an Android project)") + + if ios_project: + check_mobile_recipe(report, project, kind="ios", number=13, ext=".ipa") + else: + report.add(SKIP, 13, "ios 目标产物形状与纯构建", "(not an iOS project)") + + check_push_delegates( + report, + project, + deb_project=deb_project, + docker_project=docker_project, + copy_project=copy_project, + ) 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 + report, + deb_project=deb_project, + docker_project=docker_project, + copy_project=copy_project, ) check_ready_env_keys( - report, project, deb_project=deb_project, docker_project=docker_project + report, + project, + deb_project=deb_project, + docker_project=docker_project, + copy_project=copy_project, ) total_fail = report.failures diff --git a/skills/builder/scripts/copy_artifact.sh b/skills/builder/scripts/copy_artifact.sh new file mode 100755 index 0000000..ba8a5ec --- /dev/null +++ b/skills/builder/scripts/copy_artifact.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + COPY_DEST=/path/or/container:/dir \ + copy_artifact.sh FILE [FILE ...] + +Copy built artifacts to COPY_DEST. Dest is a local directory, or a Docker +copy target `container:/absolute/dir` (docker cp). Does not read `.env`. + +Environment (project root .env.builder; shell values win): + COPY_DEST Required. Local dir or container:/dir + COPY_AS Optional. Extra basenames for the first file + (comma or space separated) + ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree + +Options: + -d DEST Override COPY_DEST + -a NAMES Override COPY_AS + --dry-run Print the copies without executing + -h Show help + +The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override. +EOF +} + +project_root=$(git rev-parse --show-toplevel 2>/dev/null || true) + +if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; then + while IFS='=' read -r key value; do + key=${key%%[[:space:]]*} + [[ -z "$key" || "$key" == \#* ]] && continue + if [[ -n "${!key:-}" ]]; then + continue + fi + value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'} + [[ -z "$value" ]] && continue + printf -v "$key" '%s' "$value" + export "$key" + done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder") +fi + +dest=${COPY_DEST:-} +extra_as=${COPY_AS:-} +dry_run=false + +while (($#)); do + case "$1" in + -d) dest=$2; shift 2 ;; + -a) extra_as=$2; shift 2 ;; + --dry-run) dry_run=true; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + -*) echo "Error: unknown option: $1" >&2; usage >&2; exit 2 ;; + *) break ;; + esac +done + +if [[ -z "$dest" || $# -eq 0 ]]; then + echo "Error: COPY_DEST and at least one file are required." >&2 + echo "Set them in the environment or the project root .env.builder." >&2 + usage >&2 + exit 2 +fi + +if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then + if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then + echo "Error: working tree has uncommitted changes; refusing to publish." >&2 + echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2 + exit 3 + fi +fi + +is_docker_dest() { + local d=$1 + [[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \ + && [[ "$d" != *://* ]] \ + && [[ "$d" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*:/ ]] +} + +docker_container= +docker_dir= +local_dir= +if is_docker_dest "$dest"; then + docker_container=${dest%%:*} + docker_dir=${dest#*:} + if [[ -z "$docker_container" || -z "$docker_dir" || "$docker_dir" != /* ]]; then + echo "Error: docker dest must be container:/absolute/dir" >&2 + exit 2 + fi + docker_dir=${docker_dir%/} +else + local_dir=${dest%/} +fi + +split_as() { + local raw=$1 + raw=${raw//,/ } + # shellcheck disable=SC2086 + printf '%s\n' $raw +} + +copy_one() { + local src=$1 name=$2 + if [[ ! -f "$src" ]]; then + echo "Error: not a file: $src" >&2 + exit 2 + fi + if [[ -n "$docker_container" ]]; then + local target="${docker_container}:${docker_dir}/${name}" + if $dry_run; then + echo "docker cp $src $target" + return + fi + docker cp "$src" "$target" + docker exec "$docker_container" ls -l "${docker_dir}/${name}" >/dev/null + echo "copied $(basename -- "$src") -> $target" + else + local target="${local_dir}/${name}" + if $dry_run; then + echo "cp $src $target" + return + fi + mkdir -p "$local_dir" + cp -a "$src" "$target" + test -f "$target" + echo "copied $(basename -- "$src") -> $target" + fi +} + +first=true +success=0 +for src in "$@"; do + copy_one "$src" "$(basename -- "$src")" + if $first; then + first=false + if [[ -n "$extra_as" ]]; then + while IFS= read -r alias; do + [[ -z "$alias" ]] && continue + copy_one "$src" "$alias" + done < <(split_as "$extra_as") + fi + fi + if ! $dry_run && command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$src" + fi + success=$((success + 1)) +done + +echo "copy_artifact: $success file(s) to $dest" diff --git a/skills/builder/templates/env.builder b/skills/builder/templates/env.builder index f7d5307..76e41a0 100644 --- a/skills/builder/templates/env.builder +++ b/skills/builder/templates/env.builder @@ -11,3 +11,5 @@ # DOCKER_REPOSITORY= # IMAGE_TAG= # PLATFORMS= # optional override; project ARCHES lives in makefile.builder +# COPY_DEST= # local dir, or docker dest container:/absolute/dir +# COPY_AS= # optional extra basenames for the first file diff --git a/skills/builder/templates/makefile.android.builder b/skills/builder/templates/makefile.android.builder new file mode 100644 index 0000000..c84894b --- /dev/null +++ b/skills/builder/templates/makefile.android.builder @@ -0,0 +1,57 @@ +# Builder 契约文件。放到项目根,用 `make -f makefile.builder `。 +# Android 轨道:build 产出 dist/__.apk,push 走 copy_artifact.sh。 +# ARCHES 是构建机 ISA;APK 可含多 ABI。不要把这些目标写进用户 Makefile。 + +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 + +PROJECT_NAME ?= $(notdir $(CURDIR)) +DIST_DIR ?= dist + +ifneq ($(BUILDER_SKILL_DIR),) + include $(BUILDER_SKILL_DIR)/scripts/version.mk +else + include $(HOME)/.pouch/skills/builder/scripts/version.mk +endif + +BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts +APK_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).apk + +.PHONY: help version arches clean build android push + +help: + @echo "$(PROJECT_NAME) $(VERSION)" + @echo " ARCHES=$(ARCHES) ARCH=$(ARCH)" + @echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 Release APK" + @echo " make -f makefile.builder version - 打印规范版本" + @echo " make -f makefile.builder arches - 打印架构清单" + @echo " make -f makefile.builder clean - 删除 $(DIST_DIR)" + @echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST" + +version: + @echo $(VERSION) + +arches: + @echo $(ARCHES) + +clean: + rm -rf $(DIST_DIR) + +# 替换为项目真实 assemble 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。 +build: + mkdir -p $(DIST_DIR) + cp app-release.apk $(APK_OUT) + +android: build + +push: + $(BUILDER_SCRIPTS)/copy_artifact.sh $(APK_OUT) diff --git a/skills/builder/templates/makefile.ios.builder b/skills/builder/templates/makefile.ios.builder new file mode 100644 index 0000000..6add0bb --- /dev/null +++ b/skills/builder/templates/makefile.ios.builder @@ -0,0 +1,57 @@ +# Builder 契约文件。放到项目根,用 `make -f makefile.builder `。 +# iOS 轨道:build 产出 dist/__.ipa,push 走 copy_artifact.sh。 +# ARCHES 是构建机 ISA。不要把这些目标写进用户 Makefile。 + +ARCHES ?= arm64 +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 + +PROJECT_NAME ?= $(notdir $(CURDIR)) +DIST_DIR ?= dist + +ifneq ($(BUILDER_SKILL_DIR),) + include $(BUILDER_SKILL_DIR)/scripts/version.mk +else + include $(HOME)/.pouch/skills/builder/scripts/version.mk +endif + +BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts +IPA_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).ipa + +.PHONY: help version arches clean build ios push + +help: + @echo "$(PROJECT_NAME) $(VERSION)" + @echo " ARCHES=$(ARCHES) ARCH=$(ARCH)" + @echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 IPA" + @echo " make -f makefile.builder version - 打印规范版本" + @echo " make -f makefile.builder arches - 打印架构清单" + @echo " make -f makefile.builder clean - 删除 $(DIST_DIR)" + @echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST" + +version: + @echo $(VERSION) + +arches: + @echo $(ARCHES) + +clean: + rm -rf $(DIST_DIR) + +# 替换为项目真实 xcodebuild/export 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。 +build: + mkdir -p $(DIST_DIR) + cp app.ipa $(IPA_OUT) + +ios: build + +push: + $(BUILDER_SCRIPTS)/copy_artifact.sh $(IPA_OUT) diff --git a/tests/test_builder_check.py b/tests/test_builder_check.py index 1b4d1fa..2865642 100644 --- a/tests/test_builder_check.py +++ b/tests/test_builder_check.py @@ -109,6 +109,9 @@ class BuilderCheckTests(unittest.TestCase): self.assertIn("ARCHES", readme) self.assertIn("amd64 arm64", readme) self.assertIn("多架构", readme) + self.assertIn("copy_artifact.sh", skill) + self.assertIn("android", skill.lower()) + self.assertIn("COPY_DEST", contract) def test_no_makefile_without_ready_is_usage_error(self) -> None: with tempfile.TemporaryDirectory() as temp: @@ -406,6 +409,61 @@ class BuilderCheckTests(unittest.TestCase): self.assertIn("Platform: linux/amd64", overridden.stdout) self.assertNotIn("linux/arm64", overridden.stdout) + def test_android_copy_makefile_skips_missing_copy_dest(self) -> None: + template = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.android.builder" + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + init_repo(project) + text = template.read_text(encoding="utf-8") + text = text.replace( + "include $(HOME)/.pouch/skills/builder/scripts/version.mk", + f"include {SCRIPTS / 'version.mk'}", + ) + (project / "makefile.builder").write_text(text, encoding="utf-8") + code, out = run_check(project, "--ready") + self.assertEqual(code, 0, out) + self.assertIn("[PASS] 12. android 目标产物形状与纯构建", out) + self.assertIn("[SKIP] 13. ios 目标产物形状与纯构建", out) + self.assertIn("COPY_DEST: MISSING", out) + self.assertIn("blocks publish, not build", out) + + def test_inline_docker_cp_in_push_fails(self) -> None: + with tempfile.TemporaryDirectory() as temp: + project = Path(temp) + init_repo(project) + (project / "makefile.builder").write_text( + "\n".join( + [ + "ARCHES ?= amd64", + "ARCH ?= amd64", + "ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))", + "$(error ARCH must be one of: $(ARCHES))", + "endif", + "DIST_DIR ?= dist", + f"include {SCRIPTS / 'version.mk'}", + ".PHONY: help version arches clean build push", + "help:", + "\t@echo app $(VERSION)", + "version:", + "\t@echo $(VERSION)", + "arches:", + "\t@echo $(ARCHES)", + "clean:", + "\trm -rf $(DIST_DIR)", + "build:", + "\tmkdir -p $(DIST_DIR)/$(ARCH).apk", + "push:", + "\tdocker cp dist/app.apk file-server:/srv/app.apk", + "", + ] + ), + encoding="utf-8", + ) + code, out = run_check(project, "--ready") + self.assertEqual(code, 1, out) + self.assertIn("[FAIL] 7. push 仅委托 builder 脚本", out) + self.assertIn("docker cp", out) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_builder_copy.py b/tests/test_builder_copy.py new file mode 100644 index 0000000..9165314 --- /dev/null +++ b/tests/test_builder_copy.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "skills" / "builder" / "scripts" / "copy_artifact.sh" + + +def git(cwd: Path, *args: str) -> None: + result = subprocess.run( + ["git", *args], + cwd=cwd, + text=True, + capture_output=True, + check=False, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@example.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@example.com", + }, + ) + if result.returncode != 0: + raise AssertionError(f"git {args} failed: {result.stderr}") + + +class CopyArtifactTests(unittest.TestCase): + def test_local_copy_and_alias(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + git(root, "init", "-b", "main") + git(root, "config", "user.email", "test@example.com") + git(root, "config", "user.name", "Test") + src = root / "app.apk" + src.write_bytes(b"apk") + git(root, "add", "app.apk") + git(root, "commit", "-m", "apk") + dest = root / "out" + result = subprocess.run( + [str(SCRIPT), "-d", str(dest), "-a", "latest.apk", str(src)], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((dest / "app.apk").is_file()) + self.assertTrue((dest / "latest.apk").is_file()) + self.assertEqual((dest / "latest.apk").read_bytes(), b"apk") + self.assertIn("copy_artifact: 1 file(s)", result.stdout) + + def test_dirty_tree_requires_allow(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + git(root, "init", "-b", "main") + git(root, "config", "user.email", "test@example.com") + git(root, "config", "user.name", "Test") + src = root / "app.apk" + src.write_bytes(b"apk") + git(root, "add", "app.apk") + git(root, "commit", "-m", "apk") + src.write_bytes(b"changed") + dest = root / "out" + blocked = subprocess.run( + [str(SCRIPT), "-d", str(dest), str(src)], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(blocked.returncode, 3, blocked.stderr) + allowed = subprocess.run( + [str(SCRIPT), "-d", str(dest), str(src)], + cwd=root, + capture_output=True, + text=True, + check=False, + env={**os.environ, "ALLOW_UNCOMMITTED": "1"}, + ) + self.assertEqual(allowed.returncode, 0, allowed.stdout + allowed.stderr) + + def test_dry_run_docker_dest_prints_docker_cp(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + git(root, "init", "-b", "main") + git(root, "config", "user.email", "test@example.com") + git(root, "config", "user.name", "Test") + src = root / "app.apk" + src.write_bytes(b"apk") + git(root, "add", "app.apk") + git(root, "commit", "-m", "apk") + result = subprocess.run( + [ + str(SCRIPT), + "--dry-run", + "-d", + "file-server:/srv", + "-a", + "latest.apk", + str(src), + ], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("docker cp", result.stdout) + self.assertIn("file-server:/srv/app.apk", result.stdout) + self.assertIn("file-server:/srv/latest.apk", result.stdout) + + +if __name__ == "__main__": + unittest.main()