1
0

feat: 迁移 versionctl 为独立模块并新增 make version-bump 命令

- 将 backend/cmd/versionctl 和 backend/pkg/projectversion 迁移至独立 versionctl/ Go 模块
- 新增 bump 子命令支持 major/minor/patch 和指定版本号,含版本倒退防护
- 新增 make version-bump 编排完整升迁流程(bump + sync + check + commit + tag)
- 更新所有引用路径:根 Makefile、backend/Makefile、release.yml、.golangci.yml
- 新增 versionctl/.golangci.yml(精简配置)和 Makefile(lint/test/coverage)
- 根 Makefile lint/test 集成 versionctl 模块
- 同步 openspec specs:新增 version-bump spec,更新 release-pipeline spec
This commit is contained in:
2026-05-05 04:18:10 +08:00
parent 3cd0458c2c
commit bc7a7c6e81
16 changed files with 515 additions and 47 deletions

View File

@@ -24,13 +24,15 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: backend/go.sum
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Verify tag and VERSION
id: version
run: |
version=$(go run ./backend/cmd/versionctl print)
go run ./backend/cmd/versionctl verify-tag "${GITHUB_REF_NAME}"
version=$(go run ./versionctl print)
go run ./versionctl verify-tag "${GITHUB_REF_NAME}"
printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT"
build-linux:
@@ -47,7 +49,9 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: backend/go.sum
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -93,7 +97,9 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: backend/go.sum
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2
@@ -153,7 +159,9 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: go.work
cache-dependency-path: backend/go.sum
cache-dependency-path: |
backend/go.sum
versionctl/go.sum
- name: Setup Bun
uses: oven-sh/setup-bun@v2

View File

