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
|
||||
|
||||
按统一契约完成项目的 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=<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。
|
||||
|
||||
+11
-8
@@ -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`:把 `<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。
|
||||
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`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
|
||||
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_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 <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、工作区状态和未完成项。
|
||||
|
||||
@@ -36,23 +36,29 @@ make -f makefile.builder <target>
|
||||
|------|------|------|
|
||||
| `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 |
|
||||
| `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,隐式单架构 |
|
||||
| `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/<arch>,…`。
|
||||
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 <target>
|
||||
| `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 轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|
||||
+129
-18
@@ -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
|
||||
|
||||
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=
|
||||
# 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
|
||||
|
||||
@@ -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("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()
|
||||
|
||||
@@ -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