Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47bd454fa3 |
@@ -66,7 +66,7 @@ flowchart TD
|
||||
|
||||
B --> B1["skills/ack"]
|
||||
B --> B2["skills/skiff"]
|
||||
B --> B3["skills/deb-publisher"]
|
||||
B --> B3["skills/builder"]
|
||||
|
||||
C --> C1["Git 或本地目录"]
|
||||
C1 --> C2["单 Skill"]
|
||||
@@ -132,7 +132,7 @@ layout: single | collection
|
||||
|
||||
```text
|
||||
[ ] ack builtin
|
||||
[ ] deb-publisher builtin
|
||||
[ ] builder builtin
|
||||
[-] company custom source
|
||||
[ ] company/release custom:company
|
||||
[ ] company/security-review custom:company
|
||||
|
||||
@@ -111,9 +111,9 @@ intents:
|
||||
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
|
||||
SHA-256;OCI image 必须记录完整引用、platform 和 digest。产物必须绑定当前 source
|
||||
revision,不能在目标机器重新拉源码构建。
|
||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 可使用已安装的
|
||||
`deb-publisher`;Docker 只有在用户明确指定 `$publish-docker-image` 时才加载该
|
||||
explicit-only skill,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
||||
- `publish`:验证 artifact/destination 类型兼容,上传精确产物。DEB 与 Docker 均可使用
|
||||
已安装的 `builder`;Docker 轨道保持显式触发——只有用户明确点名(builder / 发布镜像)
|
||||
时才加载,否则必须走契约中已审查的 upload entrypoint。项目入口只接受
|
||||
刚校验的精确 artifact。preview/staging 使用不可覆盖的 commit/PR 标识,不隐式使用
|
||||
`latest`。既没有可用 skill 也没有 upload 入口时标记 blocked。
|
||||
- `deploy`:把同一不可变 artifact 交给 environment 的 deploy entrypoint;获取目标
|
||||
@@ -147,7 +147,7 @@ ID 指向不同 commit、digest 或目标时停止,不覆盖或另建伪装成
|
||||
## 7. 与低层 Skill 的边界
|
||||
|
||||
ACK 只负责读取项目交付契约、编排顺序、守住审批点并汇总证据,不复制低层 skill 的
|
||||
上传、镜像或 Git 发布实现。`deb-publisher`、`publish-docker-image` 和
|
||||
上传、镜像或 Git 发布实现。`builder` 和
|
||||
`manage-release` 仍是可独立使用、独立安装的能力;缺失时 ACK 使用契约中已审查的
|
||||
项目 entrypoint,二者都不可用时把对应步骤标为 `blocked`。低层 skill 自身要求显式
|
||||
调用时,ACK 不能绕过它的触发与授权边界。
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# builder
|
||||
|
||||
按统一契约完成项目的 DEB 包与 Docker 镜像构建和发布。规范本体见
|
||||
[references/contract.md](references/contract.md),`scripts/check.py` 是契约的
|
||||
可执行校验器。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
- "帮我构建这个项目的 DEB / Docker 镜像"
|
||||
- "把 1.2.3 发布到包仓库 / 镜像仓库"
|
||||
- "检查这个项目的 Makefile 是否符合 builder 契约"
|
||||
- "看看项目现在的发布流程"
|
||||
|
||||
只构建不上传时明确说明即可;上传永远需要你显式授权。
|
||||
|
||||
## 项目接入契约
|
||||
|
||||
1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version`
|
||||
+ 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。
|
||||
2. 运行 `python3 -I -S <builder>/scripts/check.py .` 直到全部 PASS。
|
||||
3. 在项目根 `.env` 配置发布环境变量:
|
||||
|
||||
```text
|
||||
DEB_SERVER_URL=https://deb.example.com
|
||||
DEB_REPOSITORY=main
|
||||
DEB_TOKEN=<token> # 只放 .env 或密钥系统,不进 git
|
||||
DOCKER_REGISTRY=registry.example.com
|
||||
```
|
||||
|
||||
4. 日常发布就是两条命令:`make deb && make push-deb`、`make push-docker`。
|
||||
|
||||
## 使用示例
|
||||
|
||||
```text
|
||||
用 builder 检查这个项目的 Makefile 是否符合契约。
|
||||
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
|
||||
用 builder 把 dist/example_1.2.3_amd64.deb 发布到项目已配置的测试仓库。
|
||||
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
|
||||
```
|
||||
|
||||
## 脚本一览
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `scripts/check.py` | 校验项目 Makefile 是否符合契约(`--build` 实构核对产物) |
|
||||
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name) |
|
||||
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
|
||||
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
|
||||
|
||||
环境变量契约、脚本解析顺序(`$BUILDER_SKILL_DIR` → `~/.skills/skills/builder/scripts/`)、
|
||||
脏工作树策略等完整规则见 contract.md。
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
name: builder
|
||||
description: >-
|
||||
按统一契约构建并发布项目的 DEB 包与 Docker 镜像:先校验项目 Makefile 是否符合
|
||||
builder 契约(check.py),再 make 构建产物,经授权后用 skill 自带脚本上传并验证。
|
||||
触发词:构建 deb、发布 deb、上传 deb、推送 apt 仓库、打 Debian 包、构建镜像、
|
||||
发布镜像、推送 Docker 镜像、make push、检查 Makefile 是否符合规范。仅分析打包
|
||||
逻辑或只构建不上传时也可使用;不会在未获授权时执行任何上传。Docker 轨道保持
|
||||
显式触发:用户点名(builder/publish docker)时才走镜像发布。
|
||||
---
|
||||
|
||||
# Builder:DEB / Docker 构建发布
|
||||
|
||||
复用项目已有发布约定,安全地完成"校验 → 构建 → 检查 → 授权 → 上传 → 验证"。
|
||||
|
||||
分工原则:**make 管构建,skill 脚本管发布,本 SKILL.md 只留脚本做不了的决策。**
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求构建、发布、上传 `.deb` 包或 Docker/OCI 镜像。
|
||||
- 用户要求检查项目 Makefile 是否符合 builder 契约。
|
||||
- 用户要求梳理或接通项目现有的 DEB/镜像发布流程。
|
||||
|
||||
不适用:本地安装/卸载 DEB;RPM/APK/语言包管理器;从零设计全新打包体系(先出方案);
|
||||
普通编码与 Dockerfile 编辑。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 0. 校验契约
|
||||
|
||||
```bash
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> # 静态检查
|
||||
python3 -I -S <skill-dir>/scripts/check.py <project-dir> --build # 额外实构 deb 并核对产物
|
||||
```
|
||||
|
||||
任一 FAIL:停下修复(引导用 create-makefile skill 补齐),不要绕过校验继续发布。
|
||||
完整要求见 [contract.md](references/contract.md)。存量项目未接契约时走第 6 节
|
||||
fallback;成功交付一次后引导用户迁移到契约。
|
||||
|
||||
### 1. 确认发布边界
|
||||
|
||||
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;只要求查看、诊断或构建
|
||||
则停在相应阶段。
|
||||
|
||||
执行上传前确认:
|
||||
|
||||
- 目标服务和仓库来自项目配置(`.env`)或用户输入,不猜测生产端点。
|
||||
- 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复,
|
||||
不用 `set -x` 执行含凭据的命令。
|
||||
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。
|
||||
- Docker 轨道需要用户已明确指定目标 registry/repository/tag 后才继续。
|
||||
|
||||
脏工作树默认拒绝发布;用户明确接受时设置 `ALLOW_UNCOMMITTED=1` 并在汇报中注明
|
||||
包含的未提交修改。
|
||||
|
||||
### 2. 构建
|
||||
|
||||
```bash
|
||||
make build ARCH=<amd64|arm64> VERSION=<version> # 主产物
|
||||
make deb ARCH=<amd64|arm64> # DEB 项目
|
||||
```
|
||||
|
||||
版本缺省由 make 从 `git describe --tags --always --dirty` 推导。构建目标若会自动
|
||||
上传而当前仅获构建授权,改用纯构建目标。执行前确认所需工具可用(docker、
|
||||
dpkg-deb 等)。不得擅自清理宽泛目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。
|
||||
|
||||
### 3. 上传前检查
|
||||
|
||||
```bash
|
||||
find $(DIST_DIR) -maxdepth 2 -type f -name '*.deb' -print
|
||||
<skill-dir>/scripts/verify_deb.sh <exact-package-path.deb> [期望版本] [期望架构]
|
||||
```
|
||||
|
||||
verify_deb.sh 输出元数据、关键内容清单和 SHA-256。匹配到多个包时不凭文件时间猜测,
|
||||
向用户确认唯一产物。镜像轨道无需单独校验步骤(publish_docker.sh 自带远端 inspect)。
|
||||
|
||||
### 4. 发布
|
||||
|
||||
优先 `make push[-deb|-docker]`(契约要求的薄包装);直接调用等价:
|
||||
|
||||
```bash
|
||||
DEB_SERVER_URL=… DEB_TOKEN=… DEB_REPOSITORY=… \
|
||||
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
||||
|
||||
DOCKER_REGISTRY=… \
|
||||
<skill-dir>/scripts/publish_docker.sh # env 优先,flag 可覆盖
|
||||
```
|
||||
|
||||
环境变量缺失时脚本会自动向上查找项目 `.env` 加载(shell 显式值优先)。不把 token
|
||||
作为命令行参数;不把脚本复制进项目。upload_deb.sh 默认请求 `/api/v2/upload/package`
|
||||
(multipart 字段 `package`/`token`/`repository_name`,接受 200/201),协议不符时设
|
||||
`DEB_UPLOAD_PATH` 或改用项目专属逻辑。publish_docker.sh 用 buildx 一步完成构建+推送,
|
||||
多平台只能走它,不能拆进 make。
|
||||
|
||||
### 5. 验证与汇报
|
||||
|
||||
发布成功不能只依据"curl 已执行"/"push 已执行"。综合检查:
|
||||
|
||||
- 上传命令退出码为零,HTTP 状态与响应体明确成功;镜像以 `imagetools inspect`
|
||||
的远端 digest 为准。
|
||||
- 若仓库提供查询/索引/下载地址,确认该版本已可见;索引异步时报告
|
||||
"上传已接受,索引尚待更新",不声称完全可用。
|
||||
|
||||
最终回复给出:包名/镜像引用、版本、架构/platform、产物路径与 SHA-256 或远端 digest、
|
||||
源 commit 与工作区状态、各阶段验证结果、未完成项或覆盖风险。
|
||||
|
||||
## 存量项目 fallback(legacy)
|
||||
|
||||
从项目根目录查找,不预设文件位置:
|
||||
|
||||
```bash
|
||||
rg -n -i --hidden --glob '!.git' \
|
||||
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
||||
```
|
||||
|
||||
重点检查 Makefile、CI 配置、`debian/`、构建脚本和发布文档中的入口、变量传递方式、
|
||||
端点与认证方式。优先复用已有构建入口;上传仍用 builder 脚本。交付后引导迁移到契约
|
||||
(create-makefile + check.py 通过为准)。
|
||||
|
||||
## 修改 builder 自身时
|
||||
|
||||
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
|
||||
业务项目。
|
||||
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`。
|
||||
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
|
||||
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
||||
- 仅校验:check.py 结果逐条可解释,修复建议明确。
|
||||
- 仅构建:产物已生成并通过 verify_deb.sh,未发生上传。
|
||||
- 发布:构建检查通过,服务端接受上传,仓库可见性已验证或准确标记为待更新。
|
||||
@@ -0,0 +1,100 @@
|
||||
# Builder 构建发布契约 v1
|
||||
|
||||
本契约是 builder skill 的规范本体。`scripts/check.py` 是它的可执行形态:改契约先改
|
||||
check.py,本文档跟随。所有接入项目按同一套 make 目标、产物形状和环境变量执行,
|
||||
builder 脚本只做发布,不做项目特定的构建逻辑。
|
||||
|
||||
分工原则:**make 管构建(项目内、确定性),skill 脚本管发布(跨项目 SSOT),
|
||||
Agent 只保留授权判断和歧义处理。**
|
||||
|
||||
## 1. Make 目标
|
||||
|
||||
### 必备目标(所有项目)
|
||||
|
||||
| 目标 | 要求 |
|
||||
|------|------|
|
||||
| `help` | 分组列出全部目标;首屏含当前版本 |
|
||||
| `version` | 输出一行版本号,适合脚本消费 |
|
||||
| `clean` | 只删除明确、受限的构建产物目录 |
|
||||
| `build` | 编译/打包主产物;尊重 `ARCH`;**不得内含任何上传动作** |
|
||||
|
||||
### 条件目标
|
||||
|
||||
| 目标 | 适用 | 要求 |
|
||||
|------|------|------|
|
||||
| `deb` | 有 DEB 产物的项目 | 产出唯一 `$(DIST_DIR)/<name>_<version>_<arch>.deb`;只构建不上传 |
|
||||
| `docker` | 有镜像的项目 | 构建本地单平台镜像 `linux/$(ARCH)`;**禁止 `--push`、禁止多平台** |
|
||||
| `push-deb` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `upload_deb.sh` 上传 `dist/*.deb` |
|
||||
| `push-docker` | 同时有 DEB 和镜像的项目 | 仅调 builder 的 `publish_docker.sh` |
|
||||
| `push` | 单一产物类型时必备;双产物项目为聚合 | 依序调用对应 push-* 或直接调脚本;是发布的唯一 make 入口 |
|
||||
|
||||
规则:
|
||||
|
||||
1. 项目有 DEB 产物的判据:Makefile 配方引用 `dpkg-deb`/`debuild` 或产出 `.deb`。
|
||||
有镜像的判据:项目根存在 `Dockerfile`。
|
||||
2. 双产物项目必须拆 `push-deb`/`push-docker`,`push` 依序聚合两者;单产物项目一个
|
||||
`push` 即可。
|
||||
3. `docker` 目标只能本地构建。多平台镜像无法拆成"make 构建 + 单独推送"
|
||||
(`buildx --push` 是一步),因此多平台发布只能走 `publish_docker.sh`。
|
||||
4. push 类目标必须是薄包装:解析脚本路径后委托,不内联 curl/token/端点。
|
||||
|
||||
## 2. 变量
|
||||
|
||||
| 变量 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 |
|
||||
| `VERSION` | `` (空) | 为空时由 make 从 `git describe --tags --always --dirty` 推导 |
|
||||
| `DIST_DIR` | `dist` | DEB 产物目录 |
|
||||
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
|
||||
|
||||
## 3. 发布环境变量
|
||||
|
||||
### DEB 轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `DEB_SERVER_URL` | 是 | 仓库服务地址 |
|
||||
| `DEB_TOKEN` | 是 | 认证令牌;只从环境读取,绝不进 argv/日志/git |
|
||||
| `DEB_REPOSITORY` | 是 | 目标仓库名 |
|
||||
| `DEB_UPLOAD_PATH` | 否 | 覆盖默认上传路径 `/api/v2/upload/package` |
|
||||
|
||||
### Docker 轨道
|
||||
|
||||
| 变量 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
|
||||
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
|
||||
| `IMAGE_TAG` | 否 | 默认 `git describe --tags --always --dirty` |
|
||||
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
|
||||
|
||||
配置来源优先级:shell 已显式设置的值 > 项目根 `.env` > 失败并询问用户。
|
||||
`.env` 由 builder 脚本自动向上查找并加载(不回显任何值);当前 shell 已设置的值
|
||||
优先于 `.env`。
|
||||
|
||||
### 工作区安全
|
||||
|
||||
脏工作树(有未提交修改)默认拒绝发布;`ALLOW_UNCOMMITTED=1` 显式放行并在汇报中
|
||||
注明镜像/包包含哪些未提交修改。该门在 builder 脚本层实现,不在 make 层。
|
||||
|
||||
## 4. 脚本解析顺序
|
||||
|
||||
push 目标定位 builder 脚本时按以下顺序,命中即用,不做静默兜底:
|
||||
|
||||
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
|
||||
2. `$HOME/.skills/skills/builder/scripts/`(标准 clone 位)
|
||||
|
||||
两个位置都不可用时必须失败并提示:设置 `BUILDER_SKILL_DIR`,或把 skills 仓库
|
||||
clone 到 `~/.skills`。
|
||||
|
||||
## 5. 校验
|
||||
|
||||
`python3 -I -S <builder-scripts>/check.py <project-dir> [--build]` 对本项目逐条检查
|
||||
上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构 `make deb` 并核对
|
||||
产物元数据(默认只静态检查配方)。校验失败时的修复路径:用 create-makefile skill
|
||||
补齐或修正 Makefile,不要绕过校验器。
|
||||
|
||||
## 6. 存量项目(legacy fallback)
|
||||
|
||||
未接入契约的项目:builder 仍可按发现流程工作——从 `Makefile`、CI 配置、`debian/`
|
||||
与发布文档中找已有构建/上传入口,优先复用;上传仍使用 builder 脚本。完成一次成功
|
||||
交付后应引导用户用 create-makefile 把项目迁移到本契约,之后以 check.py 为准。
|
||||
+7
-6
@@ -1,28 +1,29 @@
|
||||
# 镜像仓库规则
|
||||
|
||||
执行发布前,从用户输入和当前项目文档中确定以下信息:
|
||||
执行发布前,从用户输入和当前项目配置中确定以下信息:
|
||||
|
||||
| 字段 | 要求 |
|
||||
| --- | --- |
|
||||
| Registry | 必须显式确定,例如 `registry.example.com` |
|
||||
| Repository | 必须包含项目约定的 namespace,例如 `team/service` |
|
||||
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
|
||||
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
|
||||
| Platform | 必须显式确定,例如 `linux/amd64` 或 `linux/amd64,linux/arm64` |
|
||||
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
|
||||
| Context | 默认当前项目根目录 |
|
||||
|
||||
## 信息来源优先级
|
||||
信息来源优先级:
|
||||
|
||||
1. 用户本次请求中明确给出的值。
|
||||
2. 当前项目的 `AGENTS.md` 和发布文档。
|
||||
3. `Makefile`、CI 配置、Compose 文件或现有构建脚本中一致且无歧义的配置。
|
||||
2. 当前项目的 `.env` 与 `AGENTS.md`、发布文档。
|
||||
3. Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
|
||||
4. 询问用户。
|
||||
|
||||
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
|
||||
|
||||
## 认证
|
||||
|
||||
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
|
||||
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作
|
||||
检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
|
||||
|
||||
不要读取、打印或复制以下内容:
|
||||
|
||||
Executable
+357
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Executable form of the builder contract (references/contract.md).
|
||||
|
||||
Checks a project's Makefile against the contract by probing make itself with
|
||||
dry runs (`make -n`) instead of parsing Makefile text: includes, conditionals,
|
||||
and variable expansion are resolved by make, so behavior is what gets judged.
|
||||
|
||||
Usage:
|
||||
python3 -I -S check.py <project-dir> [--build]
|
||||
|
||||
Exit codes: 0 = all PASS, 1 = at least one FAIL, 2 = usage/environment error.
|
||||
|
||||
Change the contract here first, then mirror the change into contract.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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",
|
||||
)
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(r"(TOKEN|PASSWORD|SECRET|API_KEY|PASSWD)[A-Z_]*\s*[:?]?=\s*['\"]?[^\s$({\"']+", re.IGNORECASE),
|
||||
re.compile(r"\b[A-Za-z0-9_]*token[A-Za-z0-9_]*\s*[:?]?=\s*['\"]?[A-Za-z0-9._\-]{16,}", re.IGNORECASE),
|
||||
)
|
||||
FLOATING_TAGS = (":latest", ":stable")
|
||||
DEB_SHAPE = re.compile(r"^[^_\s]+_[^_\s]+_[^_\s]+\.deb$")
|
||||
VALID_SCRIPT_NAMES = ("upload_deb.sh", "publish_docker.sh")
|
||||
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
SKIP = "SKIP"
|
||||
|
||||
|
||||
class Report:
|
||||
def __init__(self) -> None:
|
||||
self.failures = 0
|
||||
self.skips = 0
|
||||
|
||||
def add(self, status: str, number: int, title: str, detail: str) -> None:
|
||||
print(f"[{status}] {number}. {title}")
|
||||
for line in detail.splitlines():
|
||||
print(f" {line}")
|
||||
if status == FAIL:
|
||||
self.failures += 1
|
||||
elif status == SKIP:
|
||||
self.skips += 0 if self.skips else 1
|
||||
|
||||
|
||||
def run_make(project: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["make", "-C", str(project), "-n", *args],
|
||||
capture_output=True, text=True, timeout=timeout, check=False,
|
||||
)
|
||||
|
||||
|
||||
def has_no_rule(result: subprocess.CompletedProcess[str]) -> bool:
|
||||
return result.returncode != 0 and (
|
||||
"No rule to make target" in result.stderr or "no rule to make target" in result.stderr.lower()
|
||||
)
|
||||
|
||||
|
||||
BANNER_RE = re.compile(r"^make(?:\[[0-9]+\])?: (进入|离开|Entering|Leaving)")
|
||||
|
||||
|
||||
def clean_make_output(result: subprocess.CompletedProcess[str]) -> list[str]:
|
||||
"""Drop make directory banners and dry-run command echoes, keep real output."""
|
||||
lines = []
|
||||
for line in result.stdout.splitlines():
|
||||
if BANNER_RE.match(line.strip()):
|
||||
continue
|
||||
stripped = line.lstrip()
|
||||
if stripped.startswith(("echo ", "echo\t", "printf ")):
|
||||
continue
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def check_required_targets(report: Report, project: Path) -> dict[str, bool]:
|
||||
present: dict[str, bool] = {}
|
||||
lines = []
|
||||
for target in REQUIRED_TARGETS:
|
||||
result = run_make(project, target)
|
||||
ok = result.returncode == 0
|
||||
present[target] = ok
|
||||
lines.append(f"{target}: {'found' if ok else 'missing'}")
|
||||
report.add(PASS if all(present.values()) else FAIL, 1, "必备目标存在(help/version/clean/build)", "\n".join(lines))
|
||||
return present
|
||||
|
||||
|
||||
def check_arch_guard(report: Report, project: Path) -> None:
|
||||
bad = run_make(project, "build", "ARCH=loongarch")
|
||||
guard_ok = bad.returncode != 0 and ("amd64" in bad.stderr or "arm64" 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()))
|
||||
else:
|
||||
report.add(FAIL, 2, "ARCH 守卫与缺省值", "\n".join(lines) + hint)
|
||||
|
||||
|
||||
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()
|
||||
single = len(out.splitlines()) == 1 and out != ""
|
||||
report.add(
|
||||
PASS if single else FAIL,
|
||||
3,
|
||||
"version 输出一行非空版本号",
|
||||
f"stdout={out!r}",
|
||||
)
|
||||
|
||||
|
||||
def check_build_has_no_upload(report: Report, project: Path) -> None:
|
||||
result = run_make(project, "build")
|
||||
text = chr(10).join(clean_make_output(result))
|
||||
hits = [token for token in UPLOAD_TOKENS if token in text]
|
||||
report.add(
|
||||
PASS if not hits else FAIL,
|
||||
4,
|
||||
"build 不含上传动作",
|
||||
"clean" if not hits else "found upload commands in build recipe:\n " + ", ".join(hits),
|
||||
)
|
||||
|
||||
|
||||
def detect_deb_project(recipe_all: str, project: Path) -> bool:
|
||||
return ".deb" in recipe_all or "dpkg-deb" in recipe_all or "debuild" in recipe_all or any(project.glob("debian/*"))
|
||||
|
||||
|
||||
def check_deb_recipe(report: Report, project: Path, built_deb: Path | None) -> None:
|
||||
dry = run_make(project, "deb")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
problems = []
|
||||
if dry.returncode != 0:
|
||||
problems.append(f"`make -n deb` 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")
|
||||
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")
|
||||
if built_deb is not None:
|
||||
shape_ok = DEB_SHAPE.match(built_deb.name) is not None
|
||||
if not shape_ok:
|
||||
problems.append(f"artifact name does not match <name>_<version>_<arch>.deb: {built_deb.name}")
|
||||
dpkg = shutil.which("dpkg-deb")
|
||||
if dpkg:
|
||||
info = subprocess.run([dpkg, "--field", str(built_deb), "Package"], capture_output=True, text=True, check=False)
|
||||
if info.returncode != 0 or not info.stdout.strip():
|
||||
problems.append(f"dpkg-deb --info failed on {built_deb.name}")
|
||||
else:
|
||||
problems.append("dpkg-deb unavailable; metadata not verified (--build)")
|
||||
if problems:
|
||||
report.add(FAIL, 5, "deb 目标产物形状与纯构建", "\n".join(problems))
|
||||
else:
|
||||
extra = f"\nartifact: {built_deb.name}" if built_deb else "\n(static recipe check only; run --build to verify real artifact)"
|
||||
report.add(PASS, 5, "deb 目标产物形状与纯构建", extra.lstrip("\n"))
|
||||
|
||||
|
||||
def detect_docker_project(project: Path) -> bool:
|
||||
return (project / "Dockerfile").exists() or (project / "docker-compose.yaml").exists()
|
||||
|
||||
|
||||
def check_docker_recipe(report: Report, project: Path) -> None:
|
||||
dry = run_make(project, "docker")
|
||||
text = chr(10).join(clean_make_output(dry))
|
||||
if has_no_rule(dry):
|
||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no docker target)")
|
||||
return
|
||||
problems = []
|
||||
if "--push" in text or " docker push" in text or "docker push\n" in text:
|
||||
problems.append("make docker must be local-only; pushing belongs to publish_docker.sh")
|
||||
if "--platform" in text and "," in text.split("--platform")[1][:80].split()[0]:
|
||||
problems.append("make docker must stay single-platform; multi-platform belongs to publish_docker.sh")
|
||||
report.add(FAIL if problems else PASS, 6, "docker 目标为本地单平台构建", "\n".join(problems) or "local single-platform build")
|
||||
|
||||
|
||||
SCRIPT_RESOLVE_SNIPPETS = tuple(
|
||||
f"{prefix}{name}"
|
||||
for prefix in ("$$BUILDER_SKILL_DIR", "$BUILDER_SKILL_DIR", "$$HOME/.skills/skills/builder/scripts", "$HOME/.skills/skills/builder/scripts", "~/.skills/skills/builder/scripts")
|
||||
for name in VALID_SCRIPT_NAMES
|
||||
)
|
||||
|
||||
|
||||
def check_push_delegates(report: Report, project: Path, dual_artifact: bool) -> None:
|
||||
targets = ("push-deb", "push-docker") if dual_artifact else ("push",)
|
||||
missing = []
|
||||
inline = []
|
||||
thin = []
|
||||
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]
|
||||
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:
|
||||
inline.append(f"{target}: does not call a builder script (expected $BUILDER_SKILL_DIR/... or ~/.skills/... path)")
|
||||
else:
|
||||
thin.append(target)
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append("missing targets: " + ", ".join(missing))
|
||||
problems.extend(inline)
|
||||
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)
|
||||
|
||||
|
||||
def check_secrets_and_tags(report: Report, project: Path) -> None:
|
||||
makefile = project / "Makefile"
|
||||
included_text = ""
|
||||
problems = []
|
||||
files = [makefile]
|
||||
if makefile.exists():
|
||||
for match in re.finditer(r"^include\s+(.+)$", makefile.read_text(encoding="utf-8"), re.MULTILINE):
|
||||
inc = (project / match.group(1).strip()).resolve()
|
||||
if inc.is_file():
|
||||
files.append(inc)
|
||||
for file in files:
|
||||
text = file.read_text(encoding="utf-8")
|
||||
rel = file.relative_to(project) if file.is_relative_to(project) else file
|
||||
for pattern in SECRET_PATTERNS:
|
||||
for hit in pattern.finditer(text):
|
||||
problems.append(f"{rel}: possible hardcoded secret near `{hit.group(0)[:40]}...`")
|
||||
for tag in FLOATING_TAGS:
|
||||
for line in text.splitlines():
|
||||
stripped = line.split("#", 1)[0]
|
||||
if tag in stripped:
|
||||
problems.append(f"{rel}: implicit floating tag `{tag}` in: {stripped.strip()[:70]}")
|
||||
report.add(FAIL if problems else PASS, 8, "无内联机密、无隐式 latest/stable", "\n".join(problems) or "clean")
|
||||
|
||||
|
||||
def check_script_paths(report: Report) -> None:
|
||||
import os
|
||||
|
||||
candidates = []
|
||||
env_dir = os.environ.get("BUILDER_SKILL_DIR")
|
||||
if env_dir:
|
||||
candidates.append(Path(env_dir) / "scripts")
|
||||
home = Path(os.environ.get("HOME", ""))
|
||||
candidates.append(home / ".skills" / "skills" / "builder" / "scripts")
|
||||
found = next((c for c in candidates if c.is_dir() and any((c / n).is_file() for n in VALID_SCRIPT_NAMES)), None)
|
||||
if found:
|
||||
report.add(PASS, 9, "builder 脚本路径可达", str(found))
|
||||
else:
|
||||
report.add(FAIL, 9, "builder 脚本路径可达", "\n".join([
|
||||
"none of these resolve to scripts/upload_deb.sh:",
|
||||
*(f" {c}" for c in candidates),
|
||||
"Fix: set BUILDER_SKILL_DIR, or clone the skills repo to ~/.skills.",
|
||||
]))
|
||||
|
||||
|
||||
def build_project(project: Path) -> Path | None:
|
||||
"""Run `make deb` for real and return the produced .deb, or None."""
|
||||
result = subprocess.run(["make", "-C", str(project), "deb"], capture_output=True, text=True, timeout=1800, check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"--build: `make deb` failed:\n{result.stderr[-2000:]}", file=sys.stderr)
|
||||
return None
|
||||
debs = sorted((p for p in (project / "dist").glob("*.deb") if p.is_file()), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return debs[0] if debs else None
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("project", type=Path, help="project directory containing the Makefile")
|
||||
parser.add_argument("--build", action="store_true", help="actually run `make deb` and verify the artifact")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
project = args.project.resolve()
|
||||
makefile = project / "Makefile"
|
||||
if not makefile.is_file():
|
||||
print(f"Error: no Makefile in {project}", file=sys.stderr)
|
||||
return 2
|
||||
if shutil.which("make") is None:
|
||||
print("Error: make is required.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
report = Report()
|
||||
|
||||
# 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"):
|
||||
result = run_make(project, target)
|
||||
if result.returncode == 0:
|
||||
recipe_all_parts.append(result.stdout)
|
||||
recipe_all = "\n".join(recipe_all_parts)
|
||||
|
||||
present = check_required_targets(report, project)
|
||||
|
||||
built_deb: Path | None = None
|
||||
deb_project = detect_deb_project(recipe_all, project)
|
||||
docker_project = detect_docker_project(project)
|
||||
|
||||
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, 3, "version 输出一行非空版本号", "(version target missing)")
|
||||
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
|
||||
|
||||
if deb_project:
|
||||
if args.build:
|
||||
print("--build: running `make deb` ...")
|
||||
built_deb = build_project(project)
|
||||
if built_deb is None:
|
||||
print("--build: no .deb produced; artifact checks degrade to recipe-only.", file=sys.stderr)
|
||||
check_deb_recipe(report, project, built_deb)
|
||||
else:
|
||||
report.add(SKIP, 5, "deb 目标产物形状与纯构建", "(not a DEB project)")
|
||||
|
||||
if docker_project:
|
||||
check_docker_recipe(report, project)
|
||||
else:
|
||||
report.add(SKIP, 6, "docker 目标为本地单平台构建", "(no Dockerfile)")
|
||||
|
||||
dual = deb_project and docker_project
|
||||
check_push_delegates(report, project, dual)
|
||||
check_secrets_and_tags(report, project)
|
||||
check_script_paths(report)
|
||||
|
||||
total_fail = report.failures
|
||||
print()
|
||||
if total_fail:
|
||||
print(f"RESULT: FAILED ({total_fail} check(s) failed)")
|
||||
return 1
|
||||
print("RESULT: PASSED")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
sed -n '2,30p' "$0"
|
||||
}
|
||||
|
||||
# Build and publish a Docker image with buildx. Configuration comes from the
|
||||
# environment first (optionally loaded from the project root .env); flags
|
||||
# override.
|
||||
#
|
||||
# Usage:
|
||||
# publish_docker.sh [--registry HOST] [--repository PATH] [--tag TAG] \
|
||||
# [--platform LIST] [options]
|
||||
#
|
||||
# Environment:
|
||||
# DOCKER_REGISTRY Required (or --registry)
|
||||
# DOCKER_REPOSITORY Optional, default: git repository name (or --repository)
|
||||
# IMAGE_TAG Optional, default: git describe --tags --always --dirty (or --tag)
|
||||
# PLATFORMS Optional, default: linux/amd64 (or --platform)
|
||||
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
|
||||
# DOCKER_CONTEXT Optional, default: . (--context)
|
||||
# DOCKER_BUILDER Optional buildx builder name (--builder)
|
||||
# ALLOW_UNCOMMITTED=1 Publish despite a dirty working tree
|
||||
#
|
||||
# Options:
|
||||
# --load Load a single-platform image instead of pushing
|
||||
# --dry-run Print the resolved build without executing it
|
||||
# -h, --help Show this help
|
||||
|
||||
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
|
||||
# Load project .env without printing values; explicitly exported shell values keep precedence.
|
||||
if [[ -n "$project_root" && -f "$project_root/.env" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key=${key%%[[:space:]]*}
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
if [[ -n "${!key:-}" ]]; then
|
||||
continue # shell value already set: wins over .env
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||
printf -v "$key" '%s' "$value"
|
||||
export "$key"
|
||||
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
|
||||
fi
|
||||
|
||||
git_repo_name=
|
||||
if [[ -n "$project_root" ]]; then
|
||||
git_repo_name=$(basename "$(git -C "$project_root" rev-parse --show-toplevel)")
|
||||
fi
|
||||
|
||||
registry=${DOCKER_REGISTRY:-}
|
||||
repository=${DOCKER_REPOSITORY:-$git_repo_name}
|
||||
tag=${IMAGE_TAG:-}
|
||||
platform=${PLATFORMS:-linux/amd64}
|
||||
dockerfile=${DOCKER_DOCKERFILE:-Dockerfile}
|
||||
build_context=${DOCKER_CONTEXT:-.}
|
||||
builder=${DOCKER_BUILDER:-}
|
||||
mode=push
|
||||
dry_run=false
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--registry) registry=$2; shift 2 ;;
|
||||
--repository) repository=$2; shift 2 ;;
|
||||
--tag) tag=$2; shift 2 ;;
|
||||
--platform) platform=$2; shift 2 ;;
|
||||
--file) dockerfile=$2; shift 2 ;;
|
||||
--context) build_context=$2; shift 2 ;;
|
||||
--builder) builder=$2; shift 2 ;;
|
||||
--load) mode=load; shift ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "$registry" && ( "$registry" == *://* || "$registry" == */* ) ]]; then
|
||||
echo "Error: registry must be a bare host without scheme or slash: $registry" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$registry" ]]; then
|
||||
echo "Error: DOCKER_REGISTRY (or --registry) is required." >&2
|
||||
echo "Set it in the environment or the project root .env." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$repository" || "$repository" == /* || "$repository" == */ || "$repository" != */* ]]; then
|
||||
echo "Error: repository must be namespace/name without leading or trailing slash: $repository" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -z "$tag" ]]; then
|
||||
if [[ -n "$project_root" ]]; then
|
||||
tag=$(git -C "$project_root" describe --tags --always --dirty 2>/dev/null) || tag=
|
||||
fi
|
||||
if [[ -z "$tag" ]]; then
|
||||
echo "Error: IMAGE_TAG (or --tag) is required outside a git repository." >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
if [[ "$tag" == *:* || "$tag" == */* ]]; then
|
||||
echo "Error: tag must not contain : or /: $tag" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$tag" == latest && ${ALLOW_LATEST:-0} != 1 && "$mode" == push ]]; then
|
||||
echo "Error: refusing to publish floating tag 'latest'; pass an explicit version." >&2
|
||||
echo "Set ALLOW_LATEST=1 only when the user explicitly asked for 'latest'." >&2
|
||||
exit 3
|
||||
fi
|
||||
if [[ "$mode" == load && "$platform" == *,* ]]; then
|
||||
echo "Error: --load cannot be combined with multiple platforms: $platform" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$dockerfile" ]]; then
|
||||
echo "Error: Dockerfile not found: $dockerfile" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$build_context" ]]; then
|
||||
echo "Error: build context not found: $build_context" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$dry_run" == false ]] && ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Error: docker is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
|
||||
if [[ "$mode" == push && "$dry_run" == false && -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
|
||||
|
||||
image_ref="${registry}/${repository}:${tag}"
|
||||
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
|
||||
if [[ -n "$builder" ]]; then
|
||||
build_cmd+=(--builder "$builder")
|
||||
fi
|
||||
if [[ "$mode" == push ]]; then
|
||||
build_cmd+=(--push)
|
||||
else
|
||||
build_cmd+=(--load)
|
||||
fi
|
||||
build_cmd+=("$build_context")
|
||||
|
||||
printf 'Image: %s\n' "$image_ref"
|
||||
printf 'Platform: %s\n' "$platform"
|
||||
printf 'Dockerfile: %s\n' "$dockerfile"
|
||||
printf 'Context: %s\n' "$build_context"
|
||||
printf 'Mode: %s\n' "$mode"
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
printf 'Command: %s\n' "${build_cmd[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"${build_cmd[@]}"
|
||||
|
||||
if [[ "$mode" == push ]]; then
|
||||
docker buildx imagetools inspect "$image_ref"
|
||||
fi
|
||||
@@ -15,12 +15,33 @@ Options:
|
||||
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
|
||||
-h Show help
|
||||
|
||||
The endpoint must accept multipart fields named package, token, and
|
||||
repository_name. Authentication is read only from DEB_TOKEN so it is not
|
||||
exposed in the process command line.
|
||||
Environment variables may live in the project root .env; this script walks up
|
||||
from the current directory, loads it silently (existing shell values win), and
|
||||
never echoes variable values. The endpoint must accept multipart fields named
|
||||
package, token, and repository_name. Authentication is read only from
|
||||
DEB_TOKEN so it is not exposed in the process command line.
|
||||
|
||||
The working tree must be clean to publish; set ALLOW_UNCOMMITTED=1 to override.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Locate project root (.git) upward from cwd for .env loading and git checks.
|
||||
project_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
||||
|
||||
# Load project .env without printing values; explicitly exported shell values keep precedence.
|
||||
if [[ -n "$project_root" && -f "$project_root/.env" ]]; then
|
||||
while IFS='=' read -r key value; do
|
||||
key=${key%%[[:space:]]*}
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
if [[ -n "${!key:-}" ]]; then
|
||||
continue # shell value already set: wins over .env
|
||||
fi
|
||||
value=${value%\"}; value=${value#\"}; value=${value%\'}; value=${value#\'}
|
||||
printf -v "$key" '%s' "$value"
|
||||
export "$key"
|
||||
done < <(grep -v '^[[:space:]]*$' "$project_root/.env")
|
||||
fi
|
||||
|
||||
server_url=${DEB_SERVER_URL:-}
|
||||
repository=${DEB_REPOSITORY:-}
|
||||
upload_path=${DEB_UPLOAD_PATH:-/api/v2/upload/package}
|
||||
@@ -40,9 +61,20 @@ shift $((OPTIND - 1))
|
||||
|
||||
if [[ -z "$server_url" || -z "$repository" || -z "$token" || $# -eq 0 ]]; then
|
||||
echo "Error: DEB_SERVER_URL, DEB_TOKEN, DEB_REPOSITORY, and at least one file are required." >&2
|
||||
echo "Set them in the environment or the project root .env." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Dirty-tree gate: publishing uncommitted content requires explicit opt-in.
|
||||
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
|
||||
|
||||
if [[ "$upload_path" != /* ]]; then
|
||||
echo "Error: upload path must start with /" >&2
|
||||
exit 2
|
||||
@@ -130,4 +162,3 @@ echo "Done. Success: $success_count, Failed: $fail_count"
|
||||
if ((fail_count > 0)); then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
verify_deb.sh FILE.deb [EXPECTED_VERSION] [EXPECTED_ARCH]
|
||||
|
||||
Prints package metadata, key content listing, and SHA-256. When an expected
|
||||
version and/or architecture is given, mismatches fail with a non-zero exit.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 3 ]]; then
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
package=$1
|
||||
expected_version=${2:-}
|
||||
expected_arch=${3:-}
|
||||
fail=0
|
||||
|
||||
if [[ ! -f "$package" ]]; then
|
||||
echo "Error: file not found: $package" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -s "$package" ]]; then
|
||||
echo "Error: empty file: $package" >&2
|
||||
exit 2
|
||||
fi
|
||||
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||
echo "Error: dpkg-deb is required." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "== metadata =="
|
||||
info=$(dpkg-deb --info "$package") || {
|
||||
echo "Error: dpkg-deb --info failed; not a valid Debian package." >&2
|
||||
exit 1
|
||||
}
|
||||
printf '%s\n' "$info"
|
||||
|
||||
package_name=$(dpkg-deb --field "$package" Package 2>/dev/null || true)
|
||||
package_version=$(dpkg-deb --field "$package" Version 2>/dev/null || true)
|
||||
package_arch=$(dpkg-deb --field "$package" Architecture 2>/dev/null || true)
|
||||
|
||||
# Debian versions never start with 'v'; git tags usually do. Compare normalized.
|
||||
expected_version=${expected_version#v}
|
||||
|
||||
if [[ -z "$package_name" || -z "$package_version" || -z "$package_arch" ]]; then
|
||||
echo "FAIL: missing Package/Version/Architecture field." >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
if [[ -n "$expected_version" && "$package_version" != "$expected_version" ]]; then
|
||||
echo "FAIL: version mismatch: expected $expected_version, got $package_version" >&2
|
||||
fail=1
|
||||
fi
|
||||
if [[ -n "$expected_arch" && "$package_arch" != "$expected_arch" ]]; then
|
||||
echo "FAIL: architecture mismatch: expected $expected_arch, got $package_arch" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# Filename shape per contract: <name>_<version>_<arch>.deb
|
||||
base=$(basename -- "$package")
|
||||
if [[ ! "$base" =~ ^[^_]+_[^_]+_[^_]+\.deb$ ]]; then
|
||||
echo "FAIL: filename does not match <name>_<version>_<arch>.deb: $base" >&2
|
||||
fail=1
|
||||
elif [[ -n "$package_version" && ! "$base" == *"${package_version}"* ]]; then
|
||||
echo "FAIL: filename version does not match package Version ($package_version): $base" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
echo "== contents (top level + binaries) =="
|
||||
dpkg-deb --contents "$package" | sed -n '1,40p'
|
||||
|
||||
echo "== maintainer scripts permissions (when present) =="
|
||||
control_dir=$(mktemp -d)
|
||||
trap 'rm -rf -- "$control_dir"' EXIT
|
||||
if dpkg-deb --control "$package" "$control_dir" 2>/dev/null; then
|
||||
found_scripts=false
|
||||
for script in preinst postinst prerm postrm; do
|
||||
if [[ -f "$control_dir/$script" ]]; then
|
||||
found_scripts=true
|
||||
mode=$(stat -c '%a' "$control_dir/$script")
|
||||
if [[ $mode =~ .*[2367]$ ]]; then
|
||||
echo "OK: $script mode $mode"
|
||||
else
|
||||
echo "FAIL: $script not executable (mode $mode)" >&2
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ "$found_scripts" == false ]]; then
|
||||
echo "(no maintainer scripts)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "== sha256 =="
|
||||
sha256sum "$package"
|
||||
|
||||
if ((fail > 0)); then
|
||||
echo "VERIFY: FAILED" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "VERIFY: OK"
|
||||
@@ -1,80 +0,0 @@
|
||||
# deb-publisher
|
||||
|
||||
帮助 Agent 复用项目已有的 DEB 打包与发布方式,完成构建、包检查、上传和发布验证。
|
||||
|
||||
## 什么时候使用
|
||||
|
||||
当你希望 Agent 处理以下任务时使用:
|
||||
|
||||
- “帮我构建这个项目的 DEB 包”
|
||||
- “把 1.2.3 版本的 DEB 发布到包仓库”
|
||||
- “看看项目里的 DEB 发布流程”
|
||||
- “检查这个 DEB 是否可以发布”
|
||||
|
||||
只想在本机安装一个 `.deb`,或者要构建 RPM、Docker 镜像时,不需要使用这个 skill。
|
||||
|
||||
## 使用前准备
|
||||
|
||||
请准备或确认:
|
||||
|
||||
- 要构建或发布的版本号。
|
||||
- 项目已经配置好构建脚本、Make 目标或 `debian/` 目录;上传脚本由 skill 自带,
|
||||
不需要放进项目。
|
||||
- 发布所需的令牌已经放入项目约定的环境变量或密钥系统。
|
||||
- 如果要真实发布,明确告诉 Agent 目标仓库以及是否允许覆盖同版本。
|
||||
|
||||
不要把令牌直接粘贴到对话、命令参数或项目文件中。
|
||||
|
||||
## 使用示例
|
||||
|
||||
仅分析,不产生或上传包:
|
||||
|
||||
```text
|
||||
看看这个项目的 DEB 是怎么构建和发布的。
|
||||
```
|
||||
|
||||
只构建和检查,不上传:
|
||||
|
||||
```text
|
||||
使用 deb-publisher 构建 1.2.3 的 DEB,检查包元数据和内容,不要上传。
|
||||
```
|
||||
|
||||
构建并发布:
|
||||
|
||||
```text
|
||||
使用 deb-publisher 构建并发布 1.2.3 的 amd64 DEB 到项目已配置的测试仓库。
|
||||
```
|
||||
|
||||
发布已有产物:
|
||||
|
||||
```text
|
||||
使用 deb-publisher 检查并发布 ./dist/example_1.2.3_amd64.deb。
|
||||
```
|
||||
|
||||
## Agent 会做什么
|
||||
|
||||
Agent 会优先发现和复用项目已有入口,然后:
|
||||
|
||||
1. 确认版本、架构、产物路径、目标仓库和授权范围。
|
||||
2. 构建 DEB,或定位你指定的已有产物。
|
||||
3. 检查包的元数据、内容和 SHA-256。
|
||||
4. 在你明确要求发布时,通过 skill 自带的 `scripts/upload_deb.sh` 上传。
|
||||
5. 检查服务端响应,并在仓库支持时确认该版本已经可见。
|
||||
|
||||
## 如何判断完成
|
||||
|
||||
结果中应包含包名、版本、架构、产物路径、SHA-256,以及构建、上传和仓库可见性的
|
||||
独立状态。异步索引尚未完成时,Agent 应明确说明“上传已接受,索引待更新”。
|
||||
|
||||
## 上传协议
|
||||
|
||||
自带脚本适用于接收 multipart 字段 `package`、`token` 和 `repository_name` 的
|
||||
HTTP DEB 仓库,默认路径是 `/api/v2/upload/package`。使用以下环境变量:
|
||||
|
||||
- `DEB_SERVER_URL`:仓库服务地址。
|
||||
- `DEB_TOKEN`:认证令牌。
|
||||
- `DEB_REPOSITORY`:目标仓库名。
|
||||
- `DEB_UPLOAD_PATH`:可选的上传路径覆盖。
|
||||
|
||||
令牌只从环境变量读取,不作为命令行参数传递。若你的仓库使用其他 API 协议,应先告诉
|
||||
Agent,不要直接套用该脚本。
|
||||
@@ -1,167 +0,0 @@
|
||||
---
|
||||
name: deb-publisher
|
||||
description: >-
|
||||
构建并发布 Debian DEB 包:发现项目已有的 Makefile 和打包入口,使用 skill 自带的
|
||||
通用上传脚本提交包,校验包元数据与内容,并验证发布结果。触发词:构建 deb、
|
||||
发布 deb、上传 deb、提交 deb、推送 apt 仓库、打 Debian 包。仅分析打包逻辑时也可使用,
|
||||
但不会在未获授权时执行上传。
|
||||
---
|
||||
|
||||
# DEB Publisher
|
||||
|
||||
复用项目已有发布约定,安全地完成“发现入口 → 构建 → 检查 → 上传 → 验证”。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 用户要求构建、发布、上传或提交 `.deb` 包。
|
||||
- 用户要求梳理或接通项目现有的 DEB 发布流程。
|
||||
- 用户要求把已经生成的 `.deb` 推送到 APT/DEB 包仓库。
|
||||
|
||||
## 不适用
|
||||
|
||||
- 只需要安装或卸载本地 DEB 包。
|
||||
- 目标是 RPM、APK、容器镜像或语言包管理器。
|
||||
- 用户只要求设计全新的 Debian 打包体系;此时应先完成方案设计。
|
||||
|
||||
## 工作流
|
||||
|
||||
### 1. 发现项目约定
|
||||
|
||||
从项目根目录查找,不预设文件位置:
|
||||
|
||||
```bash
|
||||
rg -n -i --hidden --glob '!.git' \
|
||||
'build-deb|upload-deb|publish-deb|dpkg-deb|debuild|curl.*deb|\.deb\b|aptly|reprepro'
|
||||
```
|
||||
|
||||
重点检查:
|
||||
|
||||
- `Makefile`、CI 配置、`debian/`、构建脚本和发布文档。
|
||||
- 版本号、包名、架构、产物目录和仓库名如何传入。
|
||||
- 发布端点、认证方式以及发布是否由构建目标自动触发。
|
||||
- 项目根目录或发布文档是否提供 `.env` 配置;只确认变量名,不打印其值。
|
||||
- 当前工作树和目标版本是否匹配。
|
||||
|
||||
优先复用已有构建入口。上传默认使用本 skill 的 `scripts/upload_deb.sh`,不要把它
|
||||
复制到项目中;仅当目标仓库协议不兼容时才复用或修改项目专属上传逻辑。
|
||||
|
||||
### 2. 确认发布边界
|
||||
|
||||
上传是外部写操作。仅当用户明确要求发布、上传或提交时执行;若用户只要求查看、
|
||||
诊断或构建,则停在相应阶段。
|
||||
|
||||
执行上传前确认:
|
||||
|
||||
- 目标服务和仓库来自项目配置或用户输入,不猜测生产端点。
|
||||
- 认证令牌已通过环境变量、密钥系统或项目 `.env` 提供。
|
||||
- 目标版本、架构和产物路径能够从构建配置推导。
|
||||
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问用户。
|
||||
|
||||
绝不把令牌写入命令输出、文件、提交或最终回复。不要用 `set -x` 执行含凭据的脚本。
|
||||
|
||||
如果项目根目录存在 `.env`,且当前 shell 尚未提供所需变量,上传前必须显式加载
|
||||
该文件;上传脚本不会自动读取 `.env`:
|
||||
|
||||
```bash
|
||||
set -a
|
||||
. "$PROJECT_ROOT/.env"
|
||||
set +a
|
||||
```
|
||||
|
||||
加载后确认 `DEB_SERVER_URL`、`DEB_REPOSITORY`、`DEB_TOKEN` 已非空;如配置了自定义
|
||||
上传路径,也确认 `DEB_UPLOAD_PATH`。不得输出 `.env` 内容、令牌或完整环境变量值。
|
||||
当前 shell 中已显式设置的值优先于 `.env`,若需覆盖 `.env`,加载后重新导出显式值。
|
||||
|
||||
### 3. 构建包
|
||||
|
||||
使用项目声明的构建目标,并显式传入版本。例如项目提供 Make 目标时:
|
||||
|
||||
```bash
|
||||
make build-deb VERSION="$RELEASE_VERSION"
|
||||
```
|
||||
|
||||
如果构建目标会自动上传,而当前仅获构建授权,应改用其纯构建子目标。执行前检查
|
||||
所需工具和环境,例如 Docker、`dpkg-deb`、编译器、SSH 访问或前端工具链。
|
||||
|
||||
不得擅自清理宽泛目录。若脚本包含 `rm -rf`,先解析并确认目标是明确、受限的构建目录。
|
||||
|
||||
### 4. 上传前检查
|
||||
|
||||
定位唯一目标产物;若匹配多个包,不凭文件时间猜测:
|
||||
|
||||
```bash
|
||||
find <artifact-dir> -maxdepth 2 -type f -name '*.deb' -print
|
||||
dpkg-deb --info <package.deb>
|
||||
dpkg-deb --contents <package.deb>
|
||||
```
|
||||
|
||||
至少验证:
|
||||
|
||||
- 文件存在、非空且 `dpkg-deb --info` 成功。
|
||||
- `Package`、`Version`、`Architecture` 与本次发布一致。
|
||||
- 包内容包含预期的主程序或关键文件。
|
||||
- maintainer scripts 存在时权限正确,且没有明显的宿主机破坏性操作。
|
||||
|
||||
建议记录 SHA-256:
|
||||
|
||||
```bash
|
||||
sha256sum <package.deb>
|
||||
```
|
||||
|
||||
### 5. 发布
|
||||
|
||||
解析当前 `SKILL.md` 所在目录,以绝对路径调用随 skill 分发的脚本:
|
||||
|
||||
```bash
|
||||
DEB_SERVER_URL="$DEB_SERVER_URL" \
|
||||
DEB_TOKEN="$DEB_TOKEN" \
|
||||
DEB_REPOSITORY="$DEB_REPOSITORY" \
|
||||
<skill-dir>/scripts/upload_deb.sh <exact-package-path.deb>
|
||||
```
|
||||
|
||||
执行上述命令前,若配置来自项目 `.env`,先按第 2 步加载 `.env`;不要把 `.env`
|
||||
复制到 skill 或项目之外的临时位置,也不要把 token 作为命令行参数。
|
||||
|
||||
不要把脚本复制进当前项目,也不要将 token 作为命令行参数。脚本默认请求
|
||||
`/api/v2/upload/package`,以 multipart 字段 `package`、`token`、
|
||||
`repository_name` 上传,接受 `200` 和 `201` 为成功。端点路径不同时可设置
|
||||
`DEB_UPLOAD_PATH`。
|
||||
|
||||
调用前确认目标服务使用上述协议;不兼容时不要强行调用。传入刚刚校验过的确切路径,
|
||||
不要使用宽泛 glob。项目已有 `make upload-deb` 时,检查它是否只是包装了同一协议:
|
||||
如果是,直接使用 skill 脚本;若 CI 或其他人仍依赖 Make 目标,可将目标改为调用已安装
|
||||
skill 的脚本,但不要提交脚本副本。
|
||||
|
||||
脚本支持多个确切文件路径,会汇总每个文件的结果,并在任一失败时返回非零。
|
||||
|
||||
### 6. 验证与汇报
|
||||
|
||||
发布成功不能只依据“curl 已执行”。综合检查:
|
||||
|
||||
- 上传命令退出码为零。
|
||||
- HTTP 状态和响应体明确表示成功。
|
||||
- 若仓库提供只读查询、索引或下载地址,再确认该包和版本已可见。
|
||||
- 若索引更新是异步的,报告“上传已接受,索引尚待更新”,不要声称已完全可用。
|
||||
|
||||
最终回复给出:
|
||||
|
||||
- 包名、版本、架构。
|
||||
- 产物路径和 SHA-256。
|
||||
- 目标服务/仓库的非敏感标识。
|
||||
- 构建、上传及仓库可见性各自的验证结果。
|
||||
- 任何未完成项或回滚/覆盖风险。
|
||||
|
||||
## 修改已有发布逻辑时
|
||||
|
||||
- 保持项目现有变量名和调用入口,避免无关重构。
|
||||
- 修复行为缺陷时增加最小静态检查或可离线运行的测试。
|
||||
- 可用 `bash -n` 检查脚本语法;项目有 ShellCheck 时一并运行。
|
||||
- skill 自带上传脚本是 SSOT;通用上传行为的修改应落在
|
||||
`skills/deb-publisher/scripts/upload_deb.sh`,不要同步复制到业务项目。
|
||||
- 不通过真实生产上传来测试脚本,除非用户明确授权并给出测试版本或测试仓库。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 仅分析:入口、调用链、配置来源和风险已被准确说明。
|
||||
- 仅构建:DEB 已生成,元数据、内容和校验和通过检查,未发生上传。
|
||||
- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。
|
||||
@@ -14,8 +14,7 @@ ORC 是显式调用的薄路由器:只把开发、版本发布、DEB 和 Docke
|
||||
## 使用前准备
|
||||
|
||||
- Orca 正在运行并启用了 orchestration。
|
||||
- 安装本次需要的 `$ack`、`$manage-release`、`$deb-publisher` 或
|
||||
`$publish-docker-image`。
|
||||
- 安装本次需要的 `$ack`、`$manage-release` 或 `$builder`(DEB 与 Docker 共用)。
|
||||
- ORC 直接使用 skill 内共享的 `config.yaml`,不需要在每个项目初始化配置。修改这份
|
||||
配置会影响所有项目;旧的项目级 `docs/orc/config.yaml` 不再参与解析。
|
||||
- `/usr/bin/python3`;ORC v2 配置必须保持为 JSON-compatible YAML。
|
||||
|
||||
+4
-4
@@ -73,8 +73,8 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
|
||||
`cursor-agent`。身份不明确时停止,不从用户任务文本、默认值或已安装 executable 猜测。
|
||||
确认宿主 CLI 可用;在 Cursor 中还要用 `cursor-agent --list-models`
|
||||
核对精确模型 ID 对当前账号可见。
|
||||
4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`deb-publisher`、
|
||||
`publish-docker-image`。只检查实际会用到的项。
|
||||
4. 确认本次所需下游 Skill 已安装:`ack`、`manage-release`、`builder`。只检查实际
|
||||
会用到的项;`deb` 与 `docker` 阶段都由 `$builder` 承载。
|
||||
5. 解析 profile 时把项目根和目标 worktree 一并交给 resolver;只有 resolver 验证目标是
|
||||
当前 Git 仓库已注册的 worktree 且身份稳定后才可创建终端。不得只做文本比较或跳过
|
||||
机器校验。
|
||||
@@ -132,8 +132,8 @@ JSON-compatible YAML,并由隔离的 Python 标准库解析。它固定包含
|
||||
- 功能、缺陷、重构与验证闭环交给 `$ack`。
|
||||
- 发布版本、release 分支/PR/MR、合并、tag 与 Forge Release 交给
|
||||
`$manage-release`。
|
||||
- DEB 构建或上传交给 `$deb-publisher`。
|
||||
- Docker/OCI 镜像构建或上传交给 `$publish-docker-image`。
|
||||
- DEB 构建或上传交给 `$builder`(deb 阶段);Docker/OCI 镜像构建或上传也交给
|
||||
`$builder`(docker 阶段)。
|
||||
- 普通非发布 PR/MR 不伪装成版本发布;只有 ACK 已验证交付或明确的 release 流程才
|
||||
进入对应下游能力。
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ ORC 是薄路由器,只负责意图映射、依赖、静态档位和结构化
|
||||
|------|------------|------|--------|
|
||||
| `code` | `$ack` | 功能、缺陷、重构、测试、三角色验证闭环,以及用户明确要求的普通非发布 PR/MR | 版本发布、单独上传产物 |
|
||||
| `release` | `$manage-release` | 版本号、release worktree/分支、release PR/MR、合并、tag、Forge Release、恢复发布 | 普通非发布 PR/MR、构建或上传 DEB/Docker |
|
||||
| `deb` | `$deb-publisher` | DEB 构建、校验、上传与仓库可见性 | 源码 tag、Docker 镜像 |
|
||||
| `docker` | `$publish-docker-image` | Docker/OCI 构建、push、digest 与平台验证 | 源码版本生命周期、DEB |
|
||||
| `deb` | `$builder` | DEB 构建、契约校验、上传与仓库可见性 | 源码 tag、Docker 镜像 |
|
||||
| `docker` | `$builder` | Docker/OCI 构建、push、digest 与平台验证 | 源码版本生命周期、DEB |
|
||||
|
||||
没有匹配项时不要临时扩写某个 Skill 的职责,也不要让 ORC 自己模仿领域流程。报告缺少
|
||||
的能力,由用户决定直接执行、安装新 Skill 或另行设计。
|
||||
@@ -40,14 +40,14 @@ decision gate。不得为了填满 worker prompt 而分析代码、推断版本
|
||||
```text
|
||||
code ($ack)
|
||||
-> release ($manage-release)
|
||||
-> deb ($deb-publisher)
|
||||
-> docker ($publish-docker-image)
|
||||
-> deb ($builder)
|
||||
-> docker ($builder)
|
||||
```
|
||||
|
||||
只从当前 commit 构建产物:
|
||||
|
||||
```text
|
||||
deb ($deb-publisher) || docker ($publish-docker-image)
|
||||
deb ($builder) || docker ($builder)
|
||||
```
|
||||
|
||||
仅发布源码版本:
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: publish-docker-image
|
||||
description: >-
|
||||
构建当前项目的 Docker 镜像,并将其上传到用户指定的镜像仓库。仅当用户显式指定
|
||||
$publish-docker-image 或明确说“使用 publish-docker-image skill”时使用;
|
||||
不要因普通编码、编辑 Dockerfile、本地构建、测试或一般 Docker 问题而自动触发。
|
||||
---
|
||||
|
||||
# 发布 Docker 镜像
|
||||
|
||||
安全、可复现地构建当前提交对应的 Docker 镜像,并按用户指定的目标上传。
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. 读取项目的 `AGENTS.md`、Dockerfile、构建脚本和相关发布文档。
|
||||
2. 收集目标 registry、repository、tag、platform、构建上下文和 Dockerfile。优先使用用户已明确提供的值;缺少会改变发布结果的值时,停止并询问。
|
||||
3. 检查 Git 工作区与当前提交。若存在未提交修改,明确说明镜像将包含哪些修改。
|
||||
4. 按 [registry.md](references/registry.md) 检查仓库规则和认证状态。
|
||||
5. 在执行外部写操作前,向用户展示完整镜像引用、platform、Dockerfile、构建上下文和源 commit。只有用户已明确要求上传到该目标时才继续。
|
||||
6. 使用 [publish.sh](scripts/publish.sh) 构建并上传。不要自行拼接包含凭据的命令。
|
||||
7. 检查命令退出状态,并尽可能获取远端 digest。
|
||||
8. 汇报完整镜像引用、digest、platform、源 commit,以及是否包含未提交修改。
|
||||
|
||||
## 命令
|
||||
|
||||
默认构建并上传:
|
||||
|
||||
```bash
|
||||
scripts/publish.sh \
|
||||
--registry REGISTRY \
|
||||
--repository NAMESPACE/IMAGE \
|
||||
--tag TAG \
|
||||
--platform PLATFORM
|
||||
```
|
||||
|
||||
先验证而不构建或上传:
|
||||
|
||||
```bash
|
||||
scripts/publish.sh \
|
||||
--registry REGISTRY \
|
||||
--repository NAMESPACE/IMAGE \
|
||||
--tag TAG \
|
||||
--platform PLATFORM \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
仅当用户明确要求本地构建时使用 `--load`。多平台镜像不能使用 `--load`。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 不把密码、访问令牌或 Docker 配置写入 skill、项目文件、命令参数或输出。
|
||||
- 不主动执行 `docker login`;认证缺失时让用户通过交互式登录或其凭据管理器完成。
|
||||
- 不覆盖已存在的 release tag,除非用户明确授权。无法可靠检查远端 tag 时说明这一限制。
|
||||
- 不把 `latest` 作为隐含默认 tag。
|
||||
- 不上传用户未指定的附加 tag。
|
||||
- 不擅自修改 Dockerfile、发布配置、仓库权限或镜像保留策略。
|
||||
- 若仓库、tag、platform 或目标环境存在歧义,在上传前询问用户。
|
||||
|
||||
## 验证
|
||||
|
||||
- 确认 `docker buildx build` 成功且启用了 `--push`。
|
||||
- 优先用 `docker buildx imagetools inspect FULL_IMAGE_REF` 验证远端引用及平台。
|
||||
- 记录远端 digest;若仓库不允许检查,明确报告只验证了 push 命令成功。
|
||||
- 将发布所用的 Git commit 与工作区状态一并报告。
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
sed -n '2,22p' "$0"
|
||||
}
|
||||
|
||||
# Build and publish a Docker image with buildx.
|
||||
#
|
||||
# Usage:
|
||||
# publish.sh --registry HOST --repository PATH --tag TAG --platform PLATFORMS [options]
|
||||
#
|
||||
# Options:
|
||||
# --registry HOST Registry host, without a URL scheme
|
||||
# --repository PATH Repository path, such as team/service
|
||||
# --tag TAG Image tag
|
||||
# --platform LIST Comma-separated platforms
|
||||
# --file PATH Dockerfile path (default: Dockerfile)
|
||||
# --context PATH Build context (default: .)
|
||||
# --builder NAME Existing buildx builder
|
||||
# --load Load a single-platform image instead of pushing
|
||||
# --dry-run Print the resolved build without executing it
|
||||
# --help Show this help
|
||||
|
||||
registry=
|
||||
repository=
|
||||
tag=
|
||||
platform=
|
||||
dockerfile=Dockerfile
|
||||
build_context=.
|
||||
builder=
|
||||
mode=push
|
||||
dry_run=false
|
||||
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--registry) registry=${2-}; shift 2 ;;
|
||||
--repository) repository=${2-}; shift 2 ;;
|
||||
--tag) tag=${2-}; shift 2 ;;
|
||||
--platform) platform=${2-}; shift 2 ;;
|
||||
--file) dockerfile=${2-}; shift 2 ;;
|
||||
--context) build_context=${2-}; shift 2 ;;
|
||||
--builder) builder=${2-}; shift 2 ;;
|
||||
--load) mode=load; shift ;;
|
||||
--dry-run) dry_run=true; shift ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*) printf 'Unknown argument: %s\n' "$1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for required_name in registry repository tag platform; do
|
||||
if [[ -z ${!required_name} ]]; then
|
||||
printf 'Missing required option: --%s\n' "$required_name" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $registry == *://* || $registry == */* ]]; then
|
||||
printf '%s\n' 'Registry must be a host without a URL scheme or path.' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $repository == /* || $repository == */ || $repository != */* ]]; then
|
||||
printf '%s\n' 'Repository must look like namespace/image.' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $tag == *:* || $tag == */* ]]; then
|
||||
printf '%s\n' 'Tag must not contain ":" or "/".' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $mode == load && $platform == *,* ]]; then
|
||||
printf '%s\n' '--load supports only one platform.' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f $dockerfile ]]; then
|
||||
printf 'Dockerfile not found: %s\n' "$dockerfile" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d $build_context ]]; then
|
||||
printf 'Build context not found: %s\n' "$build_context" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ $dry_run == false ]] && ! command -v docker >/dev/null 2>&1; then
|
||||
printf '%s\n' 'docker is not installed or not available in PATH.' >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
image_ref="${registry}/${repository}:${tag}"
|
||||
build_cmd=(docker buildx build --file "$dockerfile" --platform "$platform" --tag "$image_ref")
|
||||
if [[ -n $builder ]]; then
|
||||
build_cmd+=(--builder "$builder")
|
||||
fi
|
||||
if [[ $mode == push ]]; then
|
||||
build_cmd+=(--push)
|
||||
else
|
||||
build_cmd+=(--load)
|
||||
fi
|
||||
build_cmd+=("$build_context")
|
||||
|
||||
printf 'Image: %s\n' "$image_ref"
|
||||
printf 'Platform: %s\n' "$platform"
|
||||
printf 'Dockerfile: %s\n' "$dockerfile"
|
||||
printf 'Context: %s\n' "$build_context"
|
||||
printf 'Mode: %s\n' "$mode"
|
||||
|
||||
if [[ $dry_run == true ]]; then
|
||||
printf 'Command:'
|
||||
printf ' %q' "${build_cmd[@]}"
|
||||
printf '\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"${build_cmd[@]}"
|
||||
|
||||
if [[ $mode == push ]]; then
|
||||
docker buildx imagetools inspect "$image_ref"
|
||||
fi
|
||||
@@ -44,11 +44,11 @@ class OrcSkillTests(unittest.TestCase):
|
||||
self.assertIn("当前 shell", skill)
|
||||
self.assertNotIn("用户未指定档位时采用以下判断", skill)
|
||||
self.assertNotIn("若该档位不足以安全完成", skill)
|
||||
for child in ("$ack", "$manage-release", "$deb-publisher", "$publish-docker-image"):
|
||||
for child in ("$ack", "$manage-release", "$builder"):
|
||||
self.assertIn(child, routing)
|
||||
|
||||
def test_children_do_not_reference_orc(self) -> None:
|
||||
for child in ("ack", "manage-release", "deb-publisher", "publish-docker-image"):
|
||||
for child in ("ack", "manage-release", "builder"):
|
||||
for path in (REPO_ROOT / "skills" / child).rglob("*"):
|
||||
if not path.is_file() or "__pycache__" in path.parts:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user