@@ -1,11 +1,12 @@
.PHONY: \
lint test clean \
version-sync version-check \
version-sync version-check version-bump \
server-run server-build server-lint server-test server-clean \
desktop-build-mac desktop-build-win desktop-build-linux \
desktop-lint desktop-test desktop-clean \
release-assets-linux release-assets-windows release-assets-macos \
_backend-lint _backend-test _backend-clean _backend-build \
_versionctl-lint _versionctl-test \
_frontend-install _frontend-build _frontend-check _frontend-test _frontend-dev _frontend-clean \
_desktop-test _desktop-clean _desktop-prepare-frontend _desktop-prepare-embedfs _desktop-prepare-windows-resource \
_server-run-backend _server-run-frontend
@@ -13,28 +14,28 @@
# Delay shell lookups until a target needs them, then cache the result for this make run.
lazy_shell = $(or $($(1)),$(eval $(1) := $(shell $(2)))$($(1)))
VERSION = $(call lazy_shell,_VERSION,go run ./backend/cmd/versionctl print)
VERSION = $(call lazy_shell,_VERSION,go run ./versionctl print)
GIT_COMMIT ?= $(call lazy_shell,_GIT_COMMIT,git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(call lazy_shell,_BUILD_TIME,date -u +"%Y-%m-%dT%H:%M:%SZ")
GO_LDFLAGS = -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)
GO_LDFLAGS_WIN = $(GO_LDFLAGS) -H=windowsgui
RELEASE_DIR := build/release
SERVER_LINUX_ASSET = $(call lazy_shell,_SERVER_LINUX_ASSET,go run ./backend/cmd/versionctl asset-name server linux amd64)
SERVER_WINDOWS_ASSET = $(call lazy_shell,_SERVER_WINDOWS_ASSET,go run ./backend/cmd/versionctl asset-name server windows amd64)
SERVER_DARWIN_AMD64_ASSET = $(call lazy_shell,_SERVER_DARWIN_AMD64_ASSET,go run ./backend/cmd/versionctl asset-name server darwin amd64)
SERVER_DARWIN_ARM64_ASSET = $(call lazy_shell,_SERVER_DARWIN_ARM64_ASSET,go run ./backend/cmd/versionctl asset-name server darwin arm64)
DESKTOP_LINUX_ASSET = $(call lazy_shell,_DESKTOP_LINUX_ASSET,go run ./backend/cmd/versionctl asset-name desktop linux)
DESKTOP_WINDOWS_ASSET = $(call lazy_shell,_DESKTOP_WINDOWS_ASSET,go run ./backend/cmd/versionctl asset-name desktop windows)
DESKTOP_MACOS_ASSET = $(call lazy_shell,_DESKTOP_MACOS_ASSET,go run ./backend/cmd/versionctl asset-name desktop macos)
SERVER_LINUX_ASSET = $(call lazy_shell,_SERVER_LINUX_ASSET,go run ./versionctl asset-name server linux amd64)
SERVER_WINDOWS_ASSET = $(call lazy_shell,_SERVER_WINDOWS_ASSET,go run ./versionctl asset-name server windows amd64)
SERVER_DARWIN_AMD64_ASSET = $(call lazy_shell,_SERVER_DARWIN_AMD64_ASSET,go run ./versionctl asset-name server darwin amd64)
SERVER_DARWIN_ARM64_ASSET = $(call lazy_shell,_SERVER_DARWIN_ARM64_ASSET,go run ./versionctl asset-name server darwin arm64)
DESKTOP_LINUX_ASSET = $(call lazy_shell,_DESKTOP_LINUX_ASSET,go run ./versionctl asset-name desktop linux)
DESKTOP_WINDOWS_ASSET = $(call lazy_shell,_DESKTOP_WINDOWS_ASSET,go run ./versionctl asset-name desktop windows)
DESKTOP_MACOS_ASSET = $(call lazy_shell,_DESKTOP_MACOS_ASSET,go run ./versionctl asset-name desktop macos)
# ============================================
# 全局命令
# ============================================
lint: _backend-lint _frontend-check
lint: _backend-lint _frontend-check _versionctl-lint
@printf 'Lint complete\n'
test: _backend-test _frontend-test _desktop-test
test: _backend-test _frontend-test _desktop-test _versionctl-test
@printf 'All tests passed\n'
clean: _backend-clean _frontend-clean _desktop-clean
@@ -45,10 +46,20 @@ clean: _backend-clean _frontend-clean _desktop-clean
# ============================================
version-sync:
go run ./backend/cmd/versionctl sync
go run ./versionctl sync
version-check:
go run ./backend/cmd/versionctl check
go run ./versionctl check
version-bump:
@test -n "$(BUMP)$(SET_VERSION)" || (printf '用法: make version-bump BUMP=major|minor|patch 或 make version-bump SET_VERSION=x.y.z\n' && exit 1)
@git diff --quiet HEAD || (printf '工作区不干净,请先提交或暂存改动\n' && exit 1)
$(eval _BUMP_ARG := $(if $(SET_VERSION),$(SET_VERSION),$(BUMP)))
$(eval _NEW_VERSION := $(shell go run ./versionctl bump $(_BUMP_ARG)))
git add VERSION frontend/
git commit -m "chore: 版本升迁 v$(_NEW_VERSION)"
git tag "v$(_NEW_VERSION)"
@printf '版本升迁完成: v%s\n' "$(_NEW_VERSION)"
# ============================================
# Server 模式
@@ -97,7 +108,7 @@ desktop-build-mac: version-check _desktop-prepare-frontend _desktop-prepare-embe
printf 'Unable to read macOS minimum version\n'; \
exit 1; \
fi; \
go run ./backend/cmd/versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
go run ./versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist
chmod +x build/Nex.app/Contents/MacOS/nex
@printf 'macOS desktop build complete\n'
@@ -216,6 +227,12 @@ _backend-test:
_backend-clean:
@$(MAKE) -C backend clean
_versionctl-lint:
@$(MAKE) -C versionctl lint
_versionctl-test:
@$(MAKE) -C versionctl test
_frontend-install:
cd frontend && bun install

View File

