From a9972360c28337a61b79585203e8ff000ff57bbe Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Tue, 28 Apr 2026 14:20:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8C=96=E6=9E=84=E5=BB=BA=E4=B8=8E=E5=8F=91=E5=B8=83=E6=B5=81?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 VERSION 作为统一版本源,避免前端、后端、桌面打包和发布资产之间的版本漂移。 新增 tag 驱动的 Draft Release 流程与版本化资产命名,使本地演进和 GitHub 发布共享同一套约束。 --- .github/workflows/release.yml | 151 ++++++++ Makefile | 132 ++++--- README.md | 58 ++++ VERSION | 1 + backend/cmd/desktop/main.go | 7 +- backend/cmd/server/main.go | 7 +- backend/cmd/versionctl/main.go | 119 +++++++ backend/pkg/buildinfo/buildinfo.go | 22 ++ backend/pkg/buildinfo/buildinfo_test.go | 17 + backend/pkg/projectversion/version.go | 342 +++++++++++++++++++ backend/pkg/projectversion/version_test.go | 113 ++++++ frontend/.env.desktop | 1 + frontend/.env.development | 1 + frontend/.env.production | 1 + frontend/package.json | 2 +- openspec/specs/desktop-app/spec.md | 30 +- openspec/specs/release-pipeline/spec.md | 78 +++++ openspec/specs/repository-versioning/spec.md | 68 ++++ 18 files changed, 1082 insertions(+), 68 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 VERSION create mode 100644 backend/cmd/versionctl/main.go create mode 100644 backend/pkg/buildinfo/buildinfo.go create mode 100644 backend/pkg/buildinfo/buildinfo_test.go create mode 100644 backend/pkg/projectversion/version.go create mode 100644 backend/pkg/projectversion/version_test.go create mode 100644 openspec/specs/release-pipeline/spec.md create mode 100644 openspec/specs/repository-versioning/spec.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..84a4682 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,151 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: read + +jobs: + prepare: + name: Prepare Release + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + + - 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}" + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + + build-linux: + name: Build Linux Assets + needs: prepare + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install Linux desktop build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libayatana-appindicator3-dev libgtk-3-dev + + - name: Build Linux release assets + run: make release-assets-linux + + - name: Upload Linux release assets + uses: actions/upload-artifact@v4 + with: + name: release-linux + path: build/release/* + + build-windows: + name: Build Windows Assets + needs: prepare + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup MSYS2 toolchain + uses: msys2/setup-msys2@v2 + with: + update: true + install: >- + make + mingw-w64-x86_64-gcc + + - name: Build Windows release assets + shell: msys2 {0} + run: make release-assets-windows + + - name: Upload Windows release assets + uses: actions/upload-artifact@v4 + with: + name: release-windows + path: build/release/* + + build-macos: + name: Build macOS Assets + needs: prepare + runs-on: macos-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.work + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Build macOS release assets + run: make release-assets-macos + + - name: Upload macOS release assets + uses: actions/upload-artifact@v4 + with: + name: release-macos + path: build/release/* + + draft-release: + name: Create Draft Release + needs: [prepare, build-linux, build-windows, build-macos] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release assets + uses: actions/download-artifact@v4 + with: + pattern: release-* + merge-multiple: true + path: dist + + - name: Publish draft release + uses: softprops/action-gh-release@v2 + with: + name: v${{ needs.prepare.outputs.version }} + tag_name: ${{ github.ref_name }} + draft: true + files: | + dist/* diff --git a/Makefile b/Makefile index 99e2897..acf5c27 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ .PHONY: all dev build test lint clean \ + version-sync version-check \ backend-build backend-run backend-dev backend-test backend-test-all backend-test-unit backend-test-integration backend-test-coverage \ backend-lint backend-clean backend-deps backend-generate \ backend-db-up backend-db-down backend-db-status backend-db-create \ @@ -6,7 +7,22 @@ frontend-build frontend-dev frontend-test frontend-test-watch frontend-test-coverage frontend-test-e2e frontend-lint frontend-clean \ desktop-build desktop-build-mac desktop-build-win desktop-build-linux \ desktop-dev desktop-test desktop-clean \ - desktop-prepare-frontend desktop-prepare-embedfs desktop-prepare-windows-resource + desktop-prepare-frontend desktop-prepare-embedfs desktop-prepare-windows-resource \ + release-assets-linux release-assets-windows release-assets-macos + +VERSION := $(shell go run ./backend/cmd/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) +GO_LDFLAGS_WIN := $(GO_LDFLAGS) -H=windowsgui +RELEASE_DIR := build/release +SERVER_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server linux amd64) +SERVER_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server windows amd64) +SERVER_DARWIN_AMD64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin amd64) +SERVER_DARWIN_ARM64_ASSET := $(shell go run ./backend/cmd/versionctl asset-name server darwin arm64) +DESKTOP_LINUX_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop linux) +DESKTOP_WINDOWS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop windows) +DESKTOP_MACOS_ASSET := $(shell go run ./backend/cmd/versionctl asset-name desktop macos) # ============================================ # 顶层便捷命令 @@ -27,18 +43,28 @@ lint: backend-lint frontend-lint all: build test lint +# ============================================ +# 版本管理 +# ============================================ + +version-sync: + go run ./backend/cmd/versionctl sync + +version-check: + go run ./backend/cmd/versionctl check + # ============================================ # 后端 # ============================================ -backend-build: - cd backend && go build -o bin/server ./cmd/server +backend-build: version-check + cd backend && go build -ldflags "$(GO_LDFLAGS)" -o bin/server ./cmd/server -backend-run: - cd backend && go run ./cmd/server +backend-run: version-check + cd backend && go run -ldflags "$(GO_LDFLAGS)" ./cmd/server -backend-dev: - cd backend && go run ./cmd/server +backend-dev: version-check + cd backend && go run -ldflags "$(GO_LDFLAGS)" ./cmd/server backend-test: cd backend && go test ./internal/... ./pkg/... ./tests/... ./cmd/server/... -v @@ -125,10 +151,10 @@ test-mysql-quick: frontend-install: cd frontend && bun install -frontend-build: frontend-install +frontend-build: frontend-install version-sync cd frontend && bun run build -frontend-dev: frontend-install +frontend-dev: frontend-install version-sync cd frontend && bun dev frontend-test: frontend-install @@ -156,15 +182,15 @@ frontend-clean: desktop-build: desktop-build-mac desktop-build-win desktop-build-linux @echo "✅ Desktop builds complete for all platforms" -desktop-prepare-frontend: +desktop-prepare-frontend: frontend-install version-sync @echo "📦 Preparing frontend for desktop..." ifeq ($(OS),Windows_NT) powershell -NoProfile -Command "Copy-Item -LiteralPath 'frontend/.env.desktop' -Destination 'frontend/.env.production.local' -Force" - cd frontend && bun install && bun run build + cd frontend && bun run build powershell -NoProfile -Command "Remove-Item -LiteralPath 'frontend/.env.production.local' -Force -ErrorAction SilentlyContinue" else cd frontend && cp .env.desktop .env.production.local - cd frontend && bun install && bun run build + cd frontend && bun run build rm -f frontend/.env.production.local endif @@ -195,8 +221,8 @@ endif desktop-build-mac: desktop-prepare-frontend desktop-prepare-embedfs @echo "🍎 Building macOS..." - cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o ../build/nex-mac-arm64 ./cmd/desktop - cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -o ../build/nex-mac-amd64 ./cmd/desktop + cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-arm64 ./cmd/desktop + cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-mac-amd64 ./cmd/desktop lipo -create build/nex-mac-arm64 build/nex-mac-amd64 -output build/nex-mac-universal @echo "📦 Packaging macOS .app..." mkdir -p build/Nex.app/Contents/MacOS build/Nex.app/Contents/Resources @@ -211,44 +237,7 @@ desktop-build-mac: desktop-prepare-frontend desktop-prepare-embedfs echo "❌ 无法读取 macOS 最低系统版本"; \ exit 1; \ fi; \ - { \ - printf '%s\n' '' \ - '' \ - '' \ - '' \ - ' CFBundleDevelopmentRegion' \ - ' zh-Hans' \ - ' CFBundleExecutable' \ - ' nex' \ - ' CFBundleIconFile' \ - ' icon' \ - ' CFBundleIdentifier' \ - ' com.lanyuanxiaoyao.nex' \ - ' CFBundleInfoDictionaryVersion' \ - ' 6.0' \ - ' LSApplicationCategoryType' \ - ' public.app-category.developer-tools' \ - ' CFBundleName' \ - ' Nex' \ - ' CFBundleDisplayName' \ - ' Nex' \ - ' CFBundlePackageType' \ - ' APPL' \ - ' CFBundleShortVersionString' \ - ' 1.0.0' \ - ' CFBundleVersion' \ - ' 1.0.0' \ - ' NSHumanReadableCopyright' \ - ' Copyright © 2026 Nex' \ - ' LSMinimumSystemVersion' \ - " $$MIN_MACOS_VERSION" \ - ' LSUIElement' \ - ' ' \ - ' NSHighResolutionCapable' \ - ' ' \ - '' \ - ''; \ - } > build/Nex.app/Contents/Info.plist + go run ./backend/cmd/versionctl macos-plist "$$MIN_MACOS_VERSION" > build/Nex.app/Contents/Info.plist chmod +x build/Nex.app/Contents/MacOS/nex @echo "✅ macOS app packaged: build/Nex.app" @@ -256,19 +245,50 @@ desktop-build-win: desktop-prepare-frontend desktop-prepare-embedfs desktop-prep @echo "🪟 Building Windows..." ifeq ($(OS),Windows_NT) powershell -NoProfile -Command "New-Item -ItemType Directory -Path 'build' -Force | Out-Null" - cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "-H=windowsgui" -o ../build/nex-win-amd64.exe ./cmd/desktop + cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop else mkdir -p build - cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "-H=windowsgui" -o ../build/nex-win-amd64.exe ./cmd/desktop + cd backend && CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-win-amd64.exe ./cmd/desktop endif desktop-build-linux: desktop-prepare-frontend desktop-prepare-embedfs @echo "🐧 Building Linux..." - cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o ../build/nex-linux-amd64 ./cmd/desktop + cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-linux-amd64 ./cmd/desktop desktop-dev: desktop-prepare-frontend desktop-prepare-embedfs @echo "🖥️ Starting desktop app in dev mode..." - cd backend && go run ./cmd/desktop + cd backend && go run -ldflags "$(GO_LDFLAGS)" ./cmd/desktop + +# ============================================ +# 发布资产 +# ============================================ + +release-assets-linux: version-sync version-check desktop-build-linux + rm -rf "$(RELEASE_DIR)" + mkdir -p "$(RELEASE_DIR)" + cd backend && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-linux-amd64 ./cmd/server + tar -C build -czf "$(RELEASE_DIR)/$(SERVER_LINUX_ASSET)" nex-server-linux-amd64 + tar -C build -czf "$(RELEASE_DIR)/$(DESKTOP_LINUX_ASSET)" nex-linux-amd64 + +release-assets-windows: version-sync version-check desktop-build-win +ifeq ($(OS),Windows_NT) + powershell -NoProfile -Command "Remove-Item -LiteralPath '$(RELEASE_DIR)' -Recurse -Force -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Path '$(RELEASE_DIR)' -Force | Out-Null" + cd backend && set "CGO_ENABLED=1"&& set "GOOS=windows"&& set "GOARCH=amd64"&& go build -ldflags "$(GO_LDFLAGS_WIN)" -o ../build/nex-server-win-amd64.exe ./cmd/server + powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-server-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(SERVER_WINDOWS_ASSET)' -Force" + powershell -NoProfile -Command "Compress-Archive -LiteralPath 'build/nex-win-amd64.exe' -DestinationPath '$(RELEASE_DIR)/$(DESKTOP_WINDOWS_ASSET)' -Force" +else + @echo "❌ release-assets-windows 需要在 Windows 环境执行" + @exit 1 +endif + +release-assets-macos: version-sync version-check desktop-build-mac + rm -rf "$(RELEASE_DIR)" + mkdir -p "$(RELEASE_DIR)" + cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-amd64 ./cmd/server + cd backend && CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -ldflags "$(GO_LDFLAGS)" -o ../build/nex-server-darwin-arm64 ./cmd/server + tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_AMD64_ASSET)" nex-server-darwin-amd64 + tar -C build -czf "$(RELEASE_DIR)/$(SERVER_DARWIN_ARM64_ASSET)" nex-server-darwin-arm64 + ditto -c -k --keepParent build/Nex.app "$(RELEASE_DIR)/$(DESKTOP_MACOS_ASSET)" desktop-test: cd backend && go test ./cmd/desktop/... -v diff --git a/README.md b/README.md index 9248225..fcb83ee 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,64 @@ make frontend-lint # 前端代码检查 make frontend-clean # 清理前端构建产物 ``` +## 版本与发布 + +### 统一版本源 + +- 仓库根目录 `VERSION` 是全仓唯一版本源,格式固定为 `x.y.z` +- `frontend/package.json` 和前端 `.env.*` 中的 `VITE_APP_VERSION` 由仓库工具同步,不能手工漂移 + +### 本地版本演进 + +1. 手工修改根目录 `VERSION` 为新的 `x.y.z` +2. 同步镜像文件: + +```bash +go run ./backend/cmd/versionctl sync +``` + +3. 校验版本一致性: + +```bash +go run ./backend/cmd/versionctl 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 +# Linux: server + desktop +make release-assets-linux + +# Windows: server + desktop(需在 Windows 环境执行) +make release-assets-windows + +# macOS: darwin-amd64 server、darwin-arm64 server、desktop universal +make release-assets-macos +``` + +生成的版本化发布资产位于 `build/release/`。 + +### GitHub Draft Release + +- 推送 `vX.Y.Z` tag 后,`.github/workflows/release.yml` 会自动执行发布流水线 +- 流水线会先校验 tag 与 `VERSION` 一致,再构建以下资产并上传到 GitHub Draft Release: + - Linux server + - Windows server + - darwin-amd64 server + - darwin-arm64 server + - Linux desktop + - Windows desktop + - macOS desktop universal +- Release 默认以 Draft 形式创建,需人工检查后再公开发布 + ## 开发规范 详见各子项目的 README.md: diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index 4949200..c9d290d 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -25,6 +25,7 @@ import ( "nex/backend/internal/provider" "nex/backend/internal/repository" "nex/backend/internal/service" + "nex/backend/pkg/buildinfo" "github.com/getlantern/systray" "github.com/gin-gonic/gin" @@ -151,7 +152,11 @@ func main() { shutdownCtx, shutdownCancel = context.WithCancel(context.Background()) go func() { - zapLogger.Info("AI Gateway 启动", zap.String("addr", server.Addr)) + zapLogger.Info("AI Gateway 启动", + zap.String("addr", server.Addr), + zap.String("version", buildinfo.Version()), + zap.String("commit", buildinfo.Commit()), + zap.String("build_time", buildinfo.BuildTime())) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { zapLogger.Fatal("服务器启动失败", zap.Error(err)) } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 9c62084..1071582 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -22,6 +22,7 @@ import ( "nex/backend/internal/provider" "nex/backend/internal/repository" "nex/backend/internal/service" + "nex/backend/pkg/buildinfo" pkgLogger "nex/backend/pkg/logger" ) @@ -111,7 +112,11 @@ func main() { } go func() { - zapLogger.Info("AI Gateway 启动", zap.String("addr", srv.Addr)) + zapLogger.Info("AI Gateway 启动", + zap.String("addr", srv.Addr), + zap.String("version", buildinfo.Version()), + zap.String("commit", buildinfo.Commit()), + zap.String("build_time", buildinfo.BuildTime())) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { zapLogger.Fatal("服务器启动失败", zap.Error(err)) } diff --git a/backend/cmd/versionctl/main.go b/backend/cmd/versionctl/main.go new file mode 100644 index 0000000..1859e63 --- /dev/null +++ b/backend/cmd/versionctl/main.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + "os" + + "nex/backend/pkg/projectversion" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 { + return usageError() + } + + root, err := projectversion.FindRepoRoot(mustGetwd()) + if err != nil { + return err + } + + switch args[0] { + case "print": + version, readErr := projectversion.ReadString(root) + if readErr != nil { + return readErr + } + fmt.Println(version) + return nil + case "sync": + return projectversion.Sync(root) + case "check": + return projectversion.Check(root) + case "verify-tag": + if len(args) != 2 { + return fmt.Errorf("verify-tag 需要一个 tag 参数") + } + return projectversion.VerifyTag(root, args[1]) + case "macos-plist": + if len(args) != 2 { + return fmt.Errorf("macos-plist 需要一个最低系统版本参数") + } + return printMacOSPlist(root, args[1]) + case "asset-name": + return printAssetName(root, args[1:]) + default: + return usageError() + } +} + +func printMacOSPlist(root, minMacOSVersion string) error { + version, err := projectversion.ReadString(root) + if err != nil { + return err + } + + plist, err := projectversion.DesktopInfoPlist(version, minMacOSVersion) + if err != nil { + return err + } + + fmt.Print(plist) + return nil +} + +func printAssetName(root string, args []string) error { + if len(args) < 2 { + return fmt.Errorf("asset-name 至少需要 kind 和 platform 参数") + } + + version, err := projectversion.ReadString(root) + if err != nil { + return err + } + + switch args[0] { + case "server": + if len(args) != 3 { + return fmt.Errorf("server 资产命名需要 platform 和 arch 参数") + } + name, nameErr := projectversion.ServerAssetName(version, args[1], args[2]) + if nameErr != nil { + return nameErr + } + fmt.Println(name) + return nil + case "desktop": + if len(args) != 2 { + return fmt.Errorf("desktop 资产命名只需要 platform 参数") + } + name, nameErr := projectversion.DesktopAssetName(version, args[1]) + if nameErr != nil { + return nameErr + } + fmt.Println(name) + return nil + default: + return fmt.Errorf("不支持的资产类型 %q", args[0]) + } +} + +func mustGetwd() string { + wd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + return wd +} + +func usageError() error { + return fmt.Errorf("用法: versionctl ") +} diff --git a/backend/pkg/buildinfo/buildinfo.go b/backend/pkg/buildinfo/buildinfo.go new file mode 100644 index 0000000..aa64d45 --- /dev/null +++ b/backend/pkg/buildinfo/buildinfo.go @@ -0,0 +1,22 @@ +package buildinfo + +var ( + version = "dev" + commit = "unknown" + buildTime = "unknown" +) + +// Version 返回构建注入的版本号。 +func Version() string { + return version +} + +// Commit 返回构建注入的 git commit。 +func Commit() string { + return commit +} + +// BuildTime 返回构建注入的构建时间。 +func BuildTime() string { + return buildTime +} diff --git a/backend/pkg/buildinfo/buildinfo_test.go b/backend/pkg/buildinfo/buildinfo_test.go new file mode 100644 index 0000000..1321cd5 --- /dev/null +++ b/backend/pkg/buildinfo/buildinfo_test.go @@ -0,0 +1,17 @@ +package buildinfo + +import "testing" + +func TestDefaults(t *testing.T) { + if Version() == "" { + t.Fatal("Version() 不应为空") + } + + if Commit() == "" { + t.Fatal("Commit() 不应为空") + } + + if BuildTime() == "" { + t.Fatal("BuildTime() 不应为空") + } +} diff --git a/backend/pkg/projectversion/version.go b/backend/pkg/projectversion/version.go new file mode 100644 index 0000000..e4f8eb1 --- /dev/null +++ b/backend/pkg/projectversion/version.go @@ -0,0 +1,342 @@ +package projectversion + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +const versionFileName = "VERSION" + +var ( + semverRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`) + packageVersionRegex = regexp.MustCompile(`(?m)^(\s*"version"\s*:\s*")([^"]+)(",?)$`) + frontendVersionFiles = []string{ + "frontend/.env.production", + "frontend/.env.development", + "frontend/.env.desktop", + } +) + +type Version struct { + Major int + Minor int + Patch int +} + +func Parse(raw string) (Version, error) { + trimmed := strings.TrimSpace(raw) + parts := semverRegex.FindStringSubmatch(trimmed) + if parts == nil { + return Version{}, fmt.Errorf("版本号 %q 不符合 x.y.z 格式", raw) + } + + major, err := strconv.Atoi(parts[1]) + if err != nil { + return Version{}, fmt.Errorf("解析 major 失败: %w", err) + } + + minor, err := strconv.Atoi(parts[2]) + if err != nil { + return Version{}, fmt.Errorf("解析 minor 失败: %w", err) + } + + patch, err := strconv.Atoi(parts[3]) + if err != nil { + return Version{}, fmt.Errorf("解析 patch 失败: %w", err) + } + + return Version{Major: major, Minor: minor, Patch: patch}, nil +} + +func (v Version) String() string { + return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) +} + +func FindRepoRoot(start string) (string, error) { + current := start + for { + workspacePath := filepath.Join(current, "go.work") + if _, err := os.Stat(workspacePath); err == nil { + return current, nil + } + + parent := filepath.Dir(current) + if parent == current { + return "", errors.New("未找到仓库根目录 go.work") + } + + current = parent + } +} + +func Read(root string) (Version, error) { + content, err := os.ReadFile(filepath.Join(root, versionFileName)) + if err != nil { + return Version{}, fmt.Errorf("读取 VERSION 失败: %w", err) + } + + return Parse(string(content)) +} + +func ReadString(root string) (string, error) { + version, err := Read(root) + if err != nil { + return "", err + } + + return version.String(), nil +} + +func Sync(root string) error { + version, err := ReadString(root) + if err != nil { + return err + } + + packageJSONPath := filepath.Join(root, "frontend", "package.json") + packageJSONContent, err := os.ReadFile(packageJSONPath) + if err != nil { + return fmt.Errorf("读取 frontend/package.json 失败: %w", err) + } + + updatedPackageJSON, err := UpdatePackageJSONVersion(string(packageJSONContent), version) + if err != nil { + return err + } + + if err := os.WriteFile(packageJSONPath, []byte(updatedPackageJSON), 0o644); err != nil { + return fmt.Errorf("写入 frontend/package.json 失败: %w", err) + } + + for _, relPath := range frontendVersionFiles { + fullPath := filepath.Join(root, relPath) + content, err := os.ReadFile(fullPath) + if err != nil { + return fmt.Errorf("读取 %s 失败: %w", relPath, err) + } + + updated := UpsertEnvVar(string(content), "VITE_APP_VERSION", version) + if err := os.WriteFile(fullPath, []byte(updated), 0o644); err != nil { + return fmt.Errorf("写入 %s 失败: %w", relPath, err) + } + } + + return nil +} + +func Check(root string) error { + version, err := ReadString(root) + if err != nil { + return err + } + + var errs []error + + packageJSONPath := filepath.Join(root, "frontend", "package.json") + packageJSONContent, err := os.ReadFile(packageJSONPath) + if err != nil { + errs = append(errs, fmt.Errorf("读取 frontend/package.json 失败: %w", err)) + } else { + actualVersion, readErr := ReadPackageJSONVersion(string(packageJSONContent)) + if readErr != nil { + errs = append(errs, readErr) + } else if actualVersion != version { + errs = append(errs, fmt.Errorf("frontend/package.json 版本为 %s,期望 %s", actualVersion, version)) + } + } + + for _, relPath := range frontendVersionFiles { + fullPath := filepath.Join(root, relPath) + content, readErr := os.ReadFile(fullPath) + if readErr != nil { + errs = append(errs, fmt.Errorf("读取 %s 失败: %w", relPath, readErr)) + continue + } + + actualValue, ok := ReadEnvVar(string(content), "VITE_APP_VERSION") + if !ok { + errs = append(errs, fmt.Errorf("%s 缺少 VITE_APP_VERSION", relPath)) + continue + } + + if actualValue != version { + errs = append(errs, fmt.Errorf("%s 的 VITE_APP_VERSION 为 %s,期望 %s", relPath, actualValue, version)) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} + +func VerifyTag(root, tag string) error { + version, err := ReadString(root) + if err != nil { + return err + } + + if !strings.HasPrefix(tag, "v") { + return fmt.Errorf("tag %q 必须以 v 开头", tag) + } + + if tag[1:] != version { + return fmt.Errorf("tag %q 与 VERSION %q 不一致", tag, version) + } + + return nil +} + +func UpdatePackageJSONVersion(content, version string) (string, error) { + if _, err := Parse(version); err != nil { + return "", err + } + + if !packageVersionRegex.MatchString(content) { + return "", errors.New("frontend/package.json 缺少 version 字段") + } + + updated := packageVersionRegex.ReplaceAllString(content, `${1}`+version+`${3}`) + return updated, nil +} + +func ReadPackageJSONVersion(content string) (string, error) { + parts := packageVersionRegex.FindStringSubmatch(content) + if parts == nil { + return "", errors.New("frontend/package.json 缺少 version 字段") + } + + if _, err := Parse(parts[2]); err != nil { + return "", err + } + + return parts[2], nil +} + +func UpsertEnvVar(content, key, value string) string { + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = lines[:0] + } + + updated := false + for i, line := range lines { + if strings.HasPrefix(line, key+"=") { + lines[i] = key + "=" + value + updated = true + } + } + + if !updated { + lines = append(lines, key+"="+value) + } + + return strings.Join(lines, "\n") + "\n" +} + +func ReadEnvVar(content, key string) (string, bool) { + for _, line := range strings.Split(content, "\n") { + if strings.HasPrefix(line, key+"=") { + return strings.TrimPrefix(line, key+"="), true + } + } + + return "", false +} + +func ServerAssetName(version, goos, arch string) (string, error) { + if _, err := Parse(version); err != nil { + return "", err + } + + switch goos { + case "linux", "windows", "darwin": + default: + return "", fmt.Errorf("不支持的 server 平台 %q", goos) + } + + if arch == "" { + return "", errors.New("server 资产命名缺少架构") + } + + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + + return fmt.Sprintf("nex-server_%s_%s_%s%s", version, goos, arch, ext), nil +} + +func DesktopAssetName(version, platform string) (string, error) { + if _, err := Parse(version); err != nil { + return "", err + } + + switch platform { + case "linux": + return fmt.Sprintf("Nex_%s_linux_amd64.tar.gz", version), nil + case "windows": + return fmt.Sprintf("Nex_%s_windows_amd64.zip", version), nil + case "macos": + return fmt.Sprintf("Nex_%s_macOS_universal.zip", version), nil + default: + return "", fmt.Errorf("不支持的 desktop 平台 %q", platform) + } +} + +func DesktopInfoPlist(version, minMacOSVersion string) (string, error) { + if _, err := Parse(version); err != nil { + return "", err + } + + if strings.TrimSpace(minMacOSVersion) == "" { + return "", errors.New("min macOS version 不能为空") + } + + content := strings.Join([]string{ + ``, + ``, + ``, + ``, + ` CFBundleDevelopmentRegion`, + ` zh-Hans`, + ` CFBundleExecutable`, + ` nex`, + ` CFBundleIconFile`, + ` icon`, + ` CFBundleIdentifier`, + ` com.lanyuanxiaoyao.nex`, + ` CFBundleInfoDictionaryVersion`, + ` 6.0`, + ` LSApplicationCategoryType`, + ` public.app-category.developer-tools`, + ` CFBundleName`, + ` Nex`, + ` CFBundleDisplayName`, + ` Nex`, + ` CFBundlePackageType`, + ` APPL`, + ` CFBundleShortVersionString`, + ` ` + version + ``, + ` CFBundleVersion`, + ` ` + version + ``, + ` NSHumanReadableCopyright`, + ` Copyright © 2026 Nex`, + ` LSMinimumSystemVersion`, + ` ` + minMacOSVersion + ``, + ` LSUIElement`, + ` `, + ` NSHighResolutionCapable`, + ` `, + ``, + ``, + }, "\n") + + return content + "\n", nil +} diff --git a/backend/pkg/projectversion/version_test.go b/backend/pkg/projectversion/version_test.go new file mode 100644 index 0000000..dbde286 --- /dev/null +++ b/backend/pkg/projectversion/version_test.go @@ -0,0 +1,113 @@ +package projectversion + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + t.Run("valid", func(t *testing.T) { + version, err := Parse("1.2.3") + require.NoError(t, err) + assert.Equal(t, Version{Major: 1, Minor: 2, Patch: 3}, version) + assert.Equal(t, "1.2.3", version.String()) + }) + + t.Run("invalid", func(t *testing.T) { + invalidValues := []string{"", "1.2", "1.2.3.4", "v1.2.3", "01.2.3", "1.02.3"} + for _, tc := range invalidValues { + _, err := Parse(tc) + assert.Error(t, err, "%q 应校验失败", tc) + } + }) +} + +func TestUpdatePackageJSONVersion(t *testing.T) { + content := "{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n" + updated, err := UpdatePackageJSONVersion(content, "1.2.3") + require.NoError(t, err) + assert.Contains(t, updated, `"version": "1.2.3"`) + + version, err := ReadPackageJSONVersion(updated) + require.NoError(t, err) + assert.Equal(t, "1.2.3", version) +} + +func TestUpsertEnvVar(t *testing.T) { + updated := UpsertEnvVar("VITE_API_BASE=/api\n", "VITE_APP_VERSION", "1.2.3") + assert.Contains(t, updated, "VITE_API_BASE=/api\n") + assert.Contains(t, updated, "VITE_APP_VERSION=1.2.3\n") + + updated = UpsertEnvVar(updated, "VITE_APP_VERSION", "2.0.0") + value, ok := ReadEnvVar(updated, "VITE_APP_VERSION") + assert.True(t, ok) + assert.Equal(t, "2.0.0", value) + assert.Equal(t, 1, strings.Count(updated, "VITE_APP_VERSION=")) +} + +func TestSyncAndCheck(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "frontend"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", "package.json"), []byte("{\n \"name\": \"frontend\",\n \"version\": \"0.0.0\"\n}\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.production"), []byte("VITE_API_BASE=/api\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.development"), []byte("VITE_API_BASE=\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(root, "frontend", ".env.desktop"), []byte("VITE_API_BASE=\n"), 0o644)) + + require.NoError(t, Sync(root)) + require.NoError(t, Check(root)) + + packageJSONContent, err := os.ReadFile(filepath.Join(root, "frontend", "package.json")) + require.NoError(t, err) + assert.Contains(t, string(packageJSONContent), `"version": "1.2.3"`) + + for _, relPath := range frontendVersionFiles { + content, readErr := os.ReadFile(filepath.Join(root, relPath)) + require.NoError(t, readErr) + assert.Contains(t, string(content), "VITE_APP_VERSION=1.2.3\n") + } +} + +func TestVerifyTag(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.2.3\n"), 0o644)) + + require.NoError(t, VerifyTag(root, "v1.2.3")) + assert.Error(t, VerifyTag(root, "1.2.3")) + assert.Error(t, VerifyTag(root, "v1.2.4")) +} + +func TestAssetNames(t *testing.T) { + linuxServer, err := ServerAssetName("1.2.3", "linux", "amd64") + require.NoError(t, err) + assert.Equal(t, "nex-server_1.2.3_linux_amd64.tar.gz", linuxServer) + + macServer, err := ServerAssetName("1.2.3", "darwin", "arm64") + require.NoError(t, err) + assert.Equal(t, "nex-server_1.2.3_darwin_arm64.tar.gz", macServer) + + macDesktop, err := DesktopAssetName("1.2.3", "macos") + require.NoError(t, err) + assert.Equal(t, "Nex_1.2.3_macOS_universal.zip", macDesktop) + + _, err = DesktopAssetName("1.2.3", "ios") + assert.Error(t, err) +} + +func TestDesktopInfoPlist(t *testing.T) { + plist, err := DesktopInfoPlist("1.2.3", "13.0") + require.NoError(t, err) + assert.Contains(t, plist, "CFBundleShortVersionString\n 1.2.3") + assert.Contains(t, plist, "CFBundleVersion\n 1.2.3") + assert.Contains(t, plist, "LSMinimumSystemVersion\n 13.0") + + _, err = DesktopInfoPlist("1.2", "13.0") + assert.Error(t, err) + _, err = DesktopInfoPlist("1.2.3", "") + assert.Error(t, err) +} diff --git a/frontend/.env.desktop b/frontend/.env.desktop index a41b3e9..5ecb273 100644 --- a/frontend/.env.desktop +++ b/frontend/.env.desktop @@ -1 +1,2 @@ VITE_API_BASE= +VITE_APP_VERSION=0.1.0 diff --git a/frontend/.env.development b/frontend/.env.development index a41b3e9..5ecb273 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -1 +1,2 @@ VITE_API_BASE= +VITE_APP_VERSION=0.1.0 diff --git a/frontend/.env.production b/frontend/.env.production index 0a0c303..fc8bbe1 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1 +1,2 @@ VITE_API_BASE=/api +VITE_APP_VERSION=0.1.0 diff --git a/frontend/package.json b/frontend/package.json index 3a1651c..c7ce21c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "0.1.0", "license": "Apache-2.0", "type": "module", "scripts": { diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md index bc745ed..155babd 100644 --- a/openspec/specs/desktop-app/spec.md +++ b/openspec/specs/desktop-app/spec.md @@ -132,38 +132,50 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ### Requirement: 跨平台构建 -系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。 +系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,中间构建产物文件名 SHALL 保持 `nex-{os}-{arch}[.exe]` 格式,最终桌面发布资产文件名 SHALL 包含统一版本号和平台标识。 #### Scenario: macOS 构建 -- **WHEN** 执行 `desktop-build-mac` 构建命令 +- **WHEN** 执行 `desktop-build-mac` 构建命令且当前版本为 `1.2.3` - **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件 -- **AND** 可打包为 `.app` bundle +- **AND** 系统生成可打包为 `.app` bundle 的 macOS 桌面产物 +- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS` 平台标识 #### Scenario: Windows 构建 -- **WHEN** 执行 `desktop-build-win` 构建命令 -- **THEN** 生成 `nex-win-amd64.exe` 可执行文件 +- **WHEN** 执行 `desktop-build-win` 构建命令且当前版本为 `1.2.3` +- **THEN** 系统生成 Windows 桌面可执行文件 +- **AND** 生成 `nex-win-amd64.exe` 可执行文件 - **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口 +- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `windows` 平台标识 #### Scenario: Linux 构建 -- **WHEN** 执行 `desktop-build-linux` 构建命令 -- **THEN** 生成 `nex-linux-amd64` 可执行文件 +- **WHEN** 执行 `desktop-build-linux` 构建命令且当前版本为 `1.2.3` +- **THEN** 系统生成 Linux 桌面可执行文件 +- **AND** 生成 `nex-linux-amd64` 可执行文件 +- **AND** 最终发布资产文件名 SHALL 包含 `1.2.3` 和 `linux` 平台标识 ### Requirement: macOS .app 打包 -系统 SHALL 支持打包为 macOS .app bundle。 +系统 SHALL 支持打包为 macOS `.app` bundle,并使 bundle 元数据中的版本字段来源于统一版本号而非硬编码值。 #### Scenario: .app 结构 -- **WHEN** 执行打包脚本 +- **WHEN** 执行 macOS 桌面打包脚本 - **THEN** 生成 `Nex.app` 目录结构 - **AND** 包含 `Contents/Info.plist` 元数据 - **AND** 包含 `Contents/MacOS/nex` 可执行文件 - **AND** 包含 `Contents/Resources/icon.icns` 图标 - **AND** `Info.plist` 中 `LSUIElement` 为 `true`(不显示 Dock 图标) +#### Scenario: bundle 版本元数据同步 + +- **WHEN** 当前统一版本号为 `1.2.3` +- **THEN** `Info.plist` 中 `CFBundleShortVersionString` SHALL 为 `1.2.3` +- **AND** `Info.plist` 中 `CFBundleVersion` SHALL 为 `1.2.3` +- **AND** 打包流程 SHALL NOT 使用硬编码版本值 + ### Requirement: Windows 原生对话框 系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。 diff --git a/openspec/specs/release-pipeline/spec.md b/openspec/specs/release-pipeline/spec.md new file mode 100644 index 0000000..a3a50a8 --- /dev/null +++ b/openspec/specs/release-pipeline/spec.md @@ -0,0 +1,78 @@ +# 发布流水线 + +## Purpose + +定义 tag 驱动的发布流程、跨平台构建产物要求与 Draft Release 组装规则,确保发布结果可复现且可审阅。 + +## Requirements + +### Requirement: Tag 驱动发布流水线 + +系统 SHALL 仅在符合 `vX.Y.Z` 格式的 Git tag 上触发发布流水线,普通分支 push SHALL NOT 创建发布。 + +#### Scenario: 有效发布 tag + +- **WHEN** 仓库收到 `v1.2.3` tag push +- **THEN** 发布流水线 SHALL 启动版本校验、构建和 Release 组装步骤 + +#### Scenario: 普通分支推送 + +- **WHEN** 仓库收到非 tag 的分支 push +- **THEN** 系统 SHALL NOT 创建 GitHub Release + +### Requirement: 三平台发布构建 + +系统 SHALL 在发布流水线中构建 server 与 desktop 的 Linux、Windows、macOS 三个平台产物。 + +#### Scenario: Linux 发布构建 + +- **WHEN** 发布流水线执行 Linux 构建 job +- **THEN** 系统 SHALL 生成 Linux server 发布资产 +- **AND** 系统 SHALL 生成 Linux desktop 发布资产 + +#### Scenario: Windows 发布构建 + +- **WHEN** 发布流水线执行 Windows 构建 job +- **THEN** 系统 SHALL 生成 Windows server 发布资产 +- **AND** 系统 SHALL 生成 Windows desktop 发布资产 + +#### Scenario: macOS 发布构建 + +- **WHEN** 发布流水线执行 macOS 构建 job +- **THEN** 系统 SHALL 生成 darwin-amd64 server 发布资产 +- **AND** 系统 SHALL 生成 darwin-arm64 server 发布资产 +- **AND** 系统 SHALL 生成 macOS desktop universal 发布资产 + +### Requirement: 版本化发布资产命名 + +系统 SHALL 为 server 与 desktop 的发布资产使用包含统一版本号和目标平台信息的文件名,确保 Release 页面可直接区分产物用途与平台。 + +#### Scenario: server 资产命名 + +- **WHEN** 当前发布版本为 `1.2.3` +- **THEN** Linux server 发布资产文件名 SHALL 包含 `1.2.3`、`linux` 和 `amd64` +- **AND** Windows server 发布资产文件名 SHALL 包含 `1.2.3`、`windows` 和 `amd64` +- **AND** macOS server 发布资产文件名 SHALL 分别包含 `1.2.3`、`darwin`、`amd64` 与 `1.2.3`、`darwin`、`arm64` + +#### Scenario: desktop 资产命名 + +- **WHEN** 当前发布版本为 `1.2.3` +- **THEN** Linux desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `linux` +- **AND** Windows desktop 发布资产文件名 SHALL 包含 `1.2.3` 和 `windows` +- **AND** macOS desktop universal 发布资产文件名 SHALL 包含 `1.2.3` 和 `macOS` + +### Requirement: Draft Release 组装 + +系统 SHALL 将发布流水线产物上传到 GitHub Draft Release,由人工确认后再公开发布。 + +#### Scenario: 发布成功时创建 Draft Release + +- **WHEN** 版本校验通过且三平台发布资产构建完成 +- **THEN** 系统 SHALL 创建或更新与该 tag 对应的 GitHub Draft Release +- **AND** 系统 SHALL 上传 server 与 desktop 的全部发布资产 + +#### Scenario: 构建失败时阻止完成发布 + +- **WHEN** 任一平台发布资产构建失败或版本校验失败 +- **THEN** 发布流水线 SHALL 失败 +- **AND** 系统 SHALL NOT 产生可直接公开的成功发布结果 diff --git a/openspec/specs/repository-versioning/spec.md b/openspec/specs/repository-versioning/spec.md new file mode 100644 index 0000000..1ecef1d --- /dev/null +++ b/openspec/specs/repository-versioning/spec.md @@ -0,0 +1,68 @@ +# 仓库版本管理 + +## Purpose + +定义仓库统一版本源、镜像同步、发布一致性校验与构建版本注入规则,确保所有构建消费者使用同一版本语义。 + +## Requirements + +### Requirement: 统一版本源 + +系统 SHALL 使用仓库根目录 `VERSION` 文件作为全仓唯一版本源,文件内容 SHALL 仅包含一行 `x.y.z` 格式的语义版本号。 + +#### Scenario: 读取有效版本号 + +- **WHEN** 仓库中的 `VERSION` 文件内容为 `1.2.3` +- **THEN** 本地工具和 CI SHALL 将 `1.2.3` 视为当前仓库版本 +- **AND** 其他版本消费者 SHALL NOT 覆盖该值作为权威来源 + +#### Scenario: 拒绝非法版本格式 + +- **WHEN** `VERSION` 文件内容不是 `x.y.z` 格式 +- **THEN** 版本同步与发布校验流程 SHALL 失败 +- **AND** 系统 SHALL 输出格式错误信息 + +### Requirement: 版本镜像同步 + +系统 SHALL 提供仓库内的版本同步入口,将 `VERSION` 中的版本值写入需要镜像版本号的构建消费者。 + +#### Scenario: 同步前端镜像字段 + +- **WHEN** 执行版本同步流程且 `VERSION` 为 `1.2.3` +- **THEN** `frontend/package.json` 中的 `version` 字段 SHALL 被同步为 `1.2.3` + +#### Scenario: 同步构建模板值 + +- **WHEN** 执行版本同步流程且存在依赖版本号的构建模板或元数据模板 +- **THEN** 这些模板消费的版本值 SHALL 与 `VERSION` 保持一致 +- **AND** 系统 SHALL NOT 要求用户手工修改多个版本文件 + +### Requirement: 发布版本一致性校验 + +系统 SHALL 在发布前校验仓库版本与 Git tag 一致,确保发布锚点与仓库状态不漂移。 + +#### Scenario: tag 与 VERSION 一致 + +- **WHEN** 发布使用的 Git tag 为 `v1.2.3` 且 `VERSION` 为 `1.2.3` +- **THEN** 发布校验 SHALL 通过 + +#### Scenario: tag 与 VERSION 不一致 + +- **WHEN** 发布使用的 Git tag 为 `v1.2.4` 但 `VERSION` 为 `1.2.3` +- **THEN** 发布校验 SHALL 失败 +- **AND** 系统 SHALL 阻止后续发布步骤继续执行 + +### Requirement: 统一构建版本注入 + +系统 SHALL 在构建阶段把统一版本信息注入 frontend、server 和 desktop,而不是在运行时依赖外部发布平台查询版本。 + +#### Scenario: Go 二进制注入版本元数据 + +- **WHEN** 构建 server 或 desktop 二进制 +- **THEN** 构建流程 SHALL 注入 `version`、`commit` 和 `buildTime` 元数据 + +#### Scenario: 前端注入构建版本 + +- **WHEN** 执行前端生产构建 +- **THEN** 构建流程 SHALL 注入 `VITE_APP_VERSION` +- **AND** 该值 SHALL 等于 `VERSION` 中的版本号