feat: update

This commit is contained in:
2026-08-25 15:26:29 +08:00
parent 03d6298145
commit 4423e7df1a
9 changed files with 661 additions and 21 deletions
+54 -1
View File
@@ -17,6 +17,7 @@
1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version`
+ 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。
`VERSION` 通过 `include <builder>/scripts/version.mk` 从 Git 推导。
2. 运行 `python3 -I -S <builder>/scripts/check.py .` 直到全部 PASS。
3. 在项目根 `.env` 配置发布环境变量:
@@ -29,12 +30,62 @@ DOCKER_REGISTRY=registry.example.com
4. 日常发布就是两条命令:`make deb && make push-deb``make push-docker`
## 版本号
产物版本从 **当前 HEAD 的 Git 祖先** 推导,不调用 manage-release。
正式 tag 仍由 manage-release 创建;builder 只读取。完整规则见
[contract.md「版本号」](references/contract.md#版本号)。
| 谁 | 做什么 |
| --- | --- |
| manage-release | 选定下一个正式 SemVer,打 annotated tag `vX.Y.Z` |
| builder | 读 HEAD:落在稳定 tag 上则打正式产物,否则打测试产物 |
| `make version` | 输出一行规范版本(无 `v` 前缀),DEB / Docker 都从它渲染 |
**不要**用 `git tag \| sort -V \| tail -1` 取全仓库最大号,也不要把
`git describe --dirty` 的原始字符串写进 DEB 或镜像 tag。
### 正式(HEAD 恰好是稳定 tag `v1.4.2`
```text
Git tag v1.4.2
规范版本 1.4.2
DEB foo_1.4.2_amd64.deb Version: 1.4.2
Docker registry/ns/foo:1.4.2
```
稳定 tag 仅匹配 `v<major>.<minor>.<patch>`,不含 `-rc``-app-N` 等后缀。
不是 exact-match 就不是正式包。
### 测试(其它任何 commit
基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的
分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库,
所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。
分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commitSHA `abc1234`
```text
规范 / DEB 1.4.2~feat-login-v2.7+gabc1234
文件名 foo_1.4.2~feat-login-v2.7+gabc1234_amd64.deb
Docker registry/ns/foo:1.4.2-feat-login-v2.7.gabc1234
```
Docker tag 由规范版本映射:`~``-``+g``.g`Docker 不允许 `~`)。
安装测试包必须显式指定版本或完整 tag,不能靠无参 `apt upgrade`
### 分支名清洗
小写;`/``_` 改为 `-`;去掉其它非法字符;压缩连续 `-`;过长截断。
detached HEAD 用 `detached`CI 可注入 `BUILD_BRANCH` / `CI_COMMIT_BRANCH` /
`GITHUB_REF_NAME`。脏工作树不把 `-dirty` 写进版本(发布本身会拒绝)。
## 使用示例
```text
用 builder 检查这个项目的 Makefile 是否符合契约。
用 builder 构建当前版本的 DEB 和镜像,先不要上传。
用 builder 把 dist/example_1.2.3_amd64.deb 发布到项目已配置的测试仓库。
用 builder 把当前 commit 的产物发布到项目已配置的仓库。
用 builder 发布多平台 linux/amd64,linux/arm64 镜像。
```
@@ -43,6 +94,8 @@ DOCKER_REGISTRY=registry.example.com
| 脚本 | 用途 |
|------|------|
| `scripts/check.py` | 校验项目 Makefile 是否符合契约(`--build` 实构核对产物) |
| `scripts/version.sh` | 从 Git 祖先稳定 tag 推导规范版本 / Docker tag |
| `scripts/version.mk` | 项目 Makefile `include`,设置 `VERSION``IMAGE_TAG` |
| `scripts/upload_deb.sh` | 上传 `.deb` 到 HTTP 包仓库(multipart package/token/repository_name |
| `scripts/publish_docker.sh` | buildx 构建 + 推送镜像,远端 digest 验证 |
| `scripts/verify_deb.sh` | 核对包元数据、内容与 SHA-256 |
+8 -5
View File
@@ -48,7 +48,7 @@ fallback;成功交付一次后引导用户迁移到契约。
- 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复,
不用 `set -x` 执行含凭据的命令。
- 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。
- Docker 轨道需要用户已明确指定目标 registry/repository/tag 后才继续
- Docker 轨道需要已确定 registry/repositorytag 未给出时按契约「版本号」推导
脏工作树默认拒绝发布;用户明确接受时设置 `ALLOW_UNCOMMITTED=1` 并在汇报中注明
包含的未提交修改。
@@ -60,9 +60,11 @@ make build ARCH=<amd64|arm64> VERSION=<version> # 主产物
make deb ARCH=<amd64|arm64> # DEB 项目
```
版本缺省由 make 从 `git describe --tags --always --dirty` 推导。构建目标若会自动
上传而当前仅获构建授权,改用纯构建目标。执行前确认所需工具可用(docker、
dpkg-deb 等)。不得擅自清理宽泛目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。
版本缺省按契约「版本号」从 Git 祖先稳定 tag 推导(正式 = HEAD exact-match
`vX.Y.Z`;测试 = `X.Y.Z~分支.距离+gSHA`)。不要调用 manage-release 来算
产物版本,不要用全仓库最新 tag。构建目标若会自动上传而当前仅获构建授权,改用
纯构建目标。执行前确认所需工具可用(docker、dpkg-deb 等)。不得擅自清理宽泛
目录;脚本含 `rm -rf` 时先解析确认为受限构建目录。
### 3. 上传前检查
@@ -120,7 +122,8 @@ rg -n -i --hidden --glob '!.git' \
## 修改 builder 自身时
- 上传/发布脚本是 SSOT:通用行为修改落在 `skills/builder/scripts/`,不同步复制到
业务项目。
业务项目。产物版本只通过 `scripts/version.sh` 推导,不要在项目 Makefile 内联
`git describe``sort -V`
- 契约变更先改 `scripts/check.py`,再同步 `references/contract.md`
- 可用 `bash -n` 检查脚本语法;有 ShellCheck 时一并运行。
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
+40 -3
View File
@@ -43,10 +43,47 @@ Agent 只保留授权判断和歧义处理。**
| 变量 | 默认 | 说明 |
|------|------|------|
| `ARCH` | `amd64` | 仅允许 `amd64` \| `arm64`,非法值必须 `$(error)` 报错并提示合法值 |
| `VERSION` | `` (空) | 为空时由 make 从 `git describe --tags --always --dirty` 推导 |
| `VERSION` | `` (空) | 为空时按「版本号」节从 Git 祖先稳定 tag 推导;禁止 `sort -V` 取全局最新 |
| `DIST_DIR` | `dist` | DEB 产物目录 |
| `PROJECT_NAME` | git 仓库名 | 包名/镜像名主体 |
### 版本号
`make version` 输出一行规范版本(无 `v` 前缀)。DEB 的 `Version` 与文件名
直接用它;Docker tag 由它渲染。推导入口是 `scripts/version.sh``make` 通过
`scripts/version.mk` 引用);Builder 只读取 Git 状态,不调用
manage-release,不猜测下一个正式 SemVer。正式 tag 由 manage-release 事先打好。
`version.sh` 不执行 `git fetch`。
推导前 `git fetch --tags`(本地 linked worktree 共享 tags,不必再 fetch 才
能看见其它 worktree 新打的 tag)。基线是 **HEAD 祖先上最近的稳定 tag**
不是全仓库 `sort -V` 的最大号。稳定 tag 仅 `v<major>.<minor>.<patch>`。
| 判定 | 规范版本 | DEB 文件 | Docker tag |
| --- | --- | --- | --- |
| HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_<arch>.deb` | `1.4.2` |
| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~<branch>.<n>+g<sha>` | `name_1.4.2~<branch>.<n>+g<sha>_<arch>.deb` | `1.4.2-<branch>.<n>.g<sha>` |
| 祖先中没有稳定 tag | `0.0.0~<branch>+g<sha>` | 同上替换规范版本 | 同上映射 |
测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使
`1.4.2~…` < `1.4.2``apt upgrade` 不会装上测试包。Docker tag 不得含
`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。
`<branch>``git rev-parse --abbrev-ref HEAD`detached 时用
`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则
`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续
`-`;过长截断(给 base、距离、SHA 留位置;Docker tag 上限 128)。
`<n>` 为基线 tag 到 HEAD 的 commit 数;`<sha>` 为 7 位短哈希。不要把
`--dirty` 写入版本;脏树发布仍走既有门禁。显式 `VERSION=` / `IMAGE_TAG=`
可覆盖推导,但不得把非 exact-match 的 commit 标成正式 `X.Y.Z`。
项目 Makefile 不要内联 `git describe` 或 `sort -V`include 本 skill 的
`scripts/version.mk`
```makefile
include $(HOME)/.skills/skills/builder/scripts/version.mk
```
## 3. 发布环境变量
### DEB 轨道
@@ -64,7 +101,7 @@ Agent 只保留授权判断和歧义处理。**
|------|------|------|
| `DOCKER_REGISTRY` | 是 | registry 主机,无 scheme |
| `DOCKER_REPOSITORY` | 否 | 默认取 git 仓库名 |
| `IMAGE_TAG` | 否 | 默认 `git describe --tags --always --dirty` |
| `IMAGE_TAG` | 否 | 默认由规范版本渲染:正式为 `X.Y.Z`;测试将 `~` 换成 `-`、`+g` 换成 `.g` |
| `PLATFORMS` | 否 | 默认 `linux/amd64`;多平台如 `linux/amd64,linux/arm64` |
配置来源优先级:shell 已显式设置的值 > 项目根 `.env` > 失败并询问用户。
@@ -78,7 +115,7 @@ Agent 只保留授权判断和歧义处理。**
## 4. 脚本解析顺序
push 目标定位 builder 脚本时按以下顺序,命中即用,不做静默兜底:
push 目标与 `version.sh` 定位 builder 脚本时按以下顺序,命中即用,不做静默兜底:
1. `$BUILDER_SKILL_DIR/scripts/`(特殊安装位置)
2. `$HOME/.skills/skills/builder/scripts/`(标准 clone 位)
+6 -4
View File
@@ -34,7 +34,9 @@
## Tag 策略
- release tag(如 `v1.2.3`)默认视为不可变。
- Git SHA tag 应对应当前源 commit。
- `latest``stable` 等浮动 tag 只有在用户明确要求时才发布
- 用户未给 tag 且项目没有唯一明确规则时,必须询问,不要自行选择
- 未显式给出 `--tag` / `IMAGE_TAG` 时,使用契约「版本号」渲染出的 tag
(正式 `1.4.2`,测试 `1.4.2-feat-login.7.gabc1234`),不要退回
`git describe`,不要询问后另选一套
- 正式产物 tag 与 Git tag `vX.Y.Z` 对应,但镜像 tag **不含** `v`;默认视为不可变
- `latest``stable`、裸分支名等浮动 tag 只有在用户明确要求时才额外发布,
不能代替上面那条唯一身份。
+8 -4
View File
@@ -120,11 +120,15 @@ def check_version_output(report: Report, project: Path) -> None:
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 != ""
no_v = single and not out.startswith("v")
detail = f"stdout={out!r}"
if single and not no_v:
detail += "\ncanonical version must not start with 'v'"
report.add(
PASS if single else FAIL,
PASS if no_v else FAIL,
3,
"version 输出一行非空版本号",
f"stdout={out!r}",
"version 输出一行非空规范版本(无 v 前缀)",
detail,
)
@@ -321,7 +325,7 @@ def main(argv: list[str] | None = None) -> int:
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, 3, "version 输出一行非空规范版本(无 v 前缀)", "(version target missing)")
report.add(SKIP, 4, "build 不含上传动作", "(build target missing)")
if deb_project:
+22 -4
View File
@@ -16,7 +16,7 @@ usage() {
# 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)
# IMAGE_TAG Optional, default: version.sh --docker (or --tag)
# PLATFORMS Optional, default: linux/amd64 (or --platform)
# DOCKER_DOCKERFILE Optional, default: Dockerfile (--file)
# DOCKER_CONTEXT Optional, default: . (--context)
@@ -89,19 +89,37 @@ if [[ -z "$repository" || "$repository" == /* || "$repository" == */ || "$reposi
echo "Error: repository must be namespace/name without leading or trailing slash: $repository" >&2
exit 2
fi
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
version_sh=$script_dir/version.sh
if [[ -z "$tag" ]]; then
if [[ -n "$project_root" ]]; then
tag=$(git -C "$project_root" describe --tags --always --dirty 2>/dev/null) || tag=
if [[ ! -x "$version_sh" ]]; then
echo "Error: version.sh not found next to publish_docker.sh: $version_sh" >&2
exit 2
fi
if [[ -z "$tag" ]]; then
if [[ -z "$project_root" ]]; then
echo "Error: IMAGE_TAG (or --tag) is required outside a git repository." >&2
exit 2
fi
tag=$("$version_sh" -C "$project_root" --docker) || {
echo "Error: failed to derive IMAGE_TAG from Git ancestry." >&2
exit 2
}
fi
if [[ "$tag" == *:* || "$tag" == */* ]]; then
echo "Error: tag must not contain : or /: $tag" >&2
exit 2
fi
# Official-shaped tags (X.Y.Z or vX.Y.Z) are only legal on that exact Git tag.
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ && -x "$version_sh" && -n "$project_root" ]]; then
derived=$("$version_sh" -C "$project_root" --docker) || true
expected=${tag#v}
if [[ "$derived" != "$expected" ]]; then
echo "Error: IMAGE_TAG $tag looks official but HEAD is $derived" >&2
echo "Official X.Y.Z is allowed only when HEAD exact-matches vX.Y.Z." >&2
exit 2
fi
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
+39
View File
@@ -0,0 +1,39 @@
# Include from a project Makefile. Sets VERSION (canonical, no leading v)
# and IMAGE_TAG (Docker rendering) via builder version.sh.
#
# Command-line / environment VERSION= is passed as --version (official X.Y.Z
# only when HEAD exact-matches that tag). Unset or empty VERSION is derived.
#
# include $(HOME)/.skills/skills/builder/scripts/version.mk
_builder_scripts_dir := $(dir $(lastword $(MAKEFILE_LIST)))
ifeq ($(BUILDER_VERSION_SH),)
BUILDER_VERSION_SH := $(wildcard $(_builder_scripts_dir)version.sh)
endif
ifeq ($(BUILDER_VERSION_SH),)
ifneq ($(BUILDER_SKILL_DIR),)
BUILDER_VERSION_SH := $(wildcard $(BUILDER_SKILL_DIR)/scripts/version.sh)
endif
endif
ifeq ($(BUILDER_VERSION_SH),)
BUILDER_VERSION_SH := $(wildcard $(HOME)/.skills/skills/builder/scripts/version.sh)
endif
ifeq ($(BUILDER_VERSION_SH),)
$(error version.sh not found; set BUILDER_SKILL_DIR or clone skills to ~/.skills)
endif
ifeq ($(filter command line environment,$(origin VERSION)),)
VERSION := $(shell "$(BUILDER_VERSION_SH)")
else ifeq ($(strip $(VERSION)),)
VERSION := $(shell "$(BUILDER_VERSION_SH)")
else
VERSION := $(shell "$(BUILDER_VERSION_SH)" --version "$(VERSION)")
endif
ifeq ($(strip $(VERSION)),)
$(error version.sh produced an empty version)
endif
ifeq ($(origin IMAGE_TAG),undefined)
IMAGE_TAG := $(shell "$(BUILDER_VERSION_SH)" --docker --version "$(VERSION)")
endif
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
version.sh [-C GIT_DIR] [--docker] [--version VER]
Print one line: the canonical artifact version (Debian Version, no leading v).
With --docker, print the Docker tag rendering of that version.
Derivation (builder contract «版本号»):
official HEAD exact-match of vX.Y.Z → X.Y.Z
test nearest ancestor stable tag → X.Y.Z~branch.n+gSHA
no tag no stable tag reachable from HEAD → 0.0.0~branch+gSHA
Stable tags match v<major>.<minor>.<patch> only. Baseline is ancestry, not
the highest version in the repository. This script does not fetch tags.
--version VER overrides derivation. Official-shaped VER (X.Y.Z, optional
leading v) is accepted only when HEAD exact-matches that tag. Test-shaped
VER is used as-is.
Branch name: git symbolic-ref, or BUILD_BRANCH / CI_COMMIT_BRANCH /
GITHUB_REF_NAME when detached, else "detached". Sanitized to [a-z0-9-],
max 32 characters.
EOF
}
root=.
mode=canonical
override=
while (($#)); do
case "$1" in
-C) root=$2; shift 2 ;;
--docker) mode=docker; shift ;;
--version) override=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Error: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
if ! git -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Error: not a git repository: $root" >&2
exit 2
fi
root=$(git -C "$root" rev-parse --show-toplevel)
gitc() {
git -C "$root" "$@"
}
is_stable_tag() {
[[ "$1" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
is_official_version() {
[[ "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}
# Strip one leading v if present.
strip_v() {
local v=$1
if [[ "$v" == v* ]]; then
v=${v#v}
fi
printf '%s' "$v"
}
sanitize_branch() {
local b=$1
b=$(printf '%s' "$b" | tr '[:upper:]' '[:lower:]')
b=${b//\//-}
b=${b//_/-}
b=$(printf '%s' "$b" | tr -cd 'a-z0-9-')
b=$(printf '%s' "$b" | tr -s '-')
b=${b#-}
b=${b%-}
if ((${#b} > 32)); then
b=${b:0:32}
b=${b%-}
fi
if [[ -z "$b" ]]; then
b=unknown
fi
printf '%s' "$b"
}
branch_slug() {
local b
b=$(gitc rev-parse --abbrev-ref HEAD)
if [[ "$b" == HEAD ]]; then
b=${BUILD_BRANCH:-${CI_COMMIT_BRANCH:-${GITHUB_REF_NAME:-detached}}}
fi
sanitize_branch "$b"
}
short_sha() {
gitc rev-parse HEAD | cut -c1-7
}
# Highest stable tag pointing at HEAD, or empty.
official_tag_at_head() {
local tag best=
while IFS= read -r tag; do
[[ -n "$tag" ]] || continue
is_stable_tag "$tag" || continue
if [[ -z "$best" ]]; then
best=$tag
elif printf '%s\n%s\n' "$best" "$tag" | sort -V | tail -n 1 | grep -qx "$tag"; then
best=$tag
fi
done < <(gitc tag --points-at HEAD)
printf '%s' "$best"
}
# Stable ancestor tag with the fewest commits to HEAD (not sort -V globally).
nearest_stable_tag() {
local tag dist best_dist="" best_tag=""
while IFS= read -r tag; do
[[ -n "$tag" ]] || continue
is_stable_tag "$tag" || continue
dist=$(gitc rev-list --count "${tag}..HEAD")
if [[ -z "$best_dist" ]] || ((dist < best_dist)); then
best_dist=$dist
best_tag=$tag
elif ((dist == best_dist)); then
if printf '%s\n%s\n' "$best_tag" "$tag" | sort -V | tail -n 1 | grep -qx "$tag"; then
best_tag=$tag
fi
fi
done < <(gitc tag --merged HEAD)
printf '%s' "$best_tag"
}
derive_canonical() {
local tag base n sha branch
tag=$(official_tag_at_head)
if [[ -n "$tag" ]]; then
printf '%s' "${tag#v}"
return
fi
sha=$(short_sha)
branch=$(branch_slug)
base=$(nearest_stable_tag)
if [[ -z "$base" ]]; then
printf '0.0.0~%s+g%s' "$branch" "$sha"
return
fi
n=$(gitc rev-list --count "${base}..HEAD")
printf '%s~%s.%s+g%s' "${base#v}" "$branch" "$n" "$sha"
}
to_docker() {
local v=$1
v=${v//\~/-}
v=${v//+g/.g}
printf '%s' "$v"
}
canonical=
if [[ -n "$override" ]]; then
canonical=$(strip_v "$override")
if [[ "$canonical" == *'_'* ]]; then
echo "Error: version must not contain '_': $canonical" >&2
exit 2
fi
if is_official_version "$canonical"; then
derived=$(derive_canonical)
if [[ "$derived" != "$canonical" ]]; then
echo "Error: --version $canonical looks official but HEAD is $derived" >&2
echo "Official X.Y.Z is allowed only when HEAD exact-matches vX.Y.Z." >&2
exit 2
fi
fi
else
canonical=$(derive_canonical)
fi
if [[ -z "$canonical" ]]; then
echo "Error: empty version" >&2
exit 1
fi
if [[ "$mode" == docker ]]; then
tag=$(to_docker "$canonical")
if [[ "$tag" == *:* || "$tag" == */* || "$tag" == *'~'* ]]; then
echo "Error: docker tag still contains illegal characters: $tag" >&2
exit 1
fi
printf '%s\n' "$tag"
else
printf '%s\n' "$canonical"
fi