@@ -321,27 +321,24 @@ make desktop-clean # 清理 desktop 产物
### 本地版本演进
1. 手工修改根目录 `VERSION` 为新的 `x.y.z`
2. 同步镜像文件:
```bash
# 递增版本(自动 sync + check + commit + tag
make version-bump BUMP=minor
# 或指定具体版本号
make version-bump SET_VERSION=1.0.0
# 推送到远程
git push --follow-tags
```
手动同步和校验:
```bash
make version-sync
```
3. 校验版本一致性:
```bash
make version-check
```
4. 提交版本变更后,创建发布 tag
```bash
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin main
git push origin vX.Y.Z
```
### 本地生成发布资产
```bash

View File

@@ -86,9 +86,6 @@ issues:
linters:
- gocyclo
- gocritic
- path: '(internal/provider/client\.go|internal/service/model_service_impl\.go|internal/service/stats_buffer\.go|internal/handler/proxy_handler\.go|cmd/(desktop|server|versionctl)/main\.go)'
- path: '(internal/provider/client\.go|internal/service/model_service_impl\.go|internal/service/stats_buffer\.go|internal/handler/proxy_handler\.go|cmd/(desktop|server)/main\.go)'
linters:
- gocyclo
- path: 'cmd/versionctl/'
linters:
- forbidigo

View File

@@ -5,7 +5,7 @@
migrate-up migrate-down migrate-status migrate-create \
mysql-up mysql-down mysql-test mysql-test-quick
VERSION := $(shell go run ./cmd/versionctl print)
VERSION := $(shell go run ../versionctl print)
GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || printf 'unknown')
BUILD_TIME ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
GO_LDFLAGS := -X nex/backend/pkg/buildinfo.version=$(VERSION) -X nex/backend/pkg/buildinfo.commit=$(GIT_COMMIT) -X nex/backend/pkg/buildinfo.buildTime=$(BUILD_TIME)

View File

@@ -3,4 +3,5 @@ go 1.26.2
use (
backend
embedfs
versionctl
)

View File

@@ -4,8 +4,6 @@ cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQ
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.43.0/go.mod h1:o6jf7JM/zveWC/PP277BLxjHy5KjnGX/jfljhM4s34g=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -14,6 +12,7 @@ github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmO
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cristalhq/acmd v0.12.0/go.mod h1:LG5oa43pE/BbxtfMoImHCQN++0Su7dzipdgBjMCBVDQ=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
@@ -26,8 +25,6 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
@@ -48,6 +45,7 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
@@ -66,8 +64,10 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=

View File

@@ -8,18 +8,28 @@
### Requirement: Tag 驱动发布流水线
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。
系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。发布流水线 SHALL 使用 `./versionctl` 而非 `./backend/cmd/versionctl` 调用版本管理工具。
#### Scenario: 有效发布 tag
- **WHEN** 仓库收到 `v1.2.3` tag push
- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤
- **AND** 版本校验步骤 SHALL 使用 `go run ./versionctl print``go run ./versionctl verify-tag` 获取并验证版本
#### Scenario: 普通分支推送
- **WHEN** 仓库收到非 tag 的分支 push
- **THEN** 系统 SHALL NOT 创建 GitHub Release
### Requirement: 发布流水线 Go 模块缓存覆盖
发布流水线 SHALL 在所有 Go module 的 go.sum 文件存在时正确设置 Go 模块缓存路径,确保新增的 `versionctl` module 依赖也被缓存。
#### Scenario: CI 缓存覆盖所有 module
- **WHEN** 发布流水线设置 Go 模块缓存
- **THEN** `cache-dependency-path` SHALL 覆盖 `backend/go.sum``versionctl/go.sum`
### Requirement: 三平台发布构建
系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物。

View File

@@ -0,0 +1,113 @@
# 版本升迁
## Purpose
定义 `version bump` 子命令的版本号递增、下游文件同步、倒退防护及 Makefile 编排规则,确保版本升迁流程安全可自动化。
## Requirements
### Requirement: 版本号递增
`version bump` 子命令 SHALL 支持三种递增模式:`major`major+1, minor=0, patch=0`minor`minor+1, patch=0`patch`patch+1以及直接指定具体版本号。
#### Scenario: minor 递增
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump minor`
- **THEN** VERSION 文件 SHALL 被更新为 `0.2.0`
#### Scenario: major 递增
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump major`
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
#### Scenario: patch 递增
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump patch`
- **THEN** VERSION 文件 SHALL 被更新为 `0.1.1`
#### Scenario: 指定具体版本号
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 1.0.0`
- **THEN** VERSION 文件 SHALL 被更新为 `1.0.0`
#### Scenario: 指定版本号等于当前 VERSION
- **WHEN** 当前 VERSION 为 `0.1.0`,执行 `version bump 0.1.0`
- **THEN** 命令 SHALL 正常执行,完成 sync 和 check输出 `0.1.0`
#### Scenario: 非法 bump 参数
- **WHEN** 执行 `version bump` 传入既非 `major|minor|patch` 也非合法 semver 的参数
- **THEN** 命令 SHALL 以非零退出码失败并输出错误信息
### Requirement: bump 自动同步下游文件
`version bump` 子命令 SHALL 在写回 VERSION 文件后自动执行 sync 和 check确保 `frontend/package.json` 和所有 `frontend/.env.*` 文件与新版本号一致。
#### Scenario: bump 自动 sync 和 check
- **WHEN** 执行 `version bump minor` 且当前 VERSION 为 `0.1.0`
- **THEN** 命令 SHALL 自动将新版本号 `0.2.0` 同步到 `frontend/package.json``version` 字段和所有 `frontend/.env.*``VITE_APP_VERSION` 变量
- **AND** 命令 SHALL 自动验证所有下游文件版本号一致性
#### Scenario: sync 失败时 bump 中止
- **WHEN** 执行 `version bump minor` 但下游文件同步失败(如文件缺失)
- **THEN** 命令 SHALL 以非零退出码失败
### Requirement: 版本号倒退防护
`version bump` 子命令 SHALL 检查新版本号严格大于所有已有 git tag 中的最大版本号,防止版本号倒退。
#### Scenario: 新版本大于已有 tag
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump minor`
- **THEN** 命令 SHALL 成功将版本更新为 `0.2.0`
#### Scenario: 新版本等于已有 tag
- **WHEN** 已有 tag `v0.1.0`,执行 `version bump 0.1.0`
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号已存在
#### Scenario: 新版本小于已有 tag
- **WHEN** 已有 tag `v0.2.0`,执行 `version bump 0.1.5`
- **THEN** 命令 SHALL 以非零退出码失败并提示版本号倒退
#### Scenario: 无已有 tag
- **WHEN** 不存在任何 `v*.*.*` 格式的 git tag执行 `version bump 0.1.0`
- **THEN** 命令 SHALL 成功
### Requirement: bump 输出新版本号
`version bump` 子命令成功时 SHALL 仅将新版本号(不含 `v` 前缀)输出到 stdout供 Makefile 等外部工具使用。
#### Scenario: 输出格式
- **WHEN** 执行 `version bump minor`,当前版本为 `0.1.0`
- **THEN** stdout SHALL 输出 `0.2.0`(换行结尾,无额外内容)
### Requirement: 版本升迁 Makefile 编排
`make version-bump` SHALL 编排完整的版本升迁流程:工作区干净检查 → `version bump`(含 sync/check/倒退检查)→ git add → git commit → git tag。
#### Scenario: 完整升迁流程
- **WHEN** 执行 `make version-bump BUMP=minor`,工作区干净,当前版本 `0.1.0`
- **THEN** Makefile SHALL 依次执行:工作区检查 → `version bump minor``git add VERSION frontend/``git commit -m "chore: 版本升迁 v0.2.0"``git tag v0.2.0`
#### Scenario: 工作区不干净
- **WHEN** 执行 `make version-bump BUMP=minor`,但工作区有未提交的改动
- **THEN** Makefile SHALL 以非零退出码失败并提示先提交或暂存改动
#### Scenario: 支持指定版本号
- **WHEN** 执行 `make version-bump SET_VERSION=1.0.0`
- **THEN** Makefile SHALL 将 `1.0.0` 传递给 `version bump` 子命令
#### Scenario: 不自动推送
- **WHEN** `make version-bump` 成功完成
- **THEN** commit 和 tag SHALL 仅存在于本地SHALL NOT 自动 push 到远程

