Merge branch 'main' of https://git.yumee.top/laily/.pouch
This commit is contained in:
+54
-265
@@ -1,28 +1,25 @@
|
||||
# ACK
|
||||
|
||||
ACK 是一个显式调用的 Agent Skill,用三种独立角色运行工程协作闭环:
|
||||
ACK 是显式调用的 Agent Skill,用三种独立角色跑工程协作闭环:
|
||||
|
||||
- Coordinator 拆解需求、派发任务并终检。
|
||||
- Developer 实现并执行白盒验证。
|
||||
- Test 独立执行黑盒复测。
|
||||
- Coordinator 拆解需求、派发任务并终检
|
||||
- Developer 实现并做白盒验证
|
||||
- Test 独立做黑盒复测
|
||||
|
||||
关键约束是验证者不等于实现者。每个任务最多修复三轮,仍未通过时记录为
|
||||
`leftover`,然后继续处理其它任务。
|
||||
验证者不能等于实现者。每个任务最多修三轮,仍不过就记为 `leftover`,然后继续别的任务。
|
||||
|
||||
测试环境部署由 ACK 触发、**内部调用 deployer** 执行。发版仍写在同一份
|
||||
`.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,把黑盒用例收进
|
||||
`.pouch/ack/regression.yaml`;之后可以单独跑回归。
|
||||
测试环境由 ACK 触发、内部调用 [deployer](../deployer/README.md)。发版写在同一份 `.pouch/ack/delivery.yaml`。功能或 bug 验证通过后,黑盒用例收进 `.pouch/ack/regression.yaml`,之后可以单独跑回归。
|
||||
|
||||
ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
||||
只在你显式说 `/ack` 或 `$ack` 时运行。
|
||||
|
||||
## 使用场景
|
||||
## 什么时候使用
|
||||
|
||||
| 场景 | 怎么说 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 初始化 | `/ack 初始化` | 生成并补全 `.pouch/ack/`;缺项按统一格式列出 |
|
||||
| 检查 | `/ack 检查配置` | 只读校验,默认不改文件;测试环境缺 deployer 时转交 |
|
||||
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 确认 → 三角色闭环 |
|
||||
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 确认(飞书须你点「已确认」)→ 同一闭环 |
|
||||
| 做需求 | `/ack 处理这个需求:…` | 产品文档 + 拆任务 → 你确认 → 三角色闭环 |
|
||||
| 修 bug | `/ack 修这个 bug:…` 或处理飞书收件 | 短描述 + 验收 → 你确认(飞书须你点「已确认」)→ 同一闭环 |
|
||||
| 交付配置 | 说明怎么布测试环境 / 怎么发版 | 写入同一份 `delivery.yaml`;测试环境绑定 deployer |
|
||||
| 运行测试环境 | `/ack 重新布测试环境` | 内部加载 deployer,布 `.pouch/deployer/<env>` |
|
||||
| 运行版本发布 | `/ack 发布一个版本` | 按 `intents.release`;stable/生产仍要单独批准 |
|
||||
@@ -32,17 +29,18 @@ ACK 只在用户显式调用 `/ack` 或 `$ack` 时运行。
|
||||
|
||||
## 安装
|
||||
|
||||
全局安装:
|
||||
|
||||
```bash
|
||||
pouch add ack -g
|
||||
pouch add ack # 当前项目
|
||||
pouch add ack -g # 全局
|
||||
```
|
||||
|
||||
或只安装到当前项目:
|
||||
## 使用前准备
|
||||
|
||||
```bash
|
||||
pouch add ack
|
||||
```
|
||||
- 项目是 Git 仓库。
|
||||
- 自动拉起 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
|
||||
```
|
||||
|
||||
初始化后,项目只保存自己的 ACK 状态:
|
||||
也可以直接说 `/ack 初始化`。初始化后项目只保存自己的状态:
|
||||
|
||||
```text
|
||||
.pouch/ack/
|
||||
@@ -62,271 +60,62 @@ pouch init ack --project ~/code/my-app
|
||||
└── regression.yaml # 默认 cases: []
|
||||
```
|
||||
|
||||
不会在项目中复制或链接 ACK Skill。通用规范、模板和脚本始终从已安装的 Skill
|
||||
目录读取。
|
||||
不会把 ACK Skill 复制进项目。规范、模板和脚本始终从已安装的 Skill 目录读取。
|
||||
|
||||
ACK 从当前命令指定的 `--project-root/.pouch/ack/` 定位项目状态,不要求在
|
||||
`tasks.yaml` 中持久化 `repoPath` 或 `devWorktree`。自动 worker 的实际工作目录由
|
||||
`--worktree` 指定;默认在 `--project-root` 工作,v0.19 起不再配置 `allowedWorktrees` 白名单。
|
||||
旧项目如果只有 `project.md` 和 `tasks.yaml`,不要重跑初始化。说 `/ack 检查配置`,授权后再补空的 `knowledge.yaml` 等缺件。
|
||||
|
||||
## 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
|
||||
/ack 初始化
|
||||
/ack 检查配置
|
||||
/ack 处理这个需求:给后台加导出 CSV
|
||||
/ack 修这个 bug:保存后列表不刷新。复现:… 期望:…
|
||||
/ack 处理飞书里待整理的 bug
|
||||
/ack 测试环境用项目里的 .pouch/deployer/test;发版方式以后再告诉你
|
||||
/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 跑 full 回归
|
||||
/ack 回归 REG-login-001
|
||||
/ack 修这些回归失败
|
||||
```
|
||||
|
||||
ACK 先按 deployer 布测试环境,再派独立 Test 按用例用浏览器或 API 执行。失败只
|
||||
报告,不会自动开修;要修再说 `/ack 修这些回归失败`。默认跑 smoke。细则见
|
||||
`references/regression.md`。
|
||||
|
||||
首次配置交付可以说:
|
||||
首次配发版也可以一次说清:
|
||||
|
||||
```text
|
||||
/ack 更新项目交付配置:用 make build-deb 构建 DEB,发布到 preview APT 仓库,
|
||||
部署到 test-server 并跑健康检查;完成后创建 PR,停在 review_ready 给我审核。
|
||||
```
|
||||
|
||||
之后处理需求时只需在确认计划中选择 profile:
|
||||
之后做需求时只要在确认计划里选 profile:
|
||||
|
||||
```text
|
||||
/ack 处理这个需求:<一句话需求>。任务验证通过后执行 review profile。
|
||||
/ack 处理这个需求:…。任务验证通过后执行 review profile。
|
||||
```
|
||||
|
||||
ACK 会自动读取 `delivery.yaml`,无需再逐步提醒它构建、上传、部署或开 PR;目标或
|
||||
权限发生漂移、缺少凭据、进入 stable/production approval 时才停下来请求决策。
|
||||
## Agent 会做什么
|
||||
|
||||
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 记录
|
||||
`ackVersion`。从 `0.10.0` 起,`project.orchestration` 与顶层 `workerReceipts` 必须
|
||||
同时存在;从 `0.11.0` 起,新项目还会生成默认关闭的 `delivery.yaml`,并在任务板声明
|
||||
`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`。
|
||||
当前 Skill 版本见 `VERSION`。新项目在 `tasks.yaml` 里记 `ackVersion`。
|
||||
|
||||
从 0.19.0 起,测试环境写成 `{via: deployer, env: <env>}`,回归目录是 `.pouch/ack/regression.yaml`。旧的测试环境 profile ID 字符串不再执行,需要迁过去。旧项目可以不迁回归目录,继续用原来的闭环。旧的 `kitVersion` 仍可读,建议改成 `ackVersion`。
|
||||
|
||||
+105
-12
@@ -1,19 +1,37 @@
|
||||
# builder
|
||||
|
||||
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
|
||||
按统一契约完成项目的 DEB 包、Docker 镜像、Android APK / iOS IPA 构建和发布,
|
||||
或把产物拷到目录 / 容器。规范本体见
|
||||
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
||||
可执行校验器。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- "用 builder 初始化这个项目"
|
||||
- "帮我构建这个项目的 DEB / Docker 镜像"
|
||||
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
||||
- "帮我构建这个项目的 DEB / Docker 镜像 / APK"
|
||||
- "把 1.2.3 发布到包仓库 / 镜像仓库 / File Browser"
|
||||
- "检查这个项目的 makefile.builder 是否符合 builder 契约"
|
||||
- "看看项目现在的发布流程"
|
||||
|
||||
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
||||
|
||||
## 安装
|
||||
|
||||
```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 会探测轨道、按
|
||||
@@ -21,8 +39,9 @@
|
||||
不改用户已有的 `Makefile` 或 `.env`。
|
||||
|
||||
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 推导。
|
||||
多架构清单是 `ARCHES`,见下方「多架构」。
|
||||
2. 运行 `python3 -I -S <builder>/scripts/check.py . --ready` 直到构建项 PASS。
|
||||
缺发布键只挡住上传,不挡住构建。
|
||||
3. 在项目根 `.env.builder` 配置发布环境变量(不要写进 `.env`):
|
||||
@@ -32,11 +51,58 @@ DEB_SERVER_URL=https://deb.example.com
|
||||
DEB_REPOSITORY=main
|
||||
DEB_TOKEN=<token> # 只放 .env.builder 或密钥系统,不进 git
|
||||
DOCKER_REGISTRY=registry.example.com
|
||||
COPY_DEST=file-server:/srv
|
||||
COPY_AS=latest.apk
|
||||
```
|
||||
|
||||
4. 日常发布就是两条命令:
|
||||
`make -f makefile.builder deb && make -f makefile.builder push-deb`、
|
||||
`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)
|
||||
|
||||
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的
|
||||
分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库,
|
||||
所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。
|
||||
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`)。有基线时测试号把
|
||||
patch 机械 +1,再加构建日 `YYYYMMDD`、清洗后的分支名、相对距离、短 SHA。
|
||||
这是排序键,不是在选定下一正式 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
|
||||
规范 / DEB 1.4.2~feat-login-v2.7+gabc1234
|
||||
文件名 foo_1.4.2~feat-login-v2.7+gabc1234_amd64.deb
|
||||
Docker registry/ns/foo:1.4.2-feat-login-v2.7.gabc1234
|
||||
规范 / DEB 1.4.3~20260908.feat-login-v2.7+gabc1234
|
||||
文件名 foo_1.4.3~20260908.feat-login-v2.7+gabc1234_amd64.deb
|
||||
Docker registry/ns/foo:1.4.3-20260908.feat-login-v2.7.gabc1234
|
||||
```
|
||||
|
||||
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 把当前 commit 的产物发布到项目已配置的仓库。
|
||||
用 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/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
||||
| `scripts/copy_artifact.sh` | 把产物拷到 `COPY_DEST`(本地目录或 `container:/dir`) |
|
||||
| `templates/makefile.android.builder` | Android APK + copy 轨道骨架 |
|
||||
| `templates/makefile.ios.builder` | iOS IPA + copy 轨道骨架 |
|
||||
|
||||
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.pouch/skills/builder/scripts/`)、
|
||||
脏工作树策略等完整规则见 contract.md。
|
||||
|
||||
+14
-11
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: builder
|
||||
description: >-
|
||||
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像。触发词:初始化
|
||||
builder、检查 makefile.builder、构建/发布 deb、推送 apt、构建/发布 Docker
|
||||
镜像、make push。未授权不上传。Docker 仅用户点名镜像时才走。
|
||||
初始化或检查 makefile.builder 契约,再构建或发布 deb/镜像/Android APK/iOS IPA,
|
||||
或把产物拷到目录/容器。触发词:初始化 builder、检查 makefile.builder、构建/发布
|
||||
deb、推送 apt、构建/发布 Docker 镜像、打 APK/IPA、拷到 File Browser、make push。
|
||||
未授权不上传。Docker 仅用户点名镜像时才走。
|
||||
---
|
||||
|
||||
# Builder:DEB / Docker 构建发布
|
||||
# Builder:DEB / Docker / Android / iOS 构建发布
|
||||
|
||||
**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||
项目状态是根目录 `makefile.builder` 与发布用 `.env.builder`,不要创建
|
||||
@@ -20,15 +21,15 @@ description: >-
|
||||
- 初始化、接入 builder,或还没有 `makefile.builder`:执行「初始化」。
|
||||
- 检查契约或发布配置:执行「检查」。
|
||||
- 构建、发布、上传:执行「工作流」。不要静默初始化。
|
||||
- 不适用:本地安装/卸载 DEB;RPM/APK/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
|
||||
- 不适用:本地安装/卸载 DEB;RPM/语言包;从零设计打包体系(先出方案);普通编码与 Dockerfile 编辑。
|
||||
- 改本 skill 自身:契约先改 `scripts/check.py`,再同步 [contract.md](references/contract.md) 与 templates;不在生产上传上试脚本。
|
||||
|
||||
## 初始化
|
||||
|
||||
1. 确认项目根。探测 `makefile.builder`、`.env.builder`、用户 Makefile(只当抄配方的证据,不改)、`Dockerfile`、`debian/`。`.env.builder` 只看键是否存在且非空,不读、不打印值。不要读取用户 `.env`。
|
||||
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;都不清则问。不要猜测 registry、token 或仓库名。
|
||||
3. 没有 `makefile.builder`:把 `<skill-dir>/templates/makefile.builder` 拷到项目根。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它)。`include` builder 的 `scripts/version.mk`。双产物把 `push` 改成 `push: push-deb push-docker`。不要改用户 Makefile。
|
||||
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖该文件,除非用户明确要求按契约改。不要调用 create-makefile(版本规则冲突)。
|
||||
2. 判定轨道:有 Dockerfile → docker;有 deb 信号或用户要打 deb → deb;`app.config.ts` / `assembleRelease` / `.apk` → android;`ios/` / `xcodebuild` / `.ipa` → ios;用户要拷到目录或容器 → copy。可多轨道。都不清则问。不要猜测 registry、token、COPY_DEST 或仓库名。
|
||||
3. 没有 `makefile.builder`:按轨道拷模板。deb/docker 用 `templates/makefile.builder`;仅 android 用 `templates/makefile.android.builder`;仅 ios 用 `templates/makefile.ios.builder`。按轨道删掉未使用的 deb/docker/push* 段,把 `build` 的 TODO 换成仓库里已有的真实编译命令(可从用户 Makefile 抄配方,但不要 `include` 或递归调用它),且配方必须使用 `$(ARCH)`。Android/iOS 的 `ARCHES` 是构建机 ISA(fat APK/IPA 仍只留本机一项)。按 [contract.md](references/contract.md) 填 `ARCHES`:`debian/control` / CI matrix / 用户点名的子集;否则只写本机(`x86_64`→`amd64`,`aarch64`→`arm64`)。不要默认双架构;看不出交叉编法时只留一项并说明。`include` builder 的 `scripts/version.mk`。多产物把 `push` 改成 `push: push-deb push-docker push-copy` 的子集。不要改用户 Makefile。
|
||||
4. 已有 `makefile.builder`:跑检查;按 FAIL 给出修补说明。不覆盖 `build`。用户明确要求按契约改时再加 `ARCHES`,并确认配方里有 `$(ARCH)`。不要调用 create-makefile(版本规则冲突)。
|
||||
5. 没有 `.env.builder`:把 `<skill-dir>/templates/env.builder` 拷到项目根(注释键,不含值)。缺发布键时在报告里给出可粘贴示例,并把 `.env.builder` 加入 `.gitignore`,不要提交。
|
||||
6. 运行 `python3 -I -S <skill-dir>/scripts/check.py <project-dir> --ready`。结构校验通过且当前轨道能构建才称「完成」;只缺发布键是「部分完成」。契约 FAIL 或轨道工具缺失是「阻塞」。除非用户明确要求,不提交、不推送、不上传。
|
||||
|
||||
@@ -47,6 +48,8 @@ DEB_SERVER_URL=https://deb.example.com
|
||||
DEB_REPOSITORY=main
|
||||
DEB_TOKEN=
|
||||
DOCKER_REGISTRY=registry.example.com
|
||||
COPY_DEST=file-server:/srv
|
||||
COPY_AS=latest.apk
|
||||
```
|
||||
|
||||
## 检查
|
||||
@@ -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`。
|
||||
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` 时先确认为受限构建目录。
|
||||
4. DEB 上传前:`verify_deb.sh <exact-package-path.deb>`。多个包时不凭文件时间猜测。镜像轨道由 `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。
|
||||
3. 构建:用户指定了架构才传 `ARCH=`;否则不要传,让 makefile 按 `ARCHES` 展开(`make -s -f makefile.builder arches`)。`make -f makefile.builder build` 始终单架构;DEB 用 `make -f makefile.builder deb`。版本按契约从 Git 祖先稳定 tag 推导,不要调用 manage-release,不要用全仓库最新 tag。仅获构建授权时不要走会自动上传的目标。脚本含 `rm -rf` 时先确认为受限构建目录。
|
||||
4. DEB 上传前:对每个包 `verify_deb.sh <exact-package-path.deb> <version> <arch>`。多个包时不凭文件时间猜测。镜像轨道由 `publish_docker.sh` 自带远端 inspect。
|
||||
5. 发布优先 `make -f makefile.builder push[-deb|-docker|-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、工作区状态和未完成项。
|
||||
|
||||
@@ -28,44 +28,62 @@ make -f makefile.builder <target>
|
||||
| `help` | 分组列出全部目标;首屏含当前版本 |
|
||||
| `version` | 输出一行版本号,适合脚本消费 |
|
||||
| `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`、禁止多平台** |
|
||||
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` |
|
||||
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
|
||||
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
||||
| `android` | 有 Android APK/AAB 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.apk`(或 `.aab`);只构建不上传。`ARCHES` 是构建机 ISA;fat APK 只留本机一项 |
|
||||
| `ios` | 有 iOS IPA 的项目 | 可与 `build` 相同。产出 `$(DIST_DIR)/<name>_<version>_<arch>.ipa`;只构建不上传 |
|
||||
| `arches` | 可选(新模板必备) | 打印一行空格分隔的 `ARCHES`;无此目标视为存量 makefile,隐式单架构 |
|
||||
| `push-deb` | 同时有多种发布轨道的项目 | 仅调 builder 的 `upload_deb.sh`。未指定 `ARCH=` 时上传 `ARCHES` 中每一项对应的 `.deb`;指定了则只上传那一个 |
|
||||
| `push-docker` | 同时有多种发布轨道的项目 | 仅调 builder 的 `publish_docker.sh` |
|
||||
| `push-copy` | 同时有多种发布轨道的项目 | 仅调 builder 的 `copy_artifact.sh` |
|
||||
| `push` | 单一产物类型时必备;多产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
||||
|
||||
规则:
|
||||
|
||||
1. 项目有 DEB 产物的判据:`makefile.builder` 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||
有镜像的判据:项目根存在 `Dockerfile`。
|
||||
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
|
||||
`push` 即可。
|
||||
Android 判据:配方产出 `.apk` / `.aab`。iOS 判据:配方产出 `.ipa`。
|
||||
拷贝发布判据:配方调用 `copy_artifact.sh`。
|
||||
2. 多于一种发布轨道必须拆 `push-deb` / `push-docker` / `push-copy`,`push` 依序聚合;
|
||||
单轨道项目一个 `push` 即可。
|
||||
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
|
||||
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
|
||||
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. 变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `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` 取全局最新 |
|
||||
| `DIST_DIR` | `dist` | DEB 产物目录 |
|
||||
| `DIST_DIR` | `dist` | 产物目录(deb / apk / ipa) |
|
||||
| `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 的
|
||||
`Version` 与文件名直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh`
|
||||
(`makefile.builder` 通过 `scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
|
||||
manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release 事先打好。
|
||||
`version.sh` 不执行 `git fetch`。
|
||||
manage-release 选定下一正式 SemVer。测试号把祖先基线的 patch 机械 +1 作为排序键
|
||||
(`v1.4.2` 之后 → `1.4.3~…`),不是在宣布下一正式号就是 1.4.3。正式 tag 由
|
||||
manage-release 事先打好。`version.sh` 不执行 `git fetch`。
|
||||
|
||||
推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才
|
||||
能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**,
|
||||
@@ -74,17 +92,22 @@ manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release
|
||||
| 判定 | 规范版本 | DEB 文件 | Docker tag |
|
||||
| --- | --- | --- | --- |
|
||||
| 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>` |
|
||||
| 祖先中没有稳定 tag | `0.0.0~<branch>+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~<date>.<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
|
||||
|
||||
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使
|
||||
`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含
|
||||
`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。
|
||||
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~` 挂在基线的下一
|
||||
patch 上:`1.4.2` < `1.4.3~…` < `1.4.3`。已装上一正式版的机器可以升到
|
||||
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 时用
|
||||
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
|
||||
`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续
|
||||
`-`;过长截断(给 base、距离、SHA 留位置;Docker tag 上限 128)。
|
||||
`-`;过长截断(给 base、日期、距离、SHA 留位置;Docker tag 上限 128)。
|
||||
`<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把
|
||||
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
|
||||
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
|
||||
@@ -107,6 +130,15 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
|
||||
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
|
||||
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
|
||||
|
||||
### 拷贝轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| `COPY_DEST` | 是 | 本地目录,或 Docker 目标 `container:/absolute/dir`(`docker cp`) |
|
||||
| `COPY_AS` | 否 | 第一份文件的额外 basename,逗号或空格分隔 |
|
||||
|
||||
`copy_artifact.sh FILE [FILE ...]` 把已构建文件拷到 `COPY_DEST`,校验目标存在后打印 SHA-256。不读 `.env`。脏工作树默认拒绝,`ALLOW_UNCOMMITTED=1` 放行。
|
||||
|
||||
### Docker 轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
@@ -114,7 +146,9 @@ include $(HOME)/.pouch/skills/builder/scripts/version.mk
|
||||
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
|
||||
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
|
||||
| `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` > 失败并询问用户。
|
||||
`.env.builder` 由 builder 脚本加载(不回显任何值);当前 shell 已设置的值优先。
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| Registry | 必须显式确定,例如 `registry.example.com` |
|
||||
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
|
||||
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
|
||||
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
|
||||
| Platform | 用户这轮显式值,否则 `.env.builder` 的 `PLATFORMS`,否则 `makefile.builder` 的 `ARCHES` 映射为 `linux/<arch>,…`,再否则 `linux/amd64` |
|
||||
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
|
||||
| Context | 默认当前项目根目录 |
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
## 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`,不要询问后另选一套。
|
||||
- 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变。
|
||||
- `latest`、`stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布,
|
||||
|
||||
+243
-27
@@ -32,8 +32,8 @@ ARCH_VALUES = ("amd64", "arm64")
|
||||
REQUIRED_TARGETS = ("help", "version", "clean", "build")
|
||||
UPLOAD_TOKENS = (
|
||||
"curl ", "curl\t", "scp ", "rsync ", "aptly ", "reprepro ",
|
||||
"docker push", "buildx build --push", "buildx --push", "upload_deb.sh",
|
||||
"publish_docker.sh",
|
||||
"docker push", "docker cp", "buildx build --push", "buildx --push",
|
||||
"upload_deb.sh", "publish_docker.sh", "copy_artifact.sh",
|
||||
)
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
|
||||
@@ -41,7 +41,7 @@ SECRET_PATTERNS = (
|
||||
)
|
||||
FLOATING_TAGS = (":latest", ":stable")
|
||||
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
|
||||
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
|
||||
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
|
||||
BUILDER_MAKEFILE = "makefile.builder"
|
||||
BUILDER_ENV = ".env.builder"
|
||||
|
||||
@@ -78,8 +78,9 @@ def run_make(
|
||||
|
||||
|
||||
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
|
||||
err = result.stderr.lower()
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
present: dict[str, bool] = {}
|
||||
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:
|
||||
title = "ARCH / ARCHES 守卫与缺省值"
|
||||
problems: list[str] = []
|
||||
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
|
||||
lines = [
|
||||
f"invalid ARCH rejected: {'yes' if guard_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 guard_ok and default_ok:
|
||||
report.add(PASS, 2, "ARCH 守卫与缺省值", "\n".join(lines + hint.splitlines()))
|
||||
if not guard_ok:
|
||||
problems.append(
|
||||
"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:
|
||||
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:
|
||||
result = run_make(project, "version")
|
||||
# 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 = "\n".join(out_lines).strip()
|
||||
out = parse_dry_echo(result)
|
||||
single = len(out.splitlines()) == 1 and out != ""
|
||||
no_v = single and not out.startswith("v")
|
||||
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}")
|
||||
else:
|
||||
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:
|
||||
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
|
||||
else:
|
||||
@@ -196,6 +285,45 @@ def detect_docker_project(project: Path) -> bool:
|
||||
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
|
||||
|
||||
|
||||
def detect_android_project(recipe_all: str) -> bool:
|
||||
return ".apk" in recipe_all or ".aab" in recipe_all
|
||||
|
||||
|
||||
def detect_ios_project(recipe_all: str) -> bool:
|
||||
return ".ipa" in recipe_all
|
||||
|
||||
|
||||
def detect_copy_project(recipe_all: str) -> bool:
|
||||
return "copy_artifact.sh" in recipe_all
|
||||
|
||||
|
||||
def check_mobile_recipe(
|
||||
report: Report, project: Path, *, kind: str, number: int, ext: str
|
||||
) -> None:
|
||||
target = "build"
|
||||
dry = run_make(project, "build")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
problems = []
|
||||
if dry.returncode != 0:
|
||||
problems.append(f"`make -n {target}` failed: {dry.stderr.strip() or 'unknown error'}")
|
||||
else:
|
||||
if "dist/" not in text and "$(DIST_DIR)" not in text:
|
||||
problems.append("recipe does not reference dist/ ($(DIST_DIR)) as artifact location")
|
||||
wanted = (".apk", ".aab") if kind == "android" else (ext,)
|
||||
if not any(item in text for item in wanted):
|
||||
problems.append(f"recipe does not mention {' or '.join(wanted)}")
|
||||
hits = [token for token in UPLOAD_TOKENS if token in text]
|
||||
if hits:
|
||||
problems.append("recipe contains upload commands: " + ", ".join(hits))
|
||||
if "rm -rf /" in text or "rm -rf ~" in text:
|
||||
problems.append("recipe contains unrestricted rm -rf")
|
||||
title = f"{kind} 目标产物形状与纯构建"
|
||||
if problems:
|
||||
report.add(FAIL, number, title, "\n".join(problems))
|
||||
else:
|
||||
report.add(PASS, number, title, f"{target} writes {ext} under dist/")
|
||||
|
||||
|
||||
def check_docker_recipe(report: Report, project: Path) -> None:
|
||||
dry = run_make(project, "docker")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
@@ -226,23 +354,43 @@ SCRIPT_RESOLVE_SNIPPETS = tuple(
|
||||
)
|
||||
|
||||
|
||||
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None:
|
||||
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
|
||||
def check_push_delegates(
|
||||
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 = []
|
||||
inline = []
|
||||
thin = []
|
||||
builder_names = ("upload_deb.sh", "publish_docker.sh", "copy_artifact.sh")
|
||||
for target in targets:
|
||||
dry = run_make(project, target)
|
||||
if has_no_rule(dry):
|
||||
missing.append(target)
|
||||
continue
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
bad_tokens = [token for token in ("curl ", "scp ", "aptly ", "reprepro ") if token in text]
|
||||
bad_tokens = [
|
||||
token
|
||||
for token in ("curl ", "scp ", "aptly ", "reprepro ", "docker cp")
|
||||
if token in text
|
||||
]
|
||||
if bad_tokens:
|
||||
inline.append(f"{target}: inline upload command ({', '.join(bad_tokens)})")
|
||||
elif not any(snippet in text for snippet in SCRIPT_RESOLVE_SNIPPETS) \
|
||||
and "$(BUILDER_SCRIPT)" not in text and "upload_deb.sh" not in text \
|
||||
and "publish_docker.sh" not in text:
|
||||
and "$(BUILDER_SCRIPT)" not in text \
|
||||
and not any(name in text for name in builder_names):
|
||||
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.pouch/... path)")
|
||||
else:
|
||||
thin.append(target)
|
||||
@@ -250,6 +398,20 @@ def check_push_delegates(report: Report, project: Path, dual_artifact: bool) ->
|
||||
if missing:
|
||||
problems.append("missing targets: " + ", ".join(missing))
|
||||
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
|
||||
detail = "\n".join(problems) if problems else "thin wrappers: " + ", ".join(thin)
|
||||
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")
|
||||
DOCKER_ENV_KEYS = ("DOCKER_REGISTRY",)
|
||||
COPY_ENV_KEYS = ("COPY_DEST",)
|
||||
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(
|
||||
report: Report, *, deb_project: bool, docker_project: bool
|
||||
report: Report, *, deb_project: bool, docker_project: bool, copy_project: bool = False
|
||||
) -> None:
|
||||
lines = [f"make: {'found' if shutil.which('make') else 'MISSING'}"]
|
||||
problems = []
|
||||
@@ -331,6 +494,11 @@ def check_ready_toolchain(
|
||||
problems.append("install dpkg-dev (or equivalent) to build .deb packages")
|
||||
else:
|
||||
lines.append("dpkg-deb: skipped (no deb track)")
|
||||
if copy_project:
|
||||
docker_ok = shutil.which("docker") is not None
|
||||
lines.append(
|
||||
f"docker (for container COPY_DEST): {'found' if docker_ok else 'optional; needed only for container:/dir dest'}"
|
||||
)
|
||||
report.add(
|
||||
FAIL if problems else PASS,
|
||||
10,
|
||||
@@ -340,13 +508,18 @@ def check_ready_toolchain(
|
||||
|
||||
|
||||
def check_ready_env_keys(
|
||||
report: Report, project: Path, *, deb_project: bool, docker_project: bool
|
||||
report: Report,
|
||||
project: Path,
|
||||
*,
|
||||
deb_project: bool,
|
||||
docker_project: bool,
|
||||
copy_project: bool = False,
|
||||
) -> None:
|
||||
env_keys = env_file_keys(project)
|
||||
lines = []
|
||||
missing: list[str] = []
|
||||
if not deb_project and not docker_project:
|
||||
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker track")
|
||||
if not deb_project and not docker_project and not copy_project:
|
||||
report.add(SKIP, 11, "发布环境变量键名(不读取值)", "no deb/docker/copy track")
|
||||
return
|
||||
if deb_project:
|
||||
for key in DEB_ENV_KEYS:
|
||||
@@ -364,6 +537,14 @@ def check_ready_env_keys(
|
||||
missing.append(key)
|
||||
else:
|
||||
lines.append("DOCKER_*: skipped (no docker track)")
|
||||
if copy_project:
|
||||
for key in COPY_ENV_KEYS:
|
||||
found = key_present(key, env_keys)
|
||||
lines.append(f"{key}: {'present' if found else 'MISSING'}")
|
||||
if not found:
|
||||
missing.append(key)
|
||||
else:
|
||||
lines.append("COPY_*: skipped (no copy track)")
|
||||
if missing:
|
||||
lines.extend(
|
||||
[
|
||||
@@ -453,7 +634,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
skip_detail = f"(no {BUILDER_MAKEFILE})"
|
||||
for number, title, detail in (
|
||||
(2, "ARCH 守卫与缺省值", skip_detail),
|
||||
(2, "ARCH / ARCHES 守卫与缺省值", skip_detail),
|
||||
(3, "version 输出一行非空规范版本(无 v 前缀)", skip_detail),
|
||||
(4, "build 不含上传动作", 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).
|
||||
recipe_all_parts = []
|
||||
for target in (*REQUIRED_TARGETS, "deb", "docker", "push", "push-deb", "push-docker"):
|
||||
for target in (
|
||||
*REQUIRED_TARGETS,
|
||||
"deb",
|
||||
"docker",
|
||||
"android",
|
||||
"ios",
|
||||
"push",
|
||||
"push-deb",
|
||||
"push-docker",
|
||||
"push-copy",
|
||||
):
|
||||
result = run_make(project, target)
|
||||
if result.returncode == 0:
|
||||
recipe_all_parts.append(result.stdout)
|
||||
@@ -491,13 +682,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
built_deb: Path | None = None
|
||||
deb_project = detect_deb_project(recipe_all, project)
|
||||
docker_project = detect_docker_project(project)
|
||||
android_project = detect_android_project(recipe_all)
|
||||
ios_project = detect_ios_project(recipe_all)
|
||||
copy_project = detect_copy_project(recipe_all)
|
||||
|
||||
if present["build"]:
|
||||
check_arch_guard(report, project)
|
||||
check_version_output(report, project)
|
||||
check_build_has_no_upload(report, project)
|
||||
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, 4, "build 不含上传动作", "(build target missing)")
|
||||
|
||||
@@ -516,16 +710,38 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
||||
|
||||
dual = deb_project and docker_project
|
||||
check_push_delegates(report, project, dual)
|
||||
if android_project:
|
||||
check_mobile_recipe(report, project, kind="android", number=12, ext=".apk")
|
||||
else:
|
||||
report.add(SKIP, 12, "android 目标产物形状与纯构建", "(not an Android project)")
|
||||
|
||||
if ios_project:
|
||||
check_mobile_recipe(report, project, kind="ios", number=13, ext=".ipa")
|
||||
else:
|
||||
report.add(SKIP, 13, "ios 目标产物形状与纯构建", "(not an iOS project)")
|
||||
|
||||
check_push_delegates(
|
||||
report,
|
||||
project,
|
||||
deb_project=deb_project,
|
||||
docker_project=docker_project,
|
||||
copy_project=copy_project,
|
||||
)
|
||||
check_secrets_and_tags(report, project)
|
||||
check_script_paths(report)
|
||||
if args.ready:
|
||||
check_ready_toolchain(
|
||||
report, deb_project=deb_project, docker_project=docker_project
|
||||
report,
|
||||
deb_project=deb_project,
|
||||
docker_project=docker_project,
|
||||
copy_project=copy_project,
|
||||
)
|
||||
check_ready_env_keys(
|
||||
report, project, deb_project=deb_project, docker_project=docker_project
|
||||
report,
|
||||
project,
|
||||
deb_project=deb_project,
|
||||
docker_project=docker_project,
|
||||
copy_project=copy_project,
|
||||
)
|
||||
|
||||
total_fail = report.failures
|
||||
|
||||
Executable
+152
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
COPY_DEST=/path/or/container:/dir \
|
||||
copy_artifact.sh FILE [FILE ...]
|
||||
|
||||
Copy built artifacts to COPY_DEST. Dest is a local directory, or a Docker
|
||||
copy target `container:/absolute/dir` (docker cp). Does not read `.env`.
|
||||
|
||||
Environment (project root .env.builder; shell values win):
|
||||
COPY_DEST Required. Local dir or container:/dir
|
||||
COPY_AS Optional. Extra basenames for the first file
|
||||
(comma or space separated)
|
||||
ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
|
||||
|
||||
Options:
|
||||
-d DEST Override COPY_DEST
|
||||
-a NAMES Override COPY_AS
|
||||
--dry-run Print the copies without executing
|
||||
-h Show help
|
||||
|
||||
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
|
||||
EOF
|
||||
}
|
||||
|
||||
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$project_root" && -f "$project_root/.env.builder" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key=${key%%[[:space:]]*}
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
if [[ -n "${!key:-}" ]]; then
|
||||
continue
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||
[[ -z "$value" ]] && continue
|
||||
printf -v "$key" '%s' "$value"
|
||||
export "$key"
|
||||
done < <(grep -v '^[[:space:]]*$' "$project_root/.env.builder")
|
||||
fi
|
||||
|
||||
dest=${COPY_DEST:-}
|
||||
extra_as=${COPY_AS:-}
|
||||
dry_run=false
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
-d) dest=$2; shift 2 ;;
|
||||
-a) extra_as=$2; shift 2 ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--) shift; break ;;
|
||||
-*) echo "Error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$dest" || $# -eq 0 ]]; then
|
||||
echo "Error: COPY_DEST and at least one file are required." >&2
|
||||
echo "Set them in the environment or the project root .env.builder." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$project_root" ]] && git -C "$project_root" rev-parse HEAD >/dev/null 2>&1; then
|
||||
if [[ ${ALLOW_UNCOMMITTED:-0} != 1 ]] && ! git -C "$project_root" diff-index --quiet HEAD -- 2>/dev/null; then
|
||||
echo "Error: working tree has uncommitted changes; refusing to publish." >&2
|
||||
echo "Commit first, or set ALLOW_UNCOMMITTED=1 to publish anyway." >&2
|
||||
exit 3
|
||||
fi
|
||||
fi
|
||||
|
||||
is_docker_dest() {
|
||||
local d=$1
|
||||
[[ "$d" != /* && "$d" != ./* && "$d" != ../* && "$d" != ~* ]] \
|
||||
&& [[ "$d" != *://* ]] \
|
||||
&& [[ "$d" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*:/ ]]
|
||||
}
|
||||
|
||||
docker_container=
|
||||
docker_dir=
|
||||
local_dir=
|
||||
if is_docker_dest "$dest"; then
|
||||
docker_container=${dest%%:*}
|
||||
docker_dir=${dest#*:}
|
||||
if [[ -z "$docker_container" || -z "$docker_dir" || "$docker_dir" != /* ]]; then
|
||||
echo "Error: docker dest must be container:/absolute/dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
docker_dir=${docker_dir%/}
|
||||
else
|
||||
local_dir=${dest%/}
|
||||
fi
|
||||
|
||||
split_as() {
|
||||
local raw=$1
|
||||
raw=${raw//,/ }
|
||||
# shellcheck disable=SC2086
|
||||
printf '%s\n' $raw
|
||||
}
|
||||
|
||||
copy_one() {
|
||||
local src=$1 name=$2
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "Error: not a file: $src" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$docker_container" ]]; then
|
||||
local target="${docker_container}:${docker_dir}/${name}"
|
||||
if $dry_run; then
|
||||
echo "docker cp $src $target"
|
||||
return
|
||||
fi
|
||||
docker cp "$src" "$target"
|
||||
docker exec "$docker_container" ls -l "${docker_dir}/${name}" >/dev/null
|
||||
echo "copied $(basename -- "$src") -> $target"
|
||||
else
|
||||
local target="${local_dir}/${name}"
|
||||
if $dry_run; then
|
||||
echo "cp $src $target"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$local_dir"
|
||||
cp -a "$src" "$target"
|
||||
test -f "$target"
|
||||
echo "copied $(basename -- "$src") -> $target"
|
||||
fi
|
||||
}
|
||||
|
||||
first=true
|
||||
success=0
|
||||
for src in "$@"; do
|
||||
copy_one "$src" "$(basename -- "$src")"
|
||||
if $first; then
|
||||
first=false
|
||||
if [[ -n "$extra_as" ]]; then
|
||||
while IFS= read -r alias; do
|
||||
[[ -z "$alias" ]] && continue
|
||||
copy_one "$src" "$alias"
|
||||
done < <(split_as "$extra_as")
|
||||
fi
|
||||
fi
|
||||
if ! $dry_run && command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -- "$src"
|
||||
fi
|
||||
success=$((success + 1))
|
||||
done
|
||||
|
||||
echo "copy_artifact: $success file(s) to $dest"
|
||||
@@ -17,7 +17,8 @@ usage() {
|
||||
# DOCKER_REGISTRY Required (or --registry)
|
||||
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
|
||||
# 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_CONTEXT Optional, default: . (--context)
|
||||
# DOCKER_BUILDER Optional buildx builder name (--builder)
|
||||
@@ -53,7 +54,7 @@ fi
|
||||
registry=${DOCKER_REGISTRY:-}
|
||||
repository=${DOCKER_REPOSITORY:-$git_repo_name}
|
||||
tag=${IMAGE_TAG:-}
|
||||
platform=${PLATFORMS:-linux/amd64}
|
||||
platform=${PLATFORMS:-}
|
||||
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
|
||||
build_context=${DOCKER_CONTEXT:-.}
|
||||
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
|
||||
exit 3
|
||||
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
|
||||
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
|
||||
exit 2
|
||||
|
||||
@@ -11,8 +11,11 @@ With --docker, print the Docker tag rendering of that version.
|
||||
|
||||
Derivation (builder contract «版本号»):
|
||||
official HEAD exact-match of vX.Y.Z → X.Y.Z
|
||||
test nearest ancestor stable tag → X.Y.Z~branch.n+gSHA
|
||||
no tag no stable tag reachable from HEAD → 0.0.0~branch+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~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
|
||||
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]+$ ]]
|
||||
}
|
||||
|
||||
# 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_v() {
|
||||
local v=$1
|
||||
@@ -100,6 +112,16 @@ short_sha() {
|
||||
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.
|
||||
official_tag_at_head() {
|
||||
local tag best=
|
||||
@@ -135,7 +157,7 @@ nearest_stable_tag() {
|
||||
}
|
||||
|
||||
derive_canonical() {
|
||||
local tag base n sha branch
|
||||
local tag base n sha branch ymd
|
||||
tag=$(official_tag_at_head)
|
||||
if [[ -n "$tag" ]]; then
|
||||
printf '%s' "${tag#v}"
|
||||
@@ -143,13 +165,14 @@ derive_canonical() {
|
||||
fi
|
||||
sha=$(short_sha)
|
||||
branch=$(branch_slug)
|
||||
ymd=$(build_date)
|
||||
base=$(nearest_stable_tag)
|
||||
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
|
||||
fi
|
||||
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() {
|
||||
|
||||
@@ -10,4 +10,6 @@
|
||||
# DOCKER_REGISTRY=
|
||||
# DOCKER_REPOSITORY=
|
||||
# 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)
|
||||
@@ -2,13 +2,26 @@
|
||||
# 不要把这些目标写进用户的 Makefile / makefile。
|
||||
# 初始化时按项目轨道删掉未使用的 deb / docker 段。
|
||||
# 把 build 的 TODO 换成真实编译命令;禁止在 build / deb / docker 里上传。
|
||||
# build 必须尊重 $(ARCH)。要发双架构时把 arm64 写进 ARCHES,不要改 .env.builder。
|
||||
#
|
||||
# include 路径见 references/contract.md。测试或非标准安装可设 BUILDER_SKILL_DIR。
|
||||
|
||||
ARCH ?= amd64
|
||||
ifneq ($(filter $(ARCH),amd64 arm64),$(ARCH))
|
||||
$(error ARCH must be amd64 or arm64)
|
||||
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
|
||||
|
||||
# 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))
|
||||
DIST_DIR ?= dist
|
||||
@@ -21,40 +34,62 @@ endif
|
||||
|
||||
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:
|
||||
@echo "$(PROJECT_NAME) $(VERSION)"
|
||||
@echo " ARCHES=$(ARCHES) ARCH=$(ARCH)"
|
||||
@echo " make -f makefile.builder build [ARCH=amd64|arm64] - 构建主产物 / build"
|
||||
@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 deb - 打 DEB(只构建不上传)"
|
||||
@echo " make -f makefile.builder deb - 打 DEB(按 ARCHES;只构建不上传)"
|
||||
@echo " make -f makefile.builder docker - 本地单平台镜像"
|
||||
@echo " make -f makefile.builder push / push-deb / push-docker"
|
||||
|
||||
version:
|
||||
@echo $(VERSION)
|
||||
|
||||
arches:
|
||||
@echo $(ARCHES)
|
||||
|
||||
clean:
|
||||
rm -rf $(DIST_DIR)
|
||||
|
||||
# 替换为项目真实构建命令。不得 curl/scp/docker push。
|
||||
# 替换为项目真实构建命令,且必须使用 $(ARCH)。不得 curl/scp/docker push。
|
||||
build:
|
||||
mkdir -p $(DIST_DIR)
|
||||
@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
|
||||
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 build --platform=linux/$(ARCH) -t $(PROJECT_NAME):$(IMAGE_TAG) .
|
||||
|
||||
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:
|
||||
$(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-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` 和校验原始输出。
|
||||
- 到上限或反复失败时停下,并说明当前进度和未通过项,而不是假装完成。
|
||||
@@ -22,9 +22,18 @@
|
||||
- 需要一张「哪台机器跑哪些服务」的清单
|
||||
- 镜像要进 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 服务的运行时数据放在哪(这些目录不能被同步覆盖)
|
||||
- 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 分别放到两台机器上
|
||||
把 test 环境重新部署一下
|
||||
本机测试镜像已经有了,直接重启,别 sync
|
||||
prod 的 compose 加个 redis,改完发上去
|
||||
|
||||
# Argo CD
|
||||
@@ -62,8 +72,8 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||
|
||||
1. 读服务/环境目录(及共享的父目录)的 `_config.yaml`,确定目标机器和远程路径;
|
||||
项目内布局从 `.pouch/deployer/` 自动发现,无需额外配置
|
||||
2. 用 skill 自带脚本把本地目录同步到远程(rsync,自动排除 `data/`、`_data/`)
|
||||
3. 在远程执行对应的 `docker compose` 操作(启动 / 重建 / 升级 / 重启)
|
||||
2. 目标是本机且镜像已在本机 docker:不 sync、不 SSH,在该环境目录直接 `docker compose`
|
||||
3. 否则用 skill 自带脚本把本地目录同步到远程(优先 rsync,没有则 scp;自动排除 `data/`、`_data/`),再远程 `docker compose`(启动 / 重建 / 升级 / 重启)
|
||||
4. deb 包安装走独立脚本:scp 上传到暂存目录后远程 apt 安装,失败自动修依赖
|
||||
5. 同步后查看容器状态和日志确认生效
|
||||
6. 只针对你指定的那一个服务操作,不会批量动整台机器
|
||||
@@ -73,13 +83,14 @@ GitOps 我已经 clone 在 ../infra-gitops,用那个目录开 MR
|
||||
项目内布局下,远程目录名自动带上项目前缀(如 `my-project-prod`),
|
||||
避免同一台机器上多个项目的同名环境互相覆盖;需要固定名字时在 `_config.yaml` 写 `name:`。
|
||||
|
||||
重要边界:同步使用 `--delete`,远程多余的文件会被删除;数据库、证书等运行时数据
|
||||
必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、清理远程文件的操作会先向你确认。
|
||||
重要边界:rsync 同步使用 `--delete`,远程多余的文件会被删除;scp 兜底不会删。
|
||||
数据库、证书等运行时数据必须放在排除目录或远程绝对路径挂载。涉及删除数据卷、
|
||||
清理远程文件的操作会先向你确认。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
- 脚本输出显示同步完成、远程命令执行成功
|
||||
- `ps` 显示容器 Up、`logs` 无报错;升级后镜像 tag 与配置一致
|
||||
- 本机:`docker compose ps` 容器 Up。远程:sync 成功且 `remote.py ps` 容器 Up
|
||||
- `logs` 无报错;升级后镜像 tag 与配置一致
|
||||
- deb 安装后 `ssh <node> dpkg -l` 能看到目标包,服务能正常启动
|
||||
- 域名/端口类服务能 curl 通
|
||||
- Argo CD:给出 MR 链接;合入后 Application Synced,域名/healthz 可访问
|
||||
|
||||
@@ -19,10 +19,10 @@ description: >-
|
||||
|
||||
- 初始化、接入 deployer,或给新项目建测试/生产环境:执行「初始化」。
|
||||
- 检查 `.pouch/deployer`、node、compose 是否齐全:执行「检查」。
|
||||
- 部署、同步、升级、重启、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
|
||||
- 部署、重新部署、同步、升级、重启、布测试环境、装 deb、新增/下线服务:执行「Compose 操作」。发现不了服务或解析不出 node 时停止,转入「初始化」。不要静默初始化。
|
||||
- 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 的「运行测试环境」和回归前布环境会加载本 skill,对 `.pouch/deployer/<env>`(通常是 `test`)按「Compose 操作」执行。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
|
||||
ACK 的「运行测试环境」和回归前布环境会加载本 skill,按「Compose 操作」的默认路径布测试环境。ACK 只负责何时布、把访问地址写入 `deliveryRuns`;不要把本 skill 的脚本复制进 ACK。生产环境、Argo CD 合入和节点级批量仍须用户明确要求。
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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>` |
|
||||
| 应用 compose/配置变更 | `sync.py <svc> && remote.py <svc> up` |
|
||||
| 改配置后强制重建 | `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> 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 挂载的远程绝对路径,否则会被清掉。
|
||||
|
||||
## 新增服务 / 环境
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ def check_toolchain(report: Report) -> None:
|
||||
rsync_ok = shutil.which("rsync") is not None
|
||||
lines = [
|
||||
f"ssh: {'found' if ssh_ok else 'MISSING (blocks compose deploy)'}",
|
||||
f"rsync: {'found' if rsync_ok else 'MISSING (tar-over-SSH fallback)'}",
|
||||
f"rsync: {'found' if rsync_ok else 'MISSING (scp fallback)'}",
|
||||
]
|
||||
if not ssh_ok:
|
||||
lines.append("install openssh-client")
|
||||
|
||||
@@ -31,7 +31,11 @@ import subprocess
|
||||
import sys
|
||||
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,
|
||||
run_remote,
|
||||
scp_base_args,
|
||||
|
||||
@@ -6,8 +6,13 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
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:
|
||||
|
||||
@@ -20,8 +20,13 @@ import argparse
|
||||
import os
|
||||
import subprocess
|
||||
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 = {
|
||||
"up": "docker compose up -d",
|
||||
|
||||
@@ -16,15 +16,20 @@ import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
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:
|
||||
if shutil.which("rsync"):
|
||||
return _sync_rsync(info)
|
||||
print("本机没有 rsync,改用 tar over SSH(不会删除远程多余文件)")
|
||||
return _sync_tar(info)
|
||||
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
|
||||
return _sync_scp(info)
|
||||
|
||||
|
||||
def _sync_rsync(info: dict) -> int:
|
||||
@@ -58,42 +63,81 @@ def _sync_rsync(info: dict) -> int:
|
||||
print(f"错误: rsync 执行失败,退出码: {e.returncode}")
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print("错误: rsync 命令未找到,请确保已安装 rsync")
|
||||
return 1
|
||||
print("本机没有 rsync,改用 scp(不会删除远程多余文件)")
|
||||
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"]
|
||||
remote_dir = info["remote_dir"]
|
||||
tar_cmd = ["tar", "czf", "-", "-C", source_dir]
|
||||
for item in info["sync_exclude"]:
|
||||
tar_cmd.append(f"--exclude={item}")
|
||||
tar_cmd.append(".")
|
||||
node = info["node"]
|
||||
source = Path(source_dir)
|
||||
files = collect_sync_files(source_dir, info["sync_exclude"])
|
||||
|
||||
remote_shell = (
|
||||
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}")
|
||||
print(f"正在同步 {source_dir} 到 {node}:{remote_dir}")
|
||||
if info["sync_exclude"]:
|
||||
print(f"排除目录: {', '.join(info['sync_exclude'])}")
|
||||
print(f"执行命令: tar | {' '.join(ssh)}")
|
||||
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:
|
||||
completed = subprocess.run(ssh, stdin=tar.stdout, check=False)
|
||||
finally:
|
||||
if tar.stdout:
|
||||
tar.stdout.close()
|
||||
tar.wait()
|
||||
if tar.returncode:
|
||||
print(f"错误: tar 打包失败,退出码: {tar.returncode}")
|
||||
return tar.returncode
|
||||
completed = subprocess.run(mkdir_cmd, check=False)
|
||||
except FileNotFoundError:
|
||||
print("错误: ssh 命令未找到")
|
||||
return 1
|
||||
if completed.returncode:
|
||||
print(f"错误: 远程 tar 解包失败,退出码: {completed.returncode}")
|
||||
print(f"错误: 远程 mkdir 失败,退出码: {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("同步完成!")
|
||||
return 0
|
||||
|
||||
@@ -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,6 +8,13 @@
|
||||
- 已有 `.drawio` 原型,希望补齐跳转、异常态、权限或规则说明。
|
||||
- 需要一张能用于产品评审、开发对齐和测试设计的产品说明板。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pouch add draw-prototype-flow # 当前项目
|
||||
pouch add draw-prototype-flow -g # 全局
|
||||
```
|
||||
|
||||
## 使用前准备
|
||||
|
||||
最好提供产品目标、目标用户、核心任务和已有需求材料。缺少细节时,Agent 会先列出必要假设;只有关键范围无法判断时才会询问。
|
||||
@@ -34,4 +41,4 @@
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
|
||||
最终会得到可在 diagrams.net/Draw.io 打开的 `.drawio` 源文件。你指定路径就用指定路径,没指定时 Agent 会在项目里给出文件名。核心任务应能从入口走到结果,每个关键按钮有明确去向,异常状态有恢复动作,原型、动线和规格使用同一套页面与状态 ID。
|
||||
|
||||
@@ -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,不依赖外网资源。
|
||||
- 画面上图大于字;没背景的人能看出「这东西怎么走」。
|
||||
- 聊天没有再贴一遍长文解释。
|
||||
@@ -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;聊天没有墙一样的解释。
|
||||
@@ -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 不应出现在完整原文里。
|
||||
@@ -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
|
||||
Executable
+53
@@ -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 解读;不要把本输出里的密钥贴进聊天。"
|
||||
@@ -4,6 +4,13 @@
|
||||
版本文件、worktree、分支、PR/MR 和合并。普通发布优先走“更新说明 + annotated tag”的
|
||||
短路径;只有发布要求修改仓库内容时,才进入准备版本的分支流程。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pouch add manage-release # 当前项目
|
||||
pouch add manage-release -g # 全局
|
||||
```
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- 项目已经是 Git 仓库,并配置了可访问的远端。
|
||||
@@ -13,6 +20,12 @@
|
||||
- 请求里写清目标版本、基线分支,以及允许执行到哪一步。没有明确授权的远端写操作
|
||||
不会执行。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
使用 manage-release,为已经合并到 main 的 1.6.0 发布 v1.6.0 tag。不要创建 Forge Release。
|
||||
```
|
||||
|
||||
## 常见用法
|
||||
|
||||
### 确定下一个版本号
|
||||
|
||||
@@ -12,6 +12,13 @@ memory-distill 提炼一下刚刚 ack 执行过程中可以沉淀的知识。
|
||||
|
||||
也会响应「沉淀记忆」「回顾刚才记下来」「更新记忆」等说法。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pouch add memory-distill # 当前项目
|
||||
pouch add memory-distill -g # 全局
|
||||
```
|
||||
|
||||
## 使用前准备
|
||||
|
||||
通常不用准备路径。Agent 会:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# note-style
|
||||
|
||||
把已有材料整理成可长期查阅的知识笔记:压缩空话、统一术语,保留事实、公式、条件和参考。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 要把聊天稿、草稿或 AI 生成的长文改成能放进知识库的笔记。
|
||||
- 要整理 Obsidian 笔记,去掉重复和口语。
|
||||
- 要把技术原理或操作方案写成教科书 / 手册口吻。
|
||||
|
||||
不适合:还在讨论、还没有可依据的材料,或需要 Agent 发明新结论。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- 给出要整理的材料(文件路径、粘贴正文,或当前对话里的那一段)。
|
||||
- 可选:目标读者、篇幅要求、笔记路径。不说则在原材料风格上压缩约三成。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
用 note-style 把这篇对话整理成可放进 Obsidian 的技术笔记,保留公式和适用条件。
|
||||
```
|
||||
|
||||
```text
|
||||
把 docs/draft-auth.md 改写成手册口吻,删掉空话,有疑点列待核实项。
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
1. 抽出事实、定义、步骤、条件和参考,删掉铺垫和重复。
|
||||
2. 按材料类型选结构(原理或操作),用中性书面语重写全文。
|
||||
3. 先给你完整笔记;只有事实或技术疑点时才追加「待核实项」。
|
||||
|
||||
不会补没有依据的结论,也不会为缩短篇幅删掉必要条件。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
- 得到一份完整笔记,而不是改写说明。
|
||||
- 原文里的公式、条件、安全警告和参考还在。
|
||||
- 没有聊天式过渡或段末复述。
|
||||
@@ -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. 不解释改写过程,不输出修改心得,不复述本技能的要求。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "教科书式笔记整理"
|
||||
short_description: "将材料压缩并改写为准确、简洁的结构化知识笔记"
|
||||
default_prompt: "使用 $note-style 将这份材料整理为可长期查阅的知识笔记。"
|
||||
@@ -11,6 +11,15 @@ ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docke
|
||||
|
||||
只做单一领域任务时可以直接调用对应 Skill;ORC 不替代它们的安全规则。
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
pouch add orc # 当前项目
|
||||
pouch add orc -g # 全局
|
||||
```
|
||||
|
||||
`$orc` / `/orc` 只在显式调用时运行。下游阶段还需要已安装 `$ack`、`$manage-release` 或 `$builder`。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
- Orca 正在运行并启用了 orchestration。
|
||||
|
||||
+65
-16
@@ -1,16 +1,39 @@
|
||||
# pouch
|
||||
|
||||
`pouch` 用于创建、维护、安装和发布团队自研的 Agent Skill。Skill 的唯一来源位于
|
||||
`~/.pouch/skills/<name>/`,安装到各 Agent 时使用软链接。
|
||||
创建、维护、安装和发布团队自研的 Agent Skill。唯一来源在 `~/.pouch/skills/<name>/`,装到各 Agent 时用软链。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- 想把项目里的重复工作沉淀成一个 skill。
|
||||
- 想完善、校验、转正或发布已有 skill。
|
||||
- 想把自研 skill 安装到当前项目或全局 Agent。
|
||||
- 想让 Agent 代装 pouch,或介绍有哪些 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
|
||||
|
||||
```bash
|
||||
@@ -32,6 +55,8 @@ pouch check my-skill
|
||||
pouch finalize my-skill
|
||||
```
|
||||
|
||||
转正不会自动 commit、push 或安装。
|
||||
|
||||
## 优化已有 skill 的结构
|
||||
|
||||
skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点中后把用不到的参考一并读进上下文时:
|
||||
@@ -40,42 +65,66 @@ skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点
|
||||
pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费
|
||||
```
|
||||
|
||||
Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版/部署一类的安全限制。改完仍需 `pouch check` 通过。
|
||||
Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版、部署一类的安全限制。改完仍需 `pouch check` 通过。
|
||||
|
||||
不要对社区 catalog skill 的安装目录直接改;那些不是本仓库的 SSOT。
|
||||
|
||||
## 提交和发布
|
||||
|
||||
只提交:
|
||||
|
||||
```bash
|
||||
pouch publish skills/my-skill -m "add my-skill"
|
||||
```
|
||||
|
||||
提交并推送:
|
||||
|
||||
```bash
|
||||
pouch publish skills/my-skill -m "add my-skill" --push
|
||||
```
|
||||
|
||||
## 安装
|
||||
## 安装与维护
|
||||
|
||||
安装到当前项目的 Codex:
|
||||
`./install.sh` 会执行 `pouch bootstrap`,把本 skill 全局装到所有 Agent。也可以随时重跑:
|
||||
|
||||
```bash
|
||||
pouch add my-skill -a codex -y
|
||||
pouch bootstrap
|
||||
```
|
||||
|
||||
安装到全局 Codex:
|
||||
安装其它 skill:
|
||||
|
||||
```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>` 输出校验通过。
|
||||
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
|
||||
- `pouch status` 或 `pouch doctor` 显示目标软链接正常。
|
||||
- 代装完成后 CLI 可用,且介绍了 builtin skill 的一句话和示例请求。
|
||||
- 项目接入后,建议的 skill 已 `pouch add`;ack / builder / deployer 要么已初始化,要么明确问过。
|
||||
- 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。
|
||||
|
||||
|
||||
+25
-11
@@ -1,10 +1,10 @@
|
||||
---
|
||||
name: pouch
|
||||
description: >-
|
||||
创建、校验、转正、安装或发布 ~/.pouch 自研 skill,初始化 skill 项目状态,
|
||||
或优化 skill 分层加载结构以减少 token 浪费。
|
||||
触发词:pouch、skiff、自研 skill、创建 skill、publish skill、安装自研 skill、
|
||||
初始化 skill、优化 skill 结构、减少 token 浪费、skill 太长、分层加载。
|
||||
创建、校验、转正、安装或发布 ~/.pouch 自研 skill;代装 pouch、介绍 builtin
|
||||
skill、在项目里接入并引导初始化;或优化 skill 分层结构以减少 token 浪费。
|
||||
触发词:pouch、skiff、帮我装 pouch、有哪些 skill、skill 怎么用、接入当前项目、
|
||||
初始化 skill、创建 skill、publish skill、优化 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」。
|
||||
- 行为不对、触发不准、校验失败、可复用优化回流:执行「问题或优化回流」。
|
||||
- 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。先读
|
||||
[token-structure.md](references/token-structure.md)。
|
||||
- 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。
|
||||
- 安装、卸载、浏览、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
|
||||
pouch add --list
|
||||
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
|
||||
|
||||
@@ -159,7 +173,7 @@ pouch remove discussion-notes -g -a cursor -y # 全局
|
||||
pouch rm discussion-notes -g -y # rm 别名
|
||||
```
|
||||
|
||||
浏览可用自研 skill:
|
||||
浏览可用自研 skill(给 Agent 匹配用,不要念给用户):
|
||||
|
||||
```bash
|
||||
pouch add --list
|
||||
|
||||
@@ -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,走「接入当前项目」。
|
||||
@@ -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`,只念本次装上的那几行「可以这么说」。
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user