feat(builder): Android/iOS 构建与 copy_artifact 发布
新增 APK/IPA 轨道和 copy_artifact.sh(本地目录或 container:/dir)。 push 必须委托该脚本,禁止在 makefile 里内联 docker cp。
This commit is contained in:
@@ -1,14 +1,15 @@
|
|||||||
# builder
|
# builder
|
||||||
|
|
||||||
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
|
按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布,
|
||||||
|
或把产物拷到目录 / 容器。规范本体见
|
||||||
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
||||||
可执行校验器。
|
可执行校验器。
|
||||||
|
|
||||||
## 什么时候使用
|
## 什么时候使用
|
||||||
|
|
||||||
- "用 builder 初始化这个项目"
|
- "用 builder 初始化这个项目"
|
||||||
- "帮我构建这个项目的 DEB / Docker 镜像"
|
- "帮我构建这个项目的 DEB / Docker 镜像 / APK"
|
||||||
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
|
||||||
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
|
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
|
||||||
- "看看项目现在的发布流程"
|
- "看看项目现在的发布流程"
|
||||||
|
|
||||||
@@ -50,6 +51,8 @@ DEB_SERVER_URL=https://deb.example.com
|
|||||||
DEB_REPOSITORY=main
|
DEB_REPOSITORY=main
|
||||||
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
|
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
|
||||||
DOCKER_REGISTRY=registry.example.com
|
DOCKER_REGISTRY=registry.example.com
|
||||||
|
COPY_DEST=file-server:/srv
|
||||||
|
COPY_AS=latest.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 日常发布就是两条命令:
|
4. 日常发布就是两条命令:
|
||||||
@@ -166,11 +169,12 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
|
|||||||
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
|
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
|
||||||
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
||||||
用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。
|
用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。
|
||||||
|
用 builder 打一份测试 APK 并拷到 File Browser。
|
||||||
```
|
```
|
||||||
|
|
||||||
## Agent 会做什么
|
## 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`,不改文件。
|
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
|
||||||
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
|
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
|
||||||
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
|
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
|
||||||
@@ -196,6 +200,9 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回
|
|||||||
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
||||||
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||||
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
| `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/`)、
|
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
|
||||||
脏工作树策略等完整规则见 contract.md。
|
脏工作树策略等完整规则见 contract.md。
|
||||||
|
|||||||
+11
-8
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: builder
|
name: builder
|
||||||
description: >-
|
description: >-
|
||||||
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化
|
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
|
||||||
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker
|
或把产物拷到目录/容器。触发词:初始化 builder、检查 makefile.builder、构建/发布
|
||||||
镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。
|
deb、推送 apt、构建/发布 Docker 镜像、打 APK/IPA、拷到 File Browser、make push。
|
||||||
|
未授权不上传。Docker 仅用户点名镜像时才走。
|
||||||
---
|
---
|
||||||
|
|
||||||
# Builder:DEB / Docker 构建发布
|
# Builder:DEB / Docker / Android / iOS 构建发布
|
||||||
|
|
||||||
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||||
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
|
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
|
||||||
@@ -20,14 +21,14 @@ description: >-
|
|||||||
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
|
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
|
||||||
- 检查契约或发布配置:执行「检查」。
|
- 检查契约或发布配置:执行「检查」。
|
||||||
- 构建、发布、上传:执行「工作流」。不要静默初始化。
|
- 构建、发布、上传:执行「工作流」。不要静默初始化。
|
||||||
- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
|
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
|
||||||
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
|
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
|
||||||
|
|
||||||
## 初始化
|
## 初始化
|
||||||
|
|
||||||
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
|
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
|
||||||
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。
|
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
|
||||||
3. 没有 `makefile.builder`:把 `<skill-dir>/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。
|
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(版本规则冲突)。
|
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
|
||||||
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
|
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
|
||||||
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
|
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
|
||||||
@@ -47,6 +48,8 @@ DEB_SERVER_URL=https://deb.example.com
|
|||||||
DEB_REPOSITORY=main
|
DEB_REPOSITORY=main
|
||||||
DEB_TOKEN=
|
DEB_TOKEN=
|
||||||
DOCKER_REGISTRY=registry.example.com
|
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` 并注明未提交修改。
|
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` 时先确认为受限构建目录。
|
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 <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
|
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `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、工作区状态和未完成项。
|
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
|
||||||
|
|||||||
@@ -36,23 +36,29 @@ make -f makefile.builder <target>
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 |
|
| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 |
|
||||||
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
|
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
|
||||||
|
| `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 |
|
||||||
|
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
|
||||||
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
|
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
|
||||||
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
|
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
|
||||||
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
|
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` |
|
||||||
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh` |
|
||||||
|
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
|
|
||||||
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||||
有镜像的判据:项目根存在 `Dockerfile`。
|
有镜像的判据:项目根存在 `Dockerfile`。
|
||||||
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
|
Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。
|
||||||
`push` 即可。
|
拷贝发布判据:配方调用 `copy_artifact.sh`。
|
||||||
|
2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合;
|
||||||
|
单轨道项目一个 `push` 即可。
|
||||||
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
|
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
|
||||||
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
|
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
|
||||||
未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为
|
未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为
|
||||||
`linux/<arch>,…`。
|
`linux/<arch>,…`。
|
||||||
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。
|
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点/`docker cp`。
|
||||||
5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。
|
5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。
|
||||||
|
`build` 禁止 `docker cp`、`copy_artifact.sh`、curl/scp。
|
||||||
|
|
||||||
## 2. 变量
|
## 2. 变量
|
||||||
|
|
||||||
@@ -61,7 +67,7 @@ make -f makefile.builder <target>
|
|||||||
| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
|
| `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
|
||||||
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
|
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
|
||||||
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
|
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
|
||||||
| `DIST_DIR` | `dist` | DEB 产物目录 |
|
| `DIST_DIR` | `dist` | 产物目录(deb / apk / ipa) |
|
||||||
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
|
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
|
||||||
|
|
||||||
`ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。
|
`ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。
|
||||||
@@ -124,6 +130,15 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
|
|||||||
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
|
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
|
||||||
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
|
| `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 轨道
|
### Docker 轨道
|
||||||
|
|
||||||
| 变量 | 必填 | 说明 |
|
| 变量 | 必填 | 说明 |
|
||||||
|
|||||||
+129
-18
@@ -32,8 +32,8 @@ ARCH_VALUES = ("amd64", "arm64")
|
|||||||
REQUIRED_TARGETS = ("help", "version", "clean", "build")
|
REQUIRED_TARGETS = ("help", "version", "clean", "build")
|
||||||
UPLOAD_TOKENS = (
|
UPLOAD_TOKENS = (
|
||||||
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
|
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
|
||||||
"docker push", "buildx build --push", "buildx --push", "upload_deb.sh",
|
"docker push", "docker cp", "buildx build --push", "buildx --push",
|
||||||
"publish_docker.sh",
|
"upload_deb.sh", "publish_docker.sh", "copy_artifact.sh",
|
||||||
)
|
)
|
||||||
SECRET_PATTERNS = (
|
SECRET_PATTERNS = (
|
||||||
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
|
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")
|
FLOATING_TAGS = (":latest", ":stable")
|
||||||
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
|
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_MAKEFILE = "makefile.builder"
|
||||||
BUILDER_ENV = ".env.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()
|
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:
|
def check_docker_recipe(report: Report, project: Path) -> None:
|
||||||
dry = run_make(project, "docker")
|
dry = run_make(project, "docker")
|
||||||
text = chr(10).join(clean_make_output(dry))
|
text = chr(10).join(clean_make_output(dry))
|
||||||
@@ -316,24 +355,42 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
|
|||||||
|
|
||||||
|
|
||||||
def check_push_delegates(
|
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:
|
) -> 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 = []
|
missing = []
|
||||||
inline = []
|
inline = []
|
||||||
thin = []
|
thin = []
|
||||||
|
builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
|
||||||
for target in targets:
|
for target in targets:
|
||||||
dry = run_make(project, target)
|
dry = run_make(project, target)
|
||||||
if has_no_rule(dry):
|
if has_no_rule(dry):
|
||||||
missing.append(target)
|
missing.append(target)
|
||||||
continue
|
continue
|
||||||
text = chr(10).join(clean_make_output(dry))
|
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:
|
if bad_tokens:
|
||||||
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
|
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
|
||||||
elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \
|
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 "$(BUILDER_SCRIPT)" not in text \
|
||||||
and "publish_docker.sh" 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)")
|
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
|
||||||
else:
|
else:
|
||||||
thin.append(target)
|
thin.append(target)
|
||||||
@@ -342,7 +399,7 @@ def check_push_delegates(
|
|||||||
problems.append("missing targets: " + ", ".join(missing))
|
problems.append("missing targets: " + ", ".join(missing))
|
||||||
problems.extend(inline)
|
problems.extend(inline)
|
||||||
if deb_project:
|
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:
|
if push_deb_target not in missing:
|
||||||
arches = read_arches(project)
|
arches = read_arches(project)
|
||||||
if arches and len(arches) > 1:
|
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")
|
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
|
||||||
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
|
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
|
||||||
|
COPY_ENV_KEYS = ("COPY_DEST",)
|
||||||
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
|
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(
|
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:
|
) -> None:
|
||||||
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
|
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
|
||||||
problems = []
|
problems = []
|
||||||
@@ -436,6 +494,11 @@ def check_ready_toolchain(
|
|||||||
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
|
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
|
||||||
else:
|
else:
|
||||||
lines.append("dpkg-deb: skipped (no deb track)")
|
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(
|
report.add(
|
||||||
FAIL if problems else PASS,
|
FAIL if problems else PASS,
|
||||||
10,
|
10,
|
||||||
@@ -445,13 +508,18 @@ def check_ready_toolchain(
|
|||||||
|
|
||||||
|
|
||||||
def check_ready_env_keys(
|
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:
|
) -> None:
|
||||||
env_keys = env_file_keys(project)
|
env_keys = env_file_keys(project)
|
||||||
lines = []
|
lines = []
|
||||||
missing: list[str] = []
|
missing: list[str] = []
|
||||||
if not deb_project and not docker_project:
|
if not deb_project and not docker_project and not copy_project:
|
||||||
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track")
|
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track")
|
||||||
return
|
return
|
||||||
if deb_project:
|
if deb_project:
|
||||||
for key in DEB_ENV_KEYS:
|
for key in DEB_ENV_KEYS:
|
||||||
@@ -469,6 +537,14 @@ def check_ready_env_keys(
|
|||||||
missing.append(key)
|
missing.append(key)
|
||||||
else:
|
else:
|
||||||
lines.append("DOCKER_*: skipped (no docker track)")
|
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:
|
if missing:
|
||||||
lines.extend(
|
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).
|
# Gather every recipe once via dry-running all known targets (best effort).
|
||||||
recipe_all_parts = []
|
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)
|
result = run_make(project, target)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
recipe_all_parts.append(result.stdout)
|
recipe_all_parts.append(result.stdout)
|
||||||
@@ -596,6 +682,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
built_deb: Path | None = None
|
built_deb: Path | None = None
|
||||||
deb_project = detect_deb_project(recipe_all, project)
|
deb_project = detect_deb_project(recipe_all, project)
|
||||||
docker_project = detect_docker_project(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"]:
|
if present["build"]:
|
||||||
check_arch_guard(report, project)
|
check_arch_guard(report, project)
|
||||||
@@ -621,16 +710,38 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
else:
|
else:
|
||||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
||||||
|
|
||||||
dual = deb_project and docker_project
|
if android_project:
|
||||||
check_push_delegates(report, project, dual, deb_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_secrets_and_tags(report, project)
|
||||||
check_script_paths(report)
|
check_script_paths(report)
|
||||||
if args.ready:
|
if args.ready:
|
||||||
check_ready_toolchain(
|
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(
|
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
|
total_fail = report.failures
|
||||||
|
|||||||
Executable
+152
@@ -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"
|
||||||
@@ -11,3 +11,5 @@
|
|||||||
# DOCKER_REPOSITORY=
|
# DOCKER_REPOSITORY=
|
||||||
# IMAGE_TAG=
|
# IMAGE_TAG=
|
||||||
# PLATFORMS= # optional override; project ARCHES lives in makefile.builder
|
# 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
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
|
||||||
|
# Android 轨道:build 产出 dist/<name>_<version>_<arch>.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)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
|
||||||
|
# iOS 轨道:build 产出 dist/<name>_<version>_<arch>.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)
|
||||||
@@ -109,6 +109,9 @@ class BuilderCheckTests(unittest.TestCase):
|
|||||||
self.assertIn("ARCHES", readme)
|
self.assertIn("ARCHES", readme)
|
||||||
self.assertIn("amd64 arm64", readme)
|
self.assertIn("amd64 arm64", readme)
|
||||||
self.assertIn("多架构", 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:
|
def test_no_makefile_without_ready_is_usage_error(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temp:
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
@@ -406,6 +409,61 @@ class BuilderCheckTests(unittest.TestCase):
|
|||||||
self.assertIn("Platform: linux/amd64", overridden.stdout)
|
self.assertIn("Platform: linux/amd64", overridden.stdout)
|
||||||
self.assertNotIn("linux/arm64", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user