From 4423e7df1a6fd7576c59956e4f3a452c6c4ef9c1 Mon Sep 17 00:00:00 2001 From: laily Date: Tue, 25 Aug 2026 15:26:29 +0800 Subject: [PATCH] feat: update --- skills/builder/README.md | 55 ++++- skills/builder/SKILL.md | 13 +- skills/builder/references/contract.md | 43 +++- skills/builder/references/registry.md | 10 +- skills/builder/scripts/check.py | 12 +- skills/builder/scripts/publish_docker.sh | 26 +- skills/builder/scripts/version.mk | 39 +++ skills/builder/scripts/version.sh | 195 +++++++++++++++ tests/test_builder_version.py | 289 +++++++++++++++++++++++ 9 files changed, 661 insertions(+), 21 deletions(-) create mode 100644 skills/builder/scripts/version.mk create mode 100755 skills/builder/scripts/version.sh create mode 100644 tests/test_builder_version.py diff --git a/skills/builder/README.md b/skills/builder/README.md index bd63a10..fe30a4f 100644 --- a/skills/builder/README.md +++ b/skills/builder/README.md @@ -17,6 +17,7 @@ 1. 用 create-makefile skill 生成或修正 Makefile(目标 `help/build/clean/version` + 条件 `deb/docker/push*`,变量 `ARCH/VERSION/DIST_DIR/PROJECT_NAME`)。 + `VERSION` 通过 `include /scripts/version.mk` 从 Git 推导。 2. 运行 `python3 -I -S /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..`,不含 `-rc`、`-app-N` 等后缀。 +不是 exact-match 就不是正式包。 + +### 测试(其它任何 commit) + +基线 = 祖先上最近的那颗稳定 tag(没有则为 `0.0.0`),再加上清洗后的 +分支名、相对距离、短 SHA。测试与正式进**同一个** apt / Docker 仓库, +所以 DEB 必须用 `~`,保证测试包不会 `apt upgrade` 盖住正式包。 + +分支 `feat/login-v2`,相对 `v1.4.2` 第 7 个 commit,SHA `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 | diff --git a/skills/builder/SKILL.md b/skills/builder/SKILL.md index 47b741c..ab97a5a 100644 --- a/skills/builder/SKILL.md +++ b/skills/builder/SKILL.md @@ -48,7 +48,7 @@ fallback;成功交付一次后引导用户迁移到契约。 - 认证令牌已通过环境变量或密钥系统提供;绝不写入命令输出、文件、提交或回复, 不用 `set -x` 执行含凭据的命令。 - 相同版本是否允许覆盖;无法确认且可能覆盖时,先询问。 -- Docker 轨道需要用户已明确指定目标 registry/repository/tag 后才继续。 +- Docker 轨道需要已确定 registry/repository;tag 未给出时按契约「版本号」推导。 脏工作树默认拒绝发布;用户明确接受时设置 `ALLOW_UNCOMMITTED=1` 并在汇报中注明 包含的未提交修改。 @@ -60,9 +60,11 @@ make build ARCH= VERSION= # 主产物 make deb ARCH= # 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 时一并运行。 - 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。 diff --git a/skills/builder/references/contract.md b/skills/builder/references/contract.md index 3780887..611b333 100644 --- a/skills/builder/references/contract.md +++ b/skills/builder/references/contract.md @@ -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..`。 + +| 判定 | 规范版本 | DEB 文件 | Docker tag | +| --- | --- | --- | --- | +| HEAD exact-match 稳定 tag `v1.4.2` | `1.4.2` | `name_1.4.2_.deb` | `1.4.2` | +| 其它 commit;祖先最近稳定 tag `v1.4.2` | `1.4.2~.+g` | `name_1.4.2~.+g_.deb` | `1.4.2-..g` | +| 祖先中没有稳定 tag | `0.0.0~+g` | 同上替换规范版本 | 同上映射 | + +测试与正式进入同一 apt / Docker 仓库。测试 DEB 必须用 `~`,使 +`1.4.2~…` < `1.4.2`,`apt upgrade` 不会装上测试包。Docker tag 不得含 +`~`、`/`、`:`,由规范版本把 `~` → `-`、`+g` → `.g`。 + +``:`git rev-parse --abbrev-ref HEAD`,detached 时用 +`BUILD_BRANCH` / `CI_COMMIT_BRANCH` / `GITHUB_REF_NAME`,再没有则 +`detached`。清洗:小写;`/`、`_` → `-`;去掉非 `[a-z0-9-]`;压缩连续 +`-`;过长截断(给 base、距离、SHA 留位置;Docker tag 上限 128)。 +`` 为基线 tag 到 HEAD 的 commit 数;`` 为 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 位) diff --git a/skills/builder/references/registry.md b/skills/builder/references/registry.md index 2bdfc8c..6bae393 100644 --- a/skills/builder/references/registry.md +++ b/skills/builder/references/registry.md @@ -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 只有在用户明确要求时才额外发布, + 不能代替上面那条唯一身份。 diff --git a/skills/builder/scripts/check.py b/skills/builder/scripts/check.py index 3613abb..19131ce 100755 --- a/skills/builder/scripts/check.py +++ b/skills/builder/scripts/check.py @@ -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: diff --git a/skills/builder/scripts/publish_docker.sh b/skills/builder/scripts/publish_docker.sh index 5064fac..e50627c 100755 --- a/skills/builder/scripts/publish_docker.sh +++ b/skills/builder/scripts/publish_docker.sh @@ -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 diff --git a/skills/builder/scripts/version.mk b/skills/builder/scripts/version.mk new file mode 100644 index 0000000..3138384 --- /dev/null +++ b/skills/builder/scripts/version.mk @@ -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 diff --git a/skills/builder/scripts/version.sh b/skills/builder/scripts/version.sh new file mode 100755 index 0000000..735a191 --- /dev/null +++ b/skills/builder/scripts/version.sh @@ -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.. 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 diff --git a/tests/test_builder_version.py b/tests/test_builder_version.py new file mode 100644 index 0000000..32bc8fc --- /dev/null +++ b/tests/test_builder_version.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = REPO_ROOT / "skills" / "builder" / "scripts" +VERSION_SH = SCRIPTS / "version.sh" +PUBLISH_DOCKER = SCRIPTS / "publish_docker.sh" +VERSION_MK = SCRIPTS / "version.mk" + + +def git(cwd: Path, *args: str, env: dict[str, str] | None = None) -> str: + merged = os.environ.copy() + if env: + merged.update(env) + merged.setdefault("GIT_AUTHOR_NAME", "Test") + merged.setdefault("GIT_AUTHOR_EMAIL", "test@example.com") + merged.setdefault("GIT_COMMITTER_NAME", "Test") + merged.setdefault("GIT_COMMITTER_EMAIL", "test@example.com") + result = subprocess.run( + ["git", *args], + cwd=cwd, + env=merged, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise AssertionError(f"git {args} failed: {result.stderr}") + return result.stdout.strip() + + +def commit(cwd: Path, message: str) -> str: + git(cwd, "add", "-A") + git(cwd, "commit", "-m", message) + return git(cwd, "rev-parse", "HEAD") + + +def sha7(cwd: Path) -> str: + return git(cwd, "rev-parse", "HEAD")[:7] + + +def version_sh(*args: str, cwd: Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + merged = os.environ.copy() + if env: + merged.update(env) + return subprocess.run( + ["bash", str(VERSION_SH), *args], + cwd=cwd, + env=merged, + text=True, + capture_output=True, + check=False, + ) + + +def init_repo(path: Path) -> None: + git(path, "init", "-b", "main") + git(path, "config", "user.email", "test@example.com") + git(path, "config", "user.name", "Test") + + +class BuilderVersionTests(unittest.TestCase): + def test_version_sh_is_executable(self) -> None: + mode = VERSION_SH.stat().st_mode + self.assertTrue(mode & stat.S_IXUSR, "version.sh must be executable") + + def test_official_on_stable_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "-a", "v1.4.2", "-m", "1.4.2") + result = version_sh(cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "1.4.2\n") + docker = version_sh("--docker", cwd=repo) + self.assertEqual(docker.returncode, 0, docker.stderr) + self.assertEqual(docker.stdout, "1.4.2\n") + + def test_test_version_uses_ancestor_not_global_max(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + repo = root / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "base") + git(repo, "tag", "v1.4.2") + + git(repo, "checkout", "-b", "feat/login-v2") + (repo / "b").write_text("2\n", encoding="utf-8") + commit(repo, "feature") + (repo / "c").write_text("3\n", encoding="utf-8") + commit(repo, "feature 2") + feature_sha = sha7(repo) + + git(repo, "checkout", "main") + (repo / "d").write_text("4\n", encoding="utf-8") + commit(repo, "release") + git(repo, "tag", "v1.4.3") + + git(repo, "checkout", "feat/login-v2") + result = version_sh(cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + result.stdout.strip(), + f"1.4.2~feat-login-v2.2+g{feature_sha}", + ) + docker = version_sh("--docker", cwd=repo) + self.assertEqual( + docker.stdout.strip(), + f"1.4.2-feat-login-v2.2.g{feature_sha}", + ) + + def test_ignores_prerelease_and_app_tags(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v1.0.0") + (repo / "b").write_text("2\n", encoding="utf-8") + commit(repo, "next") + git(repo, "tag", "v1.1.0-rc.1") + git(repo, "tag", "v1.1.0-app-1") + sha = sha7(repo) + result = version_sh(cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), f"1.0.0~main.1+g{sha}") + + def test_no_stable_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + sha = sha7(repo) + result = version_sh(cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), f"0.0.0~main+g{sha}") + + def test_detached_uses_env_branch(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v0.1.0") + (repo / "b").write_text("2\n", encoding="utf-8") + commit(repo, "next") + sha = sha7(repo) + git(repo, "checkout", "--detach", "HEAD") + result = version_sh(cwd=repo, env={"BUILD_BRANCH": "ci_job"}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), f"0.1.0~ci-job.1+g{sha}") + + def test_override_official_rejected_off_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v1.4.2") + (repo / "b").write_text("2\n", encoding="utf-8") + commit(repo, "next") + result = version_sh("--version", "1.4.3", cwd=repo) + self.assertEqual(result.returncode, 2) + self.assertIn("looks official", result.stderr) + + def test_override_official_ok_on_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v1.4.2") + result = version_sh("--version", "v1.4.2", cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.4.2") + + def test_override_test_shaped_accepted(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + result = version_sh("--version", "1.4.2~feat.1+gabc1234", "--docker", cwd=repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), "1.4.2-feat.1.gabc1234") + + def test_version_mk_sets_make_version(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "a").write_text("1\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v2.0.0") + makefile = repo / "Makefile" + makefile.write_text( + "include {mk}\n" + ".PHONY: version\n" + "version:\n" + "\t@echo \"$(VERSION)\"\n".format(mk=VERSION_MK), + encoding="utf-8", + ) + result = subprocess.run( + ["make", "--no-print-directory", "-C", str(repo), "version"], + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertEqual(result.stdout.strip(), "2.0.0") + + def test_publish_docker_dry_run_uses_derived_tag(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v3.1.4") + result = subprocess.run( + [ + "bash", + str(PUBLISH_DOCKER), + "--registry", + "registry.example.com", + "--repository", + "ns/app", + "--dry-run", + ], + cwd=repo, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertIn("Image: registry.example.com/ns/app:3.1.4", result.stdout) + + def test_publish_docker_rejects_official_tag_off_head(self) -> None: + with tempfile.TemporaryDirectory() as temp: + repo = Path(temp) / "repo" + repo.mkdir() + init_repo(repo) + (repo / "Dockerfile").write_text("FROM scratch\n", encoding="utf-8") + commit(repo, "init") + git(repo, "tag", "v1.0.0") + (repo / "b").write_text("2\n", encoding="utf-8") + commit(repo, "next") + result = subprocess.run( + [ + "bash", + str(PUBLISH_DOCKER), + "--registry", + "registry.example.com", + "--repository", + "ns/app", + "--tag", + "1.0.1", + "--dry-run", + ], + cwd=repo, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 2) + self.assertIn("looks official", result.stderr) + + +if __name__ == "__main__": + unittest.main()