52
versionctl/.golangci.yml Normal file
View File

@@ -0,0 +1,52 @@
run:
timeout: 5m
tests: true
linters:
disable-all: true
enable:
- errorlint
- errcheck
- staticcheck
- revive
- gocritic
- gosec
- nilerr
- goimports
- gocyclo
linters-settings:
errcheck:
check-blank: true
check-type-assertions: true
revive:
rules:
- name: exported
- name: var-naming
- name: indent-error-flow
- name: error-strings
- name: error-return
- name: blank-imports
goimports:
local-prefixes: nex/versionctl
gocyclo:
min-complexity: 10
issues:
exclude-generated: true
exclude-rules:
- path: '_test\.go'
linters:
- errcheck
source: '(^\s*_\s*=|,\s*_)'
- path: '_test\.go'
linters:
- revive
text: '^exported:'
- path: '_test\.go'
linters:
- gosec
text: 'G(101|401|501)'
- path: 'main\.go'
linters:
- gocyclo

16
versionctl/Makefile Normal file
View File

@@ -0,0 +1,16 @@
.PHONY: \
lint test test-coverage clean
lint:
go tool golangci-lint run ./...
test:
go test ./... -v
test-coverage:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
@printf 'Coverage report generated: versionctl/coverage.html\n'
clean:
rm -rf coverage.out coverage.html

