1
0
Files
nex/versionctl/projectversion/version.go
lanyuanxiaoyao 5655fc5560 chore: 移除 Windows arm64 构建与发布支持
Windows ARM64 使用场景极少,windows-11-arm runner 上 MSYS2
CLANGARM64 交叉编译不稳定,CGO 编译问题难以排查,维护成本
远超收益。移除 arm64 的 CI 矩阵条目、Makefile Windows 变量、
versionctl 资产白名单、README 文档和规范中的相关需求。
Linux 和 macOS arm64 不受影响。
2026-05-05 20:20:04 +08:00

484 lines
12 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"
"os/exec"
"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 (v Version) Less(other Version) bool {
if v.Major != other.Major {
return v.Major < other.Major
}
if v.Minor != other.Minor {
return v.Minor < other.Minor
}
return v.Patch < other.Patch
}
func FindRepoRoot(start string) (string, error) {
current := start
for {
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), 0o600); 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), 0o600); 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 ReleaseAssetName(version, component, platform, arch, format string) (string, error) {
if _, err := Parse(version); err != nil {
return "", err
}
switch component {
case "server":
return serverAssetName(version, platform, arch, format)
case "web":
return webAssetName(version, platform, arch, format)
case "desktop":
return desktopAssetName(version, platform, arch, format)
default:
return "", fmt.Errorf("不支持的资产组件 %q", component)
}
}
func serverAssetName(version, platform, arch, format string) (string, error) {
if !validCombination(platform, arch, format, []releaseAssetTarget{
{platform: "linux", arch: "amd64", format: "tar.gz"},
{platform: "linux", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "amd64", format: "tar.gz"},
{platform: "macos", arch: "arm64", format: "tar.gz"},
{platform: "macos", arch: "universal", format: "tar.gz"},
{platform: "windows", arch: "amd64", format: "zip"},
}) {
return "", fmt.Errorf("不支持的 server 资产目标 %s/%s/%s", platform, arch, format)
}
return fmt.Sprintf("nex-server_%s_%s_%s.%s", version, platform, arch, format), nil
}
func webAssetName(version, platform, arch, format string) (string, error) {
if platform != "" || arch != "" {
return "", errors.New("web 资产命名不支持平台或架构参数")
}
if format != "tar.gz" {
return "", fmt.Errorf("不支持的 web 资产格式 %q", format)
}
return fmt.Sprintf("nex-web_%s.tar.gz", version), nil
}
func desktopAssetName(version, platform, arch, format string) (string, error) {
if !validCombination(platform, arch, format, []releaseAssetTarget{
{platform: "linux", arch: "amd64", format: "tar.gz"},
{platform: "linux", arch: "amd64", format: "AppImage"},
{platform: "linux", arch: "amd64", format: "deb"},
{platform: "linux", arch: "amd64", format: "rpm"},
{platform: "linux", arch: "arm64", format: "tar.gz"},
{platform: "linux", arch: "arm64", format: "AppImage"},
{platform: "linux", arch: "arm64", format: "deb"},
{platform: "linux", arch: "arm64", format: "rpm"},
{platform: "macos", arch: "universal", format: "zip"},
{platform: "macos", arch: "universal", format: "dmg"},
{platform: "windows", arch: "amd64", format: "zip"},
}) {
return "", fmt.Errorf("不支持的 desktop 资产目标 %s/%s/%s", platform, arch, format)
}
return fmt.Sprintf("nex-desktop_%s_%s_%s.%s", version, platform, arch, format), nil
}
type releaseAssetTarget struct {
platform string
arch string
format string
}
func validCombination(platform, arch, format string, targets []releaseAssetTarget) bool {
for _, target := range targets {
if target.platform == platform && target.arch == arch && target.format == format {
return true
}
}
return false
}
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
}
var tagRegex = regexp.MustCompile(`^v(\d+\.\d+\.\d+)$`)
func ListGitTags(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "tag", "--list", "--merge", "HEAD")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("获取 git tag 列表失败: %w", err)
}
var tags []string
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
line = strings.TrimSpace(line)
if line != "" {
tags = append(tags, line)
}
}
return tags, nil
}
func CheckNoRegression(newVersion Version, tags []string) error {
var maxVersion Version
found := false
for _, tag := range tags {
parts := tagRegex.FindStringSubmatch(tag)
if parts == nil {
continue
}
v, err := Parse(parts[1])
if err != nil {
continue
}
if !found || maxVersion.Less(v) {
maxVersion = v
found = true
}
}
if !found {
return nil
}
if newVersion == maxVersion {
return fmt.Errorf("版本号 %s 已存在tag v%s", newVersion, maxVersion)
}
if newVersion.Less(maxVersion) {
return fmt.Errorf("版本号 %s 小于已有 tag v%s不允许倒退", newVersion, maxVersion)
}
return nil
}
func Bump(root, arg string) (Version, error) {
current, err := Read(root)
if err != nil {
return Version{}, err
}
var newVersion Version
switch arg {
case "major":
newVersion = Version{Major: current.Major + 1, Minor: 0, Patch: 0}
case "minor":
newVersion = Version{Major: current.Major, Minor: current.Minor + 1, Patch: 0}
case "patch":
newVersion = Version{Major: current.Major, Minor: current.Minor, Patch: current.Patch + 1}
default:
parsed, parseErr := Parse(arg)
if parseErr != nil {
return Version{}, fmt.Errorf("参数 %q 既非 major|minor|patch 也非合法版本号: %w", arg, parseErr)
}
newVersion = parsed
}
versionPath := filepath.Join(root, versionFileName)
if err := os.WriteFile(versionPath, []byte(newVersion.String()+"\n"), 0o600); err != nil {
return Version{}, fmt.Errorf("写入 VERSION 失败: %w", err)
}
return newVersion, nil
}