feat: add skill init/check and isolate builder makefile
Give ack, builder, and deployer an explicit init/check mode that reports missing project config instead of failing mid-work. Point builder at makefile.builder so its contract targets do not collide with an existing Makefile.
This commit is contained in:
@@ -75,6 +75,9 @@ Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目
|
||||
pouch init ack
|
||||
```
|
||||
|
||||
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(检查配置并引导补齐),
|
||||
不要 `pouch init builder` / `pouch init deployer`。
|
||||
|
||||
### 预置目录(Catalog)
|
||||
|
||||
在 `catalog.yaml` 中预置,通过 pouch 拉取安装:
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ pouch bootstrap
|
||||
|------|------|
|
||||
| `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent |
|
||||
| `pouch update` | 在 `~/.pouch` 执行 `git pull`,更新 pouch 自身 |
|
||||
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 |
|
||||
| `pouch init <name> [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态(目前用于 ack) |
|
||||
|
||||
### Skill 安装
|
||||
|
||||
|
||||
@@ -27,6 +27,31 @@ description: >-
|
||||
2. 第二步
|
||||
3. 第三步
|
||||
|
||||
## 运营类 skill 可选:初始化 / 检查 / 工作
|
||||
|
||||
需要项目配置才能工作的 skill(例如构建、部署、协作闭环)增加这三个模式:
|
||||
|
||||
- **初始化**(用户点名才跑):探项目 → 只建本 skill 拥有的安全空结构 → 用仓库
|
||||
证据填能填的 → 列出待配置项。不覆盖已有文件,不猜密钥、主机、仓库地址。
|
||||
- **检查**(只读):同一套诊断,不写文件。
|
||||
- **工作**:配置不齐就停,告诉用户先初始化。禁止静默初始化。
|
||||
|
||||
报告格式:
|
||||
|
||||
```text
|
||||
## <skill> 初始化:完成 | 部分完成 | 阻塞
|
||||
|
||||
已具备: …
|
||||
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||
工具链: …
|
||||
下一步: 一句话
|
||||
```
|
||||
|
||||
项目状态放在 skill 真正消费的位置(覆盖层、`makefile.builder`、compose),不要为了对齐
|
||||
而新建一层没人读的 `.pouch/<skill>/`。`pouch init` 只用于确实有 templates/
|
||||
覆盖层的 skill。需要 make 目标的 skill 用自己的文件名(如 `makefile.builder`),
|
||||
不要占用用户的 `Makefile`。
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -19,8 +19,8 @@ ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
||||
|
||||
| 场景 | 怎么说 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/` |
|
||||
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件 |
|
||||
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
|
||||
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
|
||||
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 |
|
||||
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 |
|
||||
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
|
||||
@@ -171,7 +171,9 @@ delivery profile。可以直接说:
|
||||
```
|
||||
|
||||
ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成
|
||||
`intents.release` 指向的 profile。首次配置保持关闭,确认后才启用。之后可以说:
|
||||
`intents.release` 指向的 profile。`.pouch/deployer/<env>` 还没就绪时,ACK
|
||||
会转去 deployer 的初始化,而不是自己编 compose。首次配置保持关闭,确认后才启用。
|
||||
之后可以说:
|
||||
|
||||
```text
|
||||
/ack 重新布一下测试环境,我要测试
|
||||
|
||||
+23
-8
@@ -89,10 +89,18 @@ description: >-
|
||||
|
||||
11. 检查 `project.md`、`tasks.yaml`、`knowledge.yaml`、`delivery.yaml` 与
|
||||
`regression.yaml` 是否仍有 `<...>` 占位符。
|
||||
结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”并列出
|
||||
缺失值。
|
||||
12. 报告创建的路径、检测到的命令、校验结果和下一步。除非用户明确要求,不提交、
|
||||
不推送。
|
||||
12. 按下面格式报告。结构校验通过且必填项目事实完整时才称「完成」;否则称
|
||||
「部分完成」或「阻塞」并列出待配置项。除非用户明确要求,不提交、不推送。
|
||||
初始化 ACK **不**自动初始化 deployer 或 builder。
|
||||
|
||||
```text
|
||||
## ack 初始化:完成 | 部分完成 | 阻塞
|
||||
|
||||
已具备: …
|
||||
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||
工具链: pouch …
|
||||
下一步: 一句话
|
||||
```
|
||||
|
||||
## 检查
|
||||
|
||||
@@ -132,6 +140,11 @@ description: >-
|
||||
必须同时为空或同时填写。
|
||||
缺少结构化路由的旧任务板只能使用手动模式,不能自动创建 worker。
|
||||
8. 检查不会自动修复或覆盖现有配置;用户明确要求修复后再修改。
|
||||
9. 若 `delivery.yaml` 已把 `intents.testEnvironment` 写成 `{via: deployer, env: <env>}`,
|
||||
只读运行已安装 deployer skill 的 `scripts/deploy/check.py --project <project-root>`。
|
||||
未通过时列入待配置,加载 deployer skill 的「检查」说明;不要在 ACK 里复制
|
||||
compose/rsync 命令,也不要静默初始化 deployer。
|
||||
10. 用与「初始化」相同的报告格式,标题改为 `## ack 检查:…`。
|
||||
|
||||
## 工作
|
||||
|
||||
@@ -232,8 +245,9 @@ description: >-
|
||||
|
||||
1. 读取 `references/delivery.md`、deployer skill、模板、schema、现有
|
||||
`delivery.yaml`、项目构建/发布入口和 CI。测试环境写成
|
||||
`intents.testEnvironment: {via: deployer, env: <env>}`,并按 deployer skill
|
||||
准备 `.pouch/deployer/<env>`;不要把 compose/rsync 命令写进 ACK。发版仍指向
|
||||
`intents.testEnvironment: {via: deployer, env: <env>}`。若
|
||||
`.pouch/deployer/<env>` 不存在或 deployer `check.py` 未通过:停止,加载
|
||||
deployer skill 的「初始化」,不要在 ACK 里复制 compose/rsync 命令。发版仍指向
|
||||
本文件的 profile。不要拆成第二份文档。配置只引用仓库内脚本或声明式工具
|
||||
target,不保存 shell。本地 `npm run dev` / `go run` 写在 `project.md` 的
|
||||
Developer 白盒命令里,不算测试环境部署。
|
||||
@@ -262,8 +276,9 @@ description: >-
|
||||
5. 把访问地址交给用户或随后的 Test 黑盒。证据写入 `deliveryRuns`,
|
||||
`intent: testEnvironment`,`profile` 记 `deployer-<env>`,`taskIds` 可为空。
|
||||
6. 派发 Test 前若该 intent 已启用,必须先完成本步骤。deployer 未安装、环境目录
|
||||
不存在或健康检查失败:fail closed,报告 `userAction`,不把环境失败写成产品
|
||||
失败。
|
||||
不存在或 `check.py` 未通过:停止,加载 deployer skill 的「初始化」,报告
|
||||
`userAction`,不把环境失败写成产品失败,也不要在 ACK 里发明 compose 命令。
|
||||
健康检查失败同样 fail closed。
|
||||
|
||||
## 运行回归
|
||||
|
||||
|
||||
@@ -68,11 +68,12 @@ intents:
|
||||
|
||||
- `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」
|
||||
「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.pouch/deployer/<env>`
|
||||
按服务执行 sync + up 和健康检查。派发 Test 复测或跑回归前,若该 intent 已配置
|
||||
且 `enabled: true`,Coordinator 也先执行它。不要求当前有 `verified` 任务。
|
||||
Test 不对这个 intent 发明编译或启动命令。旧的 profile ID 字符串不再执行,必须
|
||||
迁到 `{via: deployer, env: <env>}`。本地进程启动写在 `project.md`,不算这个
|
||||
intent。
|
||||
按服务执行 sync + up 和健康检查。环境目录不存在或 deployer `check.py` 未通过
|
||||
时,加载 deployer skill 的「初始化」,不要在 ACK 里复制 compose 命令。派发
|
||||
Test 复测或跑回归前,若该 intent 已配置且 `enabled: true`,Coordinator 也先
|
||||
执行它。不要求当前有 `verified` 任务。Test 不对这个 intent 发明编译或启动
|
||||
命令。旧的 profile ID 字符串不再执行,必须迁到 `{via: deployer, env: <env>}`。
|
||||
本地进程启动写在 `project.md`,不算这个 intent。
|
||||
- `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。
|
||||
口头「发版」不能代替 stable/production 的 `approval` 步骤。
|
||||
- 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」
|
||||
|
||||
@@ -130,8 +130,9 @@ candidate 留在任务证据中,不会被派发。只有 Test 独立验证且
|
||||
新项目的 `.pouch/ack/delivery.yaml` 保持 `enabled: false`、空能力表、空 profile,以及
|
||||
`intents.testEnvironment: null` 与 `intents.release: null`。
|
||||
不要根据 README 或 CI 自动推断并启用发布/部署。用户说明测试环境后,Coordinator
|
||||
按 deployer skill 准备 `.pouch/deployer/<env>`,并把
|
||||
`intents.testEnvironment` 写成 `{via: deployer, env: <env>}`;发版仍指向 profile。
|
||||
把 `intents.testEnvironment` 写成 `{via: deployer, env: <env>}`,若
|
||||
`.pouch/deployer/<env>` 尚未就绪则加载 deployer skill 的「初始化」;发版仍指向
|
||||
profile。ACK 初始化不自动跑 deployer 初始化。
|
||||
配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式
|
||||
approval 步骤。
|
||||
|
||||
@@ -171,12 +172,8 @@ python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yam
|
||||
|
||||
## 初始化报告
|
||||
|
||||
完成后报告:
|
||||
按 `SKILL.md`「初始化」最后一步的格式报告(`## ack 初始化:完成 | 部分完成 | 阻塞`),
|
||||
列出已具备项、待配置项(路径 + 字段 + 示例)、工具链和下一步。
|
||||
|
||||
- 创建或确认的项目文件(含回归目录)。
|
||||
- 检测到的技术栈和验证命令。
|
||||
- 任务板、项目知识和交付契约校验结果。
|
||||
- 仍需用户补充的值。
|
||||
|
||||
只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出
|
||||
具体阻塞项。除非用户明确要求,不提交、不推送。
|
||||
只有结构校验通过且必填项目事实完整时才称「完成」;否则称「部分完成」或「阻塞」。
|
||||
除非用户明确要求,不提交、不推送。
|
||||
|
||||
@@ -6,19 +6,25 @@
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- "用 builder 初始化这个项目"
|
||||
- "帮我构建这个项目的 DEB / Docker 镜像"
|
||||
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
||||
- "检查这个项目的 Makefile 是否符合 builder 契约"
|
||||
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
|
||||
- "看看项目现在的发布流程"
|
||||
|
||||
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
||||
|
||||
## 项目接入契约
|
||||
|
||||
1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version`
|
||||
+ 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。
|
||||
对新项目说「用 builder 初始化」。Agent 会探测轨道、按
|
||||
`templates/makefile.builder` 写出项目根 `makefile.builder`,并列出缺的发布配置。
|
||||
不改用户已有的 `Makefile`。
|
||||
|
||||
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
|
||||
变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
|
||||
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
|
||||
2. 运行 `python3 -I -S <builder>/scripts/check.py .` 直到全部 PASS。
|
||||
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
|
||||
缺发布键只挡住上传,不挡住构建。
|
||||
3. 在项目根 `.env` 配置发布环境变量:
|
||||
|
||||
```text
|
||||
@@ -28,7 +34,9 @@ DEB_TOKEN=<token> # 只放 .env 或密钥系统,不进 git
|
||||
DOCKER_REGISTRY=registry.example.com
|
||||
```
|
||||
|
||||
4. 日常发布就是两条命令:`make deb && make push-deb`、`make push-docker`。
|
||||
4. 日常发布就是两条命令:
|
||||
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
|
||||
`make -f makefile.builder push-docker`。
|
||||
|
||||
## 版本号
|
||||
|
||||
@@ -40,7 +48,7 @@ DOCKER_REGISTRY=registry.example.com
|
||||
| --- | --- |
|
||||
| manage-release | 选定下一个正式 SemVer,打 annotated tag `vX.Y.Z` |
|
||||
| builder | 读 HEAD:落在稳定 tag 上则打正式产物,否则打测试产物 |
|
||||
| `make version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
|
||||
| `make -f makefile.builder version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
|
||||
|
||||
**不要**用 `git tag \| sort -V \| tail -1` 取全仓库最大号,也不要把
|
||||
`git describe --dirty` 的原始字符串写进 DEB 或镜像 tag。
|
||||
@@ -83,7 +91,8 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
用 builder 检查这个项目的 Makefile 是否符合契约。
|
||||
用 builder 初始化这个项目。
|
||||
用 builder 检查这个项目的 makefile.builder 是否符合契约。
|
||||
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
||||
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
|
||||
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
||||
@@ -93,9 +102,10 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `scripts/check.py` | 校验项目 Makefile 是否符合契约(`--build` 实构核对产物) |
|
||||
| `scripts/check.py` | 校验契约(`--ready` 含工具链与发布键名,`--build` 实构核对产物) |
|
||||
| `templates/makefile.builder` | 初始化用的契约文件骨架,拷到项目根 |
|
||||
| `scripts/version.sh` | 从 Git 祖先稳定 tag 推导规范版本 / Docker tag |
|
||||
| `scripts/version.mk` | 项目 Makefile `include`,设置 `VERSION` 与 `IMAGE_TAG` |
|
||||
| `scripts/version.mk` | `makefile.builder` `include`,设置 `VERSION` 与 `IMAGE_TAG` |
|
||||
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
||||
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
||||
|
||||
+84
-18
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: builder
|
||||
description: >-
|
||||
按统一契约构建并发布项目的 DEB 包与 Docker 镜像:先校验项目 Makefile 是否符合
|
||||
builder 契约(check.py),再 make 构建产物,经授权后用 skill 自带脚本上传并验证。
|
||||
触发词:构建 deb、发布 deb、上传 deb、推送 apt 仓库、打 Debian 包、构建镜像、
|
||||
发布镜像、推送 Docker 镜像、make push、检查 Makefile 是否符合规范。仅分析打包
|
||||
逻辑或只构建不上传时也可使用;不会在未获授权时执行任何上传。Docker 轨道保持
|
||||
显式触发:用户点名(builder/publish docker)时才走镜像发布。
|
||||
初始化或检查项目是否满足 DEB/Docker 构建发布契约,再按契约构建并发布:
|
||||
先校验 makefile.builder(check.py),再 make -f makefile.builder 构建产物,
|
||||
经授权后用 skill 自带脚本上传并验证。触发词:初始化 builder、接入 builder、
|
||||
检查 makefile.builder、构建 deb、发布 deb、上传 deb、推送 apt 仓库、打 Debian
|
||||
包、构建镜像、发布镜像、推送 Docker 镜像、make push。仅分析打包逻辑或只构建
|
||||
不上传时也可使用;不会在未获授权时执行任何上传。Docker 轨道保持显式触发:
|
||||
用户点名(builder/publish docker)时才走镜像发布。
|
||||
---
|
||||
|
||||
# Builder:DEB / Docker 构建发布
|
||||
@@ -14,28 +15,91 @@ description: >-
|
||||
复用项目已有发布约定,安全地完成"校验 → 构建 → 检查 → 授权 → 上传 → 验证"。
|
||||
|
||||
分工原则:**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||
项目状态是根目录 `makefile.builder` 与发布用 `.env`,不要创建 `.pouch/builder/`,
|
||||
不要改用户的 `Makefile` / `makefile`。
|
||||
|
||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
|
||||
`git rev-parse --show-toplevel` 解析项目根。
|
||||
|
||||
## 选择模式
|
||||
|
||||
- 用户要求初始化、接入 builder,或新项目还没有 `makefile.builder`:执行“初始化”。
|
||||
- 用户要求检查 builder 契约或发布配置是否齐全:执行“检查”。
|
||||
- 用户要求构建、发布、上传:执行“工作流”。不要静默初始化。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求初始化或接入 builder。
|
||||
- 用户要求构建、发布、上传 `.deb` 包或 Docker/OCI 镜像。
|
||||
- 用户要求检查项目 Makefile 是否符合 builder 契约。
|
||||
- 用户要求检查 `makefile.builder` 是否符合 builder 契约。
|
||||
- 用户要求梳理或接通项目现有的 DEB/镜像发布流程。
|
||||
|
||||
不适用:本地安装/卸载 DEB;RPM/APK/语言包管理器;从零设计全新打包体系(先出方案);
|
||||
普通编码与 Dockerfile 编辑。
|
||||
|
||||
## 初始化
|
||||
|
||||
1. 确认项目根。探测 `makefile.builder`、用户 `Makefile`/`makefile`(只当证据,
|
||||
不改)、`Dockerfile`、`debian/`、语言清单、`.env` 键名(只看键是否存在,不读、
|
||||
不打印值)。
|
||||
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。
|
||||
不要猜测 registry、token 或仓库名。
|
||||
3. 没有 `makefile.builder`:把 `<skill-dir>/templates/makefile.builder` 拷到项目根。
|
||||
按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的
|
||||
真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它)。
|
||||
`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成
|
||||
`push: push-deb push-docker`。不要改用户的 `Makefile` / `makefile`。
|
||||
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非
|
||||
用户明确要求按契约改。不要调用 create-makefile(其版本规则与本契约冲突)。
|
||||
5. 不要创建 `.env`。缺发布键时在报告里给出可粘贴示例,并说明 `.env` 不进 Git。
|
||||
6. 运行:
|
||||
|
||||
```bash
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready
|
||||
```
|
||||
|
||||
7. 按下面格式报告。结构校验通过且当前轨道能构建时才能称「完成」;只缺发布
|
||||
键是「部分完成」(可构建,不可发布)。契约 FAIL 或轨道工具缺失是「阻塞」。
|
||||
除非用户明确要求,不提交、不推送、不上传。
|
||||
|
||||
```text
|
||||
## builder 初始化:完成 | 部分完成 | 阻塞
|
||||
|
||||
已具备: …
|
||||
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||
工具链: make / docker / dpkg-deb(缺则怎么装,不擅自安装)
|
||||
下一步: 一句话
|
||||
```
|
||||
|
||||
发布键示例(只示范键名):
|
||||
|
||||
```text
|
||||
DEB_SERVER_URL=https://deb.example.com
|
||||
DEB_REPOSITORY=main
|
||||
DEB_TOKEN=
|
||||
DOCKER_REGISTRY=registry.example.com
|
||||
```
|
||||
|
||||
## 检查
|
||||
|
||||
只读。运行 `check.py <project-dir> --ready`,用同一报告格式,标题改为
|
||||
`## builder 检查:…`。不写 `makefile.builder`、不改用户 Makefile、不创建 `.env`。
|
||||
用户明确要求修复后再转入初始化。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 0. 校验契约
|
||||
|
||||
```bash
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> # 静态检查
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready # 含工具链与发布键
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --build # 额外实构 deb 并核对产物
|
||||
```
|
||||
|
||||
任一 FAIL:停下修复(引导用 create-makefile skill 补齐),不要绕过校验继续发布。
|
||||
完整要求见 [contract.md](references/contract.md)。存量项目未接契约时走第 6 节
|
||||
fallback;成功交付一次后引导用户迁移到契约。
|
||||
契约 FAIL 或轨道工具缺失:停下,转入「初始化」按契约补齐 `makefile.builder`,
|
||||
不要绕过校验继续发布,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。
|
||||
完整要求见 [contract.md](references/contract.md)。存量项目未接契约时走
|
||||
「存量项目 fallback」;成功交付一次后引导用户迁到 `makefile.builder`。
|
||||
|
||||
### 1. 确认发布边界
|
||||
|
||||
@@ -56,8 +120,8 @@ fallback;成功交付一次后引导用户迁移到契约。
|
||||
### 2. 构建
|
||||
|
||||
```bash
|
||||
make build ARCH=<amd64|arm64> VERSION=<version> # 主产物
|
||||
make deb ARCH=<amd64|arm64> # DEB 项目
|
||||
make -f makefile.builder build ARCH=<amd64|arm64> VERSION=<version> # 主产物
|
||||
make -f makefile.builder deb ARCH=<amd64|arm64> # DEB 项目
|
||||
```
|
||||
|
||||
版本缺省按契约「版本号」从 Git 祖先稳定 tag 推导(正式 = HEAD exact-match
|
||||
@@ -78,7 +142,7 @@ verify_deb.sh 输出元数据、关键内容清单和 SHA-256。匹配到多个
|
||||
|
||||
### 4. 发布
|
||||
|
||||
优先 `make push[-deb|-docker]`(契约要求的薄包装);直接调用等价:
|
||||
优先 `make -f makefile.builder push[-deb|-docker]`(契约要求的薄包装);直接调用等价:
|
||||
|
||||
```bash
|
||||
DEB_SERVER_URL=… DEB_TOKEN=… DEB_REPOSITORY=… \
|
||||
@@ -115,21 +179,23 @@ rg -n -i --hidden --glob '!.git' \
|
||||
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
||||
```
|
||||
|
||||
重点检查 Makefile、CI 配置、`debian/`、构建脚本和发布文档中的入口、变量传递方式、
|
||||
端点与认证方式。优先复用已有构建入口;上传仍用 builder 脚本。交付后引导迁移到契约
|
||||
(create-makefile + check.py 通过为准)。
|
||||
重点检查用户 Makefile、CI 配置、`debian/`、构建脚本和发布文档中的入口、变量传递方式、
|
||||
端点与认证方式。优先复用已有构建入口;上传仍用 builder 脚本。交付后引导迁移到
|
||||
`makefile.builder`(`templates/makefile.builder` + `check.py` 通过为准)。
|
||||
|
||||
## 修改 builder 自身时
|
||||
|
||||
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
|
||||
业务项目。产物版本只通过 `scripts/version.sh` 推导,不要在项目 Makefile 内联
|
||||
业务项目。产物版本只通过 `scripts/version.sh` 推导,不要在 `makefile.builder` 内联
|
||||
`git describe` 或 `sort -V`。
|
||||
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`。
|
||||
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md` 与
|
||||
`templates/makefile.builder`。
|
||||
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
|
||||
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 初始化/检查:报告为完成、部分完成或阻塞;待配置项含文件、字段和示例。
|
||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
||||
- 仅校验:check.py 结果逐条可解释,修复建议明确。
|
||||
- 仅构建:产物已生成并通过 verify_deb.sh,未发生上传。
|
||||
|
||||
@@ -7,6 +7,15 @@ builder 脚本只做发布,不做项目特定的构建逻辑。
|
||||
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
|
||||
Agent 只保留授权判断和歧义处理。**
|
||||
|
||||
契约文件固定为项目根 `makefile.builder`,调用方式:
|
||||
|
||||
```bash
|
||||
make -f makefile.builder <target>
|
||||
```
|
||||
|
||||
不要把 builder 目标写进用户的 `Makefile` 或 `makefile`。`check.py` 只读
|
||||
`makefile.builder`。
|
||||
|
||||
## 1. Make 目标
|
||||
|
||||
### 必备目标(所有项目)
|
||||
@@ -30,7 +39,7 @@ Agent 只保留授权判断和歧义处理。**
|
||||
|
||||
规则:
|
||||
|
||||
1. 项目有 DEB 产物的判据:Makefile 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||
有镜像的判据:项目根存在 `Dockerfile`。
|
||||
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
|
||||
`push` 即可。
|
||||
@@ -49,9 +58,9 @@ Agent 只保留授权判断和歧义处理。**
|
||||
|
||||
### 版本号
|
||||
|
||||
`make version` 输出一行规范版本(无 `v` 前缀)。DEB 的 `Version` 与文件名
|
||||
直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`(`make` 通过
|
||||
`scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
|
||||
`make -f makefile.builder version` 输出一行规范版本(无 `v` 前缀)。DEB 的
|
||||
`Version` 与文件名直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`
|
||||
(`makefile.builder` 通过 `scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
|
||||
manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release 事先打好。
|
||||
`version.sh` 不执行 `git fetch`。
|
||||
|
||||
@@ -77,7 +86,7 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release
|
||||
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
|
||||
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
|
||||
|
||||
项目 Makefile 不要内联 `git describe` 或 `sort -V`,include 本 skill 的
|
||||
`makefile.builder` 不要内联 `git describe` 或 `sort -V`,include 本 skill 的
|
||||
`scripts/version.mk`:
|
||||
|
||||
```makefile
|
||||
@@ -126,12 +135,16 @@ clone 到 `~/.pouch`。
|
||||
## 5. 校验
|
||||
|
||||
`python3 -I -S <builder-scripts>/check.py <project-dir> [--build]` 对本项目逐条检查
|
||||
上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构 `make deb` 并核对
|
||||
产物元数据(默认只静态检查配方)。校验失败时的修复路径:用 create-makefile skill
|
||||
补齐或修正 Makefile,不要绕过校验器。
|
||||
上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构
|
||||
`make -f makefile.builder deb` 并核对产物元数据(默认只静态检查配方)。`--ready`
|
||||
额外检查轨道工具链,以及 `.env` / 环境中的发布键名是否存在(不读取、不打印值;
|
||||
缺键只挡住发布)。校验失败时的修复路径:按 `templates/makefile.builder` 补齐或
|
||||
修正 `makefile.builder`,再跑 check.py,不要绕过校验器,不要改用户 Makefile,
|
||||
不要用 create-makefile(版本推导与本契约冲突)。
|
||||
|
||||
## 6. 存量项目(legacy fallback)
|
||||
|
||||
未接入契约的项目:builder 仍可按发现流程工作——从 `Makefile`、CI 配置、`debian/`
|
||||
未接入契约的项目:builder 仍可按发现流程工作——从用户 `Makefile`、CI 配置、`debian/`
|
||||
与发布文档中找已有构建/上传入口,优先复用;上传仍使用 builder 脚本。完成一次成功
|
||||
交付后应引导用户用 create-makefile 把项目迁移到本契约,之后以 check.py 为准。
|
||||
交付后应引导用户按 `templates/makefile.builder` 写入项目根 `makefile.builder`,之后以
|
||||
check.py 为准。不把契约目标合并进用户 Makefile。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
1. 用户本次请求中明确给出的值。
|
||||
2. 当前项目的 `.env` 与 `AGENTS.md`、发布文档。
|
||||
3. Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
|
||||
3. `makefile.builder`、用户 Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
|
||||
4. 询问用户。
|
||||
|
||||
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
|
||||
|
||||
+186
-20
@@ -1,14 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Executable form of the builder contract (references/contract.md).
|
||||
|
||||
Checks a project's Makefile against the contract by probing make itself with
|
||||
dry runs (`make -n`) instead of parsing Makefile text: includes, conditionals,
|
||||
and variable expansion are resolved by make, so behavior is what gets judged.
|
||||
Checks a project's makefile.builder against the contract by probing make
|
||||
itself with dry runs (`make -f makefile.builder -n`) instead of parsing
|
||||
makefile text: includes, conditionals, and variable expansion are resolved
|
||||
by make, so behavior is what gets judged. The default Makefile/makefile is
|
||||
not read.
|
||||
|
||||
Usage:
|
||||
python3 -I -S check.py <project-dir> [--build]
|
||||
python3 -I -S check.py <project-dir> [--build] [--ready]
|
||||
|
||||
Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error.
|
||||
`--ready` 额外检查轨道工具链和发布环境变量键名(只看键是否存在,永不打印值)。
|
||||
无 makefile.builder 时普通模式退出 2;`--ready` 输出结构化 FAIL 并继续工具链/发布键检查。
|
||||
|
||||
Change the contract here first, then mirror the change into contract.md.
|
||||
"""
|
||||
@@ -17,6 +21,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -37,6 +42,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")
|
||||
BUILDER_MAKEFILE = "makefile.builder"
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
@@ -58,10 +64,15 @@ class Report:
|
||||
self.skips += 0 if self.skips else 1
|
||||
|
||||
|
||||
def run_make(project: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess[str]:
|
||||
def run_make(
|
||||
project: Path, *args: str, timeout: int = 60, dry_run: bool = True
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
cmd = ["make", "-C", str(project), "-f", BUILDER_MAKEFILE]
|
||||
if dry_run:
|
||||
cmd.append("-n")
|
||||
cmd.extend(args)
|
||||
return subprocess.run(
|
||||
["make", "-C", str(project), "-n", *args],
|
||||
capture_output=True, text=True, timeout=timeout, check=False,
|
||||
cmd, capture_output=True, text=True, timeout=timeout, check=False
|
||||
)
|
||||
|
||||
|
||||
@@ -244,8 +255,7 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
|
||||
|
||||
|
||||
def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||
makefile = project / "Makefile"
|
||||
included_text = ""
|
||||
makefile = project / BUILDER_MAKEFILE
|
||||
problems = []
|
||||
files = [makefile]
|
||||
if makefile.exists():
|
||||
@@ -267,9 +277,104 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||
report.add(FAIL if problems else PASS, 8, "无内联机密、无隐式 latest/stable", "\n".join(problems) or "clean")
|
||||
|
||||
|
||||
def check_script_paths(report: Report) -> None:
|
||||
import os
|
||||
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
|
||||
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
|
||||
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=")
|
||||
|
||||
|
||||
def env_file_keys(project: Path) -> set[str]:
|
||||
"""Return key names defined in project `.env`. Never return or print values."""
|
||||
path = project / ".env"
|
||||
keys: set[str] = set()
|
||||
if not path.is_file():
|
||||
return keys
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return keys
|
||||
for raw in text.splitlines():
|
||||
stripped = raw.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
match = ENV_KEY_LINE.match(stripped)
|
||||
if match:
|
||||
keys.add(match.group(1))
|
||||
return keys
|
||||
|
||||
|
||||
def key_present(key: str, env_keys: set[str]) -> bool:
|
||||
return bool(os.environ.get(key)) or key in env_keys
|
||||
|
||||
|
||||
def check_ready_toolchain(
|
||||
report: Report, *, deb_project: bool, docker_project: bool
|
||||
) -> None:
|
||||
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
|
||||
problems = []
|
||||
if docker_project:
|
||||
docker_ok = shutil.which("docker") is not None
|
||||
lines.append(f"docker: {'found' if docker_ok else 'MISSING (blocks docker track)'}")
|
||||
if not docker_ok:
|
||||
problems.append("install docker to build/publish images")
|
||||
else:
|
||||
lines.append("docker: skipped (no docker track)")
|
||||
if deb_project:
|
||||
dpkg_ok = shutil.which("dpkg-deb") is not None
|
||||
lines.append(
|
||||
f"dpkg-deb: {'found' if dpkg_ok else 'MISSING (blocks make deb / --build)'}"
|
||||
)
|
||||
if not dpkg_ok:
|
||||
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
|
||||
else:
|
||||
lines.append("dpkg-deb: skipped (no deb track)")
|
||||
report.add(
|
||||
FAIL if problems else PASS,
|
||||
10,
|
||||
"轨道工具链",
|
||||
"\n".join(lines + ([""] + problems if problems else [])),
|
||||
)
|
||||
|
||||
|
||||
def check_ready_env_keys(
|
||||
report: Report, project: Path, *, deb_project: bool, docker_project: bool
|
||||
) -> 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")
|
||||
return
|
||||
if deb_project:
|
||||
for key in DEB_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("DEB_*: skipped (no deb track)")
|
||||
if docker_project:
|
||||
for key in DOCKER_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("DOCKER_*: skipped (no docker track)")
|
||||
if missing:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"blocks publish, not build. Put keys in the environment or project `.env`:",
|
||||
*[f" {key}=" for key in missing],
|
||||
"Do not commit `.env`. Never print values.",
|
||||
]
|
||||
)
|
||||
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "\n".join(lines))
|
||||
return
|
||||
report.add(PASS, 11, "发布环境变量键名(不读取值)", "\n".join(lines))
|
||||
|
||||
|
||||
def check_script_paths(report: Report) -> None:
|
||||
candidates = []
|
||||
env_dir = os.environ.get("BUILDER_SKILL_DIR")
|
||||
if env_dir:
|
||||
@@ -289,26 +394,80 @@ def check_script_paths(report: Report) -> None:
|
||||
|
||||
|
||||
def build_project(project: Path) -> Path | None:
|
||||
"""Run `make deb` for real and return the produced .deb, or None."""
|
||||
result = subprocess.run(["make", "-C", str(project), "deb"], capture_output=True, text=True, timeout=1800, check=False)
|
||||
"""Run `make -f makefile.builder deb` for real and return the produced .deb, or None."""
|
||||
result = run_make(project, "deb", timeout=1800, dry_run=False)
|
||||
if result.returncode != 0:
|
||||
print(f"--build: `make deb` failed:\n{result.stderr[-2000:]}", file=sys.stderr)
|
||||
print(
|
||||
f"--build: `make -f {BUILDER_MAKEFILE} deb` failed:\n{result.stderr[-2000:]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
debs = sorted((p for p in (project / "dist").glob("*.deb") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return debs[0] if debs else None
|
||||
|
||||
|
||||
MAKEFILE_HINT = (
|
||||
f"Fix: copy <builder-skill>/templates/{BUILDER_MAKEFILE} to the project "
|
||||
f"root as {BUILDER_MAKEFILE}. Do not put builder targets in Makefile or "
|
||||
"makefile. Keep help/build/clean/version, include builder "
|
||||
"scripts/version.mk, and enable deb/docker/push* for the tracks this "
|
||||
"project actually uses. Then re-run check.py. Do not use create-makefile."
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("project", type=Path, help="project directory containing the Makefile")
|
||||
parser.add_argument("--build", action="store_true", help="actually run `make deb` and verify the artifact")
|
||||
parser.add_argument(
|
||||
"project",
|
||||
type=Path,
|
||||
help="project directory containing makefile.builder",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build",
|
||||
action="store_true",
|
||||
help="actually run `make -f makefile.builder deb` and verify the artifact",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ready",
|
||||
action="store_true",
|
||||
help="also check toolchain and publish env key names (init/check mode)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project = args.project.resolve()
|
||||
makefile = project / "Makefile"
|
||||
makefile = project / BUILDER_MAKEFILE
|
||||
if not makefile.is_file():
|
||||
print(f"Error: no Makefile in {project}", file=sys.stderr)
|
||||
return 2
|
||||
if not args.ready:
|
||||
print(f"Error: no {BUILDER_MAKEFILE} in {project}", file=sys.stderr)
|
||||
return 2
|
||||
report = Report()
|
||||
report.add(
|
||||
FAIL,
|
||||
1,
|
||||
f"{BUILDER_MAKEFILE} 存在",
|
||||
f"no {BUILDER_MAKEFILE} in {project}\n{MAKEFILE_HINT}",
|
||||
)
|
||||
skip_detail = f"(no {BUILDER_MAKEFILE})"
|
||||
for number, title, detail in (
|
||||
(2, "ARCH 守卫与缺省值", skip_detail),
|
||||
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
|
||||
(4, "build 不含上传动作", skip_detail),
|
||||
(5, "deb 目标产物形状与纯构建", skip_detail),
|
||||
(6, "docker 目标为本地单平台构建", skip_detail),
|
||||
(7, "push 仅委托 builder 脚本(薄包装)", skip_detail),
|
||||
(8, "无内联机密、无隐式 latest/stable", skip_detail),
|
||||
(9, "builder 脚本路径可达", skip_detail),
|
||||
):
|
||||
report.add(SKIP, number, title, detail)
|
||||
docker_project = detect_docker_project(project)
|
||||
deb_project = any(project.glob("debian/*"))
|
||||
check_ready_toolchain(report, deb_project=deb_project, docker_project=docker_project)
|
||||
check_ready_env_keys(
|
||||
report, project, deb_project=deb_project, docker_project=docker_project
|
||||
)
|
||||
print()
|
||||
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
|
||||
return 1
|
||||
if shutil.which("make") is None:
|
||||
print("Error: make is required.", file=sys.stderr)
|
||||
return 2
|
||||
@@ -340,7 +499,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
if deb_project:
|
||||
if args.build:
|
||||
print("--build: running `make deb` ...")
|
||||
print(f"--build: running `make -f {BUILDER_MAKEFILE} deb` ...")
|
||||
built_deb = build_project(project)
|
||||
if built_deb is None:
|
||||
print("--build: no .deb produced; artifact checks degrade to recipe-only.", file=sys.stderr)
|
||||
@@ -357,6 +516,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
check_push_delegates(report, project, dual)
|
||||
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
|
||||
)
|
||||
check_ready_env_keys(
|
||||
report, project, deb_project=deb_project, docker_project=docker_project
|
||||
)
|
||||
|
||||
total_fail = report.failures
|
||||
print()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Include from a project Makefile. Sets VERSION (canonical, no leading v)
|
||||
# Include from makefile.builder. Sets VERSION (canonical, no leading v)
|
||||
# and IMAGE_TAG (Docker rendering) via builder version.sh.
|
||||
#
|
||||
# Command-line / environment VERSION= is passed as --version (official X.Y.Z
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
|
||||
# 不要把这些目标写进用户的 Makefile / makefile。
|
||||
# 初始化时按项目轨道删掉未使用的 deb / docker 段。
|
||||
# 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。
|
||||
#
|
||||
# include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。
|
||||
|
||||
ARCH ?= amd64
|
||||
ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))
|
||||
$(error ARCH must be amd64 or arm64)
|
||||
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
|
||||
|
||||
.PHONY: help version clean build deb docker push push-deb push-docker
|
||||
|
||||
help:
|
||||
@echo "$(PROJECT_NAME) $(VERSION)"
|
||||
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build"
|
||||
@echo " make -f makefile.builder version - 打印规范版本 / canonical version"
|
||||
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
|
||||
@echo " make -f makefile.builder deb - 打 DEB(只构建不上传)"
|
||||
@echo " make -f makefile.builder docker - 本地单平台镜像"
|
||||
@echo " make -f makefile.builder push / push-deb / push-docker"
|
||||
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST_DIR)
|
||||
|
||||
# 替换为项目真实构建命令。不得 curl/scp/docker push。
|
||||
build:
|
||||
mkdir -p $(DIST_DIR)
|
||||
@echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)"
|
||||
|
||||
deb: build
|
||||
mkdir -p $(DIST_DIR)
|
||||
dpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
|
||||
|
||||
docker:
|
||||
docker build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) .
|
||||
|
||||
push-deb:
|
||||
$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
|
||||
|
||||
push-docker:
|
||||
$(BUILDER_SCRIPTS)/publish_docker.sh
|
||||
|
||||
# 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker
|
||||
push: push-deb
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 给新项目初始化 deployer:检查缺什么,引导补 `.pouch/deployer/`
|
||||
- 想用一套固定流程把本地改好的 Docker Compose 配置发到某台服务器
|
||||
- 要升级某个服务的镜像版本、重启服务、看远程容器状态和日志
|
||||
- 有编译好的 .deb 包要装到某台机器上(scp 上传安装,或从 URL 直接拉)
|
||||
@@ -30,6 +31,10 @@
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
# 新项目
|
||||
用 deployer 初始化这个项目
|
||||
给这个项目接上 test 环境,SSH 别名是 my-vps
|
||||
|
||||
# 独立配置中心
|
||||
帮我把 vyyo1/naiveproxy 的配置改完部署上去
|
||||
升级 vora3/gpt-load 的镜像版本
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
---
|
||||
name: deployer
|
||||
description: >-
|
||||
管理两类部署:多机 Docker Compose(仓库存 compose.yaml 与静态配置,本 skill 脚本
|
||||
同步到 SSH 节点后 docker compose 应用),以及 Argo CD GitOps(改 GitOps 仓库清单、
|
||||
开 PR/MR,用户合并后由 Argo CD 同步)。当用户要求部署、同步、升级、重启远程
|
||||
Compose 服务,向节点装 deb,新增/迁移/下线服务,梳理节点清单,make deploy TGT、
|
||||
_config.yaml、rsync、tar over SSH、NAS 部署失败;或要求 ArgoCD / GitOps / K8s
|
||||
部署、更新 Application、升镜像 tag、开 MR 让用户合并部署;或 ACK 要求拉起/
|
||||
重布项目测试环境时使用。
|
||||
初始化或检查项目部署配置,并管理两类部署:多机 Docker Compose(仓库存
|
||||
compose.yaml 与静态配置,本 skill 脚本同步到 SSH 节点后 docker compose 应用),
|
||||
以及 Argo CD GitOps(改 GitOps 仓库清单、开 PR/MR,用户合并后由 Argo CD 同步)。
|
||||
当用户要求初始化 deployer、接入测试/生产环境、检查 .pouch/deployer 是否齐全;
|
||||
或部署、同步、升级、重启远程 Compose 服务,向节点装 deb,新增/迁移/下线服务,
|
||||
梳理节点清单,make deploy TGT、_config.yaml、rsync、tar over SSH、NAS 部署失败;
|
||||
或要求 ArgoCD / GitOps / K8s 部署、更新 Application、升镜像 tag、开 MR 让用户
|
||||
合并部署;或 ACK 要求拉起/重布项目测试环境时使用。
|
||||
---
|
||||
|
||||
# deployer:Compose 节点与 Argo CD GitOps
|
||||
@@ -18,7 +19,16 @@ description: >-
|
||||
- **Argo CD**:改 GitOps 仓库清单 → 开 PR/MR → 用户合并 → Argo CD 同步。不要用
|
||||
Compose 的 `sync.py`/`remote.py` 去推集群。
|
||||
|
||||
---
|
||||
开始时解析当前 `SKILL.md` 所在目录,记为 `<skill-dir>`。优先
|
||||
`git rev-parse --show-toplevel` 解析项目根。不要创建 `.pouch/deployer/` 之外的
|
||||
假配置中心,也不要用 `pouch init deployer`。
|
||||
|
||||
## 选择模式
|
||||
|
||||
- 用户要求初始化、接入 deployer,或给新项目建测试/生产环境:执行“初始化”。
|
||||
- 用户要求检查 `.pouch/deployer`、node、compose 是否齐全:执行“检查”。
|
||||
- 用户要求部署、同步、升级、重启、装 deb、开 GitOps MR:执行下面对应轨道步骤。
|
||||
发现不了服务或解析不出 node 时停止,转入“初始化”。不要静默初始化。
|
||||
|
||||
## 何时使用
|
||||
|
||||
@@ -29,6 +39,7 @@ description: >-
|
||||
- 提到 `make deploy TGT=...`、`TGT=`、`_config.yaml`、rsync/tar 同步
|
||||
- Argo CD / GitOps / 集群部署:新增 Application、改清单、升镜像 tag、开 MR 等用户合并
|
||||
- ACK Coordinator 拉起或重布项目测试环境(`.pouch/deployer/<env>`)
|
||||
- 初始化 deployer、给项目接上 test/prod、检查部署配置缺什么
|
||||
|
||||
## 不适用
|
||||
|
||||
@@ -98,6 +109,54 @@ my-project/
|
||||
(如 `my-project-prod`),防止同主机多项目的同名环境互相覆盖;
|
||||
`_config.yaml` 写 `name:` 可显式指定。
|
||||
|
||||
## 初始化
|
||||
|
||||
独立配置中心仓库(已设 `DEPLOYER_ROOT`)只做检查,不要改成项目内布局。
|
||||
|
||||
1. 探测:`.pouch/deployer/`、根目录 `compose.yaml`/`docker-compose.yml`、
|
||||
`Dockerfile`、ACK `intents.testEnvironment`、`.pouch/deployer/argocd.yaml`。
|
||||
2. Compose 与 Argo 都有且意图不清:先问。两边都要也可以,必须分开确认。
|
||||
3. **Compose / 新项目**(ACK 默认需要 `test`):
|
||||
- 问环境:默认只建 `test`。`prod`/`dev` 用户点名再加。不默默建空的 prod。
|
||||
- 问 SSH Host 别名(必须在 `~/.ssh/config`)。没给就**不要写假 node**;
|
||||
目录可以建,`_config.yaml` 列为待配置。
|
||||
- 有根目录 compose:提议迁到 `.pouch/deployer/<env>/`,确认后才动。
|
||||
- 只有 Dockerfile:可给**单服务** compose 草稿(镜像名来自仓库名),用户确认
|
||||
后写入。不发明多服务网格。
|
||||
4. **Argo**:只问 GitOps `repo`(或 `repo_dir`),写 `.pouch/deployer/argocd.yaml`。
|
||||
不 clone、不开 MR、不 `kubectl apply`。
|
||||
5. 运行(不 SSH、不 up):
|
||||
|
||||
```bash
|
||||
python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
|
||||
```
|
||||
|
||||
6. 按下面格式报告。缺 `node` / compose / ssh 别名 = 部分完成或阻塞。
|
||||
不覆盖已有 compose/`_config.yaml`。除非用户明确要求,不部署、不提交。
|
||||
|
||||
```text
|
||||
## deployer 初始化:完成 | 部分完成 | 阻塞
|
||||
|
||||
已具备: …
|
||||
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
|
||||
工具链: ssh / rsync(缺则怎么装,不擅自安装)
|
||||
下一步: 一句话
|
||||
```
|
||||
|
||||
`_config.yaml` 示例(`node` 必须是用户给出的 SSH Host 别名):
|
||||
|
||||
```yaml
|
||||
# .pouch/deployer/_config.yaml
|
||||
node: my-vps
|
||||
base_path: /opt/app
|
||||
```
|
||||
|
||||
## 检查
|
||||
|
||||
只读。运行 `scripts/deploy/check.py --project <project-root>`,用同一报告格式,
|
||||
标题改为 `## deployer 检查:…`。不写文件、不 SSH 连接。用户明确要求修复后再转入
|
||||
初始化。
|
||||
|
||||
## 被 ACK 调用
|
||||
|
||||
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对项目
|
||||
@@ -267,6 +326,7 @@ python3 <skill-dir>/scripts/deploy/deb.py <target> apt https://example.com/foo_1
|
||||
| `scripts/deploy/remote.py` | SSH 远程 docker compose:up/recreate/restart/upgrade/ps/logs |
|
||||
| `scripts/deploy/deb.py` | deb 包分发安装:push(scp+apt)/scp/dpkg/apt(URL) |
|
||||
| `scripts/deploy/list.py` | 扫描全部可部署服务 |
|
||||
| `scripts/deploy/check.py` | 只读就绪检查:布局、compose、node、ssh config、工具链 |
|
||||
|
||||
## references/
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Readiness checker for deployer project layout.
|
||||
|
||||
Usage:
|
||||
python3 -I -S check.py [--project DIR]
|
||||
|
||||
Resolves the deploy root as DEPLOYER_ROOT, else <project>/.pouch/deployer
|
||||
(or .skiff/deployer), else walking up from cwd. Does not SSH, rsync, or
|
||||
start containers. Exit 0 = PASS (SKIP allowed), 1 = FAIL, 2 = usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
|
||||
import lib # noqa: E402
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
SKIP = "SKIP"
|
||||
_PROJECT_LAYOUT_DIRS = (".pouch", ".skiff")
|
||||
_REPO_OR_DIR = re.compile(r"^(repo|repo_dir)\s*:", re.MULTILINE)
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.failures = 0
|
||||
|
||||
def add(self, status: str, number: int, title: str, detail: str) -> None:
|
||||
print(f"[{status}] {number}. {title}")
|
||||
for line in detail.splitlines():
|
||||
print(f" {line}")
|
||||
if status == FAIL:
|
||||
self.failures += 1
|
||||
|
||||
|
||||
def node_in_ssh_config(node: str, hosts: set[str]) -> bool:
|
||||
if node in hosts:
|
||||
return True
|
||||
if "@" in node:
|
||||
_, host = node.rsplit("@", 1)
|
||||
return host in hosts
|
||||
return False
|
||||
|
||||
|
||||
def resolve_deploy_root(project: Path | None) -> tuple[Path | None, str]:
|
||||
env = os.environ.get("DEPLOYER_ROOT", "").strip()
|
||||
if env:
|
||||
path = Path(env).expanduser().resolve()
|
||||
return (path if path.is_dir() else None), "DEPLOYER_ROOT"
|
||||
if project is not None:
|
||||
root = project.resolve()
|
||||
for dirname in _PROJECT_LAYOUT_DIRS:
|
||||
candidate = root / dirname / "deployer"
|
||||
if candidate.is_dir():
|
||||
return candidate, "project"
|
||||
return None, "project"
|
||||
found = lib._find_project_root()
|
||||
if found.name == "deployer" and found.parent.name in _PROJECT_LAYOUT_DIRS:
|
||||
return found, "project"
|
||||
if found == lib._SKILL_DIR.parent:
|
||||
return None, "cwd"
|
||||
return found, "cwd"
|
||||
|
||||
|
||||
def compose_service_dirs(root: Path) -> list[Path]:
|
||||
dirs: list[Path] = []
|
||||
for compose in sorted(root.glob("**/compose.yaml")):
|
||||
service_dir = compose.parent
|
||||
if not lib.is_deployable_dir(service_dir, root):
|
||||
continue
|
||||
dirs.append(service_dir)
|
||||
return dirs
|
||||
|
||||
|
||||
def check_layout(report: Report, root: Path | None, source: str, project: Path | None) -> bool:
|
||||
if root is None:
|
||||
if source == "DEPLOYER_ROOT":
|
||||
report.add(
|
||||
FAIL,
|
||||
1,
|
||||
"部署根存在",
|
||||
"DEPLOYER_ROOT is set but is not a directory",
|
||||
)
|
||||
else:
|
||||
hint_root = project.resolve() if project is not None else Path.cwd()
|
||||
report.add(
|
||||
FAIL,
|
||||
1,
|
||||
"部署根存在",
|
||||
"\n".join(
|
||||
[
|
||||
f"no .pouch/deployer under {hint_root}",
|
||||
"Fix: run deployer 初始化 and create .pouch/deployer/",
|
||||
" _config.yaml # node: <ssh-host-alias>",
|
||||
" test/compose.yaml # default env for ACK",
|
||||
]
|
||||
),
|
||||
)
|
||||
return False
|
||||
kind = "project layout" if lib.in_project_layout(root) else "standalone deploy root"
|
||||
report.add(PASS, 1, "部署根存在", f"{root} ({kind}, via {source})")
|
||||
return True
|
||||
|
||||
|
||||
def check_toolchain(report: Report) -> None:
|
||||
ssh_ok = shutil.which("ssh") is not None
|
||||
rsync_ok = shutil.which("rsync") is not None
|
||||
lines = [
|
||||
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
|
||||
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}",
|
||||
]
|
||||
if not ssh_ok:
|
||||
lines.append("install openssh-client")
|
||||
report.add(FAIL, 2, "工具链", "\n".join(lines))
|
||||
return
|
||||
report.add(PASS if rsync_ok else SKIP, 2, "工具链", "\n".join(lines))
|
||||
|
||||
|
||||
def check_argocd(report: Report, root: Path) -> bool:
|
||||
path = root / "argocd.yaml"
|
||||
if not path.is_file():
|
||||
report.add(SKIP, 3, "Argo CD 指针", "no argocd.yaml")
|
||||
return False
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if _REPO_OR_DIR.search(text):
|
||||
report.add(PASS, 3, "Argo CD 指针", "argocd.yaml has repo or repo_dir")
|
||||
return True
|
||||
report.add(
|
||||
FAIL,
|
||||
3,
|
||||
"Argo CD 指针",
|
||||
"argocd.yaml exists but has neither repo: nor repo_dir:\n"
|
||||
"Fix: repo: git@host:org/infra-gitops.git",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def check_services(report: Report, root: Path) -> None:
|
||||
services = compose_service_dirs(root)
|
||||
if not services:
|
||||
report.add(
|
||||
FAIL,
|
||||
4,
|
||||
"至少有一个 compose.yaml",
|
||||
"no compose.yaml under the deploy root\n"
|
||||
"Fix: add .pouch/deployer/<env>/compose.yaml (env usually test)",
|
||||
)
|
||||
report.add(SKIP, 5, "每个服务能解析 node", "(no compose.yaml)")
|
||||
report.add(SKIP, 6, "node 出现在 SSH config", "(no compose.yaml)")
|
||||
report.add(SKIP, 7, "list 可发现服务", "(no compose.yaml)")
|
||||
return
|
||||
|
||||
rels = [str(path.relative_to(root)) for path in services]
|
||||
report.add(PASS, 4, "至少有一个 compose.yaml", "\n".join(rels))
|
||||
|
||||
hosts = lib.ssh_config_hosts()
|
||||
node_lines = []
|
||||
ssh_lines = []
|
||||
node_fail = False
|
||||
ssh_fail = False
|
||||
for path in services:
|
||||
rel = str(path.relative_to(root))
|
||||
info = lib.service_info(rel, strict=False)
|
||||
if info is None:
|
||||
node_lines.append(f"{rel}: MISSING node")
|
||||
ssh_lines.append(f"{rel}: skipped (no node)")
|
||||
node_fail = True
|
||||
continue
|
||||
node = str(info["node"])
|
||||
node_lines.append(f"{rel}: node={node}")
|
||||
if node_in_ssh_config(node, hosts):
|
||||
ssh_lines.append(f"{rel}: {node} in ~/.ssh/config")
|
||||
else:
|
||||
ssh_lines.append(f"{rel}: {node} NOT in ~/.ssh/config")
|
||||
ssh_fail = True
|
||||
|
||||
if node_fail:
|
||||
node_lines.extend(
|
||||
[
|
||||
"",
|
||||
"Fix: write node in _config.yaml (deploy root or env dir).",
|
||||
"Example:",
|
||||
" node: my-vps",
|
||||
" base_path: /opt/app",
|
||||
"Do not invent a hostname. It must be an SSH Host alias.",
|
||||
]
|
||||
)
|
||||
report.add(FAIL, 5, "每个服务能解析 node", "\n".join(node_lines))
|
||||
else:
|
||||
report.add(PASS, 5, "每个服务能解析 node", "\n".join(node_lines))
|
||||
|
||||
if ssh_fail or node_fail:
|
||||
if ssh_fail:
|
||||
ssh_lines.extend(
|
||||
[
|
||||
"",
|
||||
"Fix: add a Host entry to ~/.ssh/config for the node alias.",
|
||||
"This check does not open an SSH connection.",
|
||||
]
|
||||
)
|
||||
report.add(FAIL, 6, "node 出现在 SSH config", "\n".join(ssh_lines))
|
||||
else:
|
||||
report.add(PASS, 6, "node 出现在 SSH config", "\n".join(ssh_lines))
|
||||
|
||||
found = lib.discover_services()
|
||||
if not found:
|
||||
report.add(
|
||||
FAIL,
|
||||
7,
|
||||
"list 可发现服务",
|
||||
"compose.yaml exists but discover_services found none "
|
||||
"(need resolvable node)",
|
||||
)
|
||||
return
|
||||
names = [os.path.relpath(item["service_dir"], root) for item in found]
|
||||
report.add(PASS, 7, "list 可发现服务", f"{len(found)} service(s): " + ", ".join(names))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="project root (looks for .pouch/deployer); ignored when DEPLOYER_ROOT is set",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
report = Report()
|
||||
project = args.project.resolve() if args.project is not None else None
|
||||
root, source = resolve_deploy_root(project)
|
||||
layout_ok = check_layout(report, root, source, project)
|
||||
check_toolchain(report)
|
||||
if not layout_ok:
|
||||
report.add(SKIP, 3, "Argo CD 指针", "(no deploy root)")
|
||||
report.add(SKIP, 4, "至少有一个 compose.yaml", "(no deploy root)")
|
||||
report.add(SKIP, 5, "每个服务能解析 node", "(no deploy root)")
|
||||
report.add(SKIP, 6, "node 出现在 SSH config", "(no deploy root)")
|
||||
report.add(SKIP, 7, "list 可发现服务", "(no deploy root)")
|
||||
print()
|
||||
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
|
||||
return 1
|
||||
|
||||
assert root is not None
|
||||
lib.PROJECT_ROOT = root
|
||||
lib._SSH_HOSTS = None
|
||||
previous_cwd = Path.cwd()
|
||||
try:
|
||||
os.chdir(root)
|
||||
has_argocd = check_argocd(report, root)
|
||||
services = compose_service_dirs(root)
|
||||
if services:
|
||||
check_services(report, root)
|
||||
elif has_argocd:
|
||||
report.add(SKIP, 4, "至少有一个 compose.yaml", "Argo CD only; no compose env")
|
||||
report.add(SKIP, 5, "每个服务能解析 node", "Argo CD only")
|
||||
report.add(SKIP, 6, "node 出现在 SSH config", "Argo CD only")
|
||||
report.add(SKIP, 7, "list 可发现服务", "Argo CD only")
|
||||
else:
|
||||
check_services(report, root)
|
||||
finally:
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
print()
|
||||
if report.failures:
|
||||
print(f"RESULT: FAILED ({report.failures} check(s) failed)")
|
||||
return 1
|
||||
print("RESULT: PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -145,7 +145,8 @@ pouch init ack --project ~/app
|
||||
|
||||
`pouch` 只负责可靠地生成项目状态文件,不复制或链接 Skill。需要分析项目并完善
|
||||
ACK 配置、检查接入状态或
|
||||
运行三角色闭环时,显式调用全局 `/ack` skill。
|
||||
运行三角色闭环时,显式调用全局 `/ack` skill。builder / deployer 的项目接入走
|
||||
对应 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ class AckSkillContentTests(unittest.TestCase):
|
||||
"运行版本发布",
|
||||
"运行回归",
|
||||
"via: deployer",
|
||||
"## ack 初始化:完成 | 部分完成 | 阻塞",
|
||||
"加载 deployer skill 的「初始化」",
|
||||
):
|
||||
self.assertIn(expected, content)
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts"
|
||||
CHECK_PY = SCRIPTS / "check.py"
|
||||
TEMPLATE = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.builder"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("builder_check", CHECK_PY)
|
||||
assert spec is not None and spec.loader is not None
|
||||
builder_check = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder_check)
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
def init_repo(path: Path) -> None:
|
||||
git(path, "init", "-b", "main")
|
||||
git(path, "config", "user.email", "test@example.com")
|
||||
git(path, "config", "user.name", "Test")
|
||||
(path / "README").write_text("x\n", encoding="utf-8")
|
||||
git(path, "add", "README")
|
||||
git(path, "commit", "-m", "init")
|
||||
|
||||
|
||||
def write_contract_makefile(project: Path) -> None:
|
||||
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")
|
||||
|
||||
|
||||
def run_check(
|
||||
project: Path,
|
||||
*flags: str,
|
||||
env: dict[str, str] | None = None,
|
||||
which: dict[str, str | None] | None = None,
|
||||
) -> tuple[int, str]:
|
||||
merged = os.environ.copy()
|
||||
if env:
|
||||
merged.update(env)
|
||||
merged["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent)
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
real_which = shutil.which
|
||||
|
||||
def fake_which(name: str, *args: object, **kwargs: object) -> str | None:
|
||||
if which is not None and name in which:
|
||||
return which[name]
|
||||
return real_which(name)
|
||||
|
||||
with mock.patch.dict(os.environ, merged, clear=True):
|
||||
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
|
||||
with mock.patch.object(builder_check.shutil, "which", side_effect=fake_which):
|
||||
code = builder_check.main([str(project), *flags])
|
||||
return code, stdout.getvalue() + stderr.getvalue()
|
||||
|
||||
|
||||
class BuilderCheckTests(unittest.TestCase):
|
||||
def test_skill_documents_init_and_does_not_call_create_makefile(self) -> None:
|
||||
skill = (REPO_ROOT / "skills" / "builder" / "SKILL.md").read_text(encoding="utf-8")
|
||||
contract = (
|
||||
REPO_ROOT / "skills" / "builder" / "references" / "contract.md"
|
||||
).read_text(encoding="utf-8")
|
||||
self.assertIn("## 初始化", skill)
|
||||
self.assertIn("check.py", skill)
|
||||
self.assertIn("--ready", skill)
|
||||
self.assertIn("makefile.builder", skill)
|
||||
self.assertIn("不要改用户的 `Makefile`", skill)
|
||||
self.assertIn("不要调用 create-makefile", skill)
|
||||
self.assertIn("不要用 create-makefile", contract)
|
||||
self.assertIn("makefile.builder", contract)
|
||||
|
||||
def test_no_makefile_without_ready_is_usage_error(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
code, text = run_check(project)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("no makefile.builder", text)
|
||||
|
||||
def test_ready_without_makefile_fails_with_repair_hint(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
code, text = run_check(project, "--ready")
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn("[FAIL] 1. makefile.builder 存在", text)
|
||||
self.assertIn("makefile.builder", text)
|
||||
self.assertIn("RESULT: FAILED", text)
|
||||
|
||||
def test_contract_makefile_without_env_passes_build_and_skips_publish_keys(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("RESULT: PASSED", text)
|
||||
self.assertIn("[SKIP] 11. 发布环境变量键名", text)
|
||||
self.assertIn("DEB_SERVER_URL: MISSING", text)
|
||||
self.assertIn("blocks publish, not build", text)
|
||||
self.assertNotRegex(text, r"DEB_TOKEN: (?!MISSING|present).+")
|
||||
|
||||
def test_env_keys_present_without_printing_values(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
init_repo(project)
|
||||
write_contract_makefile(project)
|
||||
(project / ".env").write_text(
|
||||
"DEB_SERVER_URL=https://secret.example.com\n"
|
||||
"DEB_TOKEN=super-secret-token-value\n"
|
||||
"DEB_REPOSITORY=main\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
code, text = run_check(
|
||||
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
|
||||
)
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertIn("DEB_SERVER_URL: present", text)
|
||||
self.assertIn("DEB_TOKEN: present", text)
|
||||
self.assertNotIn("super-secret-token-value", text)
|
||||
self.assertNotIn("https://secret.example.com", text)
|
||||
|
||||
def test_ready_fails_when_docker_track_missing_docker(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")
|
||||
code, text = run_check(
|
||||
project,
|
||||
"--ready",
|
||||
which={"docker": None, "dpkg-deb": "/usr/bin/dpkg-deb"},
|
||||
)
|
||||
self.assertEqual(code, 1, text)
|
||||
self.assertIn("[FAIL] 10. 轨道工具链", text)
|
||||
self.assertIn("docker: MISSING", text)
|
||||
|
||||
def test_user_makefile_does_not_satisfy_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
(project / "Makefile").write_text(
|
||||
"help:\n\t@echo user\nbuild:\n\t@echo user-build\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
code, text = run_check(project, "--ready")
|
||||
self.assertEqual(code, 1, text)
|
||||
self.assertIn("no makefile.builder", text)
|
||||
|
||||
def test_user_makefile_is_ignored_when_builder_file_exists(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
init_repo(project)
|
||||
write_contract_makefile(project)
|
||||
(project / "Makefile").write_text(
|
||||
"TOKEN=super-secret-user-makefile-token\n"
|
||||
"help:\n\t@echo hijacked\n"
|
||||
"docker:\n\tdocker push example:latest\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
code, text = run_check(
|
||||
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
|
||||
)
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertNotIn("super-secret-user-makefile-token", text)
|
||||
self.assertNotIn("hijacked", text)
|
||||
self.assertNotIn(":latest", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CHECK_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "check.py"
|
||||
LIB_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "lib.py"
|
||||
|
||||
lib_spec = importlib.util.spec_from_file_location("deployer_lib", LIB_PY)
|
||||
assert lib_spec is not None and lib_spec.loader is not None
|
||||
deployer_lib = importlib.util.module_from_spec(lib_spec)
|
||||
lib_spec.loader.exec_module(deployer_lib)
|
||||
|
||||
check_spec = importlib.util.spec_from_file_location("deployer_check", CHECK_PY)
|
||||
assert check_spec is not None and check_spec.loader is not None
|
||||
deployer_check = importlib.util.module_from_spec(check_spec)
|
||||
check_spec.loader.exec_module(deployer_check)
|
||||
|
||||
|
||||
def run_check(project: Path, hosts: set[str] | None = None) -> tuple[int, str]:
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
deployer_lib.PROJECT_ROOT = None
|
||||
deployer_lib._SSH_HOSTS = None
|
||||
deployer_check.lib.PROJECT_ROOT = None
|
||||
deployer_check.lib._SSH_HOSTS = None
|
||||
patched_hosts = hosts if hosts is not None else set()
|
||||
with mock.patch("sys.stdout", stdout), mock.patch("sys.stderr", stderr):
|
||||
with mock.patch.object(deployer_check.lib, "ssh_config_hosts", return_value=patched_hosts):
|
||||
with mock.patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop("DEPLOYER_ROOT", None)
|
||||
code = deployer_check.main(["--project", str(project)])
|
||||
return code, stdout.getvalue() + stderr.getvalue()
|
||||
|
||||
|
||||
class DeployerCheckTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
deployer_lib.PROJECT_ROOT = None
|
||||
deployer_lib._SSH_HOSTS = None
|
||||
deployer_check.lib.PROJECT_ROOT = None
|
||||
deployer_check.lib._SSH_HOSTS = None
|
||||
|
||||
def test_skill_documents_init_and_check_script(self) -> None:
|
||||
skill = (REPO_ROOT / "skills" / "deployer" / "SKILL.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
self.assertIn("## 初始化", skill)
|
||||
self.assertIn("scripts/deploy/check.py", skill)
|
||||
self.assertIn("不要写假 node", skill)
|
||||
|
||||
def test_missing_layout_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
code, text = run_check(project)
|
||||
self.assertEqual(code, 1, text)
|
||||
self.assertIn("[FAIL] 1. 部署根存在", text)
|
||||
self.assertIn("test/compose.yaml", text)
|
||||
|
||||
def test_compose_without_node_is_partial_failure(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
env = project / ".pouch" / "deployer" / "test"
|
||||
env.mkdir(parents=True)
|
||||
(env / "compose.yaml").write_text("services:\n web:\n image: nginx\n", encoding="utf-8")
|
||||
code, text = run_check(project, hosts={"my-vps"})
|
||||
self.assertEqual(code, 1, text)
|
||||
self.assertIn("[PASS] 1. 部署根存在", text)
|
||||
self.assertIn("[PASS] 4. 至少有一个 compose.yaml", text)
|
||||
self.assertIn("[FAIL] 5. 每个服务能解析 node", text)
|
||||
self.assertIn("MISSING node", text)
|
||||
|
||||
def test_node_missing_from_ssh_config_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
root = project / ".pouch" / "deployer"
|
||||
env = root / "test"
|
||||
env.mkdir(parents=True)
|
||||
(root / "_config.yaml").write_text("node: my-vps\n", encoding="utf-8")
|
||||
(env / "compose.yaml").write_text("services:\n web:\n image: nginx\n", encoding="utf-8")
|
||||
code, text = run_check(project, hosts=set())
|
||||
self.assertEqual(code, 1, text)
|
||||
self.assertIn("[PASS] 5. 每个服务能解析 node", text)
|
||||
self.assertIn("[FAIL] 6. node 出现在 SSH config", text)
|
||||
self.assertIn("my-vps NOT in ~/.ssh/config", text)
|
||||
|
||||
def test_ready_when_node_and_compose_present(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
root = project / ".pouch" / "deployer"
|
||||
env = root / "test"
|
||||
env.mkdir(parents=True)
|
||||
(root / "_config.yaml").write_text("node: my-vps\nbase_path: /opt/app\n", encoding="utf-8")
|
||||
(env / "compose.yaml").write_text("services:\n web:\n image: nginx\n", encoding="utf-8")
|
||||
code, text = run_check(project, hosts={"my-vps"})
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertIn("RESULT: PASSED", text)
|
||||
self.assertIn("[PASS] 7. list 可发现服务", text)
|
||||
|
||||
def test_argocd_only_passes_compose_as_skip(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
project = Path(temp)
|
||||
root = project / ".pouch" / "deployer"
|
||||
root.mkdir(parents=True)
|
||||
(root / "argocd.yaml").write_text(
|
||||
"repo: git@git.example.com:org/infra-gitops.git\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
code, text = run_check(project)
|
||||
self.assertEqual(code, 0, text)
|
||||
self.assertIn("[PASS] 3. Argo CD 指针", text)
|
||||
self.assertIn("[SKIP] 4. 至少有一个 compose.yaml", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user