This commit is contained in:
2026-09-10 17:12:11 +08:00
58 changed files with 3386 additions and 513 deletions
+1
View File
@@ -31,3 +31,4 @@ Thumbs.db
# 本地环境配置(勿提交密钥) # 本地环境配置(勿提交密钥)
.env .env
.env.* .env.*
.pouch
+32 -37
View File
@@ -12,16 +12,14 @@
## 快速开始 ## 快速开始
需要 Python 3、git,以及 Cursor / Claude Code / Codex 或其它兼容 Agent Skills 的 Agent。人类用法见根 [README.md](README.md) 和各 skill 的 `README.md`。用户要代装时:pouch skill 已安装则走其「安装 pouch」模式;否则读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行。不要只用 `pouch select`。
```bash ```bash
# 1. 克隆并关联
git clone https://git.yumee.top/laily/pouch.git ~/.pouch git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch && ./install.sh cd ~/.pouch && ./install.sh
# install.sh 会自动把 pouch 项目 skill 安装到所有 Agent # install.sh 只把 pouch skill 装到所有 Agent;其它 skill 用 select / add
# 2. 安装其他 skill pouch select -g
pouch add declarative-openspec-loop -g
# 3. 查看状态
pouch list pouch list
pouch status pouch status
``` ```
@@ -31,19 +29,11 @@ pouch status
## 仓库结构 ## 仓库结构
``` ```
skills/ skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md(Agent)和 README.md(人类)
├── _template/ # 新建 skill 的脚手架 pouch/ # CLI 源码(Python 3)
├── ack/ # 完整 ACK skill,含 references/templates/scripts bin/pouch # CLI 入口
├── declarative-openspec-loop/ # 自研 skill,必须含 SKILL.md catalog.yaml # pouch 预置 Skill 来源目录
│ ├── SKILL.md AGENTS.md # 本文档
│ └── reference.md
├── discussion-notes/ # 讨论沉淀笔记
│ ├── SKILL.md
│ └── reference.md
pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 本文档
``` ```
**本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md` **本仓库包含**:`skills/`、`pouch/`、`bin/pouch`、`catalog.yaml`、`AGENTS.md`
@@ -56,16 +46,24 @@ AGENTS.md # 本文档
### 内置(Builtin) ### 内置(Builtin)
| Skill | 说明 | | Skill | 说明 |
| ---------------------------------------------------------------------- | ------------------------------------------------- | | --- | --- |
| [orc](skills/orc/SKILL.md) | ORC 入口:显式编排开发、版本发布与产物任务,支持 Agent 分档 | | [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [ack](skills/ack/SKILL.md) | ACK 入口:显式初始化、检查并运行三角色闭环、测试环境、发版与回归 | | [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [pouch](skills/pouch/SKILL.md) | 本项目工作流:创建、使用、反馈与更新 builtin skill | | [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动 propose/apply/校验并迭代直到通过 | | [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记,无 .raw.md | | [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
新建 skill:复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md`,在本仓库 commit。 新建 skill:优先 `pouch create`;或复制 `skills/_template/` → `skills/<name>/`,编辑 `SKILL.md` 和 `README.md`,在本仓库 commit。
Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如 Skill 需要的稳定规范、模板、示例和脚本直接放在自己的目录中,例如
`skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成 `skills/ack/references/`、`templates/`、`examples/` 和 `scripts/`。项目初始化只生成
@@ -164,12 +162,6 @@ catalog.yaml ←── pouch add / fetch
| **Custom Source** | `~/.local/share/pouch/sources/` 或本地路径 | `pouch source add/fetch` | | **Custom Source** | `~/.local/share/pouch/sources/` 或本地路径 | `pouch source add/fetch` |
| **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` | | **External NPM** | `node_modules/` | `npx skills add` / `skills-npm` |
### 非 Skill 资料分类
| 类型 | 位置 | 维护方式 |
| --- | --- | --- |
| **OpenSpec 配置** | `openspec/` | 本仓库 commit;服务于本仓库自身的规格流程 |
### 多 Agent 路径 ### 多 Agent 路径
@@ -260,7 +252,8 @@ pouch sync
``` ```
skill-name/ skill-name/
├── SKILL.md # 必需 ├── SKILL.md # 必需,给 Agent
├── README.md # 必需,给人类
├── reference.md # 可选 ├── reference.md # 可选
├── examples.md # 可选 ├── examples.md # 可选
└── scripts/ # 可选 └── scripts/ # 可选
@@ -285,7 +278,7 @@ description: >-
### 新建流程 ### 新建流程
1. `pouch create my-skill --idea "..." --from-project .` 1. `pouch create my-skill --idea "..." --from-project .`
2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md` 2. Agent 编辑 `~/.pouch/.drafts/my-skill/SKILL.md` 和 `README.md`
3. `pouch check my-skill` 3. `pouch check my-skill`
4. 用户确认后执行 `pouch finalize my-skill` 4. 用户确认后执行 `pouch finalize my-skill`
5. `pouch add my-skill -a cursor -g -y` 验证 5. `pouch add my-skill -a cursor -g -y` 验证
@@ -340,9 +333,11 @@ Claude Code 对 symlink 支持不稳定:可能无法发现 skill,或写入
| 我要… | 命令 | 在哪 | | 我要… | 命令 | 在哪 |
| ---------- | --------------------------------- | ---- | | ---------- | --------------------------------- | ---- |
| 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 | | 首次安装 | `git clone <repo> ~/.pouch && ~/.pouch/install.sh` | 任意 |
| 让 Agent 代装 | 说「帮我安装 pouch」或丢仓库 URL | 任意 |
| 介绍 / 接入 skill | 说「有哪些 skill」或「把这个项目该用的 skill 接上」 | 项目目录 |
| 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 | | 新建 skill | `pouch create` → Agent 完善 → `check/finalize` | 任意项目 |
| 全局启用 | `pouch install <name>` | 任意 | | 全局启用 | `pouch add <name> -g` | 任意 |
| 项目启用 | `pouch enable <name>` | 项目目录 | | 项目启用 | `pouch add <name>` 或 `pouch enable <name>` | 项目目录 |
| 看状态 | `pouch status` | 任意 | | 看状态 | `pouch status` | 任意 |
| 装社区 skill | `npx skills add owner/repo -g -y` | 任意 | | 装社区 skill | `npx skills add owner/repo -g -y` | 任意 |
| 更新外部 skill | `pouch fetch <name>` | 任意 | | 更新外部 skill | `pouch fetch <name>` | 任意 |
+76 -23
View File
@@ -2,15 +2,28 @@
自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。 自研 [Agent Skills](https://agentskills.io) 的单一事实来源(SSOT)。仓库、家目录 `~/.pouch` 和 CLI 都叫 **pouch**。Skill 内容与 CLI 在本仓库一并维护。
pouch 本义是小袋子,这里取做锦囊妙计的锦囊:skill 就是里面的妙计。
给同事看怎么装、怎么打版本、怎么构建,读 [docs/getting-started.md](docs/getting-started.md)。人类用法看各 skill 的 `README.md`;`SKILL.md` 是给 Agent 的指令。用户要你代装时,读 [skills/pouch/references/install.md](skills/pouch/references/install.md) 并执行,不要只把命令贴回去,也不要用 `pouch select`。
## 前置
- Python 3、git
- Cursor、Claude Code、Codex,或其它兼容 Agent Skills 的 Agent
- `~/.local/bin` 需要在 `PATH` 里(`install.sh` 会写入 bash / zsh / fish 的 rc,当前 shell 要重开或 `source` 一次才生效)
## 快速开始 ## 快速开始
```bash ```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch cd ~/.pouch
./install.sh # 安装 CLI,并将 pouch 项目 skill 安装到所有 Agent ./install.sh
pouch add declarative-openspec-loop -g # install.sh 会安装 pouch 命令,并把 pouch 这一个 skill 装到所有 Agent
pouch select # 交互式选择并批量安装 # 若当前 shell 找不到 pouch:重开终端,或 source 对应 rc
pouch select # 交互选择要装的 skill(默认当前项目)
pouch select -g # 或装到全局,所有项目都能用
pouch list pouch list
pouch status pouch status
``` ```
@@ -24,34 +37,69 @@ mv ~/.skills ~/.pouch
`install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。 `install.sh` 会安装 `pouch` 命令,并保留 `pouch` 作为旧命令别名。项目里已有的 `.skills.yaml` 仍可读取;新写入使用 `.pouch.yaml`。
## 让 Agent 代装
对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。步骤只维护在
[skills/pouch/references/install.md](skills/pouch/references/install.md):
确认 python3/git → 处理 `~/.pouch` → `install.sh` → 用短清单介绍 skill → 问要装哪些。
不要用 `pouch select`。不要在未确认时覆盖已有的 `~/.pouch`。
自己装仍看上面的「快速开始」。
## 装完之后怎么用
1. 打开已安装目标的 Agent(Cursor / Claude Code / Codex 等)。
2. 项目级安装:打开那个项目再说话。全局安装(`-g`):任意项目都可以。
3. 按对应 skill 的 README,把示例请求发给 Agent。
`./install.sh` 只全局安装 `pouch`。其它 skill 都要 `pouch add` 或 `pouch select`。
```bash
cd ~/code/my-app
pouch add ack # 当前项目
pouch add discussion-notes -g # 全局
```
## 仓库结构 ## 仓库结构
``` ```
skills/ # 自研 skill(SSOT):每个子目录必须有 SKILL.md skills/ # 自研 skill(SSOT):每个子目录含 SKILL.md 和 README.md
pouch/ # CLI 源码(Python 3) pouch/ # CLI 源码(Python 3)
bin/pouch # CLI 入口 bin/pouch # CLI 入口
catalog.yaml # pouch 预置 Skill 来源目录 catalog.yaml # pouch 预置 Skill 来源目录
AGENTS.md # 详细规范与架构说明 AGENTS.md # 详细规范与架构说明
``` ```
| 路径 | 说明 |
|------|------| | 路径 | 说明 |
| [skills/](skills/) | 自研 skill,每个子目录含 `SKILL.md`,可附带 references、templates 和 scripts | | ---------------------------- | ----------------------------------------------------- |
| [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI | | [skills/](skills/) | 自研 skill,每个子目录含给人看的 `README.md` 和给 Agent 的 `SKILL.md` |
| [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 | | [pouch/](pouch/README.md) | 安装、软链、健康检查 CLI |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 | | [catalog.yaml](catalog.yaml) | pouch 预置 Skill 来源目录 |
| [AGENTS.md](AGENTS.md) | 设计原则、编写规范、架构详解 |
## 自研 Skill ## 自研 Skill
| Skill | 说明 |
|-------|------|
| [orc](skills/orc/SKILL.md) | 显式编排开发、版本发布和产物任务,支持 low/mid/high Agent 档位 |
| [ack](skills/ack/SKILL.md) | 显式初始化、检查并运行 ACK 三角色协作及可选交付闭环 |
| [pouch](skills/pouch/SKILL.md) | 在项目中创建、安装、反馈和维护 builtin skill |
| [declarative-openspec-loop](skills/declarative-openspec-loop/SKILL.md) | 声明式编程循环:用户提供校验方式,Agent 自动迭代直到通过 |
| [discussion-notes](skills/discussion-notes/SKILL.md) | 讨论沉淀:边讨论边维护 Markdown 笔记 |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装 Skill 后可初始化当前项目状态: | Skill | 说明 |
| ----------------------------------------------------------------------- | ------------------------------------------- |
| [ack](skills/ack/README.md) | 显式初始化、检查并运行三角色闭环:需求 / bug、测试环境、发版与回归 |
| [builder](skills/builder/README.md) | 按统一契约构建和发布 DEB / Docker 镜像 |
| [declarative-openspec-loop](skills/declarative-openspec-loop/README.md) | 你给校验方式,Agent 自动 propose / apply / 校验直到通过 |
| [deployer](skills/deployer/README.md) | 用 Git 里的 Compose 或 Argo CD 清单部署服务 |
| [discussion-notes](skills/discussion-notes/README.md) | 边讨论边维护 Markdown 笔记 |
| [draw-prototype-flow](skills/draw-prototype-flow/README.md) | 把产品想法画成可编辑的 Draw.io 低保真原型和用户动线 |
| [eli5](skills/eli5/README.md) | 用大图少字的单页 HTML 把话题讲给完全没背景的人听 |
| [host-sec-check](skills/host-sec-check/README.md) | 对 Linux/VPS 做入侵取向的安全检查,默认只出报告 |
| [manage-release](skills/manage-release/README.md) | 给已准备好的 commit 发版本:更新说明、tag、可选 Forge Release |
| [memory-distill](skills/memory-distill/README.md) | 任务结束后把可复用结论写入通用记忆和项目记忆 |
| [note-style](skills/note-style/README.md) | 把材料整理成可长期查阅的知识笔记 |
| [orc](skills/orc/README.md) | 把开发、发版、DEB / Docker 意图编排成阶段,按档位分发给对应 skill |
| [pouch](skills/pouch/README.md) | 创建、校验、安装和维护 builtin skill |
ACK 是包含规范、模板与校验脚本的完整 Skill。安装后可初始化当前项目状态:
```bash ```bash
pouch init ack pouch init ack
@@ -62,11 +110,13 @@ pouch init ack --project ~/app
项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务 项目可用自然语言让 `/ack` 把测试环境绑到 deployer、维护发版 profile,并在任务
验证通过后收获回归用例。 验证通过后收获回归用例。
builder 与 deployer 的项目接入走各自 skill 的「初始化」模式,不要 `pouch init builder` 或 `pouch init deployer`。
新建 skill: 新建 skill:
```bash ```bash
pouch create my-skill --idea "描述要解决的重复问题" --from-project . pouch create my-skill --idea "描述要解决的重复问题" --from-project .
# 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md # 由 Agent 完善 ~/.pouch/.drafts/my-skill/SKILL.md 和 README.md
pouch check my-skill pouch check my-skill
pouch finalize my-skill pouch finalize my-skill
pouch add my-skill -g # 全局安装验证 pouch add my-skill -g # 全局安装验证
@@ -89,7 +139,7 @@ pouch add <name> -g -a cursor
```yaml ```yaml
skills: skills:
- name: declarative-openspec-loop - name: ack
source: builtin source: builtin
- name: think - name: think
source: catalog:waza source: catalog:waza
@@ -101,9 +151,9 @@ targets: # 可选,默认 all
``` ```
```bash ```bash
pouch add declarative-openspec-loop pouch add ack
pouch sync pouch sync
pouch remove declarative-openspec-loop pouch remove ack
``` ```
## Catalog 与 Custom Source ## Catalog 与 Custom Source
@@ -133,7 +183,7 @@ npx skills find typescript
``` ```
本仓库 本仓库
├── skills/<name>/ ←── pouch install / enable ├── skills/<name>/ ←── pouch add / select
├── catalog.yaml ←── 预置来源发现与 fetch ├── catalog.yaml ←── 预置来源发现与 fetch
└── pouch/ ←── python3 -m pouch └── pouch/ ←── python3 -m pouch
↑ ↑
@@ -157,8 +207,10 @@ npx skills find typescript
## 文档 ## 文档
- [docs/getting-started.md](docs/getting-started.md):给同事的安装、打版本、构建教程
- [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定 - [AGENTS.md](AGENTS.md) — 完整规范、多 Agent 路径、编写约定
- [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明 - [pouch/README.md](pouch/README.md) — CLI 命令参考与开发说明
- 各 skill 的人类说明见上表 `README.md`
## 参考 ## 参考
@@ -166,3 +218,4 @@ npx skills find typescript
- [Vercel skills CLI](https://github.com/vercel-labs/skills) - [Vercel skills CLI](https://github.com/vercel-labs/skills)
- [skills.sh](https://skills.sh) - [skills.sh](https://skills.sh)
- [Cursor Skills 文档](https://cursor.com/docs/context/skills) - [Cursor Skills 文档](https://cursor.com/docs/context/skills)
+234
View File
@@ -0,0 +1,234 @@
# pouch 上手:安装、打版本、构建
pouch 是团队自研 skill 的仓库和安装工具。Skill 是给 Agent 看的工作说明书。你对着 Cursor、Claude Code 或 Codex 说话,Agent 按说明书干活。这份仓库里还有 CLI,用来把 skill 装到各 Agent。
这篇只讲日常会碰到的三件事:把 pouch 装上、用 manage-release 打正式版本、用 builder 构建 DEB 或 Docker 镜像。其它 skill 装上就能喊,需要时对 Agent 说「有哪些自研 skill」。
## pouch 怎么工作
Skill 的正文只存在一份,在 `~/.pouch/skills/<name>/`。装到 Cursor、Claude、Codex 时走软链,改一份全处生效。项目根的 `.pouch.yaml` 记录这个仓库启用了哪些 skill,类似 `package.json` 记依赖。
日常几乎不直接跑 skill 里的脚本。CLI 负责安装和维护,干活是对 Agent 说人话。授权写在请求里:没说 push、没说上传,Agent 就不做。
| 你想做的 | 对谁说 | 不要 |
| ------------------- | -------------- | ----------------------- |
| 打正式版本、annotated tag | manage-release | 让 builder 选下一个 SemVer |
| 打 DEB 或 Docker 镜像 | builder | 让 manage-release 去构建或上传 |
manage-release 选定正式 SemVer 并打 annotated tag。builder 只读当前 HEAD:HEAD 恰好落在 `vX.Y.Z` 上就打正式包,否则打测试包。
## 安装 pouch
需要 Python 3、git,以及至少一种兼容 Agent Skills 的 Agent。
```bash
git clone https://git.yumee.top/laily/pouch.git ~/.pouch
cd ~/.pouch && ./install.sh
export PATH="$HOME/.local/bin:$PATH"
pouch status
```
`install.sh` 会把 `pouch` 命令放到 `~/.local/bin`,并把 pouch 这一个 skill 全局装到所有 Agent。其它 skill 不会自动全装。当前 shell 找不到 `pouch` 时,重开终端,或 `source` 对应 rc。
也可以对 Agent 说「帮我安装 pouch」,或把仓库地址丢给它。不要覆盖已经存在的 `~/.pouch`。
从旧目录迁过来:
```bash
mv ~/.skills ~/.pouch
~/.pouch/install.sh
```
装完看一眼:
```bash
pouch list
pouch status
pouch doctor
```
软链异常就 `pouch doctor --fix`。更新 pouch 自身:`pouch update`(在 `~/.pouch` 里 `git pull`)。
## 把要用的 skill 装上
发版本和构建这两个,全局装一次,所有项目都能喊:
```bash
pouch add manage-release -g -y
pouch add builder -g -y
```
只想在当前仓库启用:
```bash
cd ~/code/my-app
pouch add manage-release -y
pouch add builder -y
```
也可以对 Agent 说「把这个项目该用的 skill 接上」,它会按仓库信号建议,再 `pouch add`。builder 不要 `pouch init builder`,初始化是对 Agent 说「用 builder 初始化这个项目」。
交互多选:`pouch select -g`。取消勾选不会卸载,卸载用 `pouch remove`。
## 怎么对 Agent 说话
打开已安装目标的 Agent。项目级安装要打开那个项目再说话;全局安装任意项目都可以。请求里写清 skill 名、目标和授权范围。
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
用 builder 初始化这个项目。
用 builder 构建当前版本的 DEB,先不要上传。
```
人看各 skill 目录下的 `README.md`。`SKILL.md` 是给 Agent 的指令,不必通读。
## 用 manage-release 打新版本
代码已经合进默认分支、测试过了,要打正式 tag 时用它。它不构建 DEB,也不推镜像。
### 还没决定版本号
```text
使用 manage-release 检查当前版本和这次改动,建议下一个版本号。只分析,不修改文件。
```
Agent 会读项目版本规则、现有 tag 和实际改动,给出建议和依据。没有项目约定时,单一协调版本默认 SemVer,tag 形如 `v1.6.0`。用户输入 `v1.6.0` 时规范版本是 `1.6.0`,不要产生双前缀。
### 默认短路径:直接打 tag
项目以 Git tag 作为版本来源,且不需要改 VERSION 文件或把 CHANGELOG 写回仓库时,走这条:
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。根据上一个稳定 tag 到目标 commit 的实际变化生成更新说明,把说明写入 annotated tag,并验证远端 tag 的 commit 和说明。不要创建 Forge Release。
```
请求里写清目标版本、基线分支、打到哪一步。没写 push、没写创建平台 Release,Agent 就不会做。只发 Git tag 时不要求 GitHub / GitLab / Gitea CLI,但 Git remote 必须可读写。
Agent 会锁定远端目标 commit,生成带 Summary、Changes、Validation、Base commit 的更新说明,创建 annotated tag 并推送,再从远端核对。不会给未合并分支打正式 tag,也不会覆盖已有同名 tag。
### 需要改版本文件时走长路径
权威版本文件或仓库内 CHANGELOG 必须随发布改掉时,才创建 `release/v1.6.0` 这类分支和 PR。可以一次说完,也可以拆开授权:
```text
使用 manage-release 完成 1.6.0 发布:从 main 创建分支,完成版本更新和验证,创建 PR,检查通过后合并,推送 v1.6.0 tag。不要创建 Forge Release。
```
中途停了,下次说「使用 manage-release 检查 v1.6.0 当前发布状态,从尚未完成的步骤继续。不要重复创建分支、PR、tag 或 release。」
要在 Gitea / GitHub 上建平台 Release 时,在请求里写明。没写就不会建。Forge Release 复用 tag 里同一份说明。
### 判断打没打成
远端 tag 解引用后的 commit 就是目标 commit,tag object 里有完整更新说明。只分析时会返回建议版本和依据。卡在某步时会说明停在哪、下次怎么续,不会用「发布成功」盖住未完成的步骤。
## 用 builder 构建项目
builder 按统一契约打 DEB 和 Docker 镜像。项目根会多两个文件:`makefile.builder`(构建入口)和 `.env.builder`(发布凭据,不进 git)。它不改你已有的 `Makefile` 或 `.env`。
正式版本号仍由 manage-release 打 tag。builder 只读当前 HEAD,不调用 manage-release。
### 接入一个项目
需要 Python 3 和 `make`。打 DEB 还要 `dpkg-deb`,打镜像还要 Docker 和 buildx。在项目根对 Agent 说:
```text
用 builder 初始化这个项目。
```
Agent 会探测轨道、写出 `makefile.builder`,缺发布配置时列出来。然后在 `.env.builder` 填仓库地址和 token:
```text
DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main
DEB_TOKEN=<token>
DOCKER_REGISTRY=registry.example.com
```
token 只放 `.env.builder` 或密钥系统,不要写进 `.env`,也不要进 git。检查契约:
```text
用 builder 检查这个项目的 makefile.builder 是否符合契约。
```
构建项通过就可以打本地包。缺发布键只挡住上传。
日常构建入口:
```bash
make -f makefile.builder help
make -f makefile.builder version
make -f makefile.builder build
make -f makefile.builder deb
```
### 版本从哪来
`make -f makefile.builder version` 输出一行规范版本,无 `v` 前缀。基线是 HEAD 祖先上最近的稳定 tag,稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc` 这类后缀。
HEAD 恰好是 `v1.6.0` 时,产物是正式的 `1.6.0`。其它 commit 打测试号:祖先最近稳定 tag 的 patch 机械 +1,再挂构建日、分支、距离、短 SHA。测试 DEB 用 `~`,于是 `1.6.0` &lt; `1.6.1~…` &lt; `1.6.1`。已装上一正式版的机器可以升到 bugfix 测试包;同号正式包仍盖住测试。
分支 `feat/login-v2`,相对 `v1.6.0` 第 7 个 commit,短 SHA `abc1234`,构建日 `20260908`:
```text
规范 / DEB 1.6.1~20260908.feat-login-v2.7+gabc1234
Docker registry/ns/foo:1.6.1-20260908.feat-login-v2.7.gabc1234
```
不要自己 `git tag | sort -V | tail -1` 取全仓库最大号,也不要把 `git describe --dirty` 的原始字符串写进包名。
### 构建和发布
只构建:
```text
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
```
确认后再上传:
```text
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
```
自己跑也可以:
```bash
make -f makefile.builder deb
make -f makefile.builder push-deb
```
Docker 只有点名镜像时 Agent 才走。脏工作树默认拒绝发布。可能覆盖同版本时会先问你。token 不会出现在输出、提交或回复里。
## 一条完整发布链
代码合进 `main` 且检查通过之后,按这个顺序:
1. 对 Agent:`使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。`
2. 确认远端 `v1.6.0` 指向那次 commit。
3. checkout 到该 tag,或确保当前 HEAD 就是它。
4. 对 Agent:`用 builder 构建当前版本的 DEB,先不要上传。`
5. 核对 `make -f makefile.builder version` 输出 `1.6.0`,再授权上传。
先构建再打 tag,得到的是测试包,版本号会变成下一 patch 的 `~` 形式,不是正式号。
## 常见卡住的地方
- 当前 shell 找不到 `pouch`:`~/.local/bin` 还没进 PATH,重开终端或 `source` 对应 rc。
- 在 `~/.cursor/skills/` 里直接改文件:改的是软链目标,应该改 `~/.pouch/skills/`。Claude Code 有时会把软链替换成普通文件,用 `pouch doctor --fix` 重建。
- `pouch init builder` 这条路径不存在:对 Agent 说「用 builder 初始化」。
- 请求里没写清授权:Agent 会分析或只做到本地,不会擅自 push tag 或上传包。
- 工作区有未提交修改:builder 默认拒绝发布;manage-release 不会 stash 或覆盖你的文件。
- 同名 tag 已在远端指向别的 commit:manage-release 会停,不会 force push。
- HEAD 不在稳定 tag 上却想打正式包:先打 tag,再构建。
## 还想看什么
- 仓库总览:[README.md](../README.md)
- CLI 命令:[pouch/README.md](../pouch/README.md)
- manage-release:[skills/manage-release/README.md](../skills/manage-release/README.md)
- builder:[skills/builder/README.md](../skills/builder/README.md)
- 设计原则和编写规范:[AGENTS.md](../AGENTS.md)
-20
View File
@@ -1,20 +0,0 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+2 -1
View File
@@ -201,7 +201,7 @@ pouch doctor -a cursor
```bash ```bash
cd ~/code/my-app cd ~/code/my-app
pouch add declarative-openspec-loop -a cursor -y pouch add discussion-notes -a cursor -y
``` ```
### 添加 Catalog Source ### 添加 Catalog Source
@@ -251,3 +251,4 @@ pouch/
- [项目 README](../README.md) - [项目 README](../README.md)
- [AGENTS.md](../AGENTS.md) - [AGENTS.md](../AGENTS.md)
- 各 skill 的人类说明:`skills/<name>/README.md`
+54 -265
View File
@@ -1,28 +1,25 @@
# ACK # ACK
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环: ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
- Coordinator 拆解需求、派发任务并终检。 - Coordinator 拆解需求、派发任务并终检
- Developer 实现并执行白盒验证。 - Developer 实现并做白盒验证
- Test 独立执行黑盒复测。 - Test 独立做黑盒复测
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为 验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
`leftover`,然后继续处理其它任务。
测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份 测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
`.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进
`.pouch/ack/regression.yaml`;之后可以单独跑回归。
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。 只在你显式说 `/ack` 或 `$ack` 时运行。
## 使用场景 ## 什么时候使用
| 场景 | 怎么说 | 结果 | | 场景 | 怎么说 | 结果 |
| --- | --- | --- | | --- | --- | --- |
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 | | 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 | | 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 | | 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 你确认 → 三角色闭环 |
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 | | 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 同一闭环 |
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer | | 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` | | 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 | | 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
@@ -32,17 +29,18 @@ ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
## 安装 ## 安装
全局安装:
```bash ```bash
pouch add ack -g pouch add ack # 当前项目
pouch add ack -g # 全局
``` ```
或只安装到当前项目: ## 使用前准备
```bash - 项目是 Git 仓库。
pouch add ack - 自动拉起 Developer / Test worker 时需要 Orca,以及项目里配置好的 Codex、Cursor、Grok 或 OMP。
``` - 布测试环境需要同时安装 [deployer](../deployer/README.md),并准备好 SSH 和目标机器的 Docker Compose。
- 飞书收 bug 是可选的,要在 `project.md` 里配置 `bugIntake`。
- 不要 `pouch init builder` 或 `pouch init deployer`。测试环境和发版直接对 `/ack` 说;缺 deployer 时 ACK 会转去它的初始化。
## 初始化项目 ## 初始化项目
@@ -51,7 +49,7 @@ pouch init ack
pouch init ack --project ~/code/my-app pouch init ack --project ~/code/my-app
``` ```
初始化后,项目只保存自己的 ACK 状态: 也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
```text ```text
.pouch/ack/ .pouch/ack/
@@ -62,271 +60,62 @@ pouch init ack --project ~/code/my-app
└── regression.yaml # 默认 cases: [] └── regression.yaml # 默认 cases: []
``` ```
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill 不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
目录读取。
ACK 从当前命令指定的 `--project-root/.pouch/ack/` 定位项目状态,不要求在 旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件。
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
## Skill 结构 ## 使用示例
```text
skills/ack/
├── SKILL.md
├── README.md
├── VERSION
├── references/ # 三角色规范、闭环流程和初始化说明
├── templates/ # project.md、tasks.yaml、knowledge.yaml、delivery.yaml、regression.yaml 模板和 schema
├── examples/ # 完整示例
└── scripts/ # 状态校验、任务/知识/回归选择、安全验证执行与结构化 worker launcher
```
`SKILL.md` 是 Agent 的工作流入口。`references/` 是按需读取的稳定规范;
`.pouch/ack/project.md` 只保存当前项目的命令、路径和权限差异;
`.pouch/ack/tasks.yaml` 保存当前任务状态;`.pouch/ack/knowledge.yaml` 保存跨任务复用、
已经独立验证的项目知识护栏。
`.pouch/ack/delivery.yaml` 是测试环境绑定和版本发布的唯一契约;测试环境由 ACK
内部调用 deployer,运行证据记在 `tasks.yaml.deliveryRuns`。
`.pouch/ack/regression.yaml` 是黑盒回归用例目录,运行证据记在
`tasks.yaml.regressionRuns`。
## 检查项目状态
Agent 会从当前 ACK Skill 目录解析校验脚本:
```bash
python3 <ack-skill-dir>/scripts/validate_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_knowledge.py .pouch/ack/knowledge.yaml \
--tasks .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/validate_delivery.py .pouch/ack/delivery.yaml \
--tasks .pouch/ack/tasks.yaml --project-root <project-root>
python3 <ack-skill-dir>/scripts/validate_regression.py .pouch/ack/regression.yaml \
--tasks .pouch/ack/tasks.yaml
```
Coordinator 可以按当前任务上下文做确定性推荐:
```bash
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml
python3 <ack-skill-dir>/scripts/select_tasks.py .pouch/ack/tasks.yaml \
--task-id BUG-001
python3 <ack-skill-dir>/scripts/select_knowledge.py .pouch/ack/knowledge.yaml \
--component web --path web/app.py --tag long-running-service --limit 10
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml
python3 <ack-skill-dir>/scripts/select_regression.py .pouch/ack/regression.yaml \
--suite full --case-id REG-login-001
```
任务选择器会解析并执行完整任务板的内置语义校验,但只输出 `project`、`summary`、
默认可工作状态或显式 `--task-id` 命中的任务,以及这些任务引用的 receipt 和 delivery
run。默认最多 20 条,超过预算时显式失败;Agent 不应回退为把完整 `tasks.yaml` 注入
上下文。
默认 JSON 输出会同时给出固定知识引用和已解析的 `verificationTarget.path/args`;
选择器只输出数据,不执行检查。`scope.all=true` 的全项目 active 规则优先占用
`--limit`;如果全项目规则本身超过预算,选择器会显式失败,不会静默漏派。
需要执行知识项引用的检查时,只传 registry ID 给 ACK 的安全执行入口:
```bash
python3 <ack-skill-dir>/scripts/run_verification.py \
.pouch/ack/knowledge.yaml check-api-contract --project-root <project-root>
```
该入口会在执行前重新校验知识库,只打开一次项目根目录 fd,再从同一个 fd 逐段以
`O_NOFOLLOW` 打开知识库和检查文件;检查内容复制到匿名、尽可能 sealed 的稳定
快照,再以结构化 argv 和 `shell=False` 启动。它不接受临时命令或额外参数。
选择器输出的 path/args 只用于审阅,不应由 Agent 自行拼接执行。Runner 只读取
项目内无 symlink 的权威
`.pouch/ack/knowledge.yaml`,不接受替代知识文件或放宽后的项目根。检查进程的 cwd
和 `ACK_PROJECT_ROOT` 都固定到该根 fd;后者是只在检查进程存活期间有效的
`/proc/self/fd/...` 或 `/dev/fd/...` 路径。原始可读路径另放在
`ACK_PROJECT_ROOT_DISPLAY`,只能用于日志,不能用于资源访问。Runner 还提供
`ACK_VERIFICATION_REF` 和 `ACK_VERIFICATION_PATH`;检查脚本必须据此定位资源,
不能依赖 `$0` 或 `__file__` 所在目录,因为实际执行的是匿名快照。
知识先由 Developer 或 Test 作为 `candidate` 提名,经独立验证和 Coordinator gate
后才能成为 `active`。Coordinator 按路径、组件、依赖、版本和标签推荐相关知识,
确认后将固定 revision 的 `knowledgeRefs` 写入任务上下文;每轮只派发命中的少量
条目,不全量注入知识库。
旧项目只有 `project.md` 和 `tasks.yaml` 时,不要重跑初始化。由 `/ack` 检查现有
状态,获得用户授权后补一个空的 `knowledge.yaml`;如果任务板尚未声明知识库,
同时只补 `project.knowledgeFile: .pouch/ack/knowledge.yaml`,再运行跨文件校验。
只有 Coordinator 写 `tasks.yaml`、`knowledge.yaml` 和 `regression.yaml`。知识正文
不能作为自由 shell 执行;关键约束应继续下沉到测试、lint、CI 或正式规范。ACK
不自动修改项目的 `AGENTS.md`、`CLAUDE.md` 或其它 Agent 指令文件。
## 配置与运行交付
测试环境走 deployer 的项目内布局(通常是 `.pouch/deployer/test`),发版仍用
delivery profile。可以直接说:
```text
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你。
```
ACK 把测试环境写成 `intents.testEnvironment.via: deployer`,把发版写成
`intents.release` 指向的 profile。`.pouch/deployer/<env>` 还没就绪时,ACK
会转去 deployer 的初始化,而不是自己编 compose。首次配置保持关闭,确认后才启用。
之后可以说:
```text ```text
/ack 初始化
/ack 检查配置
/ack 处理这个需求:给后台加导出 CSV
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
/ack 处理飞书里待整理的 bug
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
/ack 重新布一下测试环境,我要测试 /ack 重新布一下测试环境,我要测试
/ack 发布一个版本 /ack 发布一个版本
```
对应 intent 未配置时先问清楚并写回同一文件,不猜测。运行测试环境时 ACK 加载
deployer skill,不复制 compose 命令。本地进程启动写在 `project.md`,不算这个
模式。intent 运行不要求当前有 `verified` 任务;`deliveryRuns.intent` 记录是
测试环境还是发版。
交付配置只允许声明式工具 target 或仓库内可执行脚本,不接受自由 shell,也不保存
凭据值。任务进入 `verified` 后的常规交付仍按确认过的 profile 执行。默认
profile 只能停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须经过
对应 approval 步骤。具体契约见 `references/delivery.md`。
## 启动 Worker
worker 的机器配置位于 `tasks.yaml.project.orchestration`:项目显式维护模型
allowlist、结构化 profile、默认 profile 和允许的 worktree。不得在 `project.md`
或任务里保存完整启动命令、额外 argv 或环境变量。
先审阅计划,不产生终端:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py plan \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree>
```
确认后执行 `launch`,并显式绑定刚审阅的 fingerprint:
```bash
python3 <ack-skill-dir>/scripts/launch_worker.py launch \
--project-root <project-root> \
--task-id <task-id> \
--attempt-id <task-id>-A<round> \
--role developer \
--profile-id codex-dev-standard \
--worktree <allowed-worktree> \
--expected-launch-fingerprint <plan 中的 sha256:...>
```
launcher 是自动创建 worker 的唯一入口:它从权威任务板重读 profile,构造固定
argv,忽略调用者 PATH、使用环境 allowlist,并验证真实 Git worktree 注册;然后
通过仓库外的单次启动记录、terminal-bound nonce/proof 和受限 bootstrap 调用 Orca。
返回的 receipt 含 `runtimeId`、handle、incarnation、profile hash、slot 和 worktree
identity。Coordinator 将 receipt 追加到顶层 `workerReceipts`,再把 receipt ID
写入任务的 `dispatch.developer` 或 `dispatch.test`,并同步写入本轮 `attemptId`。
校验器要求 receipt 与当前 ACK task、角色、profile 和 attempt 完全一致;历史 receipt
不能跨任务或跨轮次改挂。
v0.10 自动 launcher 仅支持 `read-only` 与 `workspace-write`。full-access、
Codex bypass、Cursor YOLO/force、Grok `--yolo` / bypassPermissions 和关闭
sandbox 都会 fail closed;在有可信平台审批或独立签发通道之前,不用项目文件
伪装成用户授权。Grok worker 由 launcher 固定带 `--always-approve`,避免工具调用
停在确认框,sandbox 仍必须启用。v0.17 起 `cli: grok` 是一等 worker CLI;`cli: omp` 使用 OMP 的结构化模型、thinking 和 approval-mode 参数,workspace-write 默认 yolo。旧任务板没有结构化
`project.orchestration` 时仍可读取和手动协作,但不得自动创建 worker。
持久化 `receiptHash` 是无密钥 checksum,不是 launcher 身份证明。ACK 只复用同一轮
内明确空闲、角色/profile/worktree 匹配,并且可以可信清理历史消息、取得新会话身份的
worker;正在执行、等待回报、状态不明或关联未完成任务的 worker 不复用。由于当前
Orca 没有可验证的历史消息清理接口,Orca 派发仍重新 `plan` 并用 expected fingerprint
启动 fresh worker。
fingerprint 只校验完整计划没有漂移,不是一次性令牌;成功后不得用同一 fingerprint
重复启动,结果不确定时必须先 reconcile。
若创建或关闭回执不完整,或外部 launch record 状态无法可靠持久化,launcher 会返回
`indeterminate/reconcile-required`;必须先核对 record 与 Orca live state,不能
直接重试。
Coordinator 最后标记整轮任务完成后,会关闭所有只关联 `verified` 任务的 worker
终端。仍关联 `blocked`、`failed_retest`、`leftover`、其它未完成任务或未解决环境事件
的终端继续保留且不设置 TTL;receipt 与测试证据不会随终端删除。
## 开始一个需求
初始化完成后可以直接说:
```text
/ack 处理这个需求:<一句话需求>
```
Coordinator 会先读取项目状态和 `references/kickoff.md`,生成产品文档、任务拆分与
可观测验收信号;用户确认后才派发实现和复测。任务 `verified` 后会把本轮黑盒路径
收进 `.pouch/ack/regression.yaml`,确认后才写入。
## 修复 bug
可以直接说:
```text
/ack 修这个 bug:<现象、复现、期望>
```
Coordinator 写短问题说明、复现步骤和可观测验收,不写大 PRD;你确认后再派发。
Developer 先补会失败的用例再修,后续闭环与做需求相同。
配了飞书 `bugIntake` 时,飞书是审核前的唯一协作区。你只需维护标题、详细描述和
附件;Coordinator 整理问题说明、期望效果和验收标准并写回飞书。你针对当前
revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
```text
/ack 处理飞书里待整理的 bug
```
## 运行回归
平时做需求和修 bug 结束后,ACK 按本轮内容更新 `.pouch/ack/regression.yaml`。
之后可以单独跑:
```text
/ack 回归 /ack 回归
/ack 跑 full 回归 /ack 跑 full 回归
/ack 回归 REG-login-001 /ack 回归 REG-login-001
/ack 修这些回归失败
``` ```
ACK 先按 deployer 布测试环境,再派独立 Test 按用例用浏览器或 API 执行。失败只 首次配发版也可以一次说清:
报告,不会自动开修;要修再说 `/ack 修这些回归失败`。默认跑 smoke。细则见
`references/regression.md`。
首次配置交付可以说:
```text ```text
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库, /ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。 部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
``` ```
之后处理需求时只需在确认计划中选择 profile: 之后做需求时只要在确认计划里选 profile:
```text ```text
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。 /ack 处理这个需求:…。任务验证通过后执行 review profile。
``` ```
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或 ## Agent 会做什么
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
1. 初始化或检查 `.pouch/ack/`,缺项按统一格式列出;不从 README/CI 猜测并启用交付或知识。
2. 做需求:先写产品文档、任务拆分和可观测验收;修 bug 只写短说明、复现和验收。你确认后再派 Developer 和独立 Test。
3. 需要测试环境时加载 deployer,不自己编 compose,也不把本地 `npm run dev` 当成测试环境。
4. 任务 `verified` 后提议把本轮黑盒路径写入回归目录,你确认后才写。
5. 发版读同一份 `delivery.yaml`。默认最多停在 `validation_ready` 或 `review_ready`;稳定发布或生产部署必须再经过对应批准。
6. 回归失败只报告,不会自动开修。
7. 未获授权不提交、不推送、不创建终端、不部署,也不改项目的 `AGENTS.md` / `CLAUDE.md`。
飞书收件时,你维护标题、详细描述和附件。Coordinator 整理问题说明、期望和验收并写回飞书。你针对当前 revision 审核通过,并亲自把状态改成「已确认」之前,不创建任务、不派 worker。
## 如何判断完成
- 初始化:五个状态文件齐,报告为完成,或列出待配置项和下一步。
- 需求 / bug:任务变为 `verified`,或三轮后记为 `leftover`;回归目录有本轮用例,或明确「无回归」。
- 测试环境:目标环境容器 Up,健康检查通过。
- 发版:停在约定档位;stable / 生产有单独批准记录。
- 回归:列出通过和失败的用例;失败不会被自动拿去修。
## 版本 ## 版本
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 中以合法 SemVer 记录 当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明 从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
`project.deliveryFile` 与 `deliveryRuns`;从 `0.13.0` 起,Coordinator 使用
`select_tasks.py` 获取有预算的任务上下文,不再把完整任务板注入模型;从 `0.16.0` 起,
`delivery.yaml` 可用 `intents.testEnvironment` 与 `intents.release` 把测试环境部署和
版本发布写成用户可单独触发的操作;从 `0.17.0` 起,结构化 worker 路由支持
`cli: grok`(与 Codex、Cursor 并列);从 `0.18.0` 起支持 OMP 的
`cli: omp` profile(精确 provider/model、thinking 与 approval-mode);从 `0.17.1` 起 Grok worker argv 固定带
`--always-approve`,sandbox 仍必开;从 `0.19.0` 起 `intents.testEnvironment` 改为
deployer 绑定,ACK 内部调用 deployer skill 布测试环境,并增加
`.pouch/ack/regression.yaml` 与「运行回归」模式。旧的测试环境 profile ID 字符串不再
执行,需要迁到 `{via: deployer, env: <env>}`。旧项目可以不迁移回归目录而继续使用
原闭环。旧项目的 `kitVersion` 可以继续读取,但建议迁移为 `ackVersion`。
+105 -12
View File
@@ -1,19 +1,37 @@
# builder # builder
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见 按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布,
或把产物拷到目录 / 容器。规范本体见
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的 [references/contract.md](references/contract.md),`scripts/check.py` 是契约的
可执行校验器。 可执行校验器。
## 什么时候使用 ## 什么时候使用
- "用 builder 初始化这个项目" - "用 builder 初始化这个项目"
- "帮我构建这个项目的 DEB / Docker 镜像" - "帮我构建这个项目的 DEB / Docker 镜像 / APK"
- "把 1.2.3 发布到包仓库 / 镜像仓库" - "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
- "检查这个项目的 makefile.builder 是否符合 builder 契约" - "检查这个项目的 makefile.builder 是否符合 builder 契约"
- "看看项目现在的发布流程" - "看看项目现在的发布流程"
只构建不上传时明确说明即可;上传永远需要你显式授权。 只构建不上传时明确说明即可;上传永远需要你显式授权。
## 安装
```bash
pouch add builder # 当前项目
pouch add builder -g # 全局
```
不要 `pouch init builder`。对新项目说「用 builder 初始化」。
## 使用前准备
- Python 3 和 `make`。
- DEB 轨道需要 `dpkg-deb`;Docker 轨道需要 Docker 和 buildx。
- 发布时在项目根 `.env.builder` 填写仓库地址和 token(不要写进 `.env`,也不要进 git)。
- 正式版本号由 [manage-release](../manage-release/README.md) 打 annotated tag;builder 只读当前 HEAD。
- 脏工作树默认拒绝发布。
## 项目接入契约 ## 项目接入契约
对新项目说「用 builder 初始化」。Agent 会探测轨道、按 对新项目说「用 builder 初始化」。Agent 会探测轨道、按
@@ -21,8 +39,9 @@
不改用户已有的 `Makefile` 或 `.env`。 不改用户已有的 `Makefile` 或 `.env`。
1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`, 1. `makefile.builder` 目标:`help/build/clean/version` + 条件 `deb/docker/push*`,
变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`。 变量 `ARCHES/ARCH/VERSION/DIST_DIR/PROJECT_NAME`。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。 `VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
多架构清单是 `ARCHES`,见下方「多架构」。
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。 2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
缺发布键只挡住上传,不挡住构建。 缺发布键只挡住上传,不挡住构建。
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`): 3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`):
@@ -32,11 +51,58 @@ DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main DEB_REPOSITORY=main
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
DOCKER_REGISTRY=registry.example.com DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
``` ```
4. 日常发布就是两条命令: 4. 日常发布就是两条命令:
`make -f makefile.builder deb && make -f makefile.builder push-deb`、 `make -f makefile.builder deb && make -f makefile.builder push-deb`、
`make -f makefile.builder push-docker`。 `make -f makefile.builder push-docker`。
写了 `ARCHES=amd64 arm64` 时,这两条会打出并上传两个 DEB;镜像一次推多平台。
## 多架构
项目要发哪些 CPU 架构,写在**已提交的** `makefile.builder` 里,不要写进
`.env.builder`(那是密钥,换机器会丢)。
```makefile
ARCHES ?= amd64 arm64
```
`ARCH` 是「这一次」:默认等于清单第一项。不传 `ARCH=` 时,`deb` / `push-deb`
按清单逐个做完;`push-docker` 把清单映射成 `linux/amd64,linux/arm64`。
只做其中一个:`make -f makefile.builder deb ARCH=arm64`。
合法值只有 `amd64` 和 `arm64`(空格分隔)。不要默认写成双架构:本机编不出
另一边时,只留一项。
```bash
make -s -f makefile.builder arches # 打印清单,例如 amd64 arm64
make -f makefile.builder deb # 打出两个 .deb
make -f makefile.builder push-deb # 两个都上传(需你授权发布)
make -f makefile.builder push-docker # 一次 buildx 推两个平台
```
产物:
```text
dist/<name>_<version>_amd64.deb
dist/<name>_<version>_arm64.deb
```
`build` 配方必须用 `$(ARCH)`,否则两个包内容会一样。初始化时 Agent 按仓库里
已有命令来写(Go 一般是 `GOOS=linux GOARCH=$(ARCH) go build …`),不另做一套
运行时探测脚本。看不出交叉编法就不要写双架构。
`packaging/DEBIAN/control` 的 `Architecture:` 必须随 `$(ARCH)` 变,不要写死
`amd64`。模板会先拷到 `dist/packaging-$(ARCH)` 再改这一行。
本机临时只推一个平台,可以在 `.env.builder` 设 `PLATFORMS=linux/amd64`,这是
覆盖不是清单。项目清单始终以 `ARCHES` 为准。
存量 `makefile.builder` 没有 `ARCHES` 时行为与以前相同(默认 `amd64`,
`ARCH=arm64` 仍合法)。要改成双架构:加上面那一行,确认 `build` 吃 `$(ARCH)`,
再跑 `python3 -I -S <builder>/scripts/check.py . --ready`。
## 版本号 ## 版本号
@@ -67,20 +133,26 @@ Docker registry/ns/foo:1.4.2
### 测试(其它任何 commit) ### 测试(其它任何 commit)
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的 基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库, patch 机械 +1,再加构建日 `YYYYMMDD`、清洗后的分支名、相对距离、短 SHA。
所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。 这是排序键,不是在选定下一正式 SemVer。测试与正式进**同一个** apt /
Docker 仓库,所以 DEB 必须用 `~` 挂在下一 patch 上:`1.4.2` <
`1.4.3~20260908…` < `1.4.3`。已装上一正式版的机器可以升到 bugfix 测试包;
同号正式版仍盖住测试。
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`: 分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `abc1234`,
构建日 `20260908`:
```text ```text
规范 / DEB 1.4.2~feat-login-v2.7+gabc1234 规范 / DEB 1.4.3~20260908.feat-login-v2.7+gabc1234
文件名 foo_1.4.2~feat-login-v2.7+gabc1234_amd64.deb 文件名 foo_1.4.3~20260908.feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.2-feat-login-v2.7.gabc1234 Docker registry/ns/foo:1.4.3-20260908.feat-login-v2.7.gabc1234
``` ```
Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。 Docker tag 由规范版本映射:`~` → `-`,`+g` → `.g`(Docker 不允许 `~`)。
安装测试包必须显式指定版本或完整 tag,不能靠无参 `apt upgrade`。 无参 `apt upgrade` 会从上一正式版升到下一 patch 的测试包,但不会盖住同号
正式包。生产若不能自动吃到测试包,应拆仓库/组件。构建日取本机当前日期;
设了 `SOURCE_DATE_EPOCH` 则用该时刻的 UTC 日期。
### 分支名清洗 ### 分支名清洗
@@ -96,8 +168,26 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
用 builder 构建当前版本的 DEB 和镜像,先不要上传。 用 builder 构建当前版本的 DEB 和镜像,先不要上传。
用 builder 把当前 commit 的产物发布到项目已配置的仓库。 用 builder 把当前 commit 的产物发布到项目已配置的仓库。
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。 用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
用 builder 把当前版本的 amd64 和 arm64 DEB 打出来并发布。
用 builder 打一份测试 APK 并拷到 File Browser。
``` ```
## Agent 会做什么
1. 初始化:探测 DEB / Docker / Android / iOS / copy 轨道,写出 `makefile.builder` 和 `.env.builder` 骨架,按仓库证据填 `ARCHES`(不默认双架构),不改你已有的 `Makefile` 或 `.env`。
2. 检查:只跑 `scripts/check.py --ready`,不改文件。
3. 构建:走 `make -f makefile.builder`;只获构建授权时不走会自动上传的目标。
4. 发布:先校验产物,再上传。缺发布键、未授权、或可能覆盖同版本时停下问你。
Docker 只有你点名镜像时才走。token 不会写进输出、提交或回复。
## 如何判断完成
- 初始化:`check.py --ready` 的构建项 PASS。只缺发布键算部分完成,仍可构建、不能上传。
- 构建:产物路径、规范版本、架构与 `make -f makefile.builder version` 一致。
- 发布:HTTP 成功或远端 digest 对得上;回复里有包名或镜像引用、SHA-256 / digest、源 commit。
- 测试包不会通过无参 `apt upgrade` 盖住同号正式包;会作为下一 patch 候选盖住上一正式包。
## 脚本一览 ## 脚本一览
| 脚本 | 用途 | | 脚本 | 用途 |
@@ -110,6 +200,9 @@ detached HEAD 用 `detached`,CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH`
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) | | `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 | | `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 | | `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
| `scripts/copy_artifact.sh` | 把产物拷到 `COPY_DEST`(本地目录或 `container:/dir`) |
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、 环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
脏工作树策略等完整规则见 contract.md。 脏工作树策略等完整规则见 contract.md。
+14 -11
View File
@@ -1,12 +1,13 @@
--- ---
name: builder name: builder
description: >- description: >-
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化 初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker 或把产物拷到目录/容器。触发词:初始化 builder、检查 makefile.builder、构建/发布
镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。 deb、推送 apt、构建/发布 Docker 镜像、打 APK/IPA、拷到 File Browser、make push。
未授权不上传。Docker 仅用户点名镜像时才走。
--- ---
# Builder:DEB / Docker 构建发布 # Builder:DEB / Docker / Android / iOS 构建发布
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。** **make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建 项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
@@ -20,15 +21,15 @@ description: >-
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。 - 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
- 检查契约或发布配置:执行「检查」。 - 检查契约或发布配置:执行「检查」。
- 构建、发布、上传:执行「工作流」。不要静默初始化。 - 构建、发布、上传:执行「工作流」。不要静默初始化。
- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。 - 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。 - 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
## 初始化 ## 初始化
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。 1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。 2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
3. 没有 `makefile.builder`:把 `<skill-dir>/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它)。`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成 `push: push-deb push-docker`。不要改用户 Makefile。 3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非用户明确要求按契约改。不要调用 create-makefile(版本规则冲突)。 4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。 5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。 6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
@@ -47,6 +48,8 @@ DEB_SERVER_URL=https://deb.example.com
DEB_REPOSITORY=main DEB_REPOSITORY=main
DEB_TOKEN= DEB_TOKEN=
DOCKER_REGISTRY=registry.example.com DOCKER_REGISTRY=registry.example.com
COPY_DEST=file-server:/srv
COPY_AS=latest.apk
``` ```
## 检查 ## 检查
@@ -59,7 +62,7 @@ DOCKER_REGISTRY=registry.example.com
1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。 1. 跑 `check.py <project-dir>`;需要工具链与发布键时加 `--ready`;要实构 deb 时加 `--build`。契约 FAIL 或轨道工具缺失:停下,转入「初始化」,不要绕过校验,不要改用户 Makefile。只缺发布键:允许构建,禁止上传。未接契约的存量项目按 contract.md §6 发现已有入口,上传仍用 builder 脚本;成功交付一次后引导迁到 `makefile.builder`。
2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。 2. 仅当用户明确要求发布、上传或提交时才上传。不猜测生产端点;不把 token 写入输出、文件、提交或回复;不用 `set -x` 跑含凭据的命令。可能覆盖同版本时先问。脏工作树默认拒绝发布;用户明确接受时设 `ALLOW_UNCOMMITTED=1` 并注明未提交修改。
3. 构建:`make -f makefile.builder build ARCH=<amd64|arm64> VERSION=<version>`;DEB 再 `make -f makefile.builder deb ARCH=<...>`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。 3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
4. DEB 上传前:`verify_deb.sh <exact-package-path.deb>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。 4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
5. 发布优先 `make -f makefile.builder push[-deb|-docker]`,或直接调 `upload_deb.sh` / `publish_docker.sh`。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make。 5. 发布优先 `make -f makefile.builder push[-deb|-docker|-copy]`,或直接调 `upload_deb.sh` / `publish_docker.sh` / `copy_artifact.sh`。未指定 `ARCH=` 时 `push-deb` 上传清单里每一个 `.deb`。拷贝轨道要 `COPY_DEST`(本地目录或 `container:/dir`);`COPY_AS` 给第一份文件额外名字。脚本缺环境变量时加载 `.env.builder`(shell 显式值优先),不读 `.env`。不把 token 当命令行参数;不把脚本复制进项目。多平台镜像只能走 `publish_docker.sh`,不能拆进 make;不要把项目架构清单写进 `.env.builder`。`build` 里禁止 `docker cp`。
6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。 6. 发布成功不能只看「curl/push 已执行」。要有退出码、HTTP 成功或远端 digest;索引异步时报告「上传已接受,索引尚待更新」。最终回复给出包名/镜像引用、版本、架构、SHA-256 或 digest、源 commit、工作区状态和未完成项。
+53 -19
View File
@@ -28,44 +28,62 @@ make -f makefile.builder <target>
| `help` | 分组列出全部目标;首屏含当前版本 | | `help` | 分组列出全部目标;首屏含当前版本 |
| `version` | 输出一行版本号,适合脚本消费 | | `version` | 输出一行版本号,适合脚本消费 |
| `clean` | 只删除明确、受限的构建产物目录 | | `clean` | 只删除明确、受限的构建产物目录 |
| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** | | `build` | 编译/打包主产物;尊重 `ARCH`(单架构原语);**不得内含任何上传动作** |
### 条件目标 ### 条件目标
| 目标 | 适用 | 要求 | | 目标 | 适用 | 要求 |
|------|------|------| |------|------|------|
| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传 | | `deb` | 有 DEB 产物的项目 | 产出 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传。未在命令行/环境指定 `ARCH=` 且 `ARCHES` 多于一项时,按清单逐个递归构建;指定了 `ARCH=` 则只打那一个 |
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** | | `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` | | `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 |
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` | | `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 | | `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` |
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh` |
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
规则: 规则:
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。 1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
有镜像的判据:项目根存在 `Dockerfile`。 有镜像的判据:项目根存在 `Dockerfile`。
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个 Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。
`push` 即可。 拷贝发布判据:配方调用 `copy_artifact.sh`。
2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合;
单轨道项目一个 `push` 即可。
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送" 3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。 (`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。 未指定 `ARCH=` / `PLATFORMS` 时,`publish_docker.sh` 把 `ARCHES` 映射为
`linux/<arch>,…`。
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点/`docker cp`。
5. `build` 保持单架构,供 `deb` 循环调用。不要让 `build` 自己遍历 `ARCHES`。
`build` 禁止 `docker cp`、`copy_artifact.sh`、curl/scp。
## 2. 变量 ## 2. 变量
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
|------|------|------| |------|------|------|
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 | | `ARCHES` | `amd64` | 本项目要发布的架构清单,空格分隔,子集只能是 `amd64` `arm64`。写在 `makefile.builder` 里并提交;不要放进 `.env.builder`。缺省或无 `arches` 目标时视为单架构 `amd64`。不要默认写成双架构 |
| `ARCH` | `$(firstword $(ARCHES))` | 这一次构建的架构。必须落在 `ARCHES` 里;非法值必须 `$(error)` 并提示合法值。命令行/环境传入时只构建/上传这一项 |
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 | | `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
| `DIST_DIR` | `dist` | DEB 产物目录 | | `DIST_DIR` | `dist` | 产物目录(deb / apk / ipa) |
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 | | `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
`ARCHES` 是「这个项目发哪些架构」;`ARCH` 是「这一次编哪个」。宇宙集合仍是 `amd64` \| `arm64`,不开放其它 ISA。
`deb` 配方在打包前必须把 `packaging/DEBIAN/control`(或等价文件)的 `Architecture:` 写成当前 `$(ARCH)`,不要写死 `amd64`。推荐先拷到 `$(DIST_DIR)/packaging-$(ARCH)` 再改,避免弄脏源树。
`build` 配方必须使用 `$(ARCH)`(例如 `GOARCH=$(ARCH)`、`--platform=linux/$(ARCH)`)。配方若忽略 `ARCH`,双架构会打出两个文件名不同、内容却都是宿主机架构的包。Builder 不安装 qemu 或交叉编译器;看不出交叉编法时 `ARCHES` 只留本机一项。
### 版本号 ### 版本号
`make -f makefile.builder version` 输出一行规范版本(无 `v` 前缀)。DEB 的 `make -f makefile.builder version` 输出一行规范版本(无 `v` 前缀)。DEB 的
`Version` 与文件名直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh` `Version` 与文件名直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`
(`makefile.builder` 通过 `scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用 (`makefile.builder` 通过 `scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release 事先打好。 manage-release 选定下一正式 SemVer。测试号把祖先基线的 patch 机械 +1 作为排序键
`version.sh` 不执行 `git fetch`。 (`v1.4.2` 之后 → `1.4.3~…`),不是在宣布下一正式号就是 1.4.3。正式 tag 由
manage-release 事先打好。`version.sh` 不执行 `git fetch`。
推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才 推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才
能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**, 能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**,
@@ -74,17 +92,22 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release
| 判定 | 规范版本 | DEB 文件 | Docker tag | | 判定 | 规范版本 | DEB 文件 | Docker tag |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_<arch>.deb` | `1.4.2` | | HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_<arch>.deb` | `1.4.2` |
| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~<branch>.<n>+g<sha>` | `name_1.4.2~<branch>.<n>+g<sha>_<arch>.deb` | `1.4.2-<branch>.<n>.g<sha>` | | 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.3~<date>.<branch>.<n>+g<sha>` | `name_1.4.3~<date>.<branch>.<n>+g<sha>_<arch>.deb` | `1.4.3-<date>.<branch>.<n>.g<sha>` |
| 祖先中没有稳定 tag | `0.0.0~<branch>+g<sha>` | 同上替换规范版本 | 同上映射 | | 祖先中没有稳定 tag | `0.0.0~<date>.<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使 测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~` 挂在基线的下一
`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含 patch 上:`1.4.2` < `1.4.3~…` < `1.4.3`。已装上一正式版的机器可以升到
`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。 bugfix 测试包;同号正式版仍盖住测试。不要写成 `1.4.2~…`(小于已发布的
`1.4.2`),不要写成 `1.4.2+<date>` / `1.4.3-beta`(Debian 里 `-beta` 大于
`1.4.3`)。日期必须写在 `~` 之后。Docker tag 不得含 `~`、`/`、`:`,由规范
版本把 `~` → `-`、`+g` → `.g`。
`<date>`:构建日 `YYYYMMDD`。已设 `SOURCE_DATE_EPOCH` 时用该时刻的 UTC
日期,否则用本机当前日期。正式 `X.Y.Z` 不加日期。
`<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用 `<branch>`:`git rev-parse --abbrev-ref HEAD`,detached 时用
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续 `detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续
`-`;过长截断(给 base、距离、SHA 留位置;Docker tag 上限 128)。 `-`;过长截断(给 base、日期、距离、SHA 留位置;Docker tag 上限 128)。
`<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把 `<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=` `--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。 可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
@@ -107,6 +130,15 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `DEB_REPOSITORY` | 是 | 目标仓库名 | | `DEB_REPOSITORY` | 是 | 目标仓库名 |
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` | | `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
### 拷贝轨道
| 变量 | 必填 | 说明 |
|---|---|---|
| `COPY_DEST` | 是 | 本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) |
| `COPY_AS` | 否 | 第一份文件的额外 basename,逗号或空格分隔 |
`copy_artifact.sh FILE [FILE ...]` 把已构建文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。不读 `.env`。脏工作树默认拒绝,`ALLOW_UNCOMMITTED=1` 放行。
### Docker 轨道 ### Docker 轨道
| 变量 | 必填 | 说明 | | 变量 | 必填 | 说明 |
@@ -114,7 +146,9 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme | | `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 | | `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
| `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` | | `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` |
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` | | `PLATFORMS` | 否 | 本机/本轮覆盖。未设置时由 `ARCHES` 映射为 `linux/<arch>,…`;再没有则 `linux/amd64`。不要把项目的架构清单只写在这里(`.env.builder` 不进 git) |
`PLATFORMS` 优先级(高 → 低):用户这轮 `--platform` 或已 export 的 `PLATFORMS` → 命令行 `ARCH=` 映射的 `linux/$(ARCH)`(由 `push-docker` 传入)→ `.env.builder` 的 `PLATFORMS` → `makefile.builder` 的 `ARCHES` → `linux/amd64`。
配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。 配置来源优先级:shell 已显式设置的值 > 项目根 `.env.builder` > 失败并询问用户。
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。 `.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
+2 -2
View File
@@ -7,7 +7,7 @@
| Registry | 必须显式确定,例如 `registry.example.com` | | Registry | 必须显式确定,例如 `registry.example.com` |
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 | | Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
| Tag | 必须显式确定;优先使用版本号或 Git SHA | | Tag | 必须显式确定;优先使用版本号或 Git SHA |
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` | | Platform | 用户这轮显式值,否则 `.env.builder` 的 `PLATFORMS`,否则 `makefile.builder` 的 `ARCHES` 映射为 `linux/<arch>,…`,再否则 `linux/amd64` |
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 | | Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
| Context | 默认当前项目根目录 | | Context | 默认当前项目根目录 |
@@ -35,7 +35,7 @@
## Tag 策略 ## Tag 策略
- 未显式给出 `--tag` / `IMAGE_TAG` 时,使用契约「版本号」渲染出的 tag - 未显式给出 `--tag` / `IMAGE_TAG` 时,使用契约「版本号」渲染出的 tag
(正式 `1.4.2`,测试 `1.4.2-feat-login.7.gabc1234`),不要退回 (正式 `1.4.2`,测试 `1.4.3-<date>.feat-login.7.gabc1234`),不要退回
`git describe`,不要询问后另选一套。 `git describe`,不要询问后另选一套。
- 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。 - 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。
- `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布, - `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布,
+243 -27
View File
@@ -32,8 +32,8 @@ ARCH_VALUES = ("amd64", "arm64")
REQUIRED_TARGETS = ("help", "version", "clean", "build") REQUIRED_TARGETS = ("help", "version", "clean", "build")
UPLOAD_TOKENS = ( UPLOAD_TOKENS = (
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ", "curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
"docker push", "buildx build --push", "buildx --push", "upload_deb.sh", "docker push", "docker cp", "buildx build --push", "buildx --push",
"publish_docker.sh", "upload_deb.sh", "publish_docker.sh", "copy_artifact.sh",
) )
SECRET_PATTERNS = ( SECRET_PATTERNS = (
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE), re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
@@ -41,7 +41,7 @@ SECRET_PATTERNS = (
) )
FLOATING_TAGS = (":latest", ":stable") FLOATING_TAGS = (":latest", ":stable")
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$") DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh") VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
BUILDER_MAKEFILE = "makefile.builder" BUILDER_MAKEFILE = "makefile.builder"
BUILDER_ENV = ".env.builder" BUILDER_ENV = ".env.builder"
@@ -78,8 +78,9 @@ def run_make(
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool: def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
err = result.stderr.lower()
return result.returncode != 0 and ( return result.returncode != 0 and (
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower() "no rule to make target" in err or "没有规则可制作目标" in result.stderr
) )
@@ -99,6 +100,33 @@ def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
return lines return lines
def parse_dry_echo(result: subprocess.CompletedProcess[str]) -> str:
"""Recover `@echo …` payload from `make -n` output."""
out_lines = [
ln.lstrip()[5:]
for ln in result.stdout.splitlines()
if ln.lstrip().startswith("echo ")
]
return "\n".join(out_lines).strip()
def read_arches(project: Path) -> list[str] | None:
"""Return ARCHES if the `arches` target exists.
None means a legacy makefile without the target. An empty list means the
target exists but printed nothing usable.
"""
result = run_make(project, "arches")
if has_no_rule(result):
return None
if result.returncode != 0:
return []
raw = parse_dry_echo(result)
if not raw:
return []
return raw.split()
def check_required_targets(report: Report, project: Path) -> dict[str, bool]: def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
present: dict[str, bool] = {} present: dict[str, bool] = {}
lines = [] lines = []
@@ -112,25 +140,68 @@ def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
def check_arch_guard(report: Report, project: Path) -> None: def check_arch_guard(report: Report, project: Path) -> None:
title = "ARCH / ARCHES 守卫与缺省值"
problems: list[str] = []
bad = run_make(project, "build", "ARCH=loongarch") bad = run_make(project, "build", "ARCH=loongarch")
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" in bad.stderr) guard_ok = bad.returncode != 0 and (
"amd64" in bad.stderr or "arm64" in bad.stderr or "ARCH" in bad.stderr
)
default_ok = run_make(project, "build").returncode == 0 default_ok = run_make(project, "build").returncode == 0
lines = [ lines = [
f"invalid ARCH rejected: {'yes' if guard_ok else 'NO'}", f"invalid ARCH rejected: {'yes' if guard_ok else 'NO'}",
f"default ARCH works: {'yes' if default_ok else 'no'}", f"default ARCH works: {'yes' if default_ok else 'no'}",
] ]
hint = "" if guard_ok else "\n Hint: add `$(error ARCH must be amd64 or arm64)` guarded by an ifneq filter." if not guard_ok:
if guard_ok and default_ok: problems.append(
report.add(PASS, 2, "ARCH 守卫与缺省值", "\n".join(lines + hint.splitlines())) "Hint: reject unknown ARCH with `$(error)` naming allowed values "
"(amd64/arm64, or the project's ARCHES)."
)
if not default_ok:
problems.append("`make -n build` without ARCH= must succeed")
arches_make = run_make(project, "arches")
if has_no_rule(arches_make):
lines.append("arches target: absent (legacy; implicit ARCHES = default ARCH)")
elif arches_make.returncode != 0:
problems.append(
f"`make -n arches` failed: {arches_make.stderr.strip() or 'unknown error'}"
)
else: else:
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint) raw = parse_dry_echo(arches_make)
arches = raw.split()
lines.append(f"ARCHES: {raw!r}")
if not arches:
problems.append("arches target printed an empty list")
if "," in raw:
problems.append("ARCHES must be space-separated, not comma-separated")
unknown = [a for a in arches if a not in ARCH_VALUES]
if unknown:
problems.append(f"ARCHES entries must be amd64 or arm64: {unknown}")
if len(arches) != len(set(arches)):
problems.append("ARCHES must not contain duplicates")
outsider = next((a for a in ARCH_VALUES if a not in arches), None)
if outsider:
extra = run_make(project, "build", f"ARCH={outsider}")
extra_ok = extra.returncode != 0
lines.append(
f"ARCH={outsider} rejected (not in ARCHES): "
f"{'yes' if extra_ok else 'NO'}"
)
if not extra_ok:
problems.append(
f"ARCH={outsider} must fail when it is not listed in ARCHES"
)
if problems:
report.add(FAIL, 2, title, "\n".join(lines + [""] + problems))
else:
report.add(PASS, 2, title, "\n".join(lines))
def check_version_output(report: Report, project: Path) -> None: def check_version_output(report: Report, project: Path) -> None:
result = run_make(project, "version") result = run_make(project, "version")
# Dry run: the echoed `@echo <version>` line IS the would-be output. # Dry run: the echoed `@echo <version>` line IS the would-be output.
out_lines = [ln.lstrip()[5:] for ln in result.stdout.splitlines() if ln.lstrip().startswith("echo ")] out = parse_dry_echo(result)
out = "\n".join(out_lines).strip()
single = len(out.splitlines()) == 1 and out != "" single = len(out.splitlines()) == 1 and out != ""
no_v = single and not out.startswith("v") no_v = single and not out.startswith("v")
detail = f"stdout={out!r}" detail = f"stdout={out!r}"
@@ -185,6 +256,24 @@ def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> N
problems.append(f"dpkg-deb --info failed on {built_deb.name}") problems.append(f"dpkg-deb --info failed on {built_deb.name}")
else: else:
problems.append("dpkg-deb unavailable; metadata not verified (--build)") problems.append("dpkg-deb unavailable; metadata not verified (--build)")
arches = read_arches(project)
if arches and len(arches) > 1 and dry.returncode == 0:
blob = dry.stdout + dry.stderr
missing = [
a for a in arches if f"ARCH={a}" not in blob and f"_{a}.deb" not in blob
]
if missing:
problems.append(
"ARCHES has multiple values but `make -n deb` does not fan out to: "
+ ", ".join(missing)
)
pinned = arches[-1]
one = run_make(project, "deb", f"ARCH={pinned}")
one_blob = one.stdout + one.stderr
if f"_{pinned}.deb" not in one_blob and f"ARCH={pinned}" not in one_blob:
problems.append(
f"`make -n deb ARCH={pinned}` does not build that single arch"
)
if problems: if problems:
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems)) report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
else: else:
@@ -196,6 +285,45 @@ def detect_docker_project(project: Path) -> bool:
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists() return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
def detect_android_project(recipe_all: str) -> bool:
return ".apk" in recipe_all or ".aab" in recipe_all
def detect_ios_project(recipe_all: str) -> bool:
return ".ipa" in recipe_all
def detect_copy_project(recipe_all: str) -> bool:
return "copy_artifact.sh" in recipe_all
def check_mobile_recipe(
report: Report, project: Path, *, kind: str, number: int, ext: str
) -> None:
target = "build"
dry = run_make(project, "build")
text = chr(10).join(clean_make_output(dry))
problems = []
if dry.returncode != 0:
problems.append(f"`make -n {target}` failed: {dry.stderr.strip() or 'unknown error'}")
else:
if "dist/" not in text and "$(DIST_DIR)" not in text:
problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location")
wanted = (".apk", ".aab") if kind == "android" else (ext,)
if not any(item in text for item in wanted):
problems.append(f"recipe does not mention {' or '.join(wanted)}")
hits = [token for token in UPLOAD_TOKENS if token in text]
if hits:
problems.append("recipe contains upload commands: " + ", ".join(hits))
if "rm -rf /" in text or "rm -rf ~" in text:
problems.append("recipe contains unrestricted rm -rf")
title = f"{kind} 目标产物形状与纯构建"
if problems:
report.add(FAIL, number, title, "\n".join(problems))
else:
report.add(PASS, number, title, f"{target} writes {ext} under dist/")
def check_docker_recipe(report: Report, project: Path) -> None: def check_docker_recipe(report: Report, project: Path) -> None:
dry = run_make(project, "docker") dry = run_make(project, "docker")
text = chr(10).join(clean_make_output(dry)) text = chr(10).join(clean_make_output(dry))
@@ -226,23 +354,43 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
) )
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None: def check_push_delegates(
targets = ("push-deb", "push-docker") if dual_artifact else ("push",) report: Report,
project: Path,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool,
) -> None:
tracks = []
if deb_project:
tracks.append("deb")
if docker_project:
tracks.append("docker")
if copy_project:
tracks.append("copy")
multi = len(tracks) > 1
targets = tuple(f"push-{t}" for t in tracks) if multi else ("push",)
missing = [] missing = []
inline = [] inline = []
thin = [] thin = []
builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
for target in targets: for target in targets:
dry = run_make(project, target) dry = run_make(project, target)
if has_no_rule(dry): if has_no_rule(dry):
missing.append(target) missing.append(target)
continue continue
text = chr(10).join(clean_make_output(dry)) text = chr(10).join(clean_make_output(dry))
bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text] bad_tokens = [
token
for token in ("curl ", "scp ", "aptly ", "reprepro ", "docker cp")
if token in text
]
if bad_tokens: if bad_tokens:
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})") inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \ elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \
and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \ and "$(BUILDER_SCRIPT)" not in text \
and "publish_docker.sh" not in text: and not any(name in text for name in builder_names):
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)") inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
else: else:
thin.append(target) thin.append(target)
@@ -250,6 +398,20 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
if missing: if missing:
problems.append("missing targets: " + ", ".join(missing)) problems.append("missing targets: " + ", ".join(missing))
problems.extend(inline) problems.extend(inline)
if deb_project:
push_deb_target = "push-deb" if multi else "push"
if push_deb_target not in missing:
arches = read_arches(project)
if arches and len(arches) > 1:
dry = run_make(project, push_deb_target)
if not has_no_rule(dry):
text = chr(10).join(clean_make_output(dry))
absent = [a for a in arches if f"_{a}.deb" not in text]
if absent:
problems.append(
f"ARCHES has multiple values but `make -n {push_deb_target}` "
"does not upload: " + ", ".join(absent)
)
status = PASS if not problems else FAIL status = PASS if not problems else FAIL
detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin) detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin)
report.add(status, 7, "push 仅委托 builder 脚本(薄包装)", detail) report.add(status, 7, "push 仅委托 builder 脚本(薄包装)", detail)
@@ -280,6 +442,7 @@ def check_secrets_and_tags(report: Report, project: Path) -> None:
DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY") DEB_ENV_KEYS = ("DEB_SERVER_URL", "DEB_TOKEN", "DEB_REPOSITORY")
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",) DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
COPY_ENV_KEYS = ("COPY_DEST",)
ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$") ENV_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
@@ -311,7 +474,7 @@ def key_present(key: str, env_keys: set[str]) -> bool:
def check_ready_toolchain( def check_ready_toolchain(
report: Report, *, deb_project: bool, docker_project: bool report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
) -> None: ) -> None:
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"] lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
problems = [] problems = []
@@ -331,6 +494,11 @@ def check_ready_toolchain(
problems.append("install dpkg-dev (or equivalent) to build .deb packages") problems.append("install dpkg-dev (or equivalent) to build .deb packages")
else: else:
lines.append("dpkg-deb: skipped (no deb track)") lines.append("dpkg-deb: skipped (no deb track)")
if copy_project:
docker_ok = shutil.which("docker") is not None
lines.append(
f"docker (for container COPY_DEST): {'found' if docker_ok else 'optional; needed only for container:/dir dest'}"
)
report.add( report.add(
FAIL if problems else PASS, FAIL if problems else PASS,
10, 10,
@@ -340,13 +508,18 @@ def check_ready_toolchain(
def check_ready_env_keys( def check_ready_env_keys(
report: Report, project: Path, *, deb_project: bool, docker_project: bool report: Report,
project: Path,
*,
deb_project: bool,
docker_project: bool,
copy_project: bool = False,
) -> None: ) -> None:
env_keys = env_file_keys(project) env_keys = env_file_keys(project)
lines = [] lines = []
missing: list[str] = [] missing: list[str] = []
if not deb_project and not docker_project: if not deb_project and not docker_project and not copy_project:
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track") report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track")
return return
if deb_project: if deb_project:
for key in DEB_ENV_KEYS: for key in DEB_ENV_KEYS:
@@ -364,6 +537,14 @@ def check_ready_env_keys(
missing.append(key) missing.append(key)
else: else:
lines.append("DOCKER_*: skipped (no docker track)") lines.append("DOCKER_*: skipped (no docker track)")
if copy_project:
for key in COPY_ENV_KEYS:
found = key_present(key, env_keys)
lines.append(f"{key}: {'present' if found else 'MISSING'}")
if not found:
missing.append(key)
else:
lines.append("COPY_*: skipped (no copy track)")
if missing: if missing:
lines.extend( lines.extend(
[ [
@@ -453,7 +634,7 @@ def main(argv: list[str] | None = None) -> int:
) )
skip_detail = f"(no {BUILDER_MAKEFILE})" skip_detail = f"(no {BUILDER_MAKEFILE})"
for number, title, detail in ( for number, title, detail in (
(2, "ARCH 守卫与缺省值", skip_detail), (2, "ARCH / ARCHES 守卫与缺省值", skip_detail),
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail), (3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
(4, "build 不含上传动作", skip_detail), (4, "build 不含上传动作", skip_detail),
(5, "deb 目标产物形状与纯构建", skip_detail), (5, "deb 目标产物形状与纯构建", skip_detail),
@@ -480,7 +661,17 @@ def main(argv: list[str] | None = None) -> int:
# Gather every recipe once via dry-running all known targets (best effort). # Gather every recipe once via dry-running all known targets (best effort).
recipe_all_parts = [] recipe_all_parts = []
for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"): for target in (
*REQUIRED_TARGETS,
"deb",
"docker",
"android",
"ios",
"push",
"push-deb",
"push-docker",
"push-copy",
):
result = run_make(project, target) result = run_make(project, target)
if result.returncode == 0: if result.returncode == 0:
recipe_all_parts.append(result.stdout) recipe_all_parts.append(result.stdout)
@@ -491,13 +682,16 @@ def main(argv: list[str] | None = None) -> int:
built_deb: Path | None = None built_deb: Path | None = None
deb_project = detect_deb_project(recipe_all, project) deb_project = detect_deb_project(recipe_all, project)
docker_project = detect_docker_project(project) docker_project = detect_docker_project(project)
android_project = detect_android_project(recipe_all)
ios_project = detect_ios_project(recipe_all)
copy_project = detect_copy_project(recipe_all)
if present["build"]: if present["build"]:
check_arch_guard(report, project) check_arch_guard(report, project)
check_version_output(report, project) check_version_output(report, project)
check_build_has_no_upload(report, project) check_build_has_no_upload(report, project)
else: else:
report.add(SKIP, 2, "ARCH 守卫与缺省值", "(build target missing)") report.add(SKIP, 2, "ARCH / ARCHES 守卫与缺省值", "(build target missing)")
report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)") report.add(SKIP, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)")
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)") report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
@@ -516,16 +710,38 @@ def main(argv: list[str] | None = None) -> int:
else: else:
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)") report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
dual = deb_project and docker_project if android_project:
check_push_delegates(report, project, dual) check_mobile_recipe(report, project, kind="android", number=12, ext=".apk")
else:
report.add(SKIP, 12, "android 目标产物形状与纯构建", "(not an Android project)")
if ios_project:
check_mobile_recipe(report, project, kind="ios", number=13, ext=".ipa")
else:
report.add(SKIP, 13, "ios 目标产物形状与纯构建", "(not an iOS project)")
check_push_delegates(
report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
)
check_secrets_and_tags(report, project) check_secrets_and_tags(report, project)
check_script_paths(report) check_script_paths(report)
if args.ready: if args.ready:
check_ready_toolchain( check_ready_toolchain(
report, deb_project=deb_project, docker_project=docker_project report,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
) )
check_ready_env_keys( check_ready_env_keys(
report, project, deb_project=deb_project, docker_project=docker_project report,
project,
deb_project=deb_project,
docker_project=docker_project,
copy_project=copy_project,
) )
total_fail = report.failures total_fail = report.failures
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
COPY_DEST=/path/or/container:/dir \
copy_artifact.sh FILE [FILE ...]
Copy built artifacts to COPY_DEST. Dest is a local directory, or a Docker
copy target `container:/absolute/dir` (docker cp). Does not read `.env`.
Environment (project root .env.builder; shell values win):
COPY_DEST Required. Local dir or container:/dir
COPY_AS Optional. Extra basenames for the first file
(comma or space separated)
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
Options:
-d DEST Override COPY_DEST
-a NAMES Override COPY_AS
--dry-run Print the copies without executing
-h Show help
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
EOF
}
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; then
while IFS='=' read -r key value; do
key=${key%%[[:space:]]*}
[[ -z "$key" || "$key" == \#* ]] && continue
if [[ -n "${!key:-}" ]]; then
continue
fi
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
[[ -z "$value" ]] && continue
printf -v "$key" '%s' "$value"
export "$key"
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
fi
dest=${COPY_DEST:-}
extra_as=${COPY_AS:-}
dry_run=false
while (($#)); do
case "$1" in
-d) dest=$2; shift 2 ;;
-a) extra_as=$2; shift 2 ;;
--dry-run) dry_run=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) echo "Error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) break ;;
esac
done
if [[ -z "$dest" || $# -eq 0 ]]; then
echo "Error: COPY_DEST and at least one file are required." >&2
echo "Set them in the environment or the project root .env.builder." >&2
usage >&2
exit 2
fi
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
exit 3
fi
fi
is_docker_dest() {
local d=$1
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \
&& [[ "$d" != *://* ]] \
&& [[ "$d" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*:/ ]]
}
docker_container=
docker_dir=
local_dir=
if is_docker_dest "$dest"; then
docker_container=${dest%%:*}
docker_dir=${dest#*:}
if [[ -z "$docker_container" || -z "$docker_dir" || "$docker_dir" != /* ]]; then
echo "Error: docker dest must be container:/absolute/dir" >&2
exit 2
fi
docker_dir=${docker_dir%/}
else
local_dir=${dest%/}
fi
split_as() {
local raw=$1
raw=${raw//,/ }
# shellcheck disable=SC2086
printf '%s\n' $raw
}
copy_one() {
local src=$1 name=$2
if [[ ! -f "$src" ]]; then
echo "Error: not a file: $src" >&2
exit 2
fi
if [[ -n "$docker_container" ]]; then
local target="${docker_container}:${docker_dir}/${name}"
if $dry_run; then
echo "docker cp $src $target"
return
fi
docker cp "$src" "$target"
docker exec "$docker_container" ls -l "${docker_dir}/${name}" >/dev/null
echo "copied $(basename -- "$src") -> $target"
else
local target="${local_dir}/${name}"
if $dry_run; then
echo "cp $src $target"
return
fi
mkdir -p "$local_dir"
cp -a "$src" "$target"
test -f "$target"
echo "copied $(basename -- "$src") -> $target"
fi
}
first=true
success=0
for src in "$@"; do
copy_one "$src" "$(basename -- "$src")"
if $first; then
first=false
if [[ -n "$extra_as" ]]; then
while IFS= read -r alias; do
[[ -z "$alias" ]] && continue
copy_one "$src" "$alias"
done < <(split_as "$extra_as")
fi
fi
if ! $dry_run && command -v sha256sum >/dev/null 2>&1; then
sha256sum -- "$src"
fi
success=$((success + 1))
done
echo "copy_artifact: $success file(s) to $dest"
+26 -2
View File
@@ -17,7 +17,8 @@ usage() {
# DOCKER_REGISTRY Required (or --registry) # DOCKER_REGISTRY Required (or --registry)
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository) # DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
# IMAGE_TAG Optional, default: version.sh --docker (or --tag) # IMAGE_TAG Optional, default: version.sh --docker (or --tag)
# PLATFORMS Optional, default: linux/amd64 (or --platform) # PLATFORMS Optional. Else makefile.builder ARCHES mapped to
# linux/<arch>,… ; else linux/amd64 (or --platform)
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file) # DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
# DOCKER_CONTEXT Optional, default: . (--context) # DOCKER_CONTEXT Optional, default: . (--context)
# DOCKER_BUILDER Optional buildx builder name (--builder) # DOCKER_BUILDER Optional buildx builder name (--builder)
@@ -53,7 +54,7 @@ fi
registry=${DOCKER_REGISTRY:-} registry=${DOCKER_REGISTRY:-}
repository=${DOCKER_REPOSITORY:-$git_repo_name} repository=${DOCKER_REPOSITORY:-$git_repo_name}
tag=${IMAGE_TAG:-} tag=${IMAGE_TAG:-}
platform=${PLATFORMS:-linux/amd64} platform=${PLATFORMS:-}
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile} dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
build_context=${DOCKER_CONTEXT:-.} build_context=${DOCKER_CONTEXT:-.}
builder=${DOCKER_BUILDER:-} builder=${DOCKER_BUILDER:-}
@@ -126,6 +127,29 @@ if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2 echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
exit 3 exit 3
fi fi
if [[ -z "$platform" ]]; then
if [[ -n "$project_root" && -f "$project_root/makefile.builder" ]]; then
arches=$(make -C "$project_root" -f makefile.builder -s --no-print-directory arches 2>/dev/null || true)
mapped=
for arch in $arches; do
case "$arch" in
amd64|arm64)
if [[ -n "$mapped" ]]; then
mapped="$mapped,linux/$arch"
else
mapped="linux/$arch"
fi
;;
*)
echo "Error: unsupported ARCHES entry from makefile.builder: $arch" >&2
exit 2
;;
esac
done
platform=$mapped
fi
platform=${platform:-linux/amd64}
fi
if [[ "$mode" == load && "$platform" == *,* ]]; then if [[ "$mode" == load && "$platform" == *,* ]]; then
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2 echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
exit 2 exit 2
+28 -5
View File
@@ -11,8 +11,11 @@ With --docker, print the Docker tag rendering of that version.
Derivation (builder contract «版本号»): Derivation (builder contract «版本号»):
official HEAD exact-match of vX.Y.Z → X.Y.Z official HEAD exact-match of vX.Y.Z → X.Y.Z
test nearest ancestor stable tag → X.Y.Z~branch.n+gSHA test nearest ancestor stable tag vX.Y.Z → X.Y.(Z+1)~YYYYMMDD.branch.n+gSHA
no tag no stable tag reachable from HEAD → 0.0.0~branch+gSHA no tag no stable tag reachable from HEAD → 0.0.0~YYYYMMDD.branch+gSHA
YYYYMMDD is the build date. SOURCE_DATE_EPOCH, if set, is that instant in
UTC; otherwise the local calendar date. Official X.Y.Z never gets a date.
Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not
the highest version in the repository. This script does not fetch tags. the highest version in the repository. This script does not fetch tags.
@@ -59,6 +62,15 @@ is_official_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] [[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
} }
# Mechanical sort key: next patch of a stable tag / X.Y.Z version.
# v1.2.0 → 1.2.1. Not a claim that the next official release is 1.2.1.
bump_patch() {
local v=$1 major minor patch
v=${v#v}
IFS=. read -r major minor patch <<<"$v"
printf '%s.%s.%s' "$major" "$minor" "$((10#$patch + 1))"
}
# Strip one leading v if present. # Strip one leading v if present.
strip_v() { strip_v() {
local v=$1 local v=$1
@@ -100,6 +112,16 @@ short_sha() {
gitc rev-parse HEAD | cut -c1-7 gitc rev-parse HEAD | cut -c1-7
} }
# Build-day YYYYMMDD. SOURCE_DATE_EPOCH (UTC) wins when set, for reproducible
# test packages; otherwise the machine's current local date.
build_date() {
if [[ -n "${SOURCE_DATE_EPOCH:-}" ]]; then
date -u -d "@${SOURCE_DATE_EPOCH}" +%Y%m%d
else
date +%Y%m%d
fi
}
# Highest stable tag pointing at HEAD, or empty. # Highest stable tag pointing at HEAD, or empty.
official_tag_at_head() { official_tag_at_head() {
local tag best= local tag best=
@@ -135,7 +157,7 @@ nearest_stable_tag() {
} }
derive_canonical() { derive_canonical() {
local tag base n sha branch local tag base n sha branch ymd
tag=$(official_tag_at_head) tag=$(official_tag_at_head)
if [[ -n "$tag" ]]; then if [[ -n "$tag" ]]; then
printf '%s' "${tag#v}" printf '%s' "${tag#v}"
@@ -143,13 +165,14 @@ derive_canonical() {
fi fi
sha=$(short_sha) sha=$(short_sha)
branch=$(branch_slug) branch=$(branch_slug)
ymd=$(build_date)
base=$(nearest_stable_tag) base=$(nearest_stable_tag)
if [[ -z "$base" ]]; then if [[ -z "$base" ]]; then
printf '0.0.0~%s+g%s' "$branch" "$sha" printf '0.0.0~%s.%s+g%s' "$ymd" "$branch" "$sha"
return return
fi fi
n=$(gitc rev-list --count "${base}..HEAD") n=$(gitc rev-list --count "${base}..HEAD")
printf '%s~%s.%s+g%s' "${base#v}" "$branch" "$n" "$sha" printf '%s~%s.%s.%s+g%s' "$(bump_patch "$base")" "$ymd" "$branch" "$n" "$sha"
} }
to_docker() { to_docker() {
+3 -1
View File
@@ -10,4 +10,6 @@
# DOCKER_REGISTRY= # DOCKER_REGISTRY=
# DOCKER_REPOSITORY= # DOCKER_REPOSITORY=
# IMAGE_TAG= # IMAGE_TAG=
# PLATFORMS= # PLATFORMS= # optional override; project ARCHES lives in makefile.builder
# COPY_DEST= # local dir, or docker dest container:/absolute/dir
# COPY_AS= # optional extra basenames for the first file
@@ -0,0 +1,57 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# Android 轨道:build 产出 dist/<name>_<version>_<arch>.apk,push 走 copy_artifact.sh。
# ARCHES 是构建机 ISA;APK 可含多 ABI。不要把这些目标写进用户 Makefile。
ARCHES ?= amd64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
include $(HOME)/.pouch/skills/builder/scripts/version.mk
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
APK_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).apk
.PHONY: help version arches clean build android push
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 Release APK"
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实 assemble 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。
build:
mkdir -p $(DIST_DIR)
cp app-release.apk $(APK_OUT)
android: build
push:
$(BUILDER_SCRIPTS)/copy_artifact.sh $(APK_OUT)
+43 -8
View File
@@ -2,13 +2,26 @@
# 不要把这些目标写进用户的 Makefile / makefile。 # 不要把这些目标写进用户的 Makefile / makefile。
# 初始化时按项目轨道删掉未使用的 deb / docker 段。 # 初始化时按项目轨道删掉未使用的 deb / docker 段。
# 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。 # 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。
# build 必须尊重 $(ARCH)。要发双架构时把 arm64 写进 ARCHES,不要改 .env.builder。
# #
# include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。 # include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。
ARCH ?= amd64 ARCHES ?= amd64
ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH)) ARCH ?= $(firstword $(ARCHES))
$(error ARCH must be amd64 or arm64) ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
# Fan-out over ARCHES only when ARCH was not set on the CLI/environment.
_ARCH_FROM_FILE := $(filter file default undefined,$(origin ARCH))
_MULTI_ARCH := $(and $(_ARCH_FROM_FILE),$(filter-out 1,$(words $(ARCHES))))
_PUSH_DEB_ARCHES := $(if $(_ARCH_FROM_FILE),$(ARCHES),$(ARCH))
PROJECT_NAME ?= $(notdir $(CURDIR)) PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist DIST_DIR ?= dist
@@ -21,40 +34,62 @@ endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
.PHONY: help version clean build deb docker push push-deb push-docker .PHONY: help version arches clean build deb docker push push-deb push-docker
help: help:
@echo "$(PROJECT_NAME) $(VERSION)" @echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build" @echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build"
@echo " make -f makefile.builder version - 打印规范版本 / canonical version" @echo " make -f makefile.builder version - 打印规范版本 / canonical version"
@echo " make -f makefile.builder arches - 打印架构清单 / print ARCHES"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)" @echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder deb - 打 DEB(只构建不上传)" @echo " make -f makefile.builder deb - 打 DEB(按 ARCHES;只构建不上传)"
@echo " make -f makefile.builder docker - 本地单平台镜像" @echo " make -f makefile.builder docker - 本地单平台镜像"
@echo " make -f makefile.builder push / push-deb / push-docker" @echo " make -f makefile.builder push / push-deb / push-docker"
version: version:
@echo $(VERSION) @echo $(VERSION)
arches:
@echo $(ARCHES)
clean: clean:
rm -rf $(DIST_DIR) rm -rf $(DIST_DIR)
# 替换为项目真实构建命令。不得 curl/scp/docker push。 # 替换为项目真实构建命令,且必须使用 $(ARCH)。不得 curl/scp/docker push。
build: build:
mkdir -p $(DIST_DIR) mkdir -p $(DIST_DIR)
@echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)" @echo "TODO: build $(PROJECT_NAME) for linux/$(ARCH)"
ifdef _MULTI_ARCH
deb:
$(foreach a,$(ARCHES),$(MAKE) -f makefile.builder --no-print-directory deb ARCH=$(a) &&) true
else
deb: build deb: build
mkdir -p $(DIST_DIR) mkdir -p $(DIST_DIR)
dpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb rm -rf $(DIST_DIR)/packaging-$(ARCH)
cp -a packaging $(DIST_DIR)/packaging-$(ARCH)
sed -e 's/^Architecture:.*/Architecture: $(ARCH)/' \
$(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control \
> $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped
mv $(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control.stamped \
$(DIST_DIR)/packaging-$(ARCH)/DEBIAN/control
dpkg-deb --build $(DIST_DIR)/packaging-$(ARCH) $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb
endif
docker: docker:
docker build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) . docker build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) .
push-deb: push-deb:
$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb $(BUILDER_SCRIPTS)/upload_deb.sh $(foreach a,$(_PUSH_DEB_ARCHES),$(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(a).deb)
ifdef _ARCH_FROM_FILE
push-docker: push-docker:
$(BUILDER_SCRIPTS)/publish_docker.sh $(BUILDER_SCRIPTS)/publish_docker.sh
else
push-docker:
PLATFORMS=linux/$(ARCH) $(BUILDER_SCRIPTS)/publish_docker.sh
endif
# 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker # 单产物项目只保留一条 push。双产物改为:push: push-deb push-docker
push: push-deb push: push-deb
@@ -0,0 +1,57 @@
# Builder 契约文件。放到项目根,用 `make -f makefile.builder <target>`。
# iOS 轨道:build 产出 dist/<name>_<version>_<arch>.ipa,push 走 copy_artifact.sh。
# ARCHES 是构建机 ISA。不要把这些目标写进用户 Makefile。
ARCHES ?= arm64
ARCH ?= $(firstword $(ARCHES))
ifeq ($(strip $(ARCHES)),)
$(error ARCHES must not be empty)
endif
ifneq ($(filter-out amd64 arm64,$(ARCHES)),)
$(error ARCHES entries must be amd64 or arm64)
endif
ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))
$(error ARCH must be one of: $(ARCHES))
endif
PROJECT_NAME ?= $(notdir $(CURDIR))
DIST_DIR ?= dist
ifneq ($(BUILDER_SKILL_DIR),)
include $(BUILDER_SKILL_DIR)/scripts/version.mk
else
include $(HOME)/.pouch/skills/builder/scripts/version.mk
endif
BUILDER_SCRIPTS := $(or $(BUILDER_SKILL_DIR),$(HOME)/.pouch/skills/builder)/scripts
IPA_OUT := $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).ipa
.PHONY: help version arches clean build ios push
help:
@echo "$(PROJECT_NAME) $(VERSION)"
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建 IPA"
@echo " make -f makefile.builder version - 打印规范版本"
@echo " make -f makefile.builder arches - 打印架构清单"
@echo " make -f makefile.builder clean - 删除 $(DIST_DIR)"
@echo " make -f makefile.builder push - copy_artifact.sh 拷到 COPY_DEST"
version:
@echo $(VERSION)
arches:
@echo $(ARCHES)
clean:
rm -rf $(DIST_DIR)
# 替换为项目真实 xcodebuild/export 命令,且必须使用 $(ARCH)。不得 curl/scp/docker cp。
build:
mkdir -p $(DIST_DIR)
cp app.ipa $(IPA_OUT)
ios: build
push:
$(BUILDER_SCRIPTS)/copy_artifact.sh $(IPA_OUT)
@@ -0,0 +1,55 @@
# declarative-openspec-loop
你提供「怎样算符合预期」的校验方式,Agent 按 OpenSpec 走 propose → apply → 校验,不通过就分析原因再迭代,直到通过。
## 什么时候使用
- 已经能说清目标,也有办法判断结果对不对,希望 Agent 自动做到通过为止。
- 会说「声明式编程」「按预期循环」「做到校验通过」。
- 不适合:还在选型、还没有校验手段、只想改一小处且自己盯着看。
## 安装
```bash
pouch add declarative-openspec-loop # 当前项目
pouch add declarative-openspec-loop -g # 全局
```
## 使用前准备
- 本机已安装 [openspec](https://openspec.dev/) CLI。
- 当前 Agent 已安装 OpenSpec 的三个子 skill:`openspec-explore`、`openspec-propose`、`openspec-apply-change`。
- 你必须给出校验方式,缺了 Agent 会先问,不会空跑。可用下面任意组合:
- 校验命令,退出码 0 即通过,比如 `make test-one2`
- 预期文档,里面有校验项表
- 清单:日志关键字、输出文件、数值容差
- 自由描述:Agent 会先归纳成可执行检查,向你确认后再跑
## 使用示例
```text
按声明式循环做:让登录失败时返回统一错误码。
校验:跑 pytest tests/test_login.py,全部 green 即通过。最多 3 轮。
```
```text
目标:拟合轮廓与 docs/tmp/expect_desc.md 一致。
校验:make test-one2,且日志覆盖文档「校验项」表全部 8 项。
```
## Agent 会做什么
1. 记下 change 名称和校验方式,写到 `docs/tmp/<change>/expectation.md`。
2. 必要时先探索;然后 propose 出 proposal / design / tasks,每个任务尽量带验收。
3. apply 实现,再跑你给的校验。
4. 失败按原因分级:小 bug 直接修;任务遗漏就补 tasks;方向错了才改设计。默认最多 5 轮(你可改上限)。同一错误连续 2 轮会停下来问你。
5. 通过后给出变更摘要和思路,过程记录留在 `docs/tmp/<change>/`。
不会在没校验方式时开跑,也不会到上限后自己放弃或无限重试。
## 如何判断完成
- 你指定的校验命令退出码 0,或校验项全部满足。
- Agent 给出变更文件列表和思路。
- `docs/tmp/<change>/` 里有 `expectation.md`、每轮 `iteration_*.md` 和校验原始输出。
- 到上限或反复失败时停下,并说明当前进度和未通过项,而不是假装完成。
+18 -7
View File
@@ -22,9 +22,18 @@
- 需要一张「哪台机器跑哪些服务」的清单 - 需要一张「哪台机器跑哪些服务」的清单
- 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署 - 镜像要进 Kubernetes,走 Argo CD:改 GitOps、开 MR、合并后部署
## 安装
```bash
pouch add deployer # 当前项目
pouch add deployer -g # 全局
```
不要 `pouch init deployer`。对新项目说「用 deployer 初始化」。
## 使用前准备 ## 使用前准备
- Compose:本机 Python 3、`rsync`、`ssh`;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent) - Compose:本机 Python 3、`ssh`;有 `rsync` 更好,没有则 sync 用 `scp` 兜底;目标机器 Docker Compose v2;`~/.ssh/config` 免密(或 agent)
- 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖) - 知道每个 Compose 服务的运行时数据放在哪(这些目录不能被同步覆盖)
- Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。 - Argo CD:能 clone/push GitOps 仓库(SSH 或已登录的 HTTPS),并能开 MR(GitLab `glab` / GitHub `gh` / Gitea `tea`)。本机不必长期放一份 GitOps checkout。
@@ -50,6 +59,7 @@ web1 能出网,直接让它从 https://... 把包拉下来装
# 项目内环境 # 项目内环境
给这个项目建好 .pouch/deployer,prod 和 test 分别放到两台机器上 给这个项目建好 .pouch/deployer,prod 和 test 分别放到两台机器上
把 test 环境重新部署一下 把 test 环境重新部署一下
本机测试镜像已经有了,直接重启,别 sync
prod 的 compose 加个 redis,改完发上去 prod 的 compose 加个 redis,改完发上去
# Argo CD # Argo CD
@@ -62,8 +72,8 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径; 1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置 项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`) 2. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启) 3. 否则用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启)
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖 4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
5. 同步后查看容器状态和日志确认生效 5. 同步后查看容器状态和日志确认生效
6. 只针对你指定的那一个服务操作,不会批量动整台机器 6. 只针对你指定的那一个服务操作,不会批量动整台机器
@@ -73,13 +83,14 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`), 项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。 避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据 重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。 数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、
清理远程文件的操作会先向你确认。
## 如何判断完成 ## 如何判断完成
- 脚本输出显示同步完成、远程命令执行成功 - 本机:`docker compose ps` 容器 Up。远程:sync 成功且 `remote.py ps` 容器 Up
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致 - `logs` 无报错;升级后镜像 tag 与配置一致
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动 - deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
- 域名/端口类服务能 curl 通 - 域名/端口类服务能 curl 通
- Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问 - Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问
+16 -5
View File
@@ -19,10 +19,10 @@ description: >-
- 初始化、接入 deployer,或给新项目建测试/生产环境:执行「初始化」。 - 初始化、接入 deployer,或给新项目建测试/生产环境:执行「初始化」。
- 检查 `.pouch/deployer`、node、compose 是否齐全:执行「检查」。 - 检查 `.pouch/deployer`、node、compose 是否齐全:执行「检查」。
- 部署、同步、升级、重启、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。 - 部署、重新部署、同步、升级、重启、布测试环境、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
- ArgoCD / GitOps / 开 MR 部署:执行「Argo CD」。步骤见 [argocd.md](references/argocd.md)。 - ArgoCD / GitOps / 开 MR 部署:执行「Argo CD」。步骤见 [argocd.md](references/argocd.md)。
- 两者都有且意图不清:先问。 - 两者都有且意图不清:先问。
- 不适用:单机 docker、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。 - 不适用:没有 `.pouch/deployer/` 的随手 `docker compose`、Nomad、常规 `kubectl apply`、构建并推送镜像(走 builder)。本机 node 仍走本 skill,镜像已在本机则跳过 sync。
## 边界 ## 边界
@@ -56,7 +56,7 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
已具备: … 已具备: …
待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步 待配置: 路径 + 字段 + 可粘贴示例 + 缺了会挡住哪步
工具链: ssh / rsync(缺则怎么装,不擅自安装) 工具链: ssh / rsync(无 rsync 时 sync 用 scp 兜底,不擅自安装)
下一步: 一句话 下一步: 一句话
``` ```
@@ -66,11 +66,22 @@ python3 -I -S <skill-dir>/scripts/deploy/check.py --project <project-root>
## 被 ACK 调用 ## 被 ACK 调用
ACK 的「运行测试环境」和回归前布环境会加载本 skill,对 `.pouch/deployer/<env>`(通常是 `test`)按「Compose 操作」执行。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。 ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的默认路径布测试环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
## Compose 操作 ## Compose 操作
读 [compose.md](references/compose.md)。用 `list.py` 摸底,再按意图对**单个服务**执行 sync/up/recreate/upgrade/restart。解析 `_config.yaml` 见 [config-reference.md](references/config-reference.md)。sync 报错但 ssh 正常时,按 compose.md 的 NAS 兜底处理。每次操作后 `remote.py <svc> ps` / `logs` 验证。 重启 / 重新部署 / 同步 / 布测试环境走默认路径。不要读 compose.md、不要初始化、不要读 Makefile。
1. 在项目根执行:`python3 -I -S <skill-dir>/scripts/deploy/list.py`
2. 只操作**一个**服务:用户点名的 env;「测试」→ `test`,没有 `test` 就用非 `prod` 的那个。未点名 `prod` 不要动它。
3. 不要改 compose tag、不要构建镜像、不要看其它环境。
4. 本机镜像:node 的 ssh `HostName` 是 `127.0.0.1`/`localhost`,且 `docker image inspect` 看得到 compose 里的 tag → **不要** `sync.py` / `remote.py`。进 `.pouch/deployer/<env>/` 执行 `docker compose up -d --force-recreate`,再 `ps`。完。
5. 否则才 sync + remote:`python3 -I -S <skill-dir>/scripts/deploy/sync.py <svc>` 然后 `python3 -I -S <skill-dir>/scripts/deploy/remote.py <svc> recreate`。只要重启:`remote.py <svc> restart`。
6. 远程路径验证:`remote.py <svc> ps`;异常再 `logs`。完。
compose 用 `image:` 且用户说刚合并了代码:现有 tag 对不上这次提交时,**一句问**要不要先重建镜像,然后停。不要自己改 tag、不要走 builder。
新增/下线、deb、镜像 tag 升级、NAS 失败:再读 [compose.md](references/compose.md)。字段见 [config-reference.md](references/config-reference.md)。
## Argo CD ## Argo CD
+4 -2
View File
@@ -81,6 +81,8 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 意图 | 命令 | | 意图 | 命令 |
|------|------| |------|------|
| 本机已有镜像(node 是 localhost,image 已在本机 docker) | 不要 sync。在 env 目录 `docker compose up -d --force-recreate` |
| 重新部署远程环境 | `sync.py <svc> && remote.py <svc> recreate` |
| 只同步文件,不动容器 | `sync.py <svc>` | | 只同步文件,不动容器 | `sync.py <svc>` |
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` | | 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
| 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) | | 改配置后强制重建 | `remote.py <svc> recreate`(配合前置 sync) |
@@ -88,9 +90,9 @@ python3 <skill-dir>/scripts/deploy/sync.py <service-path>
| 仅重启,不同步文件 | `remote.py <svc> restart` | | 仅重启,不同步文件 | `remote.py <svc> restart` |
| 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` | | 排查 | `remote.py <svc> ps` / `remote.py <svc> logs` |
若项目有 Makefile 封装,命令形如 `make deploy TGT=<服务路径>`。没有 Makefile 时直接调 python 脚本,不要新建封装层。 脚本一律 `python3 -I -S <skill-dir>/scripts/deploy/<name>.py`。有 `make deploy TGT=` 才用 Makefile;没有就直接调脚本,不要读 Makefile 去发明封装。
`sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 tar over SSH 兜底。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。 `sync.py` 使用 `rsync -avz --delete`;本机无 rsync 时 scp 兜底(不 `--delete`)。运行时数据必须放在默认排除的 `data/`、`_data/`,或 compose 挂载的远程绝对路径,否则会被清掉。
## 新增服务 / 环境 ## 新增服务 / 环境
+1 -1
View File
@@ -117,7 +117,7 @@ def check_toolchain(report: Report) -> None:
rsync_ok = shutil.which("rsync") is not None rsync_ok = shutil.which("rsync") is not None
lines = [ lines = [
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}", f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}", f"rsync: {'found' if rsync_ok else 'MISSING (scp fallback)'}",
] ]
if not ssh_ok: if not ssh_ok:
lines.append("install openssh-client") lines.append("install openssh-client")
+5 -1
View File
@@ -31,7 +31,11 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from lib import ( _SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import ( # noqa: E402
project_root, project_root,
run_remote, run_remote,
scp_base_args, scp_base_args,
+6 -1
View File
@@ -6,8 +6,13 @@ from __future__ import annotations
import os import os
import sys import sys
from collections import defaultdict from collections import defaultdict
from pathlib import Path
from lib import discover_services, project_root _SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import discover_services, project_root # noqa: E402
def main() -> int: def main() -> int:
+6 -1
View File
@@ -20,8 +20,13 @@ import argparse
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
from lib import project_root, run_remote, service_info _SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import project_root, run_remote, service_info # noqa: E402
REMOTE_COMMANDS = { REMOTE_COMMANDS = {
"up": "docker compose up -d", "up": "docker compose up -d",
+71 -27
View File
@@ -16,15 +16,20 @@ import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path
from lib import DEFAULT_BASE_PATH, project_root, rsync_ssh_args, service_info, ssh_cmd _SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from lib import DEFAULT_BASE_PATH, rsync_ssh_args, scp_base_args, service_info, ssh_cmd # noqa: E402
def sync_directory(info: dict) -> int: def sync_directory(info: dict) -> int:
if shutil.which("rsync"): if shutil.which("rsync"):
return _sync_rsync(info) return _sync_rsync(info)
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)") print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
return _sync_tar(info) return _sync_scp(info)
def _sync_rsync(info: dict) -> int: def _sync_rsync(info: dict) -> int:
@@ -58,42 +63,81 @@ def _sync_rsync(info: dict) -> int:
print(f"错误: rsync 执行失败,退出码: {e.returncode}") print(f"错误: rsync 执行失败,退出码: {e.returncode}")
return 1 return 1
except FileNotFoundError: except FileNotFoundError:
print("错误: rsync 命令未找到,请确保已安装 rsync") print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
return 1 return _sync_scp(info)
def _sync_tar(info: dict) -> int: def collect_sync_files(source_dir: str, excludes: list[str]) -> list[Path]:
"""列出待同步文件;排除规则与 rsync `--exclude=name/` 一致(任意层级的同名目录)。"""
source = Path(source_dir)
exclude_set = set(excludes)
files: list[Path] = []
for root, dirs, names in os.walk(source):
dirs[:] = [d for d in dirs if d not in exclude_set]
for name in names:
path = Path(root) / name
if path.is_file():
files.append(path)
return files
def _sync_scp(info: dict) -> int:
source_dir = info["service_dir"] source_dir = info["service_dir"]
remote_dir = info["remote_dir"] remote_dir = info["remote_dir"]
tar_cmd = ["tar", "czf", "-", "-C", source_dir] node = info["node"]
for item in info["sync_exclude"]: source = Path(source_dir)
tar_cmd.append(f"--exclude={item}") files = collect_sync_files(source_dir, info["sync_exclude"])
tar_cmd.append(".")
remote_shell = ( print(f"正在同步 {source_dir} 到 {node}:{remote_dir}")
f"mkdir -p {shlex.quote(remote_dir)} && "
f"tar xzf - -C {shlex.quote(remote_dir)}"
)
ssh = ssh_cmd(info, remote_shell)
print(f"正在同步 {source_dir} 到 {info['node']}:{remote_dir}")
if info["sync_exclude"]: if info["sync_exclude"]:
print(f"排除目录: {', '.join(info['sync_exclude'])}") print(f"排除目录: {', '.join(info['sync_exclude'])}")
print(f"执行命令: tar | {' '.join(ssh)}")
print("-" * 60) print("-" * 60)
tar = subprocess.Popen(tar_cmd, stdout=subprocess.PIPE) remote_dirs = {remote_dir}
grouped: dict[str, list[Path]] = {}
for path in files:
parent = path.relative_to(source).parent.as_posix()
grouped.setdefault(parent, []).append(path)
if parent != ".":
remote_dirs.add(f"{remote_dir}/{parent}")
mkdir_remote = "mkdir -p " + " ".join(shlex.quote(d) for d in sorted(remote_dirs))
mkdir_cmd = ssh_cmd(info, mkdir_remote)
print(f"执行命令: {' '.join(mkdir_cmd)}")
try: try:
completed = subprocess.run(ssh, stdin=tar.stdout, check=False) completed = subprocess.run(mkdir_cmd, check=False)
finally: except FileNotFoundError:
if tar.stdout: print("错误: ssh 命令未找到")
tar.stdout.close() return 1
tar.wait()
if tar.returncode:
print(f"错误: tar 打包失败,退出码: {tar.returncode}")
return tar.returncode
if completed.returncode: if completed.returncode:
print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}") print(f"错误: 远程 mkdir 失败,退出码: {completed.returncode}")
return completed.returncode return completed.returncode
if not files:
print("没有需要同步的文件")
print("-" * 60)
print("同步完成!")
return 0
try:
for parent, group in sorted(grouped.items()):
dest = remote_dir if parent == "." else f"{remote_dir}/{parent}"
scp_cmd = [
*scp_base_args(info),
"-p",
"--",
*[str(p) for p in group],
f"{node}:{dest}/",
]
print(f"执行命令: {' '.join(scp_cmd)}")
subprocess.run(scp_cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"错误: scp 执行失败,退出码: {e.returncode}")
return 1
except FileNotFoundError:
print("错误: scp 命令未找到,请确保已安装 openssh-client")
return 1
print("-" * 60) print("-" * 60)
print("同步完成!") print("同步完成!")
return 0 return 0
+57
View File
@@ -0,0 +1,57 @@
# discussion-notes
边讨论边把可复用结论写进项目里的 Markdown 笔记。笔记是活的:理解变了就改,不另存聊天稿。
## 什么时候使用
- 要进入讨论模式、沉淀到文档、整理笔记、单开文档。
- 适合学习概念、做技术选型、把讨论变成可回顾的结论。
- 不适合:一次性问答、必须进 skill 记忆库的沉淀(用 `memory-distill`)、也不要用来当会议逐字稿。
说「结束讨论」或「不用记了」就退出;之后不再自动改笔记。
## 安装
```bash
pouch add discussion-notes # 当前项目
pouch add discussion-notes -g # 全局
```
## 使用前准备
- 指定笔记路径,或接受默认 `000inbox/<topic>.md`(按 Obsidian 收件箱习惯)。
- 有明确话题名更好;没有的话 Agent 会问一次。
- 笔记写在当前项目仓库内。默认不 git commit。
## 使用示例
```text
进入讨论模式,记到 000inbox/podman-vs-docker.md:公司 Debian VM 上容器运行时选 Podman 还是 Docker。
```
```text
讨论 Quadlet 是什么,单开一篇概念笔记,并链到刚才的选型文档。
```
```text
这轮不用记,先只回答。
```
```text
结束讨论。
```
## Agent 会做什么
1. 确认路径:你指定的文件,或默认 `000inbox/<topic>.md`。新话题新建,续聊改已有文件。
2. 按内容选结构:选型/决策,或概念/原理。模板见 [reference.md](reference.md)。
3. 每轮先用中文回答你,再更新笔记里的结论、对比、约束;纠正旧理解时直接改,不留错误版本。
4. 只写可复用知识,不把问答全文抄进文档,不创建 `.raw.md`。
5. 你说结束时,可选加一节「更新」:搞懂了、纠正了、待动手。
## 如何判断完成
- 约定路径上有一份正在维护的 `.md`,结构能看出结论而不是聊天记录。
- 本轮新结论已经写进对应章节;过时说法被改掉,而不是追加在文末。
- 没有出现 `xxx.raw.md`,也没有未经确认的 git commit。
- 退出讨论后,Agent 不再自动改那份笔记。
+8 -1
View File
@@ -8,6 +8,13 @@
- 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。 - 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。
- 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。 - 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。
## 安装
```bash
pouch add draw-prototype-flow # 当前项目
pouch add draw-prototype-flow -g # 全局
```
## 使用前准备 ## 使用前准备
最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。 最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。
@@ -34,4 +41,4 @@
## 如何判断完成 ## 如何判断完成
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。 最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。你指定路径就用指定路径,没指定时 Agent 会在项目里给出文件名。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
+53
View File
@@ -0,0 +1,53 @@
# eli5
用大图少字的单页 HTML,把一个话题讲给完全没背景的人听。
改编自 [anthropics/claude-plugins-community](https://github.com/anthropics/claude-plugins-community) 的 `eli5`。
## 什么时候使用
- `/eli5 DNS 怎么工作`
- 「像五岁小孩一样解释 RAG」
- 「画图讲一下这个项目的登录流程」
- 想要一份能转发的图解,而不是聊天长文
不适合:核对精确规格、安全审计、修代码。那类工作先用 eli5 抓大图,再换别的 skill 或原文档抠细节。
## 使用前准备
- 给出话题。可以是概念(DNS、RAG),也可以是当前项目里的一块(登录、发版)。
- 想指定输出路径就说一声;不说则写到项目根目录的 `eli5-<话题>.html`。
## 安装
```bash
pouch add eli5 # 当前项目
pouch add eli5 -g # 全局
```
## 使用示例
```text
/eli5 DNS 怎么工作
```
```text
像五岁小孩一样解释这个仓库的认证是怎么走的。
```
```text
eli5 RAG,输出到 docs/eli5-rag.html
```
## Agent 会做什么
1. 从你的话里取出话题。
2. 按「读者完全没背景」来讲,而不是装成对幼儿说话。
3. 写一页自包含 HTML:大图(inline SVG)、极少文字、至少 3 个可见步骤。
4. 把文件写到约定路径;聊天里只回路径和一句总收束。
## 如何判断完成
- 能用浏览器直接打开那份 HTML,不依赖外网资源。
- 画面上图大于字;没背景的人能看出「这东西怎么走」。
- 聊天没有再贴一遍长文解释。
+52
View File
@@ -0,0 +1,52 @@
---
name: eli5
description: >-
用大图少字的单页 HTML 把话题讲给完全没背景的人听。Use when the user types
/eli5 <topic>, says eli5, 像五岁小孩一样解释, 用小学生能懂的方式讲, 画图解释,
or asks for a dead-simple picture explainer of how something works.
---
# eli5
把话题讲给**完全没背景**的人听。交付物是一页自包含 HTML:大图、少字,不是聊天长文。
改编自 [anthropics/claude-plugins-community](https://github.com/anthropics/claude-plugins-community) 的 `eli5`(Thariq Shihipar)。原技能用 `$ARGUMENTS`;这里从用户请求里取话题。
---
## 何时使用
- `/eli5 <topic>`、`eli5`、像五岁小孩一样解释、用小学生能懂的方式讲、画图解释
- 用户要一份一眼能看懂的图解,而不是长文
## 不适用
- 精确规格、边界条件、安全审计、修代码
- 高保真 UI 或可编辑原型 → `draw-prototype-flow`
- 边讨论边写笔记 → `discussion-notes`
---
## 步骤
1. **取话题**。用户没给就问一次。
2. **读者设定**:零背景,不是幼儿口吻。去掉默认假设,不要装小孩说话。
3. **写一份自包含 HTML**(无外部 CSS / JS / 字体 / 图片 URL):
- 一页看完
- 结构用大图讲(inline SVG;可少量 emoji)
- 文字极短:标题、箭头旁短语、一句总收束
- 至少把核心机制拆成 3 个可见步骤或块
4. **落盘**。用户指定路径就用;否则写到当前项目根(没有项目就用 cwd)`eli5-<slug>.html`。Claude Code 若支持 artifact,可同时打开,**仍要写文件**。
5. **聊天里只给路径和一句总收束**,不要把正文再讲一遍。
## 注意事项
- 不为了好懂而编造机制。不确定就标「简化」,不要装精确。
- 不 git commit。
- 语言跟用户请求走;没点名时用简体中文。
## 验证
- 用浏览器或 `file://` 打开是单页,图明显大于字。
- 没背景的人能从画面看出「这东西怎么走」。
- 仓库或 cwd 里有那份 HTML;聊天没有墙一样的解释。
+41
View File
@@ -0,0 +1,41 @@
# host-sec-check
对一台 Linux/VPS 做入侵取向的安全检查:先取证,再给你一份结论和处置清单。默认不改机器。
## 什么时候用
- 「这台机做一次安全检查」
- 「好像被黑了 / 有矿机 / 服务无故 OOM」
- 「看看哪些端口对公网开着」
- 检查另一台机器时,直接点名主机:`对 vvps4 跑 /host-sec-check`
适用于单台 Linux(systemd + 常见 Docker/k3s)。不是应用代码审计,也不是云账号合规审计。
## 使用前准备
- SSH 能上(别名或 `user@host`)
- 尽量有 sudo 或 docker 组(没有也能做浅查)
- 告诉 Agent 哪张 IP 是公网,方便从外面探测
- 若要清理、关端口、重启:明确说「同意处置」
## 使用示例
```text
对 ace@vvps2 做 host-sec-check,只出报告先不要改。
```
```text
用 host-sec-check 查 vora3,公网 IP 是 x.x.x.x,发现未授权的就列处置项问我。
```
## Agent 会做什么
1. 采集账号、监听端口、cron/systemd、Docker/k3s、`ld.so.preload`、内存/hugepage。
2. 怀疑进程隐藏时,用容器读宿主机,避免被矿机库骗过。
3. 从另一台机器探测公网端口(含 IPv6,如果有)。
4. 给出结论:是否沦陷、入口、对外端口、建议处置。
5. 你点头后才停容器、占位矿机文件、收端口或重启。
## 如何判断完成
报告里有结论表;若做了处置,公网已关端口连不上,业务域名仍可用。密钥、token 不应出现在完整原文里。
+130
View File
@@ -0,0 +1,130 @@
---
name: host-sec-check
description: >-
Linux/VPS 主机安全检查与入侵取证:先取证再处置。覆盖矿机、ld.so.preload 进程隐藏、带宽挂机容器、对外端口、SSH/Docker/k8s 入口、cron/systemd 持久化、hugepage/OOM。
Use when: 安全检查, 被黑, 入侵, 矿机, 排查端口, 对公网放开, rpcbind, 挂机赚钱, Portainer, 安全审计, host security, compromise, cryptominer, exposure, /host-sec-check.
---
# host-sec-check
对一台 Linux 主机做安全检查。默认 **只读取证 + 出报告**。清理、关端口、重启、轮换密钥必须用户明确同意后再做。
需要:SSH 目标(`user@host` 或 ssh config 别名)、哪张网卡/IP 算公网。没有公网 IP 就只报「监听面」,并标明未做外部探测。
## 不适用
- Web 应用代码审计、依赖 CVE 扫描(可在报告里列为后续)
- 云厂商安全组控制台(能查就查,查不到就写「未验证」)
- 未获准的破坏性操作(`rm -rf`、整盘 wipe、强制重启)
## 铁律
1. **先取证,后动手。** 重启、删文件、停服务会毁掉证据。
2. **不要把密钥打进对话。** token、密码、authorized_keys、kubeconfig 只写「存在/已轮换/路径」,不贴原文。
3. **sudo 失败不要停。** 用户在 `docker` 组时,用特权容器 `nsenter` 看宿主机(见 [references/access.md](references/access.md))。
4. **怀疑进程隐藏时,不要只信宿主机 `ps`。** 从**不加载宿主机 `ld.so.preload` 的容器**读 `/proc` 和文件。
5. 宿主机 `chattr` 可能被隐藏库挡住;改 immutable 文件用 **Ubuntu/Alpine 自带 chattr** 操作 bind-mount 的 `/host/...`。
收集脚本(可选,先跑再解读):
```bash
bash <skill-dir>/scripts/collect.sh USER@HOST
```
`<skill-dir>` 是本 skill 目录。脚本只采集,不修改系统。
## 流程
按顺序做。每步记下「正常 / 可疑 / 未查到」。完整项见 [references/checklist.md](references/checklist.md)。
### 1. 快照
- 主机名、发行版、uptime、负载、`free -h`、`df -h`、公网/内网/Tailscale IP
- `ss -lntup`(最好 root)
- `docker ps -a`、compose 项目
- 若有 k3s/k8s:agent 是否在跑、能否 `kubectl get pods -A`
### 2. 入口
- sshd:`Port`、`PermitRootLogin`、`PasswordAuthentication`、`authorized_keys`(root 与所有 UID≥1000)
- `last` / `lastb` / `journalctl -u ssh`:异常登录;**无交互登录却出现 docker run 也是入口信号**
- sudoers、`docker` 组(= root)、NOPASSWD
- 对公网的管理面:Portainer / Cockpit / 面板 / k8s apiserver / Docker 端口,尤其 `-p 0.0.0.0:…` 且挂了 `docker.sock` 或 `/:/host`
- 单元文件或 compose 里的明文 token(k3s `--token`、agent secret)
### 3. 持久化
读 [references/persistence.md](references/persistence.md)。至少查:
- crontab(root 与所有用户)、`/etc/cron.*`
- `/etc/systemd/system/*.service`、timers
- `/etc/ld.so.preload`(用**外部** cat/hexdump,宿主机 cat 可能被藏空)
- `chattr +i` 的异常二进制(`lsattr`)
- 隐藏 Docker 数据根(非 `/var/lib/docker` 的 dockerd `--data-root`)
### 4. 进程与隐藏库
- 从特权容器列 `/proc/*/cmdline`(绕过 preload)
- 对照宿主机 `ps`:只在容器视角出现的进程 = 隐藏
- `/proc/*/maps` 是否映射陌生 `.so`(`libgcwrap`、`libprocesshider`、随机名)
- 矿机常见名:`perfcc`、`xmrig`、`kdevtmpfsi`、`javavm64`;同体积多副本(例如都是 11MB)
- hugepage:`grep Huge /proc/meminfo`。`nr_hugepages>0` 且进程列表对不上,多半是矿机预留
### 5. 容器与编排
- `docker ps -a`:镜像名含 earnfm / packetstream / repocket / traffmonetizer / honeygain / proxyrack / bitping / antgain 等挂机池
- inspect:`Privileged`、Binds(`docker.sock`、`/:/host`)、Ports 是否 `0.0.0.0`
- 第二套 dockerd / 异常 `--bridge`
- k8s:节点上是否有与 Git 仓库无关的 workload;控制面不健康时从 kubelet `pods/*/etc-hosts` 读 Pod 名
### 6. 对外暴露
- 本机 `0.0.0.0` / `[::]` 监听
- **从另一台机器探测公网 IP**(含 IPv6)。只看 `ss` 会漏安全组,只看安全组会漏「监听但没防火墙」
- 高危:`rpcbind :111`、明文 HTTP 应用端口、Docker/Portainer、k8s 6443、数据库
- 管理 SSH 若可走 Tailscale,公网 SSH 算多余攻击面
### 7. 资源与日志
- 无 swap + 高 hugepage → 应用易 OOM
- `journalctl` / `auth.log` 在处置时间点附近;日志空也要记
- 出站连接:`ss -tp` 看陌生 IP(不要把结果里的密钥打出来)
### 8. 补漏(本次 vvps2 没做或做得浅的)
见 checklist 后半:补丁、SUID、世界可写、意外用户、IPv6、定时器、内核模块、出站 C2、云安全组、备份是否可用。
## 报告格式
先给结论(是否沦陷、最可能入口、对外还开着什么),再给证据表,最后给 **需用户点头的处置清单**。不要默默清理。
```
结论: 沦陷 / 可疑 / 未见异常
最可能入口: …
对外端口: …
持久化: …
隐藏/矿机: …
容器/编排: …
未查项: …
建议处置: (等确认)
```
## 处置(仅在用户同意后)
原则见 [references/cleanup.md](references/cleanup.md)。
- 停并删除未授权容器;镜像也删
- 矿机二进制:删掉后用 **空文件 + `chattr +i` 占路径**
- 清空并锁定 `/etc/ld.so.preload`
- 关公网绑定:改成 `127.0.0.1` 或 Tailscale IP(若回源走 Tailscale 主机名,必须绑 TS IP,不能只绑 loopback)
- 停无用服务(如 `rpcbind.socket` + `rpcbind.service`,必要时 mask)
- 重启才能卸掉仍映射已删 `.so` 的老进程(sshd/dockerd)。先取证再重启
- 轮换:暴露过的 token、面板、k3s bootstrap。控制面 API 不通就不要假轮换,写阻塞原因
## 验证
- 公网探测:已关端口失败;该留的业务域名仍 200
- `ss`:无 `0.0.0.0:已关端口`
- `grep -l libgcwrap /proc/*/maps`(或同类库)重启后为 0
- 占位文件仍在且 immutable
- 业务容器 `restart: always` 仍在
@@ -0,0 +1,48 @@
# 取证入口(无 sudo 时)
目标用户常见情况:`sudo` 要密码,但在 `docker` 组。Docker 组等价于 root。
## 读宿主机(绕过 preload)
容器用自己的 libc,**不会**加载宿主机 `/etc/ld.so.preload`。
```bash
docker run --rm -i --privileged --pid=host --net=host -v /:/host alpine:latest \
cat /host/etc/ld.so.preload
hexdump -C /host/etc/ld.so.preload # 在容器内
```
列隐藏进程:
```bash
docker run --rm --privileged --pid=host alpine:latest sh -c \
'for d in /proc/[0-9]*; do
pid=${d#/proc/}
cmd=$(tr "\0" " " < "$d/cmdline" 2>/dev/null) || continue
[ -n "$cmd" ] && echo "$pid $cmd"
done'
```
## 改宿主机(systemctl / chattr)
`chroot /host /usr/bin/chattr` 会加载宿主机 preload,可能 **stat 不到** 被藏文件。
用发行版自带 chattr 操作 `/host/...`:
```bash
docker run --rm -i --privileged --pid=host --net=host -v /:/host ubuntu:latest bash -s <<'SH'
chattr -i /host/etc/ld.so.preload
: > /host/etc/ld.so.preload
chattr +i /host/etc/ld.so.preload
lsattr /host/etc/ld.so.preload
SH
```
systemctl / reboot:
```bash
docker run --rm --privileged --pid=host --net=host alpine:latest \
nsenter -t 1 -m -u -i -n -- systemctl disable --now UNIT
```
不要用业务容器当跳板;用 `alpine`/`ubuntu` 临时 `--rm`。查到一半时 `docker ps` 里出现的随机名容器就是这个跳板。
@@ -0,0 +1,75 @@
# 检查项
每项:做了 / 跳过(原因) / 可疑。
## A. 快照
- [ ] 主机名、OS、内核、uptime
- [ ] CPU/负载、内存(含 hugepage、swap)、磁盘
- [ ] 网卡:公网 v4/v6、内网、Tailscale/VPN
- [ ] 监听端口 + 进程(root `ss -lntup`)
- [ ] 本机防火墙:nft/iptables/ufw;INPUT 策略
- [ ] 云安全组(做不到就写未查)
## B. 身份与入口
- [ ] sshd 配置:端口、root、密码、Pubkey
- [ ] 所有 `authorized_keys`(含 `/root`、`/home/*`、git 用户)
- [ ] `last` / `lastb` / ssh journal
- [ ] `/etc/passwd` 里 UID 0、系统外用户、登录 shell
- [ ] sudoers、NOPASSWD、docker/lxd/libvirt 组
- [ ] 对公网的面板:Portainer/Cockpit/宝塔/1Panel/k8s/Docker TCP
- [ ] 单元或 compose 里的明文 bootstrap token
## C. 持久化
- [ ] 用户与系统 crontab、`/etc/cron.*`
- [ ] systemd 服务与 timer(非发行版默认)
- [ ] `ld.so.preload` + 对应 `.so`(容器视角 hexdump)
- [ ] `lsattr` 发现的 immutable 异常文件
- [ ] rc.local、profile.d、bashrc 后门
- [ ] 第二套 container runtime
## D. 进程
- [ ] 容器视角 vs 宿主机 `ps` 差集
- [ ] maps 里的陌生 so
- [ ] 矿机/挖矿/proxy 客户端特征名与同体积副本
- [ ] hugepage 预留但无对应进程
- [ ] 高 CPU 且不在业务名单里的进程
## E. 容器 / k8s
- [ ] `docker ps -a` 与 inspect(privileged、sock、`/:/host`、`0.0.0.0`)
- [ ] 挂机赚钱镜像名单
- [ ] k8s/k3s:节点 Ready、该节点 Pod 列表
- [ ] API 不通时:kubelet `pods/*/etc-hosts`、crictl
- [ ] 工作负载是否能在 Git/编排仓库里对上
## F. 暴露
- [ ] `0.0.0.0` 与 `[::]` 监听清单
- [ ] **从外部**扫公网 IPv4(和 IPv6)
- [ ] rpcbind :111、DB、Redis、应用明文端口
- [ ] 管理 SSH 是否可收到 VPN
- [ ] 证书/域名入口是否可被 IP:端口绕过
## G. 补漏(容易漏)
- [ ] IPv6 公网地址同样在听
- [ ] 世界可写目录、异常 SUID
- [ ] 未装补丁 / `apt` 过期很久
- [ ] 出站长连接(C2)
- [ ] 内核模块 `lsmod` 异常
- [ ] 时间偏移(影响日志)
- [ ] 备份是否还能用(清理前不要删数据盘)
- [ ] 日志被清空或 journal vacuum
- [ ] Web 根目录 webshell(若有 nginx/caddy/apache)
- [ ] `.git` 或备份文件可下载
- [ ] 云 metadata 是否对容器开放
## H. 报告与处置确认
- [ ] 结论表
- [ ] 用户确认后再改
- [ ] 改完公网+域名双侧验证
@@ -0,0 +1,59 @@
# 处置(用户同意之后)
## 顺序
1. 证据已落盘(collect 输出、关键 ls/hexdump/docker inspect)
2. 停进程 / 停容器
3. 删或占位文件
4. 关公网端口
5. 最后才重启(卸已加载的隐藏库)
## 占位
对矿机路径(`/usr/bin/perfcc`、`/tmp/javavm64`、隐藏 docker 根名等):
```bash
chattr -i "$path" 2>/dev/null
rm -f "$path"
: > "$path"
chmod 000 "$path"
chattr +i "$path"
```
`/etc/ld.so.preload` 占空文件,权限 `644` + immutable,让动态链接器能读到「没有库」。
目录型隐藏根:停进程并卸挂载后删除,再在同名路径放 immutable 空文件,阻止再 `mkdir`。
改 immutable 时用容器自带 `chattr` 操作 `/host/...`,不要 `chroot` 到宿主机再跑 chattr。见 access.md。
## 容器
```bash
docker stop NAME && docker rm NAME
docker rmi IMAGE
```
不要 `docker system prune -a`。
## 端口
- 入口在本机 loopback → `127.0.0.1:PORT:CONTAINER`
- 入口走 Tailscale 主机名 → 必须绑 **当前 Tailscale IPv4**,不能只绑 127.0.0.1
- 可同时绑 loopback + Tailscale,禁止 `0.0.0.0` / `[::]`
改 compose 后 force-recreate,再从公网 IP 和业务域名两边探测。
## rpcbind
```bash
systemctl disable --now rpcbind.socket rpcbind.service
systemctl mask rpcbind.socket rpcbind.service
```
无对外 NFS 再做。确认 `:111` 消失。
## 重启
重启前确认业务是 `restart: always`,并告知会短暂中断。
重启后:`grep -l <so名> /proc/*/maps` 为 0;若已清 hugepage 则 `HugePages_Total` 为 0;域名 200。
@@ -0,0 +1,40 @@
# 持久化位置
按这个名单扫。出现「刚被排查时时间戳又刷新」= 仍有 watchdog。
## 定时
- `crontab -u root -l` 以及 `/var/spool/cron/crontabs/*`
- `/etc/cron.d/` `/etc/cron.hourly/` `/etc/cron.daily/` `/etc/cron.weekly/`
- systemd timers:`systemctl list-timers --all`
## 服务
- `/etc/systemd/system/*.service`(尤其非发行版默认的)
- `ExecStart` 指向 `/tmp`、`/dev/shm`、`/root/.config`、随机短名
- `Restart=always` 的陌生服务
## 动态链接器
- `/etc/ld.so.preload`:内容常是 `/lib/libXXX.so`(约十几字节)。宿主机 `cat` 可能显示空,用容器 hexdump
- 对应 `.so` 可能 `chattr +i`、setgid
- 常见名:`libgcwrap.so`、`libpprocps.so`、`libfsnldev.so`、`libprocesshider.so`
## 二进制与分身
- `/usr/bin` `/bin` `/usr/local/bin` `/tmp` 下刚改过的可执行文件
- 同字节大小的多份拷贝(矿机改名)
- 占位对抗:空文件 `chmod 000` + `chattr +i`(见 cleanup.md)
## 隐藏运行时
- 第二套 dockerd:`--data-root` 不在 `/var/lib/docker`,`--host unix://` 异常路径
- 混淆文件名(把 docker 子命令拼进无关单词)
- k3s 的 `containerd-shim` 被借去跑 **moby** namespace + 异常 `--address`
## 登录与启动
- `~/.bashrc` / `/etc/profile.d` 里 curl|bash
- `/etc/rc.local`
- SSH `authorized_keys` 多出来的 key
- 意外用户、UID 0 非 root
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# 只采集,不修改。用法: collect.sh [user@]host
# 输出写到 stdout;完整日志建议重定向到文件。
set -euo pipefail
HOST="${1:?usage: collect.sh user@host}"
SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST")
echo "===== host-sec-check collect $(date -u +%Y-%m-%dT%H:%M:%SZ) host=$HOST ====="
remote() {
"${SSH[@]}" bash -s <<'EOS'
set +e
echo "----- id / os / uptime -----"
id; hostname; uname -a
. /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"
uptime
echo "----- mem / hugepage / disk -----"
free -h
grep -E "MemAvailable|HugePages_|Hugetlb|SwapTotal" /proc/meminfo
df -h
echo "----- addresses -----"
ip -4 addr; ip -6 addr show scope global
echo "----- listen (may miss pid without root) -----"
ss -lntup 2>/dev/null || netstat -lntup 2>/dev/null
echo "----- docker -----"
docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || echo "no docker"
echo "----- preload (host cat; may be hidden) -----"
ls -la /etc/ld.so.preload 2>/dev/null
echo "preload_host=[$(cat /etc/ld.so.preload 2>/dev/null)]"
echo "----- cron files -----"
ls -la /etc/cron.d /etc/cron.hourly /etc/cron.daily 2>/dev/null
echo "----- systemd extras -----"
ls /etc/systemd/system/*.service 2>/dev/null
echo "----- users uid0 / human -----"
awk -F: '$3==0 || ($3>=1000 && $3<65534) {print}' /etc/passwd
echo "----- sshd -----"
grep -E "^(Port|PermitRootLogin|PasswordAuthentication|PubkeyAuthentication)" /etc/ssh/sshd_config /etc/ssh/sshd_config.d/* 2>/dev/null
echo "----- last -----"
last -a | head -15
EOS
}
remote
# docker 组:从容器读 preload 真值
if "${SSH[@]}" "groups | grep -qw docker" 2>/dev/null; then
echo "----- preload via alpine (bypass host libc) -----"
"${SSH[@]}" "docker run --rm --privileged -v /:/host alpine:latest hexdump -C /host/etc/ld.so.preload" 2>/dev/null \
|| echo "alpine hexdump failed"
fi
echo "===== collect done ====="
echo "下一步: 按 SKILL.md 解读;不要把本输出里的密钥贴进聊天。"
+13
View File
@@ -4,6 +4,13 @@
版本文件、worktree、分支、PR/MR 和合并。普通发布优先走“更新说明 + annotated tag”的 版本文件、worktree、分支、PR/MR 和合并。普通发布优先走“更新说明 + annotated tag”的
短路径;只有发布要求修改仓库内容时,才进入准备版本的分支流程。 短路径;只有发布要求修改仓库内容时,才进入准备版本的分支流程。
## 安装
```bash
pouch add manage-release # 当前项目
pouch add manage-release -g # 全局
```
## 使用前准备 ## 使用前准备
- 项目已经是 Git 仓库,并配置了可访问的远端。 - 项目已经是 Git 仓库,并配置了可访问的远端。
@@ -13,6 +20,12 @@
- 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作 - 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作
不会执行。 不会执行。
## 使用示例
```text
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
```
## 常见用法 ## 常见用法
### 确定下一个版本号 ### 确定下一个版本号
+7
View File
@@ -12,6 +12,13 @@ memory-distill 提炼一下刚刚 ack 执行过程中可以沉淀的知识。
也会响应「沉淀记忆」「回顾刚才记下来」「更新记忆」等说法。 也会响应「沉淀记忆」「回顾刚才记下来」「更新记忆」等说法。
## 安装
```bash
pouch add memory-distill # 当前项目
pouch add memory-distill -g # 全局
```
## 使用前准备 ## 使用前准备
通常不用准备路径。Agent 会: 通常不用准备路径。Agent 会:
+40
View File
@@ -0,0 +1,40 @@
# note-style
把已有材料整理成可长期查阅的知识笔记:压缩空话、统一术语,保留事实、公式、条件和参考。
## 什么时候使用
- 要把聊天稿、草稿或 AI 生成的长文改成能放进知识库的笔记。
- 要整理 Obsidian 笔记,去掉重复和口语。
- 要把技术原理或操作方案写成教科书 / 手册口吻。
不适合:还在讨论、还没有可依据的材料,或需要 Agent 发明新结论。
## 使用前准备
- 给出要整理的材料(文件路径、粘贴正文,或当前对话里的那一段)。
- 可选:目标读者、篇幅要求、笔记路径。不说则在原材料风格上压缩约三成。
## 使用示例
```text
用 note-style 把这篇对话整理成可放进 Obsidian 的技术笔记,保留公式和适用条件。
```
```text
把 docs/draft-auth.md 改写成手册口吻,删掉空话,有疑点列待核实项。
```
## Agent 会做什么
1. 抽出事实、定义、步骤、条件和参考,删掉铺垫和重复。
2. 按材料类型选结构(原理或操作),用中性书面语重写全文。
3. 先给你完整笔记;只有事实或技术疑点时才追加「待核实项」。
不会补没有依据的结论,也不会为缩短篇幅删掉必要条件。
## 如何判断完成
- 得到一份完整笔记,而不是改写说明。
- 原文里的公式、条件、安全警告和参考还在。
- 没有聊天式过渡或段末复述。
+89
View File
@@ -0,0 +1,89 @@
---
name: note-style
description: 将现有材料整理、压缩或改写为可长期查阅的知识笔记,采用教科书、技术手册或标准说明文风格,并保留事实、公式、适用条件、安全警告和参考资料。适用于用户要求整理 Obsidian 笔记、消除聊天或 AI 写作痕迹、减少重复和空话、统一术语与技术表达,或将技术原理、操作方案改写为结构化知识文档的任务。
---
# 整理知识笔记
## 工作流程
1. 识别材料的核心问题、目标读者和材料类型。
2. 提取事实、定义、公式、条件、因果关系、步骤、例证、边界、安全警告和参考资料。
3. 删除与核心问题无关的铺垫,并合并重复内容。
4. 按材料类型选择结构并重写全文。
5. 检查技术准确性、术语、格式、重复和表达密度。
6. 输出完整笔记;存在事实或技术疑点时,追加“待核实项”。
## 保留与删减
- 保留原文中的事实、公式、适用条件、例外、安全限制和参考资料。
- 不自行补充没有依据的结论,也不为缩短篇幅而删除必要条件或公式推导。
- 围绕核心问题组织内容,删除无关铺垫。
- 使每句话至少承担一种功能:定义概念、说明条件、解释因果、给出步骤、提供例证或指出边界。
- 使同一知识点只完整解释一次。避免在摘要、正文、表格、总结和检查表之间复述。
- 在不损失必要信息的前提下,默认将篇幅压缩约 30%;若用户另有篇幅要求,以用户要求为准。
## 语言规范
- 使用中性、准确、简洁的书面语。
- 直接陈述内容,不添加聊天式过渡、开场铺垫、段末复述或写作过程说明。
- 删除情绪化、评价性、劝导性、宣传性和表演式表达,如“非常美妙”“加分项”“更稳妥”“请仔细看”。
- 不使用反问、口号、拟人化表达或用感叹号制造重点。
- 减少“通常、可能、建议、优先、具体来看”等模糊限定词。确有不确定性时,明确不确定性的来源和适用条件。
- 每段只表达一个主题;能用一句话说明的内容不扩写成多句。
- 将评价句改写为“条件、机制、结果”或“对象、操作、限制”。
## 技术表达
- 首次出现专业术语时给出简短定义,后文统一名称。
- 明确符号、变量、单位、对象类型、参考系、输入输出和变换方向。
- 区分严格定义、直观解释和示例,不用比喻替代定义。
- 每个概念最多保留一个必要的例子或比喻;使用比喻后,以正式术语重述结论。
- 检查概括的适用范围,将无条件的“X 代表 Y”改为带条件的准确表述。
- 不擅自修正不确定或可能有误的技术内容,将其列入“待核实项”。
## 结构选择
根据材料类型采用下列骨架,并按实际内容删去不需要的章节。
### 技术原理类
1. 问题或目标
2. 符号与定义
3. 原理或推导
4. 示例
5. 适用条件与限制
6. 简短结论
### 操作与方案类
1. 目标与适用范围
2. 基本原则
3. 操作步骤
4. 分类处理
5. 安全边界
6. 检查表
不要同时保留内容重复的“摘要”“重新解释”“具体调整”和“总结”。摘要已经给出结论时,正文只提供依据和展开。
## 格式规范
- 使用描述性标题,避免“到底是什么”“究竟该怎么做”等口语化标题。
- 保持标题编号连续、层级一致。
- 仅对首次定义的术语、关键条件和禁止事项使用粗体。
- 减少引号和括号,统一数学符号、变量格式和单位格式。
- 保留适合 Obsidian 的 Markdown、YAML frontmatter、Wiki-links、表格、公式和代码块;除非用户要求,不改变其语义。
## 完成检查
- 检查同一结论是否出现在两个以上位置,只保留信息最完整且位置最合适的一处。
- 检查相邻段落能否分别用一句话概括;含义相同时合并。
- 检查所有评价句是否已改为可验证的条件、机制、结果、操作或限制。
- 检查技术概括、符号定义、事实和适用条件是否成立。
- 检查内部链接、表格、公式、代码块、安全警告和参考资料是否完整保留。
## 输出
1. 先输出整理后的完整笔记。
2. 仅在存在技术或事实疑点时追加“待核实项”,逐条列出疑点。
3. 不解释改写过程,不输出修改心得,不复述本技能的要求。
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "教科书式笔记整理"
short_description: "将材料压缩并改写为准确、简洁的结构化知识笔记"
default_prompt: "使用 $note-style 将这份材料整理为可长期查阅的知识笔记。"
+9
View File
@@ -11,6 +11,15 @@ ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docke
只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。 只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。
## 安装
```bash
pouch add orc # 当前项目
pouch add orc -g # 全局
```
`$orc` / `/orc` 只在显式调用时运行。下游阶段还需要已安装 `$ack`、`$manage-release` 或 `$builder`。
## 使用前准备 ## 使用前准备
- Orca 正在运行并启用了 orchestration。 - Orca 正在运行并启用了 orchestration。
+65 -16
View File
@@ -1,16 +1,39 @@
# pouch # pouch
`pouch` 用于创建、维护、安装和发布团队自研的 Agent Skill。Skill 的唯一来源位于 创建、维护、安装和发布团队自研的 Agent Skill。唯一来源在 `~/.pouch/skills/<name>/`,装到各 Agent 时用软链。
`~/.pouch/skills/<name>/`,安装到各 Agent 时使用软链接。
## 什么时候使用 ## 什么时候使用
- 想把项目里的重复工作沉淀成一个 skill。 - 想把项目里的重复工作沉淀成一个 skill。
- 想完善、校验、转正或发布已有 skill。 - 想完善、校验、转正或发布已有 skill。
- 想把自研 skill 安装到当前项目或全局 Agent。 - 想把自研 skill 安装到当前项目或全局 Agent。
- 想让 Agent 代装 pouch,或介绍有哪些 skill、在当前项目接入。
- 想检查并修复 skill 软链接。 - 想检查并修复 skill 软链接。
- 想优化某个已有 skill 的结构,减少点中后灌进上下文的内容。 - 想优化某个已有 skill 的结构,减少点中后灌进上下文的内容。
## 使用前准备
- 已把本仓库 clone 到 `~/.pouch` 并跑过 `./install.sh`(会把本 skill 装到所有 Agent)。
- Python 3;`~/.local/bin` 已加入 `PATH`。
- 至少一种 Agent:Cursor、Claude Code、Codex,或 agents 标准目录。
- 要推到远端时,本仓库的 git remote 可写。
CLI 命令细节见仓库里的 [pouch/README.md](../../pouch/README.md)。
## 使用示例
```text
帮我安装 pouch。
帮我装 https://git.yumee.top/laily/pouch
有哪些自研 skill,各用一句话介绍。
把这个项目该用的 skill 接上。
帮我把「评审 PR 时按这份清单走」做成一个自研 skill。
pouch 帮我检查 discussion-notes 这个 skill。
pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费。
把 ack 装到当前项目的 Cursor。
检查一下 skill 软链,坏了就修。
```
## 创建一个 skill ## 创建一个 skill
```bash ```bash
@@ -32,6 +55,8 @@ pouch check my-skill
pouch finalize my-skill pouch finalize my-skill
``` ```
转正不会自动 commit、push 或安装。
## 优化已有 skill 的结构 ## 优化已有 skill 的结构
skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点中后把用不到的参考一并读进上下文时: skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点中后把用不到的参考一并读进上下文时:
@@ -40,42 +65,66 @@ skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点
pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费 pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费
``` ```
Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版/部署一类的安全限制。改完仍需 `pouch check` 通过。 Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版、部署一类的安全限制。改完仍需 `pouch check` 通过。
不要对社区 catalog skill 的安装目录直接改;那些不是本仓库的 SSOT。 不要对社区 catalog skill 的安装目录直接改;那些不是本仓库的 SSOT。
## 提交和发布 ## 提交和发布
只提交:
```bash ```bash
pouch publish skills/my-skill -m "add my-skill" pouch publish skills/my-skill -m "add my-skill"
```
提交并推送:
```bash
pouch publish skills/my-skill -m "add my-skill" --push pouch publish skills/my-skill -m "add my-skill" --push
``` ```
## 安装 ## 安装与维护
安装到当前项目的 Codex: `./install.sh` 会执行 `pouch bootstrap`,把本 skill 全局装到所有 Agent。也可以随时重跑:
```bash ```bash
pouch add my-skill -a codex -y pouch bootstrap
``` ```
安装到全局 Codex: 安装其它 skill:
```bash ```bash
pouch add my-skill -a codex -g -y pouch add my-skill -a codex -y # 当前项目的 Codex
pouch add my-skill -a codex -g -y # 全局 Codex
pouch select # 交互多选,默认当前项目
pouch select -g # 交互多选,装到全局
``` ```
项目状态初始化(目前用于 ack):
```bash
pouch init ack
pouch init ack --project ~/app
```
builder / deployer 不要 `pouch init`,对 Agent 说「用 builder / deployer 初始化」。
软链出问题:
```bash
pouch status
pouch doctor
pouch doctor --fix
```
## Agent 会做什么
1. 本机还没有 `~/.pouch` 时,按安装剧本 clone 或链接,跑 `install.sh`,再介绍 builtin skill。
2. 在业务仓库里按文件信号建议 skill,`pouch add` 之后问一句要不要初始化 ack / builder / deployer。
3. 创建草稿到 `~/.pouch/.drafts/`,按你的想法完善 `SKILL.md` 和给人看的 `README.md`。
4. 跑 `pouch check`;你确认后再 `finalize` 到 `skills/`。
5. 安装只建指向 SSOT 的软链,不复制文件。默认装当前项目;只有你明确要求时才加 `-g`。
6. 优化结构时先测量再改 SSOT,不删发版、部署类安全限制。
7. 未要求时不 commit、不 push、不全局安装。
## 如何判断完成 ## 如何判断完成
- `pouch check <name>` 输出校验通过。 - `pouch check <name>` 输出校验通过。
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。 - 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
- `pouch status` 或 `pouch doctor` 显示目标软链接正常。 - `pouch status` 或 `pouch doctor` 显示目标软链接正常。
- 代装完成后 CLI 可用,且介绍了 builtin skill 的一句话和示例请求。
- 项目接入后,建议的 skill 已 `pouch add`;ack / builder / deployer 要么已初始化,要么明确问过。
- 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。 - 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。
+25 -11
View File
@@ -1,10 +1,10 @@
--- ---
name: pouch name: pouch
description: >- description: >-
创建、校验、转正、安装或发布 ~/.pouch 自研 skill,初始化 skill 项目状态, 创建、校验、转正、安装或发布 ~/.pouch 自研 skill;代装 pouch、介绍 builtin
或优化 skill 分层加载结构以减少 token 浪费。 skill、在项目里接入并引导初始化;或优化 skill 分层结构以减少 token 浪费。
触发词:pouch、skiff、自研 skill、创建 skill、publish skill、安装自研 skill、 触发词:pouch、skiff、帮我装 pouch、有哪些 skill、skill 怎么用、接入当前项目、
初始化 skill、优化 skill 结构、减少 token 浪费、skill 太长、分层加载。 初始化 skill、创建 skill、publish skill、优化 skill 结构、分层加载。
--- ---
# pouch 自研 Skill 工作流 # pouch 自研 Skill 工作流
@@ -15,24 +15,38 @@ SSOT 固定在 `~/.pouch/skills/<name>/`。内容通过 **symlink** 分发到各
## 选择模式 ## 选择模式
- 安装 pouch、帮我装 pouch、代装、丢仓库 URL、`~/.pouch` 还不存在:读
[install.md](references/install.md) 并执行。
- 有哪些 skill、skill 怎么用、介绍 skill:执行「介绍 builtin skill」。
- 在这个项目接入、把 skill 接上、引导初始化:读
[onboard.md](references/onboard.md) 并执行。
- 创建、完善、转正草稿:执行「创建新的 skill」。 - 创建、完善、转正草稿:执行「创建新的 skill」。
- 行为不对、触发不准、校验失败、可复用优化回流:执行「问题或优化回流」。 - 行为不对、触发不准、校验失败、可复用优化回流:执行「问题或优化回流」。
- 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。先读 - 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。
[token-structure.md](references/token-structure.md)。
- 安装、卸载、浏览、init、status:执行「安装与维护」。 - 安装、卸载、浏览、init、status:执行「安装与维护」。
--- ---
## 在项目中使用 skill ## 介绍 builtin skill
先浏览可用的 builtin skill,再安装到当前项目: 不要通读各 README,不要把 `pouch add --list` 的 description 念给用户。跑:
```bash
python3 <pouch-skill-dir>/scripts/catalog.py
```
把表格里的「做什么」和「可以这么说」原样告诉用户。用户点名再 `pouch add`。
## 在项目中使用 skill
已点名则直接安装;没点名或要按仓库判断:读 [onboard.md](references/onboard.md)。
```bash ```bash
pouch add --list
pouch add <name> -a cursor -a claude -a codex -a agents -y pouch add <name> -a cursor -a claude -a codex -a agents -y
``` ```
`pouch add <name>` 默认安装到当前项目;只有用户明确需要所有项目使用时才加 `-g`。安装结果是指向 `~/.pouch/skills/<name>/` 的软链,不要在 Agent 目录创建副本。 默认当前项目;只有用户明确需要所有项目使用时才加 `-g`。只建指向
`~/.pouch/skills/<name>/` 的软链,不要在 Agent 目录创建副本。
## 创建新的 skill ## 创建新的 skill
@@ -159,7 +173,7 @@ pouch remove discussion-notes -g -a cursor -y # 全局
pouch rm discussion-notes -g -y # rm 别名 pouch rm discussion-notes -g -y # rm 别名
``` ```
浏览可用自研 skill: 浏览可用自研 skill(给 Agent 匹配用,不要念给用户):
```bash ```bash
pouch add --list pouch add --list
+65
View File
@@ -0,0 +1,65 @@
# 安装 pouch
用户说「帮我安装 pouch」「把 skill 装上」,或丢来仓库 URL / 本地路径时执行。
本文件是代装步骤的唯一详细来源。不要用 `pouch select`(必须交互式 TTY)。
不要在未确认时覆盖已有的 `~/.pouch`。CLI 只认 `~/.pouch`。
默认 remote:`https://git.yumee.top/laily/pouch.git`。用户给了 URL 或本地路径则用用户的。
## 1. 确认本机
需要 `python3` 和 `git`。缺了先说明怎么装,不要继续。
## 2. 让 `~/.pouch` 指向本仓库
按现状只走一条:
| 现状 | 动作 |
| --- | --- |
| 当前目录就是这份仓库(有 `install.sh` 和 `skills/`),且没有 `~/.pouch` | `ln -sfn "$(pwd)" "$HOME/.pouch"` |
| `~/.pouch` 已在且含 `skills/`、`install.sh` | 沿用 |
| 只有 `~/.skills` | `mv ~/.skills ~/.pouch` |
| 都没有 | `git clone <URL> ~/.pouch`。失败则问用户本地路径或可用 remote |
| `~/.pouch` 在,但不是这份仓库 | 停下问用户,不要删、不要覆盖 |
clone 私有 Git 失败时停下来问路径或认证,不要改 remote、不要改用交互式 `pouch select`。
## 3. 装 CLI 和 `pouch` skill
```bash
"$HOME/.pouch/install.sh"
export PATH="$HOME/.local/bin:$PATH"
command -v pouch
pouch status
```
`install.sh` 会改 rc,当前会话仍要 `export PATH`。不要让用户重开终端才继续。
它只全局安装 **pouch** 这一个 skill。其它 skill 不要默认全装。
## 4. 介绍 builtin skill,再问要装哪些
```bash
python3 "$HOME/.pouch/skills/pouch/scripts/catalog.py"
```
把表格里的「做什么」和「可以这么说」告诉用户。不要念 frontmatter `description`,
不要通读各 README。用户没点名就问一次:装哪些、全局还是当前项目。能判断就不要追问。
```bash
pouch add <name> -g -y # 全局,所有项目都能用
pouch add <name> -y # 仅当前项目
```
默认 `pouch add` 装到当前工作目录。cwd 是 pouch 仓库本身时用 `-g`,除非用户就在维护 pouch。
用户是在自己的应用仓库里请你代装,才做项目级安装。
## 5. 验收
```bash
pouch status
pouch doctor
```
软链异常则 `pouch doctor --fix`。已经装好时只汇报状态,问还要不要加 skill,不要重新 clone。
当前目录是业务仓库(不是 pouch 仓库)时,回到 SKILL.md,走「接入当前项目」。
+49
View File
@@ -0,0 +1,49 @@
# 在当前项目接入 skill
用户说「把 skill 接上」「在这个项目初始化 skill」「这个仓库该用哪些 pouch skill」时执行。
安装和初始化是两步,不要只 `pouch add` 就当接入完成。
当前目录是 pouch 仓库本身(有 `install.sh`、`skills/pouch/SKILL.md`、`bin/pouch`)时停:
这是维护 SSOT 的仓库,不要按业务项目接入。改 skill 走「创建」或「问题或优化回流」。
## 1. 看仓库信号
```bash
python3 <pouch-skill-dir>/scripts/suggest_skills.py
```
只根据脚本输出建议,不要为了「可能有用」推荐 eli5、note-style、host-sec-check 这类无仓库信号的 skill。
用户点名的除外。
## 2. 安装
把 `suggested` 和 `already` 都列为应安装的 skill(`already` 表示项目里已有状态,
软链仍要 `pouch add`,除非已经装上)。用户说「接上该用的」时只装这两类,不要全装 builtin。
只对 `init` 列问要不要初始化。
默认当前项目:
```bash
pouch add <name> -a cursor -a claude -a codex -a agents -y
```
只有用户明确要所有项目都能用时才加 `-g`。cwd 是 pouch 仓库时不要走到这一步。
## 3. 问一句要不要初始化
对已经装上、或本来就在的 ack / builder / deployer,看脚本的 `init` 列:
| Skill | 尚未初始化时对用户说 | 不要 |
| --- | --- | --- |
| ack | `/ack 初始化` 或 `pouch init ack` | 静默 `pouch init` |
| builder | 「用 builder 初始化」 | `pouch init builder` |
| deployer | 「用 deployer 初始化」 | `pouch init deployer` |
一次问清要初始化哪些。用户没点名不要自己跑初始化。ACK 初始化**不会**自动初始化
deployer / builder,需要的话要另说。
orc、manage-release 和其余 builtin 装上就能用,不必项目初始化。
## 4. 介绍怎么喊
装完后跑 `catalog.py`,只念本次装上的那几行「可以这么说」。
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Print a human-facing builtin skill list: one-liner + copy-paste example.
Reads each skill's README.md. Do not substitute SKILL.md descriptions.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
_HEADING_RE = re.compile(r"^#{1,6}\s+")
_FENCE_RE = re.compile(r"^```(\w+)?\s*$")
def skills_dir() -> Path:
here = Path(__file__).resolve()
repo_skills = here.parents[2]
if (repo_skills / "pouch" / "SKILL.md").is_file():
return repo_skills
return Path.home() / ".pouch" / "skills"
def _strip_md(text: str) -> str:
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
return " ".join(text.split())
def _first_sentence(text: str) -> str:
text = _strip_md(text).strip()
for sep in ("。", "!", "?"):
idx = text.find(sep)
if idx != -1:
return text[: idx + 1]
return text.rstrip("::") + ("。" if text else "")
def _lede(readme: str) -> str:
lines = readme.splitlines()
i = 0
while i < len(lines) and (
not lines[i].strip() or _HEADING_RE.match(lines[i])
):
i += 1
chunk: list[str] = []
while i < len(lines):
raw = lines[i]
stripped = raw.strip()
if not stripped:
break
if _HEADING_RE.match(raw) or stripped.startswith(("- ", "* ", "> ")):
break
chunk.append(stripped)
i += 1
return _first_sentence(" ".join(chunk))
def _section_body(readme: str, titles: tuple[str, ...]) -> str | None:
lines = readme.splitlines()
start = None
for i, line in enumerate(lines):
if line.startswith("## ") and line[3:].strip() in titles:
start = i + 1
break
if start is None:
return None
end = len(lines)
for j in range(start, len(lines)):
if lines[j].startswith("## "):
end = j
break
return "\n".join(lines[start:end])
def _first_example(readme: str) -> str:
body = _section_body(readme, ("使用示例", "常见用法"))
search = body if body is not None else readme
lines = search.splitlines()
i = 0
while i < len(lines):
fence = _FENCE_RE.match(lines[i].strip())
if not fence:
i += 1
continue
i += 1
block: list[str] = []
while i < len(lines) and not lines[i].strip().startswith("```"):
block.append(lines[i])
i += 1
for raw in block:
stripped = raw.strip()
if not stripped or stripped.startswith("#"):
continue
return stripped
i += 1
return ""
def iter_skills(root: Path) -> list[Path]:
skills: list[Path] = []
if not root.is_dir():
return skills
for entry in sorted(root.iterdir()):
if not entry.is_dir() or entry.name.startswith("_"):
continue
if (entry / "SKILL.md").is_file():
skills.append(entry)
return skills
def catalog_rows(root: Path) -> list[tuple[str, str, str]]:
rows: list[tuple[str, str, str]] = []
errors: list[str] = []
for skill in iter_skills(root):
readme_path = skill / "README.md"
if not readme_path.is_file():
errors.append(f"{skill.name}: 缺少 README.md")
continue
text = readme_path.read_text(encoding="utf-8")
lede = _lede(text)
example = _first_example(text)
if not lede:
errors.append(f"{skill.name}: README 缺少一句话说明")
if not example:
errors.append(f"{skill.name}: README 缺少可复制的使用示例")
if lede and example:
rows.append((skill.name, lede, example))
if errors:
raise SystemExit("catalog 失败:\n " + "\n ".join(errors))
return rows
def format_table(rows: list[tuple[str, str, str]]) -> str:
lines = [
"| Skill | 做什么 | 可以这么说 |",
"| --- | --- | --- |",
]
for name, lede, example in rows:
example = example.replace("|", "\\|")
lede = lede.replace("|", "\\|")
lines.append(f"| {name} | {lede} | `{example}` |")
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(
description="列出 builtin skill 的一句话说明和可复制示例"
)
parser.add_argument(
"--skills-dir",
type=Path,
default=None,
help="skill 根目录(默认本仓库 skills/ 或 ~/.pouch/skills)",
)
args = parser.parse_args()
root = args.skills_dir.resolve() if args.skills_dir else skills_dir()
if not root.is_dir():
print(f"找不到 skills 目录: {root}", file=sys.stderr)
return 2
sys.stdout.write(format_table(catalog_rows(root)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Suggest pouch skills from well-known files in a project root."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
INIT = {
"ack": "说「/ack 初始化」或 `pouch init ack`;不要静默初始化",
"builder": "说「用 builder 初始化」;不要 `pouch init builder`",
"deployer": "说「用 deployer 初始化」;不要 `pouch init deployer`",
}
FILE_SIGNALS: dict[str, tuple[str, ...]] = {
"ack": (".pouch/ack",),
"builder": (
"makefile.builder",
"debian",
"Dockerfile",
"Containerfile",
".env.builder",
),
"deployer": (
".pouch/deployer",
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
"argocd.yaml",
"k8s",
"helm",
),
"manage-release": ("CHANGELOG.md", "CHANGELOG", "VERSION"),
"declarative-openspec-loop": ("openspec", ".openspec"),
"discussion-notes": ("000inbox",),
}
INITED_MARKERS = {
"ack": (".pouch/ack",),
"builder": ("makefile.builder",),
"deployer": (".pouch/deployer",),
}
def project_root(start: Path) -> Path:
cur = start.resolve()
if cur.is_file():
cur = cur.parent
for candidate in (cur, *cur.parents):
if (candidate / ".git").exists():
return candidate
return cur
def is_pouch_repo(root: Path) -> bool:
return (
(root / "install.sh").is_file()
and (root / "bin" / "pouch").is_file()
and (root / "skills" / "pouch" / "SKILL.md").is_file()
)
def existing(root: Path, rel: str) -> bool:
return (root / rel).exists()
def hits(root: Path, names: tuple[str, ...]) -> list[str]:
return [name for name in names if existing(root, name)]
def suggest(root: Path) -> dict[str, object]:
suggested: list[tuple[str, list[str]]] = []
already: list[tuple[str, list[str]]] = []
init: list[tuple[str, str]] = []
for name, names in FILE_SIGNALS.items():
found = hits(root, names)
if not found:
continue
inited = hits(root, INITED_MARKERS.get(name, ()))
if inited:
already.append((name, inited))
else:
suggested.append((name, found))
if name in INIT and not inited:
init.append((name, INIT[name]))
return {
"root": str(root),
"this_is_pouch_repo": is_pouch_repo(root),
"suggested": suggested,
"already": already,
"init": init,
}
def format_report(data: dict[str, object]) -> str:
lines = [f"project: {data['root']}"]
if data["this_is_pouch_repo"]:
lines.append("this_is_pouch_repo: true")
lines.append("不要按业务项目接入。维护 skill 走 pouch 的创建或回流模式。")
return "\n".join(lines) + "\n"
lines.append("this_is_pouch_repo: false")
suggested = data["suggested"]
already = data["already"]
init = data["init"]
if suggested:
lines.append("suggested:")
for name, why in suggested:
lines.append(f" {name}\twhy: {', '.join(why)}")
else:
lines.append("suggested: (none)")
if already:
lines.append("already:")
for name, why in already:
lines.append(f" {name}\t{', '.join(why)}")
else:
lines.append("already: (none)")
if init:
lines.append("init:")
for name, how in init:
lines.append(f" {name}\t{how}")
else:
lines.append("init: (none)")
return "\n".join(lines) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description="根据仓库信号建议 pouch skill")
parser.add_argument(
"path",
nargs="?",
default=".",
help="项目目录(默认 cwd;向上找 .git)",
)
args = parser.parse_args()
root = project_root(Path(args.path))
sys.stdout.write(format_report(suggest(root)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+231
View File
@@ -102,6 +102,16 @@ class BuilderCheckTests(unittest.TestCase):
self.assertIn("makefile.builder", contract) self.assertIn("makefile.builder", contract)
self.assertIn(".env.builder", contract) self.assertIn(".env.builder", contract)
self.assertIn("不要读取或改写用户 `.env`", contract) self.assertIn("不要读取或改写用户 `.env`", contract)
self.assertIn("ARCHES", contract)
readme = (REPO_ROOT / "skills" / "builder" / "README.md").read_text(
encoding="utf-8"
)
self.assertIn("ARCHES", readme)
self.assertIn("amd64 arm64", readme)
self.assertIn("多架构", readme)
self.assertIn("copy_artifact.sh", skill)
self.assertIn("android", skill.lower())
self.assertIn("COPY_DEST", contract)
def test_no_makefile_without_ready_is_usage_error(self) -> None: def test_no_makefile_without_ready_is_usage_error(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
@@ -233,6 +243,227 @@ class BuilderCheckTests(unittest.TestCase):
self.assertNotIn("hijacked", text) self.assertNotIn("hijacked", text)
self.assertNotIn(":latest", text) self.assertNotIn(":latest", text)
def test_default_template_rejects_arch_outside_arches(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
result = subprocess.run(
["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"],
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(result.returncode, 0, result.stderr)
self.assertIn("amd64", result.stderr)
def test_multi_arches_deb_and_push_fan_out(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
makefile = project / "makefile.builder"
makefile.write_text(
makefile.read_text(encoding="utf-8").replace(
"ARCHES ?= amd64", "ARCHES ?= amd64 arm64"
),
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("ARCHES: 'amd64 arm64'", text)
deb = subprocess.run(
["make", "-C", str(project), "-f", "makefile.builder", "-n", "deb"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(deb.returncode, 0, deb.stderr)
combined = deb.stdout + deb.stderr
self.assertIn("ARCH=amd64", combined)
self.assertIn("ARCH=arm64", combined)
pinned = subprocess.run(
[
"make",
"-C",
str(project),
"-f",
"makefile.builder",
"-n",
"deb",
"ARCH=arm64",
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(pinned.returncode, 0, pinned.stderr)
self.assertIn("_arm64.deb", pinned.stdout)
self.assertNotIn("_amd64.deb", pinned.stdout)
def test_legacy_makefile_without_arches_still_passes(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
(project / "makefile.builder").write_text(
"\n".join(
[
"ARCH ?= amd64",
"ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))",
"$(error ARCH must be amd64 or arm64)",
"endif",
"PROJECT_NAME ?= $(notdir $(CURDIR))",
"DIST_DIR ?= dist",
f"include {SCRIPTS / 'version.mk'}",
f"BUILDER_SCRIPTS := {SCRIPTS}",
".PHONY: help version clean build deb push",
"help:",
"\t@echo $(PROJECT_NAME) $(VERSION)",
"version:",
"\t@echo $(VERSION)",
"clean:",
"\trm -rf $(DIST_DIR)",
"build:",
"\tmkdir -p $(DIST_DIR)",
"\t@echo build linux/$(ARCH)",
"deb: build",
"\tmkdir -p $(DIST_DIR)",
"\tdpkg-deb --build packaging $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb",
"push:",
"\t$(BUILDER_SCRIPTS)/upload_deb.sh $(DIST_DIR)/$(PROJECT_NAME)_$(VERSION)_$(ARCH).deb",
"",
]
),
encoding="utf-8",
)
code, text = run_check(
project, "--ready", which={"dpkg-deb": "/usr/bin/dpkg-deb"}
)
self.assertEqual(code, 0, text)
self.assertIn("arches target: absent", text)
result = subprocess.run(
["make", "-C", str(project), "-f", "makefile.builder", "-n", "build", "ARCH=arm64"],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_publish_docker_derives_platforms_from_arches(self) -> None:
script = SCRIPTS / "publish_docker.sh"
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
write_contract_makefile(project)
makefile = project / "makefile.builder"
makefile.write_text(
makefile.read_text(encoding="utf-8").replace(
"ARCHES ?= amd64", "ARCHES ?= amd64 arm64"
),
encoding="utf-8",
)
(project / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
env = os.environ.copy()
env["BUILDER_SKILL_DIR"] = str(SCRIPTS.parent)
env.pop("PLATFORMS", None)
derived = subprocess.run(
[
str(script),
"--registry",
"example.com",
"--repository",
"ns/foo",
"--tag",
"dry-run-tag",
"--dry-run",
],
cwd=project,
capture_output=True,
text=True,
check=False,
env=env,
)
self.assertEqual(derived.returncode, 0, derived.stdout + derived.stderr)
self.assertIn("Platform: linux/amd64,linux/arm64", derived.stdout)
env["PLATFORMS"] = "linux/amd64"
overridden = subprocess.run(
[
str(script),
"--registry",
"example.com",
"--repository",
"ns/foo",
"--tag",
"dry-run-tag",
"--dry-run",
],
cwd=project,
capture_output=True,
text=True,
check=False,
env=env,
)
self.assertEqual(overridden.returncode, 0, overridden.stdout + overridden.stderr)
self.assertIn("Platform: linux/amd64", overridden.stdout)
self.assertNotIn("linux/arm64", overridden.stdout)
def test_android_copy_makefile_skips_missing_copy_dest(self) -> None:
template = REPO_ROOT / "skills" / "builder" / "templates" / "makefile.android.builder"
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
text = template.read_text(encoding="utf-8")
text = text.replace(
"include $(HOME)/.pouch/skills/builder/scripts/version.mk",
f"include {SCRIPTS / 'version.mk'}",
)
(project / "makefile.builder").write_text(text, encoding="utf-8")
code, out = run_check(project, "--ready")
self.assertEqual(code, 0, out)
self.assertIn("[PASS] 12. android 目标产物形状与纯构建", out)
self.assertIn("[SKIP] 13. ios 目标产物形状与纯构建", out)
self.assertIn("COPY_DEST: MISSING", out)
self.assertIn("blocks publish, not build", out)
def test_inline_docker_cp_in_push_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp:
project = Path(temp)
init_repo(project)
(project / "makefile.builder").write_text(
"\n".join(
[
"ARCHES ?= amd64",
"ARCH ?= amd64",
"ifneq ($(filter $(ARCH),$(ARCHES)),$(ARCH))",
"$(error ARCH must be one of: $(ARCHES))",
"endif",
"DIST_DIR ?= dist",
f"include {SCRIPTS / 'version.mk'}",
".PHONY: help version arches clean build push",
"help:",
"\t@echo app $(VERSION)",
"version:",
"\t@echo $(VERSION)",
"arches:",
"\t@echo $(ARCHES)",
"clean:",
"\trm -rf $(DIST_DIR)",
"build:",
"\tmkdir -p $(DIST_DIR)/$(ARCH).apk",
"push:",
"\tdocker cp dist/app.apk file-server:/srv/app.apk",
"",
]
),
encoding="utf-8",
)
code, out = run_check(project, "--ready")
self.assertEqual(code, 1, out)
self.assertIn("[FAIL] 7. push 仅委托 builder 脚本", out)
self.assertIn("docker cp", out)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPT = REPO_ROOT / "skills" / "builder" / "scripts" / "copy_artifact.sh"
def git(cwd: Path, *args: str) -> None:
result = subprocess.run(
["git", *args],
cwd=cwd,
text=True,
capture_output=True,
check=False,
env={
**os.environ,
"GIT_AUTHOR_NAME": "Test",
"GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
},
)
if result.returncode != 0:
raise AssertionError(f"git {args} failed: {result.stderr}")
class CopyArtifactTests(unittest.TestCase):
def test_local_copy_and_alias(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test")
src = root / "app.apk"
src.write_bytes(b"apk")
git(root, "add", "app.apk")
git(root, "commit", "-m", "apk")
dest = root / "out"
result = subprocess.run(
[str(SCRIPT), "-d", str(dest), "-a", "latest.apk", str(src)],
cwd=root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertTrue((dest / "app.apk").is_file())
self.assertTrue((dest / "latest.apk").is_file())
self.assertEqual((dest / "latest.apk").read_bytes(), b"apk")
self.assertIn("copy_artifact: 1 file(s)", result.stdout)
def test_dirty_tree_requires_allow(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test")
src = root / "app.apk"
src.write_bytes(b"apk")
git(root, "add", "app.apk")
git(root, "commit", "-m", "apk")
src.write_bytes(b"changed")
dest = root / "out"
blocked = subprocess.run(
[str(SCRIPT), "-d", str(dest), str(src)],
cwd=root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(blocked.returncode, 3, blocked.stderr)
allowed = subprocess.run(
[str(SCRIPT), "-d", str(dest), str(src)],
cwd=root,
capture_output=True,
text=True,
check=False,
env={**os.environ, "ALLOW_UNCOMMITTED": "1"},
)
self.assertEqual(allowed.returncode, 0, allowed.stdout + allowed.stderr)
def test_dry_run_docker_dest_prints_docker_cp(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
git(root, "init", "-b", "main")
git(root, "config", "user.email", "test@example.com")
git(root, "config", "user.name", "Test")
src = root / "app.apk"
src.write_bytes(b"apk")
git(root, "add", "app.apk")
git(root, "commit", "-m", "apk")
result = subprocess.run(
[
str(SCRIPT),
"--dry-run",
"-d",
"file-server:/srv",
"-a",
"latest.apk",
str(src),
],
cwd=root,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("docker cp", result.stdout)
self.assertIn("file-server:/srv/app.apk", result.stdout)
self.assertIn("file-server:/srv/latest.apk", result.stdout)
if __name__ == "__main__":
unittest.main()
+60 -5
View File
@@ -14,6 +14,10 @@ VERSION_SH = SCRIPTS / "version.sh"
PUBLISH_DOCKER = SCRIPTS / "publish_docker.sh" PUBLISH_DOCKER = SCRIPTS / "publish_docker.sh"
VERSION_MK = SCRIPTS / "version.mk" VERSION_MK = SCRIPTS / "version.mk"
# Frozen build date so test versions are deterministic.
SOURCE_DATE_EPOCH = "1704067200" # 2024-01-01T00:00:00Z
BUILD_DATE = "20240101"
def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str: def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str:
merged = os.environ.copy() merged = os.environ.copy()
@@ -48,6 +52,7 @@ def sha7(cwd: Path) -> str:
def version_sh(*args: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: def version_sh(*args: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
merged = os.environ.copy() merged = os.environ.copy()
merged["SOURCE_DATE_EPOCH"] = SOURCE_DATE_EPOCH
if env: if env:
merged.update(env) merged.update(env)
return subprocess.run( return subprocess.run(
@@ -86,6 +91,29 @@ class BuilderVersionTests(unittest.TestCase):
self.assertEqual(docker.returncode, 0, docker.stderr) self.assertEqual(docker.returncode, 0, docker.stderr)
self.assertEqual(docker.stdout, "1.4.2\n") self.assertEqual(docker.stdout, "1.4.2\n")
def test_post_release_commit_bumps_patch(self) -> None:
with tempfile.TemporaryDirectory() as temp:
repo = Path(temp) / "repo"
repo.mkdir()
init_repo(repo)
(repo / "a").write_text("1\n", encoding="utf-8")
commit(repo, "release")
git(repo, "tag", "-a", "v1.2.0", "-m", "1.2.0")
(repo / "b").write_text("fix\n", encoding="utf-8")
commit(repo, "bugfix")
sha = sha7(repo)
result = version_sh(cwd=repo)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
result.stdout.strip(),
f"1.2.1~{BUILD_DATE}.main.1+g{sha}",
)
docker = version_sh("--docker", cwd=repo)
self.assertEqual(
docker.stdout.strip(),
f"1.2.1-{BUILD_DATE}.main.1.g{sha}",
)
def test_test_version_uses_ancestor_not_global_max(self) -> None: def test_test_version_uses_ancestor_not_global_max(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
root = Path(temp) root = Path(temp)
@@ -113,12 +141,12 @@ class BuilderVersionTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual( self.assertEqual(
result.stdout.strip(), result.stdout.strip(),
f"1.4.2~feat-login-v2.2+g{feature_sha}", f"1.4.3~{BUILD_DATE}.feat-login-v2.2+g{feature_sha}",
) )
docker = version_sh("--docker", cwd=repo) docker = version_sh("--docker", cwd=repo)
self.assertEqual( self.assertEqual(
docker.stdout.strip(), docker.stdout.strip(),
f"1.4.2-feat-login-v2.2.g{feature_sha}", f"1.4.3-{BUILD_DATE}.feat-login-v2.2.g{feature_sha}",
) )
def test_ignores_prerelease_and_app_tags(self) -> None: def test_ignores_prerelease_and_app_tags(self) -> None:
@@ -136,7 +164,7 @@ class BuilderVersionTests(unittest.TestCase):
sha = sha7(repo) sha = sha7(repo)
result = version_sh(cwd=repo) result = version_sh(cwd=repo)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), f"1.0.0~main.1+g{sha}") self.assertEqual(result.stdout.strip(), f"1.0.1~{BUILD_DATE}.main.1+g{sha}")
def test_no_stable_tag(self) -> None: def test_no_stable_tag(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
@@ -148,7 +176,7 @@ class BuilderVersionTests(unittest.TestCase):
sha = sha7(repo) sha = sha7(repo)
result = version_sh(cwd=repo) result = version_sh(cwd=repo)
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), f"0.0.0~main+g{sha}") self.assertEqual(result.stdout.strip(), f"0.0.0~{BUILD_DATE}.main+g{sha}")
def test_detached_uses_env_branch(self) -> None: def test_detached_uses_env_branch(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
@@ -164,7 +192,34 @@ class BuilderVersionTests(unittest.TestCase):
git(repo, "checkout", "--detach", "HEAD") git(repo, "checkout", "--detach", "HEAD")
result = version_sh(cwd=repo, env={"BUILD_BRANCH": "ci_job"}) result = version_sh(cwd=repo, env={"BUILD_BRANCH": "ci_job"})
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), f"0.1.0~ci-job.1+g{sha}") self.assertEqual(result.stdout.strip(), f"0.1.1~{BUILD_DATE}.ci-job.1+g{sha}")
def test_source_date_epoch_changes_test_date(self) -> None:
with tempfile.TemporaryDirectory() as temp:
repo = Path(temp) / "repo"
repo.mkdir()
init_repo(repo)
(repo / "a").write_text("1\n", encoding="utf-8")
commit(repo, "init")
git(repo, "tag", "v1.0.0")
(repo / "b").write_text("2\n", encoding="utf-8")
commit(repo, "next")
sha = sha7(repo)
later = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"})
self.assertEqual(later.returncode, 0, later.stderr)
self.assertEqual(later.stdout.strip(), f"1.0.1~20240901.main.1+g{sha}")
def test_official_version_ignores_build_date(self) -> None:
with tempfile.TemporaryDirectory() as temp:
repo = Path(temp) / "repo"
repo.mkdir()
init_repo(repo)
(repo / "a").write_text("1\n", encoding="utf-8")
commit(repo, "init")
git(repo, "tag", "-a", "v1.4.2", "-m", "1.4.2")
result = version_sh(cwd=repo, env={"SOURCE_DATE_EPOCH": "1725148800"})
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "1.4.2")
def test_override_official_rejected_off_tag(self) -> None: def test_override_official_rejected_off_tag(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
+43 -2
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import importlib.util import importlib.util
import os import os
import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
from io import StringIO from io import StringIO
@@ -10,8 +12,10 @@ from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
CHECK_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "check.py" DEPLOY_SCRIPTS = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy"
LIB_PY = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy" / "lib.py" CHECK_PY = DEPLOY_SCRIPTS / "check.py"
LIST_PY = DEPLOY_SCRIPTS / "list.py"
LIB_PY = DEPLOY_SCRIPTS / "lib.py"
lib_spec = importlib.util.spec_from_file_location("deployer_lib", 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 assert lib_spec is not None and lib_spec.loader is not None
@@ -55,6 +59,43 @@ class DeployerCheckTests(unittest.TestCase):
self.assertIn("scripts/deploy/check.py", skill) self.assertIn("scripts/deploy/check.py", skill)
self.assertIn("不要写假 node", skill) self.assertIn("不要写假 node", skill)
def test_skill_documents_redeploy_happy_path(self) -> None:
skill = (REPO_ROOT / "skills" / "deployer" / "SKILL.md").read_text(
encoding="utf-8"
)
compose_section = skill.split("## Compose 操作", 1)[1].split("## Argo CD", 1)[0]
self.assertIn("不要读 compose.md", compose_section)
self.assertIn("remote.py <svc> recreate", compose_section)
self.assertIn("一句问", compose_section)
self.assertIn("没有 `test` 就用非 `prod` 的那个", compose_section)
self.assertIn("不要** `sync.py` / `remote.py`", compose_section)
self.assertIn("docker compose up -d --force-recreate", compose_section)
self.assertNotIn(
"读 [compose.md](references/compose.md)。用 `list.py` 摸底",
compose_section,
)
def test_list_py_isolated_mode_imports_lib(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"
)
proc = subprocess.run(
[sys.executable, "-I", "-S", str(LIST_PY)],
cwd=str(project),
capture_output=True,
text=True,
check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout)
self.assertIn("test", proc.stdout)
self.assertNotIn("No module named 'lib'", proc.stderr)
def test_missing_layout_fails(self) -> None: def test_missing_layout_fails(self) -> None:
with tempfile.TemporaryDirectory() as temp: with tempfile.TemporaryDirectory() as temp:
project = Path(temp) project = Path(temp)
+149
View File
@@ -0,0 +1,149 @@
from __future__ import annotations
import importlib.util
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
DEPLOY_SCRIPTS = REPO_ROOT / "skills" / "deployer" / "scripts" / "deploy"
SYNC_PY = DEPLOY_SCRIPTS / "sync.py"
if str(DEPLOY_SCRIPTS) not in sys.path:
sys.path.insert(0, str(DEPLOY_SCRIPTS))
sync_spec = importlib.util.spec_from_file_location("deployer_sync", SYNC_PY)
assert sync_spec is not None and sync_spec.loader is not None
deployer_sync = importlib.util.module_from_spec(sync_spec)
sync_spec.loader.exec_module(deployer_sync)
def _info(service_dir: str, **overrides) -> dict:
info = {
"service_dir": service_dir,
"node": "edge",
"remote_dir": "/opt/app/svc",
"sync_exclude": ["data", "_data"],
"port": None,
"identity_file": None,
}
info.update(overrides)
return info
class DeployerSyncTests(unittest.TestCase):
def test_collect_sync_files_skips_excluded_directories(self) -> None:
with tempfile.TemporaryDirectory() as temp:
src = Path(temp)
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
(src / "data").mkdir()
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
nested = src / "cfg"
nested.mkdir()
(nested / "app.env").write_text("x\n", encoding="utf-8")
(nested / "data").mkdir()
(nested / "data" / "cache").write_text("nope\n", encoding="utf-8")
files = {
p.relative_to(src).as_posix()
for p in deployer_sync.collect_sync_files(str(src), ["data", "_data"])
}
self.assertEqual(files, {"compose.yaml", "cfg/app.env"})
def test_prefers_rsync_when_available(self) -> None:
info = _info("/tmp/svc")
completed = subprocess.CompletedProcess(["rsync"], 0)
with mock.patch.object(deployer_sync.shutil, "which", return_value="/usr/bin/rsync"):
with mock.patch.object(
deployer_sync.subprocess, "run", return_value=completed
) as run:
rc = deployer_sync.sync_directory(info)
self.assertEqual(rc, 0)
cmd = run.call_args[0][0]
self.assertEqual(cmd[0], "rsync")
self.assertIn("--delete", cmd)
self.assertIn("--exclude=data/", cmd)
def test_falls_back_to_scp_when_rsync_missing(self) -> None:
with tempfile.TemporaryDirectory() as temp:
src = Path(temp)
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
(src / "data").mkdir()
(src / "data" / "db").write_text("nope\n", encoding="utf-8")
nested = src / "cfg"
nested.mkdir()
(nested / "app.env").write_text("x\n", encoding="utf-8")
commands: list[list[str]] = []
def fake_run(cmd, **kwargs):
commands.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
info = _info(str(src), port=2222, identity_file="/tmp/key")
with mock.patch.object(deployer_sync.shutil, "which", return_value=None):
with mock.patch.object(
deployer_sync.subprocess, "run", side_effect=fake_run
):
rc = deployer_sync.sync_directory(info)
self.assertEqual(rc, 0)
self.assertTrue(commands)
mkdir_cmd = commands[0]
self.assertEqual(mkdir_cmd[0], "ssh")
self.assertIn("-p", mkdir_cmd)
self.assertIn("2222", mkdir_cmd)
self.assertTrue(any("/opt/app/svc/cfg" in part for part in mkdir_cmd))
scp_cmds = [c for c in commands if c and c[0] == "scp"]
self.assertEqual(len(scp_cmds), 2)
joined = "\n".join(" ".join(c) for c in scp_cmds)
self.assertIn("compose.yaml", joined)
self.assertIn("app.env", joined)
self.assertNotIn("db", joined)
self.assertNotIn("--delete", joined)
self.assertIn("-P", scp_cmds[0])
self.assertIn("2222", scp_cmds[0])
self.assertIn("-i", scp_cmds[0])
self.assertIn("/tmp/key", scp_cmds[0])
def test_rsync_filenotfound_falls_back_to_scp(self) -> None:
with tempfile.TemporaryDirectory() as temp:
src = Path(temp)
(src / "compose.yaml").write_text("ok\n", encoding="utf-8")
commands: list[list[str]] = []
def fake_run(cmd, **kwargs):
if cmd and cmd[0] == "rsync":
raise FileNotFoundError("rsync")
commands.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
with mock.patch.object(
deployer_sync.shutil, "which", return_value="/usr/bin/rsync"
):
with mock.patch.object(
deployer_sync.subprocess, "run", side_effect=fake_run
):
rc = deployer_sync.sync_directory(_info(str(src)))
self.assertEqual(rc, 0)
self.assertTrue(any(c and c[0] == "scp" for c in commands))
def test_docs_say_scp_fallback_not_tar(self) -> None:
compose = (
REPO_ROOT / "skills" / "deployer" / "references" / "compose.md"
).read_text(encoding="utf-8")
check = (DEPLOY_SCRIPTS / "check.py").read_text(encoding="utf-8")
self.assertIn("本机无 rsync 时 scp 兜底", compose)
self.assertNotIn("本机无 rsync 时 tar over SSH 兜底", compose)
self.assertIn("scp fallback", check)
self.assertNotIn("tar-over-SSH", check)
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG = REPO_ROOT / "skills" / "pouch" / "scripts" / "catalog.py"
SUGGEST = REPO_ROOT / "skills" / "pouch" / "scripts" / "suggest_skills.py"
def _run(script: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(script), *args],
check=False,
capture_output=True,
text=True,
)
class CatalogTests(unittest.TestCase):
def test_repo_skills_have_lede_and_example(self) -> None:
result = _run(CATALOG, "--skills-dir", str(REPO_ROOT / "skills"))
self.assertEqual(result.returncode, 0, result.stderr)
for name in (
"ack",
"builder",
"deployer",
"host-sec-check",
"manage-release",
"note-style",
"pouch",
):
self.assertIn(f"| {name} |", result.stdout)
self.assertIn("/ack 初始化", result.stdout)
self.assertIn("用 builder 初始化这个项目", result.stdout)
self.assertNotIn("触发词", result.stdout)
def test_missing_readme_fails(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = Path(tmp) / "demo"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: demo\ndescription: d\n---\n# demo\n",
encoding="utf-8",
)
result = _run(CATALOG, "--skills-dir", tmp)
self.assertEqual(result.returncode, 1)
self.assertIn("缺少 README.md", result.stderr)
def test_extracts_first_sentence_and_skips_hash_comments(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
skill = Path(tmp) / "demo"
skill.mkdir()
(skill / "SKILL.md").write_text(
"---\nname: demo\ndescription: d\n---\n# demo\n",
encoding="utf-8",
)
(skill / "README.md").write_text(
"# demo\n\n"
"这是一句话说明。后面还有一句不该进短清单。\n\n"
"## 使用示例\n\n"
"```text\n"
"# 注释\n"
"用 demo 做这件事。\n"
"```\n",
encoding="utf-8",
)
result = _run(CATALOG, "--skills-dir", tmp)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("这是一句话说明。", result.stdout)
self.assertNotIn("不该进短清单", result.stdout)
self.assertIn("用 demo 做这件事。", result.stdout)
class SuggestSkillsTests(unittest.TestCase):
def test_pouch_repo_is_not_an_app(self) -> None:
result = _run(SUGGEST, str(REPO_ROOT))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("this_is_pouch_repo: true", result.stdout)
self.assertNotIn("suggested:", result.stdout)
def test_dockerfile_suggests_builder(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8")
result = _run(SUGGEST, str(root))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("this_is_pouch_repo: false", result.stdout)
self.assertIn("builder", result.stdout)
self.assertIn("Dockerfile", result.stdout)
self.assertIn("用 builder 初始化", result.stdout)
self.assertIn("不要 `pouch init builder`", result.stdout)
def test_existing_makefile_builder_is_already(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "makefile.builder").write_text("build:\n", encoding="utf-8")
result = _run(SUGGEST, str(root))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("already:", result.stdout)
self.assertIn("builder", result.stdout)
self.assertIn("init: (none)", result.stdout)
def test_compose_and_ack_dir(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / "compose.yaml").write_text("services: {}\n", encoding="utf-8")
(root / ".pouch" / "ack").mkdir(parents=True)
result = _run(SUGGEST, str(root))
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("deployer", result.stdout)
self.assertIn("compose.yaml", result.stdout)
self.assertIn("ack", result.stdout)
self.assertIn(".pouch/ack", result.stdout)
self.assertIn("用 deployer 初始化", result.stdout)
self.assertNotIn("/ack 初始化", result.stdout)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -21,6 +21,9 @@ class ProjectSkillContentTests(unittest.TestCase):
"第三方 skill", "第三方 skill",
"pouch init ack", "pouch init ack",
"显式调用全局 `/ack` skill", "显式调用全局 `/ack` skill",
"references/install.md",
"scripts/catalog.py",
"references/onboard.md",
): ):
self.assertIn(expected, content) self.assertIn(expected, content)