Merge remote-tracking branch 'origin/main' into rename
This commit is contained in:
@@ -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 个 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 |
|
||||
|
||||
@@ -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=<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 时一并运行。
|
||||
- 不通过真实生产上传测试脚本,除非用户明确授权并给出测试版本/仓库。
|
||||
|
||||
@@ -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/.pouch/skills/builder/scripts/`(标准 clone 位)
|
||||
|
||||
@@ -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 只有在用户明确要求时才额外发布,
|
||||
不能代替上面那条唯一身份。
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -331,7 +335,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+195
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user