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
This commit is contained in:
ace
2026-08-24 12:52:53 +08:00
parent e7a139e2cb
commit 47bd454fa3
18 changed files with 976 additions and 456 deletions
+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 为准。
+40
View File
@@ -0,0 +1,40 @@
# 镜像仓库规则
执行发布前,从用户输入和当前项目配置中确定以下信息:
| 字段 | 要求 |
| --- | --- |
| Registry | 必须显式确定,例如 `registry.example.com` |
| Repository | 必须包含项目约定的 namespace;缺省取 git 仓库名 |
| Tag | 必须显式确定;优先使用版本号或 Git SHA |
| Platform | 必须显式确定,例如 `linux/amd64``linux/amd64,linux/arm64` |
| Dockerfile | 默认 `Dockerfile`,不存在或项目另有约定时明确指定 |
| Context | 默认当前项目根目录 |
信息来源优先级:
1. 用户本次请求中明确给出的值。
2. 当前项目的 `.env``AGENTS.md`、发布文档。
3. Makefile、CI 配置或现有构建脚本中一致且无歧义的配置。
4. 询问用户。
不要从其他项目、shell history 或无关的本地配置中猜测发布目标。
## 认证
使用 Docker 当前配置的 credential helper 或已有登录状态。可用不泄露凭据的只读操作
检查目标是否可访问。认证缺失或过期时,停止并让用户自行完成登录。
不要读取、打印或复制以下内容:
- registry 密码或访问令牌
- `~/.docker/config.json` 中的认证字段
- CI secret 的值
- 包含凭据的环境变量值
## Tag 策略
- release tag(如 `v1.2.3`)默认视为不可变。
- Git SHA tag 应对应当前源 commit。
- `latest``stable` 等浮动 tag 只有在用户明确要求时才发布。
- 用户未给 tag 且项目没有唯一明确规则时,必须询问,不要自行选择。
+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
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
DEB_SERVER_URL=https://deb.example.com \
DEB_TOKEN=secret \
DEB_REPOSITORY=main \
upload_deb.sh FILE.deb [FILE.deb ...]
Options:
-s SERVER_URL Override DEB_SERVER_URL
-n REPOSITORY Override DEB_REPOSITORY
-p UPLOAD_PATH Override DEB_UPLOAD_PATH (default: /api/v2/upload/package)
-h Show help
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}
token=${DEB_TOKEN:-}
while getopts ":s:n:p:h" option; do
case "$option" in
s) server_url=$OPTARG ;;
n) repository=$OPTARG ;;
p) upload_path=$OPTARG ;;
h) usage; exit 0 ;;
:) echo "Error: -$OPTARG requires a value" >&2; usage >&2; exit 2 ;;
\?) echo "Error: unknown option -$OPTARG" >&2; usage >&2; exit 2 ;;
esac
done
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
fi
if ! command -v curl >/dev/null 2>&1; then
echo "Error: curl is required." >&2
exit 2
fi
server_url=${server_url%/}
success_count=0
fail_count=0
response_file=
cleanup() {
if [[ -n "$response_file" && -f "$response_file" ]]; then
rm -f -- "$response_file"
fi
}
trap cleanup EXIT
print_response() {
local file=$1
if command -v jq >/dev/null 2>&1 && jq -e . "$file" >/dev/null 2>&1; then
jq . "$file"
else
cat -- "$file"
fi
}
for package_file in "$@"; do
if [[ ! -f "$package_file" ]]; then
echo "Skip: file not found: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
if [[ "$package_file" != *.deb ]]; then
echo "Skip: not a .deb file: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
if [[ ! -s "$package_file" ]]; then
echo "Skip: empty file: $package_file" >&2
fail_count=$((fail_count + 1))
continue
fi
response_file=$(mktemp)
echo "Uploading $(basename -- "$package_file") to $server_url (repository $repository)..."
http_code=000
if http_code=$(curl --silent --show-error \
--output "$response_file" \
--write-out "%{http_code}" \
--request POST \
"$server_url$upload_path" \
--form "package=@${package_file};type=application/vnd.debian.binary-package" \
--form "token=${token}" \
--form "repository_name=${repository}"); then
:
else
echo "Failed (transport error): $(basename -- "$package_file")" >&2
print_response "$response_file" >&2
fail_count=$((fail_count + 1))
cleanup
response_file=
continue
fi
if [[ "$http_code" == 200 || "$http_code" == 201 ]]; then
echo "Success ($http_code): $(basename -- "$package_file")"
print_response "$response_file"
success_count=$((success_count + 1))
else
echo "Failed ($http_code): $(basename -- "$package_file")" >&2
print_response "$response_file" >&2
fail_count=$((fail_count + 1))
fi
cleanup
response_file=
done
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"