2 Commits

Author SHA1 Message Date
laily 9ba9b3da0f feat(builder): query CI via project status script
When BUILD_VENUE=ci, init confirms tea/glab/gh/curl (or a custom
command) and writes .pouch/builder/ci-status.sh. wait_ci.sh only
polls that script and no longer guesses forge CLIs.
2026-10-15 09:56:51 +08:00
laily 0c9a530390 chore(tester): drop HX_TEST_HOST alias
HOST is the only accepted test-service address from env, local.yaml, or catalog vars.
2026-10-15 09:44:23 +08:00
21 changed files with 746 additions and 603 deletions
+6 -4
View File
@@ -47,7 +47,7 @@ pouch add builder -g # 全局
并负责上层编排。 并负责上层编排。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`, 1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE`。 以及 `obtain` / `venue`。变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME/BUILD_VENUE/CI_STATUS_SCRIPT`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。 `VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。 多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。 2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
@@ -66,7 +66,7 @@ COPY_AS=latest.apk
# COPY_SCRIPT= # 仅外部/临时脚本覆盖;项目内脚本写 makefile.builder # COPY_SCRIPT= # 仅外部/临时脚本覆盖;项目内脚本写 makefile.builder
``` ```
`BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。`make -f makefile.builder obtain`(或 `push-docker`)等待该 commit 的流水线并 inspect registry,不在本机 `docker push`。 `BUILD_VENUE=ci` 写在 `makefile.builder`:产物由 CI 构建。初始化时确认查询命令(tea / glab / gh / curl),写成 `.pouch/builder/ci-status.sh`。`make -f makefile.builder obtain`(或 `push-docker`)执行该脚本等待流水线并 inspect registry,不在本机 `docker push`。
4. 日常发布就是两条命令: 4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、 `make -f makefile.builder deb && make -f makefile.builder push-deb`、
@@ -195,7 +195,7 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
## Agent 会做什么 ## Agent 会做什么
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。 1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,确认 `BUILD_VENUE`;选 ci 时生成 `.pouch/builder/ci-status.sh`。按仓库证据填 `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. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
@@ -223,7 +223,9 @@ Docker 只有你点名镜像时才走。token 不会写进输出、提交或回
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 | | `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` | | `scripts/copy_artifact.sh` | 按 `COPY_SCRIPT` 分发产物,未配置时拷到 `COPY_DEST` |
| `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect | | `scripts/obtain.sh` | 对精确 SHA 拿到产物;`BUILD_VENUE=ci` 时等流水线再 inspect |
| `scripts/wait_ci.sh` | 只等该 SHA 的 GitLab/GitHub/Gitea 流水线,不触发;Gitea 读取 workflow jobs | | `scripts/wait_ci.sh` | 轮询项目 `CI_STATUS_SCRIPT`,只等该 SHA,不触发 |
| `scripts/init_ci_status.sh` | 初始化时把 tea/glab/gh/curl 模板拷到 `.pouch/builder/ci-status.sh` |
| `templates/ci-status/` | tea / glab / gh / curl 查询脚本模板;契约见 [ci-status.md](references/ci-status.md) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 | | `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 | | `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
+3 -2
View File
@@ -47,7 +47,8 @@ worktree,也可以指向已配对的远程 Mac/其它构建机;不要把远
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。 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. 确认 `BUILD_VENUE`:`local` 或 `ci`,不要猜。选 `ci` 时读 [ci-status.md](references/ci-status.md),确认查询命令(`tea` / `glab` / `gh` / `curl`),执行 `<skill-dir>/scripts/init_ci_status.sh --root <project-dir> --kind <kind>`,在 `makefile.builder` 写入 `BUILD_VENUE=ci` 和 `CI_STATUS_SCRIPT=.pouch/builder/ci-status.sh`。用户给了自定义查询命令时,把命令写进同一份脚本并保持 `STATUS=` 输出契约。
7. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
```text ```text
## builder 初始化:完成 | 部分完成 | 阻塞 ## builder 初始化:完成 | 部分完成 | 阻塞
@@ -90,6 +91,6 @@ COPY_AS=latest.apk
按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。 按 [obtain.md](references/obtain.md) 执行 `scripts/obtain.sh --sha <commit>`。
- `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。 - `BUILD_VENUE=local`:当前 HEAD 必须是该 SHA,跑 makefile 的 docker/deb。
- `BUILD_VENUE=ci`:等该 SHA 的 GitLab/GitHub/Gitea 流水线,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。 - `BUILD_VENUE=ci`:`wait_ci.sh` 轮询项目的 `CI_STATUS_SCRIPT`,再 inspect `version.sh --docker` 的镜像 tag。不触发流水线,不本机 push。缺脚本则停下,回到初始化。
- 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。 - 失败(无流水线、job 失败、digest 不对)必须原样返回给调用方,不得标 `artifact_built`。
- 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。 - 合进默认分支若项目不跑 CI,这是正确失败;预览用 `dev/*`,正式用 tag。
+55
View File
@@ -0,0 +1,55 @@
# CI 状态查询脚本
`BUILD_VENUE=ci` 时,流水线状态由**项目脚本**查询。builder 只轮询这份脚本,
不在 skill 里猜 gh / glab / tea / curl。
## 初始化
选 `ci` 后确认查询方式,再生成脚本:
```bash
<skill-dir>/scripts/init_ci_status.sh --root <project> --kind tea|glab|gh|curl
```
写出 `.pouch/builder/ci-status.sh`(可执行、进 git)。`makefile.builder` 写:
```makefile
BUILD_VENUE ?= ci
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
```
相对路径只能在 `.pouch/builder/` 下。凭据仍放 `.env.builder`(如 `GITEA_TOKEN`),
不要写进脚本参数或日志。
用户给了自定义命令时,把该命令写进同一份脚本,输出必须符合下方契约。
## 调用
```bash
.pouch/builder/ci-status.sh --sha <40-character-sha>
```
只查询、不触发流水线。`BUILDER_PROJECT_ROOT` 是项目根。
## 输出
退出 0 且 stdout 含 `STATUS=` 键值(可多行或同一行):
| STATUS | 含义 |
| --- | --- |
| `empty` | 该 SHA 还没有流水线 |
| `running` | 已出现、尚未结束 |
| `success` | 该 SHA 的流水线成功 |
| `failed` | 失败、取消或超时 |
| `error` | 查询本身失败(鉴权、CLI 缺失) |
可选:`ID=`、`PIPELINE=`(URL)、`JOBS=`。
| 退出码 | 含义 |
| --- | --- |
| 0 | 已查出状态(含 empty/running/failed) |
| 2 | 用法错误 |
| 3 | 无法查询(缺 CLI、未登录、被拒绝) |
`wait_ci.sh` 按这份输出轮询:`empty` 超过出现窗口记 `no_pipeline`;
`success` 才继续 obtain;`failed` 原样失败。
+4 -3
View File
@@ -52,7 +52,7 @@ make -f makefile.builder <target>
| `push-docker` | 同时有多种发布轨道的项目 | `BUILD_VENUE=local` 时仅调 `publish_docker.sh`;`ci` 时仅调 `obtain.sh` | | `push-docker` | 同时有多种发布轨道的项目 | `BUILD_VENUE=local` 时仅调 `publish_docker.sh`;`ci` 时仅调 `obtain.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 | | `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh`;由它按配置选择内置拷贝或自定义脚本 |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | | `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
| `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时等待该 SHA 的 forge 流水线再 inspect,不在本机 `docker push` | | `obtain` | 有产物的项目 | 调用 `obtain.sh`,对精确 commit SHA 拿到已存在的产物。`BUILD_VENUE=ci` 时轮询 `CI_STATUS_SCRIPT` 再 inspect,不在本机 `docker push` |
| `venue` | 可选(新模板必备) | 打印一行 `BUILD_VENUE`(`local` 或 `ci`) | | `venue` | 可选(新模板必备) | 打印一行 `BUILD_VENUE`(`local` 或 `ci`) |
规则: 规则:
@@ -82,7 +82,8 @@ make -f makefile.builder <target>
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
|------|------|------| |------|------|------|
| `BUILD_VENUE` | `local` | 产物在哪构建:`local` 本机 make;`ci` 由 GitLab/GitHub/Gitea 流水线构建。这不是新轨道。写在 `makefile.builder` 并提交。`ci` 时 `push-docker` 委托 `obtain.sh`(等流水线 + inspect),不跑 `publish_docker.sh` | | `BUILD_VENUE` | `local` | 产物在哪构建:`local` 本机 make;`ci` 由项目 CI 流水线构建。这不是新轨道。写在 `makefile.builder` 并提交。`ci` 时 `push-docker` 委托 `obtain.sh`(等流水线 + inspect),不跑 `publish_docker.sh` |
| `CI_STATUS_SCRIPT` | `.pouch/builder/ci-status.sh` | `BUILD_VENUE=ci` 时必填。项目内查询该 SHA 流水线状态的可执行脚本,相对路径只能在 `.pouch/builder/`。初始化时按 tea / glab / gh / curl 生成,契约见 [ci-status.md](ci-status.md) |
| `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` 取全局最新 |
@@ -189,7 +190,7 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。 `PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。
`BUILD_VENUE=ci` 时 Docker 发布路径是 `obtain.sh`:只认 **该 commit SHA** 的流水线。没有流水线、job 失败、超时、或 Harbor inspect 不到 `version.sh --docker` 算出的 tag,一律非零退出。不触发流水线。GitHub 用 `gh`、GitLab 用 `glab`、Gitea 用 `curl` 读取 Actions API;Gitea 会再次校验 `head_sha` 并读取 workflow jobs。合进默认分支若项目声明不构建,这是正确失败;调用方应打 tag 或推送到项目会构建的 ref(例如 `dev/*`)。CI job 若已经 `docker push`,obtain 成功即视为产物已在 registry,不要再本机 push 一次。 `BUILD_VENUE=ci` 时 Docker 发布路径是 `obtain.sh`:`wait_ci.sh` 只轮询 `CI_STATUS_SCRIPT`,只认 **该 commit SHA**。没有流水线、job 失败、超时、或 Harbor inspect 不到 `version.sh --docker` 算出的 tag,一律非零退出。不触发流水线。查询命令在初始化时写成项目脚本,运行时不再猜测 gh / glab / tea / curl。合进默认分支若项目声明不构建,这是正确失败;调用方应打 tag 或推送到项目会构建的 ref(例如 `dev/*`)。CI job 若已经 `docker push`,obtain 成功即视为产物已在 registry,不要再本机 push 一次。
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。 配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。 `.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
+2 -2
View File
@@ -36,13 +36,13 @@ TAG=1.4.2
| 0 | 产物已核验 | `artifact_built` 完成 | | 0 | 产物已核验 | `artifact_built` 完成 |
| 1 | 该 SHA 无流水线、job 失败、超时、inspect 失败 | 标 `failed`,不得 publish/deploy | | 1 | 该 SHA 无流水线、job 失败、超时、inspect 失败 | 标 `failed`,不得 publish/deploy |
| 2 | 用法或配置错误 | 标 `blocked` | | 2 | 用法或配置错误 | 标 `blocked` |
| 3 | glab/gh/curl 缺失或读不到该项目 | 停;可用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 | | 3 | 项目 CI 状态脚本无法查询 | 停;回到初始化补 `CI_STATUS_SCRIPT`,或用**已打开且已登录**的同 host Orca tab 查流水线。禁止新开未登录页 |
## `ci` 规则 ## `ci` 规则
- 只认该 SHA 的 pipeline,不认默认分支「最新一次绿」。 - 只认该 SHA 的 pipeline,不认默认分支「最新一次绿」。
- 不触发流水线。 - 不触发流水线。
- Gitea 使用 Actions REST API 的 `head_sha` 精确过滤,并读取每个 workflow run 的 jobs;私有仓库可在 `.env.builder` 配置 `GITEA_TOKEN`。自定义 Gitea host 可设置 `BUILDER_CI_FORGE=gitea`,API 前缀默认是 `https://<origin-host>/api/v1`,也可用 `GITEA_API_URL` 覆盖。 - 查询命令是项目 `.pouch/builder/ci-status.sh`(`CI_STATUS_SCRIPT`),初始化时按 tea / glab / gh / curl 写好。凭据仍放 `.env.builder`。
- job 变绿不够,docker 轨道必须 inspect 到 digest。 - job 变绿不够,docker 轨道必须 inspect 到 digest。
- 合进 `main` 若项目不构建,`no_pipeline` 是正确失败。预览走项目会构建的 ref(如 `dev/*`),正式走 tag。 - 合进 `main` 若项目不构建,`no_pipeline` 是正确失败。预览走项目会构建的 ref(如 `dev/*`),正式走 tag。
+49 -52
View File
@@ -551,8 +551,8 @@ def configured_value(
return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "") return os.environ.get(key, "") or env_values.get(key, "") or (make_values or {}).get(key, "")
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]: def resolve_builder_local_script(project: Path, script: str) -> tuple[Path | None, str | None]:
"""Resolve COPY_SCRIPT without allowing arbitrary project-relative paths.""" """Resolve a project script; relative paths must stay under .pouch/builder/."""
configured = Path(script).expanduser() configured = Path(script).expanduser()
if configured.is_absolute(): if configured.is_absolute():
return configured, None return configured, None
@@ -561,10 +561,17 @@ def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str |
allowed_root = (project / ".pouch" / "builder").resolve() allowed_root = (project / ".pouch" / "builder").resolve()
resolved.relative_to(allowed_root) resolved.relative_to(allowed_root)
except ValueError: except ValueError:
return None, "relative COPY_SCRIPT must be under .pouch/builder/" return None, "relative script must be under .pouch/builder/"
return resolved, None return resolved, None
def resolve_copy_script(project: Path, script: str) -> tuple[Path | None, str | None]:
path, error = resolve_builder_local_script(project, script)
if error:
return None, "relative COPY_SCRIPT must be under .pouch/builder/"
return path, None
def check_copy_script( def check_copy_script(
report: Report, report: Report,
project: Path, project: Path,
@@ -591,6 +598,9 @@ def check_copy_script(
def read_venue(project: Path) -> str: def read_venue(project: Path) -> str:
env = (os.environ.get("BUILD_VENUE") or "").strip()
if env in ("local", "ci"):
return env
result = run_make(project, "venue") result = run_make(project, "venue")
if not has_no_rule(result): if not has_no_rule(result):
echoed = parse_dry_echo(result) echoed = parse_dry_echo(result)
@@ -604,34 +614,35 @@ def read_venue(project: Path) -> str:
return raw or "local" return raw or "local"
def origin_forge(project: Path) -> str: def check_ci_status_script(
result = subprocess.run( report: Report,
["git", "-C", str(project), "remote", "get-url", "origin"], project: Path,
capture_output=True, env_values: dict[str, str],
text=True, make_values: dict[str, str],
check=False, ) -> None:
if read_venue(project) != "ci":
report.add(SKIP, 15, "CI_STATUS_SCRIPT 可执行", "BUILD_VENUE is not ci")
return
script = configured_value("CI_STATUS_SCRIPT", env_values, make_values)
problems = []
if not script:
problems.append("BUILD_VENUE=ci requires CI_STATUS_SCRIPT")
path = None
resolve_error = None
else:
path, resolve_error = resolve_builder_local_script(project, script)
if resolve_error:
problems.append(resolve_error)
elif path is not None and not path.is_file():
problems.append("CI_STATUS_SCRIPT does not point to a file")
elif path is not None and not os.access(path, os.X_OK):
problems.append("CI_STATUS_SCRIPT is not executable")
report.add(
FAIL if problems else PASS,
15,
"CI_STATUS_SCRIPT 可执行",
"\n".join(problems) if problems else f"ci status script: {script}",
) )
url = (result.stdout or "").strip()
configured = env_file_values(project).get("BUILDER_CI_FORGE", "").strip().lower()
forced = (os.environ.get("BUILDER_CI_FORGE") or configured).strip().lower()
if forced in ("github", "gitlab", "gitea"):
return forced
if "github.com" in url:
return "github"
host_match = re.search(r"(?:https?://|ssh://git@|git@)([^/:]+)", url)
host = host_match.group(1).lower() if host_match else ""
builder_keys = env_file_keys(project)
if (
"gitea" in host
or "GITEA_TOKEN" in builder_keys
or "GITEA_API_URL" in builder_keys
or os.environ.get("GITEA_TOKEN")
or os.environ.get("GITEA_API_URL")
):
return "gitea"
if url:
return "gitlab"
return ""
def check_ready_toolchain( def check_ready_toolchain(
@@ -655,28 +666,7 @@ def check_ready_toolchain(
lines.append( lines.append(
f"docker: {'found' if docker_ok else 'optional for inspect; obtain fails without it'}" f"docker: {'found' if docker_ok else 'optional for inspect; obtain fails without it'}"
) )
forge = origin_forge(project) if project is not None else "" lines.append("CI status: project CI_STATUS_SCRIPT (tea/glab/gh/curl)")
if forge == "github":
gh_ok = shutil.which("gh") is not None
lines.append(f"gh: {'found' if gh_ok else 'MISSING (blocks ci venue)'}")
if not gh_ok:
problems.append("install gh and authenticate for this GitHub repo")
elif forge == "gitlab":
glab_ok = shutil.which("glab") is not None
lines.append(f"glab: {'found' if glab_ok else 'MISSING (blocks ci venue)'}")
if not glab_ok:
problems.append("install glab and authenticate for this GitLab project")
elif forge == "gitea":
curl_ok = shutil.which("curl") is not None
lines.append(f"curl: {'found' if curl_ok else 'MISSING (blocks ci venue)'}")
lines.append("Gitea API: /api/v1; GITEA_TOKEN optional for public repositories")
if not curl_ok:
problems.append("install curl to read Gitea Actions for this repository")
else:
glab_ok = shutil.which("glab") is not None
lines.append(f"glab: {'found' if glab_ok else 'MISSING (blocks ci venue)'}")
if not glab_ok:
problems.append("install glab and authenticate for this GitLab project")
elif docker_project: elif docker_project:
docker_ok = shutil.which("docker") is not None docker_ok = shutil.which("docker") is not None
lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}") lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}")
@@ -867,6 +857,7 @@ def main(argv: list[str] | None = None) -> int:
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
) )
check_ci_status_script(report, project, env_file_values(project), makefile_values(project) if (project / BUILDER_MAKEFILE).is_file() else {})
print() print()
print(f"RESULT: FAILED ({report.failures} check(s) failed)") print(f"RESULT: FAILED ({report.failures} check(s) failed)")
return 1 return 1
@@ -963,6 +954,12 @@ def main(argv: list[str] | None = None) -> int:
docker_project=docker_project, docker_project=docker_project,
copy_project=copy_project, copy_project=copy_project,
) )
check_ci_status_script(
report,
project,
env_file_values(project),
makefile_values(project),
)
total_fail = report.failures total_fail = report.failures
print() print()
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
# Copy a CI status template into the project.
# Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl
usage() {
echo "Usage: init_ci_status.sh --root DIR --kind tea|glab|gh|curl" >&2
}
root=
kind=
while (($#)); do
case "$1" in
--root) [[ $# -ge 2 ]] || { usage; exit 2; }; root=$2; shift 2 ;;
--kind) [[ $# -ge 2 ]] || { usage; exit 2; }; kind=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage; exit 2 ;;
esac
done
if [[ -z "$root" || -z "$kind" ]]; then
usage
exit 2
fi
case "$kind" in
tea|glab|gh|curl) ;;
*) echo "Error: --kind must be tea, glab, gh, or curl" >&2; exit 2 ;;
esac
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
src=$script_dir/../templates/ci-status/${kind}.sh
if [[ ! -f "$src" ]]; then
echo "Error: missing template: $src" >&2
exit 2
fi
dest=$root/.pouch/builder/ci-status.sh
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
chmod +x "$dest"
echo "$dest"
+2 -3
View File
@@ -10,14 +10,13 @@ set -euo pipefail
# #
# BUILD_VENUE (makefile.builder, default local): # BUILD_VENUE (makefile.builder, default local):
# local HEAD must be SHA; run `make -f makefile.builder docker` or `deb` # local HEAD must be SHA; run `make -f makefile.builder docker` or `deb`
# ci wait for the forge pipeline of this SHA, then inspect the image # ci wait for this SHA via the project CI_STATUS_SCRIPT, then inspect the image
# (GitHub Actions, GitLab CI, or Gitea Actions)
# #
# Exit: # Exit:
# 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE= # 0 artifact verified; prints ARTIFACT= / DIGEST= / SHA= / VENUE=
# 1 no pipeline, job failed, inspect failed # 1 no pipeline, job failed, inspect failed
# 2 usage / config # 2 usage / config
# 3 forge CLI cannot read the project (ci venue) # 3 CI status script cannot query (ci venue)
usage() { usage() {
sed -n '2,20p' "$0" sed -n '2,20p' "$0"
+82 -356
View File
@@ -1,25 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Wait until the forge pipeline for an exact commit SHA finishes. # Poll the project CI status script until the exact SHA finishes.
# Does not trigger a pipeline. Does not print tokens. # Does not trigger a pipeline. Does not print tokens.
# #
# Usage: # Usage:
# wait_ci.sh --sha SHA [--root DIR] [--timeout SEC] # wait_ci.sh --sha SHA [--root DIR] [--timeout SEC]
# #
# Supported forges: # The project script is CI_STATUS_SCRIPT (makefile.builder, default
# GitHub Actions (gh), GitLab CI (glab), and Gitea Actions (curl + REST API). # .pouch/builder/ci-status.sh). See references/ci-status.md.
# Set BUILDER_CI_FORGE=gitea for a custom Gitea host that does not contain
# "gitea" in its hostname. GITEA_API_URL may override the /api/v1 endpoint.
#
# Exit:
# 0 pipeline succeeded; prints "OK pipeline=<url> sha=<sha>"
# 1 no pipeline for this SHA, or a job failed/canceled
# 2 usage / bad arguments
# 3 forge CLI missing, or it cannot read this project
usage() { usage() {
sed -n '2,18p' "$0" sed -n '2,14p' "$0"
} }
sha= sha=
@@ -51,7 +43,6 @@ if [[ -z "$sha" ]]; then
fi fi
sha=$(git -C "$root" rev-parse "$sha") sha=$(git -C "$root" rev-parse "$sha")
# Load only builder configuration. Shell values win and values are never echoed.
if [[ -f "$root/.env.builder" ]]; then if [[ -f "$root/.env.builder" ]]; then
while IFS='=' read -r key value; do while IFS='=' read -r key value; do
key=${key%%[[:space:]]*} key=${key%%[[:space:]]*}
@@ -67,391 +58,126 @@ if [[ -f "$root/.env.builder" ]]; then
done < <(grep -v '^[[:space:]]*$' "$root/.env.builder") done < <(grep -v '^[[:space:]]*$' "$root/.env.builder")
fi fi
python_json() { resolve_status_script() {
python3 -c "$1" "${@:2}" local configured=${CI_STATUS_SCRIPT:-}
} if [[ -z "$configured" && -f "$root/makefile.builder" ]]; then
configured=$(make -s --no-print-directory -C "$root" -f makefile.builder print-CI_STATUS_SCRIPT 2>/dev/null | tail -n 1 || true)
parse_origin() { fi
python_json ' configured=${configured:-.pouch/builder/ci-status.sh}
import os, re local path
url = os.environ["WAIT_CI_ORIGIN"].strip() if [[ "$configured" == /* ]]; then
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url) path=$configured
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url) else
if re.match(r"^https?://", url): path=$root/$configured
m = re.match(r"^(https?)://([^/]+)(?:/(.*))?$", url) case "$configured" in
scheme, host, path = (m.group(1), m.group(2), m.group(3) or "") if m else ("", "", "") .pouch/builder/*) ;;
else: *)
m = re.match(r"^ssh://git@([^/]+)/(.+)$", url) echo "Error: relative CI_STATUS_SCRIPT must be under .pouch/builder/: $configured" >&2
scheme, host, path = ("https", m.group(1), m.group(2)) if m else ("", "", "") exit 2
path = re.sub(r"\.git$", "", path) ;;
override = os.environ.get("BUILDER_CI_FORGE", "").strip().lower() esac
if override in ("github", "gitlab", "gitea"): fi
kind = override if [[ ! -f "$path" ]]; then
elif host == "github.com" or host.endswith(".github.com"): echo "Error: CI status script missing: $path" >&2
kind = "github" echo "Run builder init with BUILD_VENUE=ci to write .pouch/builder/ci-status.sh" >&2
elif ("gitea" in host.lower() or os.environ.get("GITEA_TOKEN") or
os.environ.get("GITEA_API_URL")):
kind = "gitea"
else:
kind = "gitlab"
print(kind, host, path, (scheme or "https") + "://" + host)
'
}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
if [[ -z "$origin" ]]; then
echo "Error: no origin remote" >&2
exit 2 exit 2
fi fi
if [[ ! -x "$path" ]]; then
export WAIT_CI_ORIGIN=$origin echo "Error: CI status script is not executable: $path" >&2
read -r forge_kind forge_host forge_path forge_base <<<"$(parse_origin)"
if [[ -z "$forge_kind" || -z "$forge_path" ]]; then
echo "Error: cannot parse origin: $origin" >&2
exit 2 exit 2
fi fi
export WAIT_CI_PATH=$forge_path printf '%s' "$path"
export WAIT_CI_SHA=$sha
gitlab_pipelines() {
local encoded
encoded=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20"
} }
gitlab_jobs() { parse_status() {
local encoded pid=$1 python3 -c '
encoded=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))') import sys
glab api "projects/${encoded}/pipelines/${pid}/jobs" text = sys.stdin.read()
} values = {}
for raw in text.replace("\n", " ").split():
github_runs() { if "=" in raw:
gh run list --repo "$forge_path" --commit "$sha" --limit 20 \ key, value = raw.split("=", 1)
--json databaseId,status,conclusion,url,headSha values[key.upper()] = value
} print(values.get("STATUS", "error").lower())
print(values.get("ID", "-"))
gitea_api_base=${GITEA_API_URL:-$forge_base/api/v1} print(values.get("PIPELINE", "-"))
gitea_api_base=${gitea_api_base%/} print(values.get("JOBS", ""))
if [[ "$gitea_api_base" != */api/v1 ]]; then
gitea_api_base+="/api/v1"
fi
gitea_repo_path=$(python_json 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe="/"))')
gitea_curl() {
local url=$1
if [[ -n "${GITEA_TOKEN:-}" ]]; then
# Process substitution keeps the token out of curl's argument list and logs.
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 \
--config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$url"
else
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$url"
fi
}
gitea_runs() {
gitea_curl "${gitea_api_base}/repos/${gitea_repo_path}/actions/runs?head_sha=${sha}&limit=20&page=1"
}
gitea_jobs() {
gitea_curl "${gitea_api_base}/repos/${gitea_repo_path}/actions/runs/$1/jobs?limit=100&page=1"
}
summarize_gitlab() {
python_json '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if not isinstance(data, list):
print("error")
raise SystemExit(0)
if not data:
print("empty")
raise SystemExit(0)
p = data[0]
print(p.get("id"), p.get("status"), p.get("web_url", ""))
' '
} }
summarize_jobs() { status_script=$(resolve_status_script)
python_json ' export BUILDER_PROJECT_ROOT=$root
import json, sys
data = json.loads(sys.stdin.read())
parts = []
for j in data if isinstance(data, list) else []:
parts.append("%s=%s" % (j.get("name"), j.get("status")))
print(",".join(parts))
'
}
summarize_github() { query() {
python_json ' local out err rc
import json, sys err=$(mktemp)
data = json.loads(sys.stdin.read()) set +e
if not data: out=$(BUILDER_PROJECT_ROOT="$root" "$status_script" --sha "$sha" 2>"$err")
print("empty") rc=$?
raise SystemExit(0) set -e
r = data[0] if [[ $rc -ne 0 ]]; then
status = r.get("status") echo "Error: CI status script exited $rc" >&2
conc = r.get("conclusion") sed '/token/Id; /Bearer/d; /Authorization/Id' "$err" >&2
if status == "completed" and conc == "success": rm -f "$err"
st = "success" if [[ $rc -eq 2 ]]; then
elif status == "completed": exit 2
st = conc or "failed"
else:
st = status or "running"
print(r.get("databaseId"), st, r.get("url", ""))
'
}
summarize_gitea_runs() {
python_json '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if isinstance(data, dict):
data = data.get("workflow_runs", data.get("runs", []))
if not isinstance(data, list):
print("error")
raise SystemExit(0)
want = sys.argv[1] if len(sys.argv) > 1 else ""
for run in data:
head = run.get("head_sha") or run.get("headSha") or (run.get("head_commit") or {}).get("id")
if not head or head.lower() != want.lower():
continue
run_id = run.get("id") or run.get("database_id")
if run_id is None:
continue
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
print("%s\t%s\t%s\t%s" % (run_id, status, conclusion, url))
' "$sha"
}
summarize_gitea_jobs() {
python_json '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not data.get("jobs"):
print("auth", data.get("message", "")[:80].replace(" ", "_"))
raise SystemExit(0)
if isinstance(data, dict):
data = data.get("jobs", [])
if not isinstance(data, list):
print("error")
raise SystemExit(0)
want = sys.argv[1] if len(sys.argv) > 1 else ""
parts = []
state = "success" if data else "empty"
for job in data:
head = job.get("head_sha") or job.get("headSha")
if head and want and head.lower() != want.lower():
continue
name = str(job.get("name") or job.get("id") or "job").replace(" ", "_")
status = str(job.get("status") or "").lower()
result = str(job.get("conclusion") or job.get("result") or "").lower()
value = result or status or "unknown"
parts.append("%s=%s" % (name, value))
if value in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out", "action_required"):
state = "failed"
elif value not in ("success", "succeeded", "completed"):
state = "running"
if not parts:
state = "empty"
print("%s\t%s" % (state, ",".join(parts)))
' "$sha"
}
gitea_status_line() {
local raw runs
if ! raw=$(gitea_runs); then
echo "error"
return 0
fi fi
runs=$(printf '%s' "$raw" | summarize_gitea_runs)
if [[ "$runs" == auth* || "$runs" == error ]]; then
printf '%s\n' "$runs"
return 0
fi
if [[ -z "$runs" ]]; then
echo "empty"
return 0
fi
local overall=success ids= urls= jobs_all= first=1
while IFS=$'\t' read -r id run_status conclusion url; do
[[ -z "$id" ]] && continue
local jobs job_line job_state job_summary
if ! jobs=$(gitea_jobs "$id"); then
echo "error"
return 0
fi
job_line=$(printf '%s' "$jobs" | summarize_gitea_jobs "$sha")
job_state=${job_line%%$'\t'*}
job_summary=${job_line#*$'\t'}
if [[ "$job_state" == auth* || "$job_state" == error ]]; then
printf '%s\n' "$job_state"
return 0
fi
if [[ "$first" -eq 0 ]]; then
ids+=","; urls+=","; jobs_all+=","
fi
ids+="$id"; urls+="$url"; jobs_all+="$job_summary"; first=0
if [[ "$run_status" =~ ^(failure|failed|cancelled|canceled|skipped|timed_out|action_required)$ || "$conclusion" =~ ^(failure|failed|cancelled|canceled|skipped|timed_out|action_required)$ || "$job_state" == failed ]]; then
overall=failed
elif [[ "$overall" == success && ( "$job_state" == running || "$job_state" == empty || ( "$run_status" != completed && "$run_status" != success ) ) ]]; then
overall=running
fi
done <<<"$runs"
printf '%s %s %s %s\n' "$ids" "$overall" "$urls" "$jobs_all"
}
if [[ "$forge_kind" == github ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required to watch GitHub Actions for $forge_host" >&2
exit 3 exit 3
fi fi
elif [[ "$forge_kind" == gitlab ]]; then rm -f "$err"
if ! command -v glab >/dev/null 2>&1; then printf '%s' "$out"
echo "Error: glab is required to watch GitLab CI for $forge_host" >&2 }
exit 3
fi
else
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required to watch Gitea Actions for $forge_host" >&2
exit 3
fi
fi
started=$(date +%s) started=$(date +%s)
first_seen=0 first_seen=0
empty_since=$started empty_since=$started
probe_auth() {
local out rc kind
if [[ "$forge_kind" == github ]]; then
set +e
out=$(github_runs 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $forge_path" >&2
echo "$out" | sed '/token/Id; /Bearer/d' >&2
exit 3
fi
return
fi
if [[ "$forge_kind" == gitlab ]]; then
set +e
out=$(gitlab_pipelines 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $forge_path on $forge_host" >&2
echo "$out" | sed '/token/Id; /Bearer/d' >&2
exit 3
fi
kind=$(printf '%s' "$out" | summarize_gitlab | awk '{print $1}')
if [[ "$kind" == auth ]]; then
echo "Error: glab is authenticated but cannot read $forge_path" >&2
exit 3
fi
return
fi
set +e
out=$(gitea_runs 2>&1)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: curl cannot read Gitea Actions for $forge_path on $forge_host" >&2
echo "$out" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3
fi
kind=$(printf '%s' "$out" | summarize_gitea_runs | awk '{print $1}')
if [[ "$kind" == auth ]]; then
echo "Error: Gitea API rejected access to $forge_path" >&2
exit 3
fi
}
probe_auth
status_line() {
if [[ "$forge_kind" == github ]]; then
github_runs | summarize_github
elif [[ "$forge_kind" == gitlab ]]; then
gitlab_pipelines | summarize_gitlab
else
gitea_status_line
fi
}
while true; do while true; do
now=$(date +%s) now=$(date +%s)
if (( now - started > timeout_sec )); then if (( now - started > timeout_sec )); then
echo "FAIL reason=timeout sha=$sha" >&2 echo "FAIL reason=timeout sha=$sha" >&2
exit 1 exit 1
fi fi
line=$(status_line) raw=$(query)
set -- $line mapfile -t fields < <(printf '%s' "$raw" | parse_status)
kind=${1:-empty} st=${fields[0]:-error}
pid=${fields[1]:--}
url=${fields[2]:--}
jobs_s=${fields[3]:-}
if [[ "$kind" == empty ]]; then case "$st" in
empty)
if (( now - empty_since >= appear_sec )); then if (( now - empty_since >= appear_sec )); then
echo "FAIL reason=no_pipeline sha=$sha origin=$forge_host/$forge_path" >&2 echo "FAIL reason=no_pipeline sha=$sha" >&2
echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2 echo "This SHA has no CI pipeline. Tag or push a ref the project actually builds." >&2
exit 1 exit 1
fi fi
sleep "$poll_sec" sleep "$poll_sec"
continue continue
fi ;;
if [[ "$kind" == error || "$kind" == auth ]]; then error)
echo "Error: unexpected forge response for $forge_path" >&2 echo "Error: unexpected CI status response" >&2
echo "$raw" | sed '/token/Id; /Bearer/d; /Authorization/Id' >&2
exit 3 exit 3
fi ;;
pid=$1
st=$2
url=$3
jobs_s=${4:-}
empty_since=$now
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
case "$st" in
success) success)
echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s" echo "OK pipeline=$url sha=$sha id=$pid jobs=$jobs_s"
exit 0 exit 0
;; ;;
failed|failure|canceled|cancelled|skipped) failed|failure|canceled|cancelled|skipped)
if [[ "$forge_kind" == gitlab ]]; then echo "FAIL reason=job_${st} sha=$sha pipeline=$url jobs=$jobs_s" >&2
jobs_s=$(gitlab_jobs "$pid" | summarize_jobs || true)
fi
echo "FAIL reason=job_$st sha=$sha pipeline=$url jobs=$jobs_s" >&2
exit 1 exit 1
;; ;;
created|pending|running|waiting_for_resource|preparing|waiting|queued|in_progress|requested) running|pending|queued|waiting|in_progress|created|preparing)
if [[ "$first_seen" -eq 0 ]]; then
echo "Watching pipeline $pid status=$st $url" >&2
first_seen=1
fi
empty_since=$now
sleep "$poll_sec" sleep "$poll_sec"
;; ;;
*) *)
empty_since=$now
sleep "$poll_sec" sleep "$poll_sec"
;; ;;
esac esac
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via curl. Output STATUS= keys.
# Auth: GITEA_TOKEN in the environment or .env.builder. GITEA_API_URL optional.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
read -r host path base <<<"$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^(https?)://([^/]+)(?:/(.*))?$", url)
scheme, host, path = (m.group(1), m.group(2), m.group(3) or "") if m else ("", "", "")
else:
m = re.match(r"^ssh://git@([^/]+)/(.+)$", url)
scheme, host, path = ("https", m.group(1), m.group(2)) if m else ("", "", "")
path = re.sub(r"\.git$", "", path)
print(host, path, (scheme or "https") + "://" + host)
')"
api=${GITEA_API_URL:-$base/api/v1}
api=${api%/}
[[ "$api" == */api/v1 ]] || api+=/api/v1
repo=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe="/"))')
gitea_curl() {
if [[ -n "${GITEA_TOKEN:-}" ]]; then
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 \
--config <(printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN") "$1"
else
curl -fsS --retry 2 --connect-timeout 10 --max-time 30 "$1"
fi
}
set +e
runs=$(gitea_curl "${api}/repos/${repo}/actions/runs?head_sha=${sha}&limit=20&page=1")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: curl cannot read Gitea Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = []
for run in runs:
head = run.get("head_sha") or run.get("headSha") or (run.get("head_commit") or {}).get("id")
if head and head.lower() == sha.lower() and (run.get("id") or run.get("database_id")):
matched.append(run)
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id")
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitHub Actions for an exact SHA via gh. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
repo=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
set +e
raw=$(gh run list --repo "$repo" --commit "$sha" --limit 20 --json databaseId,status,conclusion,url,headSha)
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: gh cannot list runs for $repo" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if not data:
print("STATUS=empty")
raise SystemExit(0)
r = data[0]
status = str(r.get("status") or "").lower()
conc = str(r.get("conclusion") or "").lower()
if status == "completed" and conc == "success":
state = "success"
elif status == "completed":
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, r.get("databaseId") or "-", r.get("url") or "-"))
' <<<"$raw"
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
set -euo pipefail
# Query GitLab CI for an exact SHA via glab. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v glab >/dev/null 2>&1; then
echo "Error: glab is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
encoded=$(WAIT_CI_PATH=$path python3 -c 'import os,urllib.parse; print(urllib.parse.quote(os.environ["WAIT_CI_PATH"], safe=""))')
set +e
raw=$(glab api "projects/${encoded}/pipelines?sha=${sha}&per_page=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: glab cannot list pipelines for $path" >&2
exit 3
fi
python3 -c '
import json, sys
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message"):
print("STATUS=error")
raise SystemExit(0)
if not isinstance(data, list) or not data:
print("STATUS=empty")
raise SystemExit(0)
p = data[0]
st = str(p.get("status") or "").lower()
if st in ("success", "passed"):
state = "success"
elif st in ("failed", "canceled", "cancelled", "skipped"):
state = "failed"
elif st in ("",):
state = "empty"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, p.get("id") or "-", p.get("web_url") or "-"))
' <<<"$raw"
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Query Gitea Actions for an exact SHA via tea. Output STATUS= keys.
sha=
while (($#)); do
case "$1" in
--sha) sha=$2; shift 2 ;;
*) echo "Usage: ci-status.sh --sha SHA" >&2; exit 2 ;;
esac
done
[[ -n "$sha" ]] || { echo "Usage: ci-status.sh --sha SHA" >&2; exit 2; }
if ! command -v tea >/dev/null 2>&1; then
echo "Error: tea is required" >&2
exit 3
fi
root=${BUILDER_PROJECT_ROOT:-$(git rev-parse --show-toplevel)}
origin=$(git -C "$root" remote get-url origin 2>/dev/null || true)
[[ -n "$origin" ]] || { echo "Error: no origin remote" >&2; exit 3; }
path=$(WAIT_CI_ORIGIN=$origin python3 -c '
import os, re
url = os.environ["WAIT_CI_ORIGIN"].strip()
url = re.sub(r"^git@([^:]+):", r"ssh://git@\1/", url)
url = re.sub(r"^ssh://git@([^/]+):", r"ssh://git@\1/", url)
if re.match(r"^https?://", url):
m = re.match(r"^https?://[^/]+(?:/(.*))?$", url)
path = m.group(1) if m else ""
else:
m = re.match(r"^ssh://git@[^/]+/(.+)$", url)
path = m.group(1) if m else ""
print(re.sub(r"\.git$", "", path or ""))
')
[[ -n "$path" ]] || { echo "Error: cannot parse origin" >&2; exit 3; }
set +e
runs=$(tea api "repos/${path}/actions/runs?head_sha=${sha}&limit=20")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "Error: tea cannot read Actions for $path" >&2
exit 3
fi
python3 -c '
import json, sys
sha = sys.argv[1]
try:
data = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("STATUS=error")
raise SystemExit(0)
if isinstance(data, dict) and data.get("message") and not (data.get("workflow_runs") or data.get("runs")):
print("STATUS=error")
raise SystemExit(0)
runs = data.get("workflow_runs", data.get("runs", [])) if isinstance(data, dict) else data
if not isinstance(runs, list) or not runs:
print("STATUS=empty")
raise SystemExit(0)
matched = [run for run in runs if str(run.get("head_sha") or run.get("headSha") or "").lower() == sha.lower()]
if not matched:
print("STATUS=empty")
raise SystemExit(0)
run = matched[0]
run_id = run.get("id") or run.get("database_id") or "-"
url = run.get("html_url") or run.get("web_url") or run.get("url") or "-"
status = str(run.get("status") or "").lower()
conclusion = str(run.get("conclusion") or run.get("result") or "").lower()
if conclusion in ("failure", "failed", "cancelled", "canceled", "skipped", "timed_out"):
state = "failed"
elif status in ("completed", "success") and conclusion in ("", "success", "succeeded"):
state = "success"
elif status == "completed" and conclusion:
state = "failed"
else:
state = "running"
print("STATUS=%s ID=%s PIPELINE=%s" % (state, run_id, url))
' "$sha" <<<"$runs"
+1 -2
View File
@@ -11,8 +11,7 @@
# 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
# BUILDER_CI_FORGE= # optional: github, gitlab, or gitea; useful for custom hosts # GITEA_API_URL= # optional for the curl CI status script
# GITEA_API_URL= # optional: defaults to https://<origin-host>/api/v1
# GITEA_TOKEN= # optional token for private Gitea repositories # GITEA_TOKEN= # optional token for private Gitea repositories
# COPY_DEST= # fallback: local dir, or docker dest container:/absolute/dir # COPY_DEST= # fallback: local dir, or docker dest container:/absolute/dir
# COPY_SCRIPT= # external/temporary executable override; project-local paths belong in makefile.builder # COPY_SCRIPT= # external/temporary executable override; project-local paths belong in makefile.builder
+3 -2
View File
@@ -8,9 +8,10 @@
ARCHES ?= amd64 ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES)) ARCH ?= $(firstword $(ARCHES))
# local: make docker/deb on this machine. ci: obtain.sh waits for the # local: make docker/deb on this machine. ci: obtain.sh polls
# forge pipeline of an exact SHA, then inspects the artifact. Not a track. # CI_STATUS_SCRIPT for an exact SHA, then inspects the artifact. Not a track.
BUILD_VENUE ?= local BUILD_VENUE ?= local
CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh
ifeq ($(strip $(ARCHES)),) ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty) $(error ARCHES must not be empty)
endif endif
+1 -1
View File
@@ -21,7 +21,7 @@
fan-out,逐个执行且不合并 cases。无需在根 `.pouch` 里增加未定义的 `include`。 fan-out,逐个执行且不合并 cases。无需在根 `.pouch` 里增加未定义的 `include`。
- 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认 - 跑真实环境前,复制 `.pouch/tester/local.yaml.example` 为 `local.yaml`;模板默认
`HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量 `HOST: "127.0.0.1"`。如果目标不是本机,显式改成目标地址。也可以用环境变量
`HX_TEST_HOST` 覆盖。 `HOST` 覆盖。
- 测试默认在当前项目执行。也可以直接提供 Orca project id、项目名称或 terminal id; - 测试默认在当前项目执行。也可以直接提供 Orca project id、项目名称或 terminal id;
tester 会自动解析 Agent、环境、项目和目录,写入 `local.yaml.agent`,并输出更新后的配置列表。 tester 会自动解析 Agent、环境、项目和目录,写入 `local.yaml.agent`,并输出更新后的配置列表。
每轮测试会创建新的 terminal,结束后自动关闭;不会复用或关闭已有 terminal。 每轮测试会创建新的 terminal,结束后自动关闭;不会复用或关闭已有 terminal。
+1 -1
View File
@@ -99,7 +99,7 @@ python3 <skill-dir>/scripts/catalog.py select --root <root> \
## 2. 运行前闸 ## 2. 运行前闸
- `HOST` 未设(环境变量 / `HX_TEST_HOST` / `local.yaml` 都没有):全部停,environment-fail。 - `HOST` 未设(环境变量 / `local.yaml` 都没有):全部停,environment-fail。
- 命中条的 `risk` 在 catalog `authorize` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`;`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。 - 命中条的 `risk` 在 catalog `authorize` 里有对应变量:变量不是 `true` 则该条 `skipped`。有 `gate` 时还要 `catalog.gates` 对应变量为 `true`;`assume` 不成立同样 `skipped`。即使用户点了 id 也不执行。
- 授权/假设不成立是**主动不跑**(`skipped`),不是 `not-run`:这是我们决定不跑,不是没轮到。 - 授权/假设不成立是**主动不跑**(`skipped`),不是 `not-run`:这是我们决定不跑,不是没轮到。
- `status_probe` 是**分类级前置条件**(业界对应 pytest 的 session fixture / Robot 的 Suite Setup)。用 `select` 返回的 `status_probe` 解析生效值,不要自己读 catalog 猜。返回的 `probe.url` 已用 `vars` 展开,直接请求即可;`select` 同批返回 `vars`: - `status_probe` 是**分类级前置条件**(业界对应 pytest 的 session fixture / Robot 的 Suite Setup)。用 `select` 返回的 `status_probe` 解析生效值,不要自己读 catalog 猜。返回的 `probe.url` 已用 `vars` 展开,直接请求即可;`select` 同批返回 `vars`:
+1 -1
View File
@@ -32,7 +32,7 @@ runs/*.csv 同一次运行的成绩单;generate_run_csv.py 从对
`local.yaml` 与环境变量覆盖 `HOST` 和授权开关。模板中的 `HOST` 默认是 `local.yaml` 与环境变量覆盖 `HOST` 和授权开关。模板中的 `HOST` 默认是
`127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` > `127.0.0.1`;远程目标必须显式覆盖。查找顺序:环境变量 > `local.yaml` >
`catalog.yaml` 的 vars。也认 `HX_TEST_HOST` 作为 `HOST`。 `catalog.yaml` 的 vars。
## agent ## agent
-5
View File
@@ -308,17 +308,12 @@ def resolve_vars(root: Path, meta: dict[str, Any]) -> dict[str, str]:
for key, value in os.environ.items(): for key, value in os.environ.items():
if key in merged or key in auth_keys or key.startswith("AUTHORIZE_") or key in { if key in merged or key in auth_keys or key.startswith("AUTHORIZE_") or key in {
"HOST", "HOST",
"HX_TEST_HOST",
"WEB_BASE", "WEB_BASE",
"API_BASE", "API_BASE",
"WS_URL", "WS_URL",
"STATUS_BASE", "STATUS_BASE",
}: }:
merged[key] = value merged[key] = value
if not merged.get("HOST") and merged.get("HX_TEST_HOST"):
merged["HOST"] = merged["HX_TEST_HOST"]
if os.environ.get("HX_TEST_HOST") and not os.environ.get("HOST"):
merged["HOST"] = os.environ["HX_TEST_HOST"]
for _ in range(10): for _ in range(10):
changed = False changed = False
for key, value in list(merged.items()): for key, value in list(merged.items()):
+43 -6
View File
@@ -112,6 +112,9 @@ class BuilderCheckTests(unittest.TestCase):
self.assertIn("copy_artifact.sh", skill) self.assertIn("copy_artifact.sh", skill)
self.assertIn("android", skill.lower()) self.assertIn("android", skill.lower())
self.assertIn("COPY_DEST", contract) self.assertIn("COPY_DEST", contract)
self.assertIn("init_ci_status.sh", skill)
self.assertIn("CI_STATUS_SCRIPT", contract)
self.assertIn("ci-status.md", skill)
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:
@@ -213,28 +216,62 @@ class BuilderCheckTests(unittest.TestCase):
self.assertIn("[FAIL] 10. 轨道工具链", text) self.assertIn("[FAIL] 10. 轨道工具链", text)
self.assertIn("docker: MISSING", text) self.assertIn("docker: MISSING", text)
def test_ready_uses_curl_for_gitea_actions(self) -> None: def test_ready_ci_requires_status_script(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
project = Path(temp) project = Path(temp)
init_repo(project) init_repo(project)
write_contract_makefile(project) write_contract_makefile(project)
(project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") (project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
git(project, "remote", "add", "origin", "https://gitea.example.test/acme/widget.git")
code, text = run_check( code, text = run_check(
project, project,
"--ready", "--ready",
env={"BUILD_VENUE": "ci"}, env={"BUILD_VENUE": "ci"},
which={ which={
"docker": "/usr/bin/docker", "docker": "/usr/bin/docker",
"curl": "/usr/bin/curl", "curl": None,
"gh": None, "gh": None,
"glab": None, "glab": None,
"tea": None,
}, },
) )
self.assertEqual(code, 1, text)
self.assertIn("[FAIL] 15. CI_STATUS_SCRIPT 可执行", text)
self.assertIn("does not point to a file", text)
self.assertIn("CI status: project CI_STATUS_SCRIPT", text)
self.assertNotIn("curl: found", text)
self.assertNotIn("Gitea API", text)
def test_ready_ci_passes_with_executable_status_script(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
(project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
script = project / ".pouch" / "builder" / "ci-status.sh"
script.parent.mkdir(parents=True)
script.write_text("#!/bin/sh\necho STATUS=empty\n", encoding="utf-8")
script.chmod(0o755)
code, text = run_check(
project,
"--ready",
env={"BUILD_VENUE": "ci"},
which={"docker": "/usr/bin/docker", "curl": None, "gh": None, "glab": None},
)
self.assertEqual(code, 0, text) self.assertEqual(code, 0, text)
self.assertIn("curl: found", text) self.assertIn("[PASS] 15. CI_STATUS_SCRIPT 可执行", text)
self.assertIn("Gitea API: /api/v1", text) self.assertIn("CI status: project CI_STATUS_SCRIPT", text)
self.assertNotIn("glab: ", text)
def test_ready_local_skips_status_script(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("[SKIP] 15. CI_STATUS_SCRIPT 可执行", text)
self.assertIn("BUILD_VENUE is not ci", text)
def test_user_makefile_does_not_satisfy_contract(self) -> None: def test_user_makefile_does_not_satisfy_contract(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
+124 -152
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
import os import os
import subprocess import subprocess
import tempfile import tempfile
@@ -9,7 +8,10 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "builder" / "scripts" / "wait_ci.sh" SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts"
WAIT_CI = SCRIPTS / "wait_ci.sh"
INIT_CI = SCRIPTS / "init_ci_status.sh"
TEMPLATES = REPO_ROOT / "skills" / "builder" / "templates" / "ci-status"
def git(cwd: Path, *args: str) -> str: def git(cwd: Path, *args: str) -> str:
@@ -33,7 +35,7 @@ def git(cwd: Path, *args: str) -> str:
class BuilderCiTests(unittest.TestCase): class BuilderCiTests(unittest.TestCase):
def make_repo(self, root: Path, origin: str) -> str: def make_repo(self, root: Path, origin: str = "https://gitea.example.test/acme/widget.git") -> str:
git(root, "init", "-b", "main") git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com") git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test") git(root, "config", "user.name", "Test")
@@ -43,6 +45,27 @@ class BuilderCiTests(unittest.TestCase):
git(root, "remote", "add", "origin", origin) git(root, "remote", "add", "origin", origin)
return git(root, "rev-parse", "HEAD") return git(root, "rev-parse", "HEAD")
def write_makefile(self, root: Path, venue: str = "ci") -> None:
(root / "makefile.builder").write_text(
"BUILD_VENUE ?= "
+ venue
+ "\n"
"CI_STATUS_SCRIPT ?= .pouch/builder/ci-status.sh\n"
"print-%:\n"
"\t@echo $($*)\n"
"venue:\n"
"\t@echo $(BUILD_VENUE)\n",
encoding="utf-8",
)
def write_status_script(self, root: Path, body: str, executable: bool = True) -> Path:
path = root / ".pouch" / "builder" / "ci-status.sh"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\n" + body + "\n", encoding="utf-8")
if executable:
path.chmod(0o755)
return path
def fake_command(self, bin_dir: Path, name: str, body: str) -> None: def fake_command(self, bin_dir: Path, name: str, body: str) -> None:
command = bin_dir / name command = bin_dir / name
command.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8") command.write_text(f"#!/bin/sh\n{body}\n", encoding="utf-8")
@@ -52,20 +75,22 @@ class BuilderCiTests(unittest.TestCase):
self, self,
root: Path, root: Path,
sha: str, sha: str,
bin_dir: Path, bin_dir: Path | None = None,
extra_env: dict[str, str] | None = None, extra_env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
path = os.environ["PATH"]
if bin_dir is not None:
path = f"{bin_dir}{os.pathsep}{path}"
env = { env = {
**os.environ, **os.environ,
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "PATH": path,
"BUILDER_CI_POLL_SEC": "0", "BUILDER_CI_POLL_SEC": "0",
"BUILDER_CI_APPEAR_SEC": "0", "BUILDER_CI_APPEAR_SEC": "0",
"BUILDER_CI_FORGE": "gitea",
} }
if extra_env: if extra_env:
env.update(extra_env) env.update(extra_env)
return subprocess.run( return subprocess.run(
["bash", str(SCRIPT), "--sha", sha, "--root", str(root)], ["bash", str(WAIT_CI), "--sha", sha, "--root", str(root)],
cwd=root, cwd=root,
env=env, env=env,
text=True, text=True,
@@ -73,176 +98,123 @@ class BuilderCiTests(unittest.TestCase):
check=False, check=False,
) )
def test_gitea_success_reads_exact_run_and_jobs(self) -> None: def test_init_copies_each_kind_template(self) -> None:
for kind in ("tea", "glab", "gh", "curl"):
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as temp:
root = Path(temp)
result = subprocess.run(
["bash", str(INIT_CI), "--root", str(root), "--kind", kind],
text=True,
capture_output=True,
check=False,
)
dest = root / ".pouch" / "builder" / "ci-status.sh"
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), str(dest))
self.assertTrue(dest.is_file())
self.assertTrue(os.access(dest, os.X_OK))
self.assertEqual(
dest.read_text(encoding="utf-8"),
(TEMPLATES / f"{kind}.sh").read_text(encoding="utf-8"),
)
def test_init_rejects_unknown_kind(self) -> None:
with tempfile.TemporaryDirectory() as temp:
result = subprocess.run(
["bash", str(INIT_CI), "--root", temp, "--kind", "jenkins"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 2)
self.assertIn("--kind must be tea, glab, gh, or curl", result.stderr)
def test_success_reads_project_script_not_forge_cli(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
sha = self.make_repo(root, "https://gitea.example.test/acme/widget.git") sha = self.make_repo(root)
self.write_makefile(root)
self.write_status_script(
root,
'echo "STATUS=success ID=12 PIPELINE=https://gitea.example.test/run/12 JOBS=build=success"',
)
bin_dir = root / "bin" bin_dir = root / "bin"
bin_dir.mkdir() bin_dir.mkdir()
self.fake_command( for name in ("curl", "tea", "glab", "gh"):
bin_dir, self.fake_command(bin_dir, name, "exit 9")
"curl", result = self.run_wait(root, sha, bin_dir)
"""
case "$*" in
*"/jobs?"*) cat "$BUILDER_CI_FIXTURE/jobs.json" ;;
*"/actions/runs?"*) cat "$BUILDER_CI_FIXTURE/runs.json" ;;
*) exit 9 ;;
esac
""",
)
fixture = root / "fixture"
fixture.mkdir()
(fixture / "runs.json").write_text(
json.dumps(
{
"workflow_runs": [
{
"id": 9,
"head_sha": "0" * 40,
"status": "completed",
"conclusion": "success",
"html_url": "https://gitea.example.test/wrong",
},
{
"id": 12,
"head_sha": sha,
"status": "completed",
"conclusion": "success",
"html_url": "https://gitea.example.test/run/12",
},
]
}
),
encoding="utf-8",
)
(fixture / "jobs.json").write_text(
json.dumps(
{"jobs": [{"id": 1, "name": "build", "head_sha": sha, "status": "completed", "conclusion": "success"}]}
),
encoding="utf-8",
)
result = self.run_wait(
root,
sha,
bin_dir,
{"BUILDER_CI_FIXTURE": str(fixture)},
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr) self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("OK pipeline=https://gitea.example.test/run/12", result.stdout) self.assertIn("OK pipeline=https://gitea.example.test/run/12", result.stdout)
self.assertIn("id=12", result.stdout) self.assertIn("id=12", result.stdout)
self.assertIn("jobs=build=success", result.stdout) self.assertIn("jobs=build=success", result.stdout)
self.assertNotIn("wrong", result.stdout + result.stderr)
def test_gitea_failed_job_is_failure(self) -> None: def test_failed_status_is_failure(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
sha = self.make_repo(root, "git@gitea.example.test:acme/widget.git") sha = self.make_repo(root)
bin_dir = root / "bin" self.write_makefile(root)
bin_dir.mkdir() self.write_status_script(
fixture = root / "fixture"
fixture.mkdir()
self.fake_command(
bin_dir,
"curl",
"""
case "$*" in
*"/jobs?"*) cat "$BUILDER_CI_FIXTURE/jobs.json" ;;
*"/actions/runs?"*) cat "$BUILDER_CI_FIXTURE/runs.json" ;;
*) exit 9 ;;
esac
""",
)
(fixture / "runs.json").write_text(
json.dumps(
{
"workflow_runs": [
{
"id": 21,
"head_sha": sha,
"status": "completed",
"conclusion": "failure",
"html_url": "https://gitea.example.test/run/21",
}
]
}
),
encoding="utf-8",
)
(fixture / "jobs.json").write_text(
json.dumps(
{"jobs": [{"id": 2, "name": "test", "head_sha": sha, "status": "completed", "conclusion": "failure"}]}
),
encoding="utf-8",
)
result = self.run_wait(
root, root,
sha, 'echo "STATUS=failed ID=21 PIPELINE=https://gitea.example.test/run/21 JOBS=test=failure"',
bin_dir,
{"BUILDER_CI_FIXTURE": str(fixture)},
) )
result = self.run_wait(root, sha)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("reason=job_failed", result.stderr) self.assertIn("reason=job_failed", result.stderr)
self.assertIn("test=failure", result.stderr) self.assertIn("test=failure", result.stderr)
def test_gitea_does_not_treat_other_sha_as_pipeline(self) -> None: def test_empty_status_is_no_pipeline(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
sha = self.make_repo(root, "https://gitea.example.test/acme/widget.git") sha = self.make_repo(root)
bin_dir = root / "bin" self.write_makefile(root)
bin_dir.mkdir() self.write_status_script(root, 'echo "STATUS=empty"')
fixture = root / "fixture" result = self.run_wait(root, sha)
fixture.mkdir()
self.fake_command(
bin_dir,
"curl",
'cat "$BUILDER_CI_FIXTURE/runs.json"',
)
(fixture / "runs.json").write_text(
json.dumps(
{
"workflow_runs": [
{
"id": 99,
"head_sha": "f" * 40,
"status": "completed",
"conclusion": "success",
}
]
}
),
encoding="utf-8",
)
result = self.run_wait(
root,
sha,
bin_dir,
{"BUILDER_CI_FIXTURE": str(fixture)},
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("reason=no_pipeline", result.stderr) self.assertIn("reason=no_pipeline", result.stderr)
def test_github_and_gitlab_branches_still_use_their_cli(self) -> None: def test_missing_script_is_usage_error(self) -> None:
for forge, command, output in ( with tempfile.TemporaryDirectory() as temp:
("github", "gh", "printf '%s\\n' '[]'"),
("gitlab", "glab", "printf '%s\\n' '[]'"),
):
with self.subTest(forge=forge), tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
sha = self.make_repo(root, f"https://{forge}.example.test/acme/widget.git") sha = self.make_repo(root)
bin_dir = root / "bin" self.write_makefile(root)
bin_dir.mkdir() result = self.run_wait(root, sha)
self.fake_command(bin_dir, command, output) self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertIn("CI status script missing", result.stderr)
def test_non_executable_script_is_usage_error(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
sha = self.make_repo(root)
self.write_makefile(root)
self.write_status_script(root, 'echo "STATUS=success"', executable=False)
result = self.run_wait(root, sha)
self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertIn("not executable", result.stderr)
def test_relative_script_outside_builder_dir_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
sha = self.make_repo(root)
self.write_makefile(root)
rogue = root / "ci-status.sh"
rogue.write_text('#!/bin/sh\necho STATUS=success\n', encoding="utf-8")
rogue.chmod(0o755)
result = self.run_wait( result = self.run_wait(
root, root, sha, extra_env={"CI_STATUS_SCRIPT": "ci-status.sh"}
sha,
bin_dir,
{
"BUILDER_CI_FORGE": forge,
"BUILDER_CI_APPEAR_SEC": "0",
},
) )
self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertEqual(result.returncode, 2, result.stdout + result.stderr)
self.assertIn("reason=no_pipeline", result.stderr) self.assertIn("must be under .pouch/builder/", result.stderr)
def test_script_exit_3_is_cannot_query(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
sha = self.make_repo(root)
self.write_makefile(root)
self.write_status_script(root, 'echo "Error: not logged in" >&2; exit 3')
result = self.run_wait(root, sha)
self.assertEqual(result.returncode, 3, result.stdout + result.stderr)
self.assertIn("CI status script exited 3", result.stderr)
self.assertIn("not logged in", result.stderr)
if __name__ == "__main__": if __name__ == "__main__":