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` 中的版本号