引入 VERSION 作为统一版本源,避免前端、后端、桌面打包和发布资产之间的版本漂移。 新增 tag 驱动的 Draft Release 流程与版本化资产命名,使本地演进和 GitHub 发布共享同一套约束。
343 lines
8.4 KiB
Go
343 lines
8.4 KiB
Go
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
|
||
}
|