Windows ARM64 使用场景极少,windows-11-arm runner 上 MSYS2 CLANGARM64 交叉编译不稳定,CGO 编译问题难以排查,维护成本 远超收益。移除 arm64 的 CI 矩阵条目、Makefile Windows 变量、 versionctl 资产白名单、README 文档和规范中的相关需求。 Linux 和 macOS arm64 不受影响。
484 lines
12 KiB
Go
484 lines
12 KiB
Go
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
|
||
}
|