diff --git a/skiff/source-model.md b/skiff/source-model.md index 511836f..7bfc85e 100644 --- a/skiff/source-model.md +++ b/skiff/source-model.md @@ -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 diff --git a/skills/ack/references/delivery.md b/skills/ack/references/delivery.md index cc3087c..fb85f33 100644 --- a/skills/ack/references/delivery.md +++ b/skills/ack/references/delivery.md @@ -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 不能绕过它的触发与授权边界。 diff --git a/skills/builder/README.md b/skills/builder/README.md new file mode 100644 index 0000000..bd63a10 --- /dev/null +++ b/skills/builder/README.md @@ -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 /scripts/check.py .` 直到全部 PASS。 +3. 在项目根 `.env` 配置发布环境变量: + +```text +DEB_SERVER_URL=https://deb.example.com +DEB_REPOSITORY=main +DEB_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。 diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md new file mode 100644 index 0000000..47b741c --- /dev/null +++ b/skills/builder/SKILL.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 /scripts/check.py # 静态检查 +python3 -I -S /scripts/check.py --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= VERSION= # 主产物 +make deb ARCH= # DEB 项目 +``` + +版本缺省由 make 从 `git describe --tags --always --dirty` 推导。构建目标若会自动 +上传而当前仅获构建授权,改用纯构建目标。执行前确认所需工具可用(docker、 +dpkg-deb 等)。不得擅自清理宽泛目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。 + +### 3. 上传前检查 + +```bash +find $(DIST_DIR) -maxdepth 2 -type f -name '*.deb' -print +/scripts/verify_deb.sh [期望版本] [期望架构] +``` + +verify_deb.sh 输出元数据、关键内容清单和 SHA-256。匹配到多个包时不凭文件时间猜测, +向用户确认唯一产物。镜像轨道无需单独校验步骤(publish_docker.sh 自带远端 inspect)。 + +### 4. 发布 + +优先 `make push[-deb|-docker]`(契约要求的薄包装);直接调用等价: + +```bash +DEB_SERVER_URL=… DEB_TOKEN=… DEB_REPOSITORY=… \ + /scripts/upload_deb.sh + +DOCKER_REGISTRY=… \ + /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,未发生上传。 +- 发布:构建检查通过,服务端接受上传,仓库可见性已验证或准确标记为待更新。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md new file mode 100644 index 0000000..3780887 --- /dev/null +++ b/skills/builder/references/contract.md @@ -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)/__.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 /check.py [--build]` 对本项目逐条检查 +上述要求,任一 FAIL 退出码非零,可直接挂 CI。`--build` 额外实构 `make deb` 并核对 +产物元数据(默认只静态检查配方)。校验失败时的修复路径:用 create-makefile skill +补齐或修正 Makefile,不要绕过校验器。 + +## 6. 存量项目(legacy fallback) + +未接入契约的项目:builder 仍可按发现流程工作——从 `Makefile`、CI 配置、`debian/` +与发布文档中找已有构建/上传入口,优先复用;上传仍使用 builder 脚本。完成一次成功 +交付后应引导用户用 create-makefile 把项目迁移到本契约,之后以 check.py 为准。 diff --git a/skills/publish-docker-image/references/registry.md b/skills/builder/references/registry.md similarity index 70% rename from skills/publish-docker-image/references/registry.md rename to skills/builder/references/registry.md index f82668c..2bdfc8c 100644 --- a/skills/publish-docker-image/references/registry.md +++ b/skills/builder/references/registry.md @@ -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 或已有登录状态。可用不泄露凭据的只读操作 +检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。 不要读取、打印或复制以下内容: diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py new file mode 100755 index 0000000..3613abb --- /dev/null +++ b/skills/builder/scripts/check.py @@ -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 [--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 ` 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 __.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()) diff --git a/skills/builder/scripts/publish_docker.sh b/skills/builder/scripts/publish_docker.sh new file mode 100755 index 0000000..5064fac --- /dev/null +++ b/skills/builder/scripts/publish_docker.sh @@ -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 diff --git a/skills/deb-publisher/scripts/upload_deb.sh b/skills/builder/scripts/upload_deb.sh similarity index 66% rename from skills/deb-publisher/scripts/upload_deb.sh rename to skills/builder/scripts/upload_deb.sh index 4282901..aadba0a 100755 --- a/skills/deb-publisher/scripts/upload_deb.sh +++ b/skills/builder/scripts/upload_deb.sh @@ -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 - diff --git a/skills/builder/scripts/verify_deb.sh b/skills/builder/scripts/verify_deb.sh new file mode 100755 index 0000000..2f9b014 --- /dev/null +++ b/skills/builder/scripts/verify_deb.sh @@ -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: __.deb +base=$(basename -- "$package") +if [[ ! "$base" =~ ^[^_]+_[^_]+_[^_]+\.deb$ ]]; then + echo "FAIL: filename does not match __.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" diff --git a/skills/deb-publisher/README.md b/skills/deb-publisher/README.md deleted file mode 100644 index dc94cfe..0000000 --- a/skills/deb-publisher/README.md +++ /dev/null @@ -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,不要直接套用该脚本。 diff --git a/skills/deb-publisher/SKILL.md b/skills/deb-publisher/SKILL.md deleted file mode 100644 index dd8d0a1..0000000 --- a/skills/deb-publisher/SKILL.md +++ /dev/null @@ -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 -maxdepth 2 -type f -name '*.deb' -print -dpkg-deb --info -dpkg-deb --contents -``` - -至少验证: - -- 文件存在、非空且 `dpkg-deb --info` 成功。 -- `Package`、`Version`、`Architecture` 与本次发布一致。 -- 包内容包含预期的主程序或关键文件。 -- maintainer scripts 存在时权限正确,且没有明显的宿主机破坏性操作。 - -建议记录 SHA-256: - -```bash -sha256sum -``` - -### 5. 发布 - -解析当前 `SKILL.md` 所在目录,以绝对路径调用随 skill 分发的脚本: - -```bash -DEB_SERVER_URL="$DEB_SERVER_URL" \ -DEB_TOKEN="$DEB_TOKEN" \ -DEB_REPOSITORY="$DEB_REPOSITORY" \ - /scripts/upload_deb.sh -``` - -执行上述命令前,若配置来自项目 `.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 已生成,元数据、内容和校验和通过检查,未发生上传。 -- 发布:构建检查通过,服务端接受上传,且仓库可见性已验证或被准确标记为待更新。 diff --git a/skills/orc/README.md b/skills/orc/README.md index 4128e0f..416598f 100644 --- a/skills/orc/README.md +++ b/skills/orc/README.md @@ -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。 diff --git a/skills/orc/SKILL.md b/skills/orc/SKILL.md index 0e261e7..89e86af 100644 --- a/skills/orc/SKILL.md +++ b/skills/orc/SKILL.md @@ -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 流程才 进入对应下游能力。 diff --git a/skills/orc/references/routing.md b/skills/orc/references/routing.md index e1b281a..2dd527c 100644 --- a/skills/orc/references/routing.md +++ b/skills/orc/references/routing.md @@ -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) ``` 仅发布源码版本: diff --git a/skills/publish-docker-image/SKILL.md b/skills/publish-docker-image/SKILL.md deleted file mode 100644 index e64bfbf..0000000 --- a/skills/publish-docker-image/SKILL.md +++ /dev/null @@ -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 与工作区状态一并报告。 diff --git a/skills/publish-docker-image/scripts/publish.sh b/skills/publish-docker-image/scripts/publish.sh deleted file mode 100755 index 6d1eb7f..0000000 --- a/skills/publish-docker-image/scripts/publish.sh +++ /dev/null @@ -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 diff --git a/tests/test_orc_skill.py b/tests/test_orc_skill.py index 8a34c52..868cb10 100644 --- a/tests/test_orc_skill.py +++ b/tests/test_orc_skill.py @@ -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