diff --git a/AGENTS.md b/AGENTS.md index df3d271..3eebfbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,9 @@ Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目 pouch init ack ``` +builder 与 deployer 的项目接入走各自 skill 的「初始化」模式(检查配置并引导补齐), +不要 `pouch init builder` / `pouch init deployer`。 + ### 预置目录(Catalog) 在 `catalog.yaml` 中预置,通过 pouch 拉取安装: diff --git a/pouch/README.md b/pouch/README.md index 88544ff..f7b3cce 100644 --- a/pouch/README.md +++ b/pouch/README.md @@ -66,7 +66,7 @@ pouch bootstrap |------|------| | `pouch bootstrap` | 将本项目的 `pouch` skill 全局安装到所有 Agent | | `pouch update` | 在 `~/.pouch` 执行 `git pull`,更新 pouch 自身 | -| `pouch init [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态 | +| `pouch init [--project DIR]` | 使用 builtin skill 自带模板初始化项目状态(目前用于 ack) | ### Skill 安装 diff --git a/skills/_template/SKILL.md b/skills/_template/SKILL.md index a7189ac..eefdb87 100644 --- a/skills/_template/SKILL.md +++ b/skills/_template/SKILL.md @@ -27,6 +27,31 @@ description: >- 2. 第二步 3. 第三步 +## 运营类 skill 可选:初始化 / 检查 / 工作 + +需要项目配置才能工作的 skill(例如构建、部署、协作闭环)增加这三个模式: + +- **初始化**(用户点名才跑):探项目 → 只建本 skill 拥有的安全空结构 → 用仓库 + 证据填能填的 → 列出待配置项。不覆盖已有文件,不猜密钥、主机、仓库地址。 +- **检查**(只读):同一套诊断,不写文件。 +- **工作**:配置不齐就停,告诉用户先初始化。禁止静默初始化。 + +报告格式: + +```text +## 初始化:完成 | 部分完成 | 阻塞 + +已具备: … +待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步 +工具链: … +下一步: 一句话 +``` + +项目状态放在 skill 真正消费的位置(覆盖层、`makefile.builder`、compose),不要为了对齐 +而新建一层没人读的 `.pouch//`。`pouch init` 只用于确实有 templates/ +覆盖层的 skill。需要 make 目标的 skill 用自己的文件名(如 `makefile.builder`), +不要占用用户的 `Makefile`。 + --- ## 注意事项 diff --git a/skills/ack/README.md b/skills/ack/README.md index a997408..8a4e139 100644 --- a/skills/ack/README.md +++ b/skills/ack/README.md @@ -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/` 还没就绪时,ACK +会转去 deployer 的初始化,而不是自己编 compose。首次配置保持关闭,确认后才启用。 +之后可以说: ```text /ack 重新布一下测试环境,我要测试 diff --git a/skills/ack/SKILL.md b/skills/ack/SKILL.md index 38def72..41ba86d 100644 --- a/skills/ack/SKILL.md +++ b/skills/ack/SKILL.md @@ -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: }`, + 只读运行已安装 deployer skill 的 `scripts/deploy/check.py --project `。 + 未通过时列入待配置,加载 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: }`,并按 deployer skill - 准备 `.pouch/deployer/`;不要把 compose/rsync 命令写进 ACK。发版仍指向 + `intents.testEnvironment: {via: deployer, env: }`。若 + `.pouch/deployer/` 不存在或 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-`,`taskIds` 可为空。 6. 派发 Test 前若该 intent 已启用,必须先完成本步骤。deployer 未安装、环境目录 - 不存在或健康检查失败:fail closed,报告 `userAction`,不把环境失败写成产品 - 失败。 + 不存在或 `check.py` 未通过:停止,加载 deployer skill 的「初始化」,报告 + `userAction`,不把环境失败写成产品失败,也不要在 ACK 里发明 compose 命令。 + 健康检查失败同样 fail closed。 ## 运行回归 diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md index c7d3900..48aa522 100644 --- a/skills/ack/references/delivery.md +++ b/skills/ack/references/delivery.md @@ -68,11 +68,12 @@ intents: - `testEnvironment` 绑定 deployer skill 的项目环境目录。用户说「重新布测试环境」 「我要测试」时,ACK 加载 deployer 的 `SKILL.md`,对 `.pouch/deployer/` - 按服务执行 sync + up 和健康检查。派发 Test 复测或跑回归前,若该 intent 已配置 - 且 `enabled: true`,Coordinator 也先执行它。不要求当前有 `verified` 任务。 - Test 不对这个 intent 发明编译或启动命令。旧的 profile ID 字符串不再执行,必须 - 迁到 `{via: deployer, 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: }`。 + 本地进程启动写在 `project.md`,不算这个 intent。 - `release` 指向 `stopAt: released` 的 profile。用户说「发布一个版本」时执行它。 口头「发版」不能代替 stable/production 的 `approval` 步骤。 - 对应 intent 为 `null` 或交付未启用:停止,请用户说明怎么做,按「交付配置维护」 diff --git a/skills/ack/references/init-new-project.md b/skills/ack/references/init-new-project.md index f795f6c..d3d0bef 100644 --- a/skills/ack/references/init-new-project.md +++ b/skills/ack/references/init-new-project.md @@ -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/`,并把 -`intents.testEnvironment` 写成 `{via: deployer, env: }`;发版仍指向 profile。 +把 `intents.testEnvironment` 写成 `{via: deployer, env: }`,若 +`.pouch/deployer/` 尚未就绪则加载 deployer skill 的「初始化」;发版仍指向 +profile。ACK 初始化不自动跑 deployer 初始化。 配置中不保存 shell、环境变量值或凭据正文;稳定发布和生产部署必须有显式 approval 步骤。 @@ -171,12 +172,8 @@ python3 /scripts/validate_regression.py .pouch/ack/regression.yam ## 初始化报告 -完成后报告: +按 `SKILL.md`「初始化」最后一步的格式报告(`## ack 初始化:完成 | 部分完成 | 阻塞`), +列出已具备项、待配置项(路径 + 字段 + 示例)、工具链和下一步。 -- 创建或确认的项目文件(含回归目录)。 -- 检测到的技术栈和验证命令。 -- 任务板、项目知识和交付契约校验结果。 -- 仍需用户补充的值。 - -只有结构校验通过且必填项目事实完整时才称“初始化完成”;否则称“部分完成”,并列出 -具体阻塞项。除非用户明确要求,不提交、不推送。 +只有结构校验通过且必填项目事实完整时才称「完成」;否则称「部分完成」或「阻塞」。 +除非用户明确要求,不提交、不推送。 diff --git a/skills/builder/README.md b/skills/builder/README.md index 7de7c63..b41f19c 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -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 /scripts/version.mk` 从 Git 推导。 -2. 运行 `python3 -I -S /scripts/check.py .` 直到全部 PASS。 +2. 运行 `python3 -I -S /scripts/check.py . --ready` 直到构建项 PASS。 + 缺发布键只挡住上传,不挡住构建。 3. 在项目根 `.env` 配置发布环境变量: ```text @@ -28,7 +34,9 @@ DEB_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 | diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index ab97a5a..a63cd5a 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -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` 所在目录,记为 ``。优先 +`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`:把 `/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 /scripts/check.py --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 --ready`,用同一报告格式,标题改为 +`## builder 检查:…`。不写 `makefile.builder`、不改用户 Makefile、不创建 `.env`。 +用户明确要求修复后再转入初始化。 + ## 工作流 ### 0. 校验契约 ```bash python3 -I -S /scripts/check.py # 静态检查 +python3 -I -S /scripts/check.py --ready # 含工具链与发布键 python3 -I -S /scripts/check.py --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= VERSION= # 主产物 -make deb ARCH= # DEB 项目 +make -f makefile.builder build ARCH= VERSION= # 主产物 +make -f makefile.builder deb ARCH= # 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,未发生上传。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 32e421a..4d38efb 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -7,6 +7,15 @@ builder 脚本只做发布,不做项目特定的构建逻辑。 分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT), Agent 只保留授权判断和歧义处理。** +契约文件固定为项目根 `makefile.builder`,调用方式: + +```bash +make -f makefile.builder +``` + +不要把 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 /check.py [--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。 diff --git a/skills/builder/references/registry.md b/skills/builder/references/registry.md index 6bae393..de50509 100644 --- a/skills/builder/references/registry.md +++ b/skills/builder/references/registry.md @@ -15,7 +15,7 @@ 1. 用户本次请求中明确给出的值。 2. 当前项目的 `.env` 与 `AGENTS.md`、发布文档。 -3. Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。 +3. `makefile.builder`、用户 Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。 4. 询问用户。 不要从其他项目、shell history 或无关的本地配置中猜测发布目标。 diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py index 310a07d..44b514d 100755 --- a/skills/builder/scripts/check.py +++ b/skills/builder/scripts/check.py @@ -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 [--build] + python3 -I -S check.py [--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 /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() diff --git a/skills/builder/scripts/version.mk b/skills/builder/scripts/version.mk index 0579093..5ab0f35 100644 --- a/skills/builder/scripts/version.mk +++ b/skills/builder/scripts/version.mk @@ -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 diff --git a/skills/builder/templates/makefile.builder b/skills/builder/templates/makefile.builder new file mode 100644 index 0000000..ea7e137 --- /dev/null +++ b/skills/builder/templates/makefile.builder @@ -0,0 +1,60 @@ +# Builder 契约文件。放到项目根,用 `make -f makefile.builder `。 +# 不要把这些目标写进用户的 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 diff --git a/skills/deployer/README.md b/skills/deployer/README.md index f4aaa15..270b904 100644 --- a/skills/deployer/README.md +++ b/skills/deployer/README.md @@ -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 的镜像版本 diff --git a/skills/deployer/SKILL.md b/skills/deployer/SKILL.md index 8a6458c..c8a9340 100644 --- a/skills/deployer/SKILL.md +++ b/skills/deployer/SKILL.md @@ -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` 所在目录,记为 ``。优先 +`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/`) +- 初始化 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//`,确认后才动。 + - 只有 Dockerfile:可给**单服务** compose 草稿(镜像名来自仓库名),用户确认 + 后写入。不发明多服务网格。 +4. **Argo**:只问 GitOps `repo`(或 `repo_dir`),写 `.pouch/deployer/argocd.yaml`。 + 不 clone、不开 MR、不 `kubectl apply`。 +5. 运行(不 SSH、不 up): + + ```bash + python3 -I -S /scripts/deploy/check.py --project + ``` + +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 `,用同一报告格式, +标题改为 `## deployer 检查:…`。不写文件、不 SSH 连接。用户明确要求修复后再转入 +初始化。 + ## 被 ACK 调用 ACK 的「运行测试环境」和回归前布环境会加载本 skill,对项目 @@ -267,6 +326,7 @@ python3 /scripts/deploy/deb.py 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/ diff --git a/skills/deployer/scripts/deploy/check.py b/skills/deployer/scripts/deploy/check.py new file mode 100644 index 0000000..81086c7 --- /dev/null +++ b/skills/deployer/scripts/deploy/check.py @@ -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 /.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: ", + " 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//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()) diff --git a/skills/pouch/SKILL.md b/skills/pouch/SKILL.md index aa48479..3710748 100644 --- a/skills/pouch/SKILL.md +++ b/skills/pouch/SKILL.md @@ -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`。 --- diff --git a/tests/test_ack_skill.py b/tests/test_ack_skill.py index c598d40..46d2ac0 100644 --- a/tests/test_ack_skill.py +++ b/tests/test_ack_skill.py @@ -35,6 +35,8 @@ class AckSkillContentTests(unittest.TestCase): "运行版本发布", "运行回归", "via: deployer", + "## ack 初始化:完成 | 部分完成 | 阻塞", + "加载 deployer skill 的「初始化」", ): self.assertIn(expected, content) diff --git a/tests/test_builder_check.py b/tests/test_builder_check.py new file mode 100644 index 0000000..a2c4111 --- /dev/null +++ b/tests/test_builder_check.py @@ -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() diff --git a/tests/test_deployer_check.py b/tests/test_deployer_check.py new file mode 100644 index 0000000..58e5c4d --- /dev/null +++ b/tests/test_deployer_check.py @@ -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()