1
0
Files
nex/backend/pkg/projectversion/version.go
lanyuanxiaoyao a9972360c2 feat: 增加版本化构建与发布流程
引入 VERSION 作为统一版本源,避免前端、后端、桌面打包和发布资产之间的版本漂移。
新增 tag 驱动的 Draft Release 流程与版本化资产命名,使本地演进和 GitHub 发布共享同一套约束。
2026-04-28 14:20:27 +08:00

343 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{
`<?xml version="1.0" encoding="UTF-8"?>`,
`<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
`<plist version="1.0">`,
`<dict>`,
` <key>CFBundleDevelopmentRegion</key>`,
` <string>zh-Hans</string>`,
` <key>CFBundleExecutable</key>`,
` <string>nex</string>`,
` <key>CFBundleIconFile</key>`,
` <string>icon</string>`,
` <key>CFBundleIdentifier</key>`,
` <string>com.lanyuanxiaoyao.nex</string>`,
` <key>CFBundleInfoDictionaryVersion</key>`,
` <string>6.0</string>`,
` <key>LSApplicationCategoryType</key>`,
` <string>public.app-category.developer-tools</string>`,
` <key>CFBundleName</key>`,
` <string>Nex</string>`,
` <key>CFBundleDisplayName</key>`,
` <string>Nex</string>`,
` <key>CFBundlePackageType</key>`,
` <string>APPL</string>`,
` <key>CFBundleShortVersionString</key>`,
` <string>` + version + `</string>`,
` <key>CFBundleVersion</key>`,
` <string>` + version + `</string>`,
` <key>NSHumanReadableCopyright</key>`,
` <string>Copyright © 2026 Nex</string>`,
` <key>LSMinimumSystemVersion</key>`,
` <string>` + minMacOSVersion + `</string>`,
` <key>LSUIElement</key>`,
` <true/>`,
` <key>NSHighResolutionCapable</key>`,
` <true/>`,
`</dict>`,
`</plist>`,
}, "\n")
return content + "\n", nil
}