1 Commits

Author SHA1 Message Date
ace 47bd454fa3 feat(builder): merge deb-publisher + publish-docker-image into contract-driven builder skill
- skills/builder: SKILL.md, README.md, references/contract.md (make/publish
  contract v1), references/registry.md
- scripts/check.py: executable contract checker (make dry-run probes, secret
  scan, push thin-wrapper and script path checks; --build verifies real .deb)
- scripts/upload_deb.sh: migrated from deb-publisher, adds project .env
  auto-load and dirty-worktree publish gate
- scripts/publish_docker.sh: migrated from publish-docker-image publish.sh,
  now env-first (DOCKER_REGISTRY/REPOSITORY/IMAGE_TAG/PLATFORMS), refuses
  floating latest and multi-platform --load
- scripts/verify_deb.sh: metadata/content/sha256 verification with v-prefix
  normalization
- orc: deb+docker stages both route to $builder; routing table, DAGs,
  README, config untouched stage names; tests updated
- ack delivery.md + skiff source-model.md: reference builder
- remove skills/deb-publisher and skills/publish-docker-image
2026-08-24 12:52:53 +08:00
18 changed files with 976 additions and 456 deletions
+2 -2
View File
@@ -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
+4 -4
View File
@@ -111,9 +111,9 @@ intents:
- `build`:调用 artifact 的 build entrypoint。DEB 必须记录包名、版本、架构和
SHA-256OCI 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 不能绕过它的触发与授权边界。
+51
View File
@@ -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。
+133
View File
@@ -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)时才走镜像发布。
---
# BuilderDEB / 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 与工作区状态、各阶段验证结果、未完成项或覆盖风险。
## 存量项目 fallbacklegacy
从项目根目录查找,不预设文件位置:
```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,未发生上传。
- 发布:构建检查通过,服务端接受上传,仓库可见性已验证或准确标记为待更新。
+100
View File
@@ -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 为准。
@@ -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 或已有登录状态。可用不泄露凭据的只读操作
检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
不要读取、打印或复制以下内容:
+357
View File
@@ -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())
+164
View File
@@ -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
+111
View File
@@ -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"
-80
View File
@@ -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,不要直接套用该脚本。
-167
View File
@@ -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 已生成,元数据、内容和校验和通过检查,未发生上传。
- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。
+1 -2
View File
@@ -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
View File
@@ -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 流程才
进入对应下游能力。
+5 -5
View File
@@ -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)
```
仅发布源码版本:
-64
View File
@@ -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
+2 -2
View File
@@ -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