14
versionctl/go.mod Normal file
View File

@@ -0,0 +1,14 @@
module nex/versionctl
go 1.26.2
require github.com/stretchr/testify v1.11.1
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

8
versionctl/go.sum Normal file
View File

@@ -0,0 +1,8 @@
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"nex/backend/pkg/projectversion"
"nex/versionctl/projectversion"
)
func main() {
@@ -41,6 +41,11 @@ func run(args []string) error {
return fmt.Errorf("verify-tag 需要一个 tag 参数")
}
return projectversion.VerifyTag(root, args[1])
case "bump":
if len(args) != 2 {
return fmt.Errorf("bump 需要一个参数: major|minor|patch 或具体版本号")
}
return runBump(root, args[1])
case "macos-plist":
if len(args) != 2 {
return fmt.Errorf("macos-plist 需要一个最低系统版本参数")
@@ -53,6 +58,33 @@ func run(args []string) error {
}
}
func runBump(root, arg string) error {
newVersion, err := projectversion.Bump(root, arg)
if err != nil {
return err
}
tags, err := projectversion.ListGitTags(root)
if err != nil {
return err
}
if err := projectversion.CheckNoRegression(newVersion, tags); err != nil {
return err
}
if err := projectversion.Sync(root); err != nil {
return err
}
if err := projectversion.Check(root); err != nil {
return err
}
fmt.Println(newVersion.String())
return nil
}
func printMacOSPlist(root, minMacOSVersion string) error {
version, err := projectversion.ReadString(root)
if err != nil {
@@ -115,5 +147,5 @@ func mustGetwd() string {
}
func usageError() error {
return fmt.Errorf("用法: versionctl <print|sync|check|verify-tag|macos-plist|asset-name>")
return fmt.Errorf("用法: version <print|sync|check|verify-tag|bump|macos-plist|asset-name>")
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
@@ -57,6 +58,18 @@ func (v Version) String() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
func (v Version) Less(other Version) bool {
if v.Major != other.Major {
return v.Major < other.Major
}
if v.Minor != other.Minor {
return v.Minor < other.Minor
}
return v.Patch < other.Patch
}
func FindRepoRoot(start string) (string, error) {
current := start
for {
@@ -340,3 +353,91 @@ func DesktopInfoPlist(version, minMacOSVersion string) (string, error) {
return content + "\n", nil
}
var tagRegex = regexp.MustCompile(`^v(\d+\.\d+\.\d+)$`)
func ListGitTags(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "tag", "--list", "--merge", "HEAD")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("获取 git tag 列表失败: %w", err)
}
var tags []string
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
tags = append(tags, line)
}
}
return tags, nil
}
func CheckNoRegression(newVersion Version, tags []string) error {
var maxVersion Version
found := false
for _, tag := range tags {
parts := tagRegex.FindStringSubmatch(tag)
if parts == nil {
continue
}
v, err := Parse(parts[1])
if err != nil {
continue
}
if !found || maxVersion.Less(v) {
maxVersion = v
found = true
}
}
if !found {
return nil
}
if newVersion == maxVersion {
return fmt.Errorf("版本号 %s 已存在tag v%s", newVersion, maxVersion)
}
if newVersion.Less(maxVersion) {
return fmt.Errorf("版本号 %s 小于已有 tag v%s不允许倒退", newVersion, maxVersion)
}
return nil
}
func Bump(root, arg string) (Version, error) {
current, err := Read(root)
if err != nil {
return Version{}, err
}
var newVersion Version
switch arg {
case "major":
newVersion = Version{Major: current.Major + 1, Minor: 0, Patch: 0}
case "minor":
newVersion = Version{Major: current.Major, Minor: current.Minor + 1, Patch: 0}
case "patch":
newVersion = Version{Major: current.Major, Minor: current.Minor, Patch: current.Patch + 1}
default:
parsed, parseErr := Parse(arg)
if parseErr != nil {
return Version{}, fmt.Errorf("参数 %q 既非 major|minor|patch 也非合法版本号: %w", arg, parseErr)
}
newVersion = parsed
}
versionPath := filepath.Join(root, versionFileName)
if err := os.WriteFile(versionPath, []byte(newVersion.String()+"\n"), 0o600); err != nil {
return Version{}, fmt.Errorf("写入 VERSION 失败: %w", err)
}
return newVersion, nil
}

View File

@@ -111,3 +111,105 @@ func TestDesktopInfoPlist(t *testing.T) {
_, err = DesktopInfoPlist("1.2.3", "")
assert.Error(t, err)
}
func TestLess(t *testing.T) {
assert.True(t, Version{1, 0, 0}.Less(Version{2, 0, 0}))
assert.True(t, Version{1, 1, 0}.Less(Version{1, 2, 0}))
assert.True(t, Version{1, 0, 1}.Less(Version{1, 0, 2}))
assert.False(t, Version{2, 0, 0}.Less(Version{1, 0, 0}))
assert.False(t, Version{1, 0, 0}.Less(Version{1, 0, 0}))
}
func TestBump(t *testing.T) {
setupRoot := func(t *testing.T) string {
t.Helper()
root := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("0.1.0\n"), 0o600))
return root
}
t.Run("major", func(t *testing.T) {
root := setupRoot(t)
v, err := Bump(root, "major")
require.NoError(t, err)
assert.Equal(t, Version{1, 0, 0}, v)
read, readErr := ReadString(root)
require.NoError(t, readErr)
assert.Equal(t, "1.0.0", read)
})
t.Run("minor", func(t *testing.T) {
root := setupRoot(t)
v, err := Bump(root, "minor")
require.NoError(t, err)
assert.Equal(t, Version{0, 2, 0}, v)
read, readErr := ReadString(root)
require.NoError(t, readErr)
assert.Equal(t, "0.2.0", read)
})
t.Run("patch", func(t *testing.T) {
root := setupRoot(t)
v, err := Bump(root, "patch")
require.NoError(t, err)
assert.Equal(t, Version{0, 1, 1}, v)
read, readErr := ReadString(root)
require.NoError(t, readErr)
assert.Equal(t, "0.1.1", read)
})
t.Run("specific version", func(t *testing.T) {
root := setupRoot(t)
v, err := Bump(root, "1.0.0")
require.NoError(t, err)
assert.Equal(t, Version{1, 0, 0}, v)
})
t.Run("same version as current", func(t *testing.T) {
root := setupRoot(t)
v, err := Bump(root, "0.1.0")
require.NoError(t, err)
assert.Equal(t, Version{0, 1, 0}, v)
})
t.Run("invalid argument", func(t *testing.T) {
root := setupRoot(t)
_, err := Bump(root, "invalid")
assert.Error(t, err)
})
}
func TestCheckNoRegression(t *testing.T) {
t.Run("greater than existing tag", func(t *testing.T) {
err := CheckNoRegression(Version{0, 2, 0}, []string{"v0.1.0"})
assert.NoError(t, err)
})
t.Run("equal to existing tag", func(t *testing.T) {
err := CheckNoRegression(Version{0, 1, 0}, []string{"v0.1.0"})
assert.Error(t, err)
})
t.Run("less than existing tag", func(t *testing.T) {
err := CheckNoRegression(Version{0, 1, 5}, []string{"v0.2.0"})
assert.Error(t, err)
})
t.Run("no tags", func(t *testing.T) {
err := CheckNoRegression(Version{0, 1, 0}, nil)
assert.NoError(t, err)
})
t.Run("skips non-semver tags", func(t *testing.T) {
err := CheckNoRegression(Version{0, 2, 0}, []string{"v0.1.0", "some-other-tag"})
assert.NoError(t, err)
})
t.Run("picks max tag", func(t *testing.T) {
err := CheckNoRegression(Version{0, 1, 5}, []string{"v0.1.0", "v0.2.0", "v0.0.5"})
assert.Error(t, err)
})
}