diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617fea4..f9a809d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/Makefile b/Makefile index 1bbeac0..e46a9d9 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index a432966..b8b3306 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.golangci.yml b/backend/.golangci.yml index fc89962..a60c343 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -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 diff --git a/backend/Makefile b/backend/Makefile index 9e6c7dd..474a12d 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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) diff --git a/go.work b/go.work index d78f82a..0b88a01 100644 --- a/go.work +++ b/go.work @@ -3,4 +3,5 @@ go 1.26.2 use ( backend embedfs + versionctl ) diff --git a/go.work.sum b/go.work.sum index d66ce50..a14a329 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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= diff --git a/openspec/specs/release-pipeline/spec.md b/openspec/specs/release-pipeline/spec.md index e931406..eeb384e 100644 --- a/openspec/specs/release-pipeline/spec.md +++ b/openspec/specs/release-pipeline/spec.md @@ -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 三个平台产物。 diff --git a/openspec/specs/version-bump/spec.md b/openspec/specs/version-bump/spec.md new file mode 100644 index 0000000..1cb34e1 --- /dev/null +++ b/openspec/specs/version-bump/spec.md @@ -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 到远程 diff --git a/versionctl/.golangci.yml b/versionctl/.golangci.yml new file mode 100644 index 0000000..28748ad --- /dev/null +++ b/versionctl/.golangci.yml @@ -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 diff --git a/versionctl/Makefile b/versionctl/Makefile new file mode 100644 index 0000000..8a0942d --- /dev/null +++ b/versionctl/Makefile @@ -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 diff --git a/versionctl/go.mod b/versionctl/go.mod new file mode 100644 index 0000000..6223b50 --- /dev/null +++ b/versionctl/go.mod @@ -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 +) diff --git a/versionctl/go.sum b/versionctl/go.sum new file mode 100644 index 0000000..b679674 --- /dev/null +++ b/versionctl/go.sum @@ -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= diff --git a/backend/cmd/versionctl/main.go b/versionctl/main.go similarity index 74% rename from backend/cmd/versionctl/main.go rename to versionctl/main.go index 1859e63..d362270 100644 --- a/backend/cmd/versionctl/main.go +++ b/versionctl/main.go @@ -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 ") + return fmt.Errorf("用法: version ") } diff --git a/backend/pkg/projectversion/version.go b/versionctl/projectversion/version.go similarity index 79% rename from backend/pkg/projectversion/version.go rename to versionctl/projectversion/version.go index af6b051..aa38a78 100644 --- a/backend/pkg/projectversion/version.go +++ b/versionctl/projectversion/version.go @@ -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 +} diff --git a/backend/pkg/projectversion/version_test.go b/versionctl/projectversion/version_test.go similarity index 59% rename from backend/pkg/projectversion/version_test.go rename to versionctl/projectversion/version_test.go index 3d4f1d6..a54cae1 100644 --- a/backend/pkg/projectversion/version_test.go +++ b/versionctl/projectversion/version_test.go @@ -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) + }) +}