feat: 迁移 versionctl 为独立模块并新增 make version-bump 命令
- 将 backend/cmd/versionctl 和 backend/pkg/projectversion 迁移至独立 versionctl/ Go 模块 - 新增 bump 子命令支持 major/minor/patch 和指定版本号,含版本倒退防护 - 新增 make version-bump 编排完整升迁流程(bump + sync + check + commit + tag) - 更新所有引用路径:根 Makefile、backend/Makefile、release.yml、.golangci.yml - 新增 versionctl/.golangci.yml(精简配置)和 Makefile(lint/test/coverage) - 根 Makefile lint/test 集成 versionctl 模块 - 同步 openspec specs:新增 version-bump spec,更新 release-pipeline spec
This commit is contained in:
443
versionctl/projectversion/version.go
Normal file
443
versionctl/projectversion/version.go
Normal file
@@ -0,0 +1,443 @@
|
||||
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 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user