feat: modernize

This commit is contained in:
eric
2026-03-21 01:27:42 +01:00
parent e9d04c7f8d
commit 8fec37023f
37 changed files with 3270 additions and 3145 deletions

View File

@@ -0,0 +1,91 @@
package release
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
func requireCleanGit(rootDir string) error {
if _, err := runCommand(rootDir, nil, io.Discard, io.Discard, "git", "diff", "--quiet"); err != nil {
return errors.New("git working tree is not clean. Commit or stash changes first")
}
if _, err := runCommand(rootDir, nil, io.Discard, io.Discard, "git", "diff", "--cached", "--quiet"); err != nil {
return errors.New("git working tree is not clean. Commit or stash changes first")
}
return nil
}
func gitOutput(dir string, args ...string) (string, error) {
return runCommand(dir, nil, nil, nil, "git", args...)
}
func runCommand(dir string, env []string, stdout io.Writer, stderr io.Writer, name string, args ...string) (string, error) {
resolvedName, err := resolveExecutable(name, env)
if err != nil {
return "", err
}
cmd := exec.Command(resolvedName, args...)
if dir != "" {
cmd.Dir = dir
}
if env != nil {
cmd.Env = env
} else {
cmd.Env = os.Environ()
}
var out bytes.Buffer
cmd.Stdout = io.MultiWriter(&out, writerOrDiscard(stdout))
cmd.Stderr = io.MultiWriter(&out, writerOrDiscard(stderr))
err = cmd.Run()
if err != nil {
output := strings.TrimSpace(out.String())
if output == "" {
return out.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return out.String(), fmt.Errorf("%s %s: %w\n%s", name, strings.Join(args, " "), err, output)
}
return out.String(), nil
}
func resolveExecutable(name string, env []string) (string, error) {
if strings.ContainsRune(name, os.PathSeparator) {
return name, nil
}
pathValue := os.Getenv("PATH")
for _, entry := range env {
if strings.HasPrefix(entry, "PATH=") {
pathValue = strings.TrimPrefix(entry, "PATH=")
}
}
for _, dir := range filepath.SplitList(pathValue) {
if dir == "" {
dir = "."
}
candidate := filepath.Join(dir, name)
info, err := os.Stat(candidate)
if err != nil || info.IsDir() {
continue
}
if info.Mode()&0o111 == 0 {
continue
}
return candidate, nil
}
return "", fmt.Errorf("executable %q not found in PATH", name)
}
func writerOrDiscard(w io.Writer) io.Writer {
if w == nil {
return io.Discard
}
return w
}

View File

@@ -0,0 +1,64 @@
package release
import (
"encoding/json"
"fmt"
)
type ReleaseStep struct {
Kind string `json:"kind"`
Path string `json:"path,omitempty"`
Text string `json:"text,omitempty"`
Regex string `json:"regex,omitempty"`
Replacement string `json:"replacement,omitempty"`
Key string `json:"key,omitempty"`
Value string `json:"value,omitempty"`
}
func decodeReleaseSteps(raw string) ([]ReleaseStep, error) {
if raw == "" {
return nil, nil
}
var steps []ReleaseStep
if err := json.Unmarshal([]byte(raw), &steps); err != nil {
return nil, fmt.Errorf("decode release steps: %w", err)
}
for i, step := range steps {
if err := validateReleaseStep(step); err != nil {
return nil, fmt.Errorf("release step %d: %w", i+1, err)
}
}
return steps, nil
}
func validateReleaseStep(step ReleaseStep) error {
switch step.Kind {
case "writeFile":
if step.Path == "" {
return fmt.Errorf("writeFile.path is required")
}
return nil
case "replace":
if step.Path == "" {
return fmt.Errorf("replace.path is required")
}
if step.Regex == "" {
return fmt.Errorf("replace.regex is required")
}
return nil
case "versionMetaSet":
if step.Key == "" {
return fmt.Errorf("versionMetaSet.key is required")
}
return nil
case "versionMetaUnset":
if step.Key == "" {
return fmt.Errorf("versionMetaUnset.key is required")
}
return nil
default:
return fmt.Errorf("unsupported release step kind %q", step.Kind)
}
}

View File

@@ -0,0 +1,39 @@
package release
import (
"fmt"
"io"
)
func (r *Runner) runReleaseSteps(rootDir string, versionPath string, versionFile *VersionFile, version Version, stdout io.Writer, stderr io.Writer) error {
steps, err := decodeReleaseSteps(r.Config.ReleaseStepsJSON)
if err != nil {
return err
}
if len(steps) == 0 {
return nil
}
ctx := newReleaseStepContext(rootDir, versionPath, versionFile, version, r.Config.Env)
for i, step := range steps {
if err := applyReleaseStep(ctx, step); err != nil {
return fmt.Errorf("release step %d (%s): %w", i+1, step.Kind, err)
}
}
return nil
}
func applyReleaseStep(ctx *ReleaseStepContext, step ReleaseStep) error {
switch step.Kind {
case "writeFile":
return applyWriteFileStep(ctx, step)
case "replace":
return applyReplaceStep(ctx, step)
case "versionMetaSet":
return applyVersionMetaSetStep(ctx, step)
case "versionMetaUnset":
return applyVersionMetaUnsetStep(ctx, step)
default:
return fmt.Errorf("unsupported release step kind %q", step.Kind)
}
}

View File

@@ -0,0 +1,73 @@
package release
import (
"os"
"path/filepath"
"strconv"
"strings"
)
type ReleaseStepContext struct {
RootDir string
VersionPath string
Version Version
VersionFile *VersionFile
Env map[string]string
}
func newReleaseStepContext(rootDir string, versionPath string, versionFile *VersionFile, version Version, env []string) *ReleaseStepContext {
return &ReleaseStepContext{
RootDir: rootDir,
VersionPath: versionPath,
Version: version,
VersionFile: versionFile,
Env: buildReleaseEnv(rootDir, versionFile, version, env),
}
}
func buildReleaseEnv(rootDir string, versionFile *VersionFile, version Version, baseEnv []string) map[string]string {
env := make(map[string]string, len(baseEnv)+8+len(versionFile.Metadata.lines))
if len(baseEnv) == 0 {
baseEnv = os.Environ()
}
for _, entry := range baseEnv {
key, value, ok := strings.Cut(entry, "=")
if ok {
env[key] = value
}
}
env["ROOT_DIR"] = rootDir
env["BASE_VERSION"] = version.BaseString()
env["CHANNEL"] = version.Channel
env["FULL_VERSION"] = version.String()
env["FULL_TAG"] = version.Tag()
if version.Channel == "stable" {
env["PRERELEASE_NUM"] = ""
} else {
env["PRERELEASE_NUM"] = strconv.Itoa(version.Prerelease)
}
for _, line := range versionFile.Metadata.lines {
key, value, ok := strings.Cut(line, "=")
if !ok || key == "" {
continue
}
env[sanitizeMetaEnvName(key)] = value
}
return env
}
func (c *ReleaseStepContext) expand(raw string) string {
return os.Expand(raw, func(name string) string {
return c.Env[name]
})
}
func (c *ReleaseStepContext) resolvePath(path string) string {
expanded := c.expand(path)
if filepath.IsAbs(expanded) {
return expanded
}
return filepath.Join(c.RootDir, expanded)
}

View File

@@ -0,0 +1,44 @@
package release
import (
"fmt"
"os"
"regexp"
"strings"
)
func applyReplaceStep(ctx *ReleaseStepContext, step ReleaseStep) error {
targetPath := ctx.resolvePath(step.Path)
content, err := os.ReadFile(targetPath)
if err != nil {
return fmt.Errorf("read %s: %w", targetPath, err)
}
pattern, err := regexp.Compile("(?m)" + ctx.expand(step.Regex))
if err != nil {
return fmt.Errorf("compile regex for %s: %w", targetPath, err)
}
replacement := translateReplacementBackrefs(ctx.expand(step.Replacement))
updated := pattern.ReplaceAllString(string(content), replacement)
if err := os.WriteFile(targetPath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write %s: %w", targetPath, err)
}
return nil
}
func translateReplacementBackrefs(raw string) string {
var b strings.Builder
b.Grow(len(raw))
for i := 0; i < len(raw); i++ {
if raw[i] == '\\' && i+1 < len(raw) && raw[i+1] >= '1' && raw[i+1] <= '9' {
b.WriteByte('$')
b.WriteByte(raw[i+1])
i++
continue
}
b.WriteByte(raw[i])
}
return b.String()
}

View File

@@ -0,0 +1,21 @@
package release
import "fmt"
func applyVersionMetaSetStep(ctx *ReleaseStepContext, step ReleaseStep) error {
ctx.VersionFile.Metadata.Set(step.Key, ctx.expand(step.Value))
ctx.Env[sanitizeMetaEnvName(step.Key)] = ctx.VersionFile.Metadata.Get(step.Key)
if err := ctx.VersionFile.Write(ctx.VersionPath); err != nil {
return fmt.Errorf("write VERSION: %w", err)
}
return nil
}
func applyVersionMetaUnsetStep(ctx *ReleaseStepContext, step ReleaseStep) error {
ctx.VersionFile.Metadata.Unset(step.Key)
delete(ctx.Env, sanitizeMetaEnvName(step.Key))
if err := ctx.VersionFile.Write(ctx.VersionPath); err != nil {
return fmt.Errorf("write VERSION: %w", err)
}
return nil
}

View File

@@ -0,0 +1,18 @@
package release
import (
"fmt"
"os"
"path/filepath"
)
func applyWriteFileStep(ctx *ReleaseStepContext, step ReleaseStep) error {
targetPath := ctx.resolvePath(step.Path)
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(targetPath), err)
}
if err := os.WriteFile(targetPath, []byte(ctx.expand(step.Text)), 0o644); err != nil {
return fmt.Errorf("write %s: %w", targetPath, err)
}
return nil
}

View File

@@ -0,0 +1,404 @@
package release
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestResolveNextVersion(t *testing.T) {
t.Parallel()
allowed := []string{"alpha", "beta", "rc", "internal"}
tests := []struct {
name string
current string
args []string
want string
wantErr string
}{
{
name: "channel only from stable bumps patch",
current: "1.0.0",
args: []string{"beta"},
want: "1.0.1-beta.1",
},
{
name: "explicit minor bump keeps requested bump",
current: "1.0.0",
args: []string{"minor", "beta"},
want: "1.1.0-beta.1",
},
{
name: "full promotes prerelease to stable",
current: "1.1.5-beta.1",
args: []string{"full"},
want: "1.1.5",
},
{
name: "set stable from prerelease requires full",
current: "1.1.5-beta.1",
args: []string{"set", "1.1.5"},
wantErr: "promote using 'stable' or 'full' only",
},
{
name: "patch stable from prerelease requires full",
current: "1.1.5-beta.1",
args: []string{"patch", "stable"},
wantErr: "promote using 'stable' or 'full' only",
},
{
name: "full no-op fails",
current: "1.1.5",
args: []string{"full"},
wantErr: "Version 1.1.5 is already current; nothing to do.",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
current, err := ParseVersion(tc.current)
if err != nil {
t.Fatalf("ParseVersion(%q): %v", tc.current, err)
}
got, err := ResolveNextVersion(current, tc.args, allowed)
if tc.wantErr != "" {
if err == nil {
t.Fatalf("ResolveNextVersion(%q, %v) succeeded, want error", tc.current, tc.args)
}
if !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("ResolveNextVersion(%q, %v) error = %q, want substring %q", tc.current, tc.args, err.Error(), tc.wantErr)
}
return
}
if err != nil {
t.Fatalf("ResolveNextVersion(%q, %v): %v", tc.current, tc.args, err)
}
if got.String() != tc.want {
t.Fatalf("ResolveNextVersion(%q, %v) = %q, want %q", tc.current, tc.args, got.String(), tc.want)
}
})
}
}
func TestVersionFileMetadataRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "VERSION")
content := strings.Join([]string{
"1.0.0",
"stable",
"0",
"desktop_backend_change_scope=bindings",
"desktop_release_mode=binary",
"desktop_unused=temporary",
"",
}, "\n")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
file, err := ReadVersionFile(path)
if err != nil {
t.Fatalf("ReadVersionFile: %v", err)
}
if got := file.Current().String(); got != "1.0.0" {
t.Fatalf("Current() = %q, want 1.0.0", got)
}
if got := file.Metadata.Get("desktop_backend_change_scope"); got != "bindings" {
t.Fatalf("Metadata.Get(scope) = %q, want bindings", got)
}
file.Version = MustParseVersion(t, "1.0.1")
file.Metadata.Set("desktop_release_mode", "codepush")
file.Metadata.Set("desktop_binary_version_min", "1.0.0")
file.Metadata.Set("desktop_binary_version_max", "1.0.1")
file.Metadata.Set("desktop_backend_compat_id", "compat-123")
file.Metadata.Unset("desktop_unused")
if err := file.Write(path); err != nil {
t.Fatalf("Write(VERSION): %v", err)
}
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(VERSION): %v", err)
}
got := string(gotBytes)
for _, needle := range []string{
"1.0.1\nstable\n0\n",
"desktop_backend_change_scope=bindings",
"desktop_release_mode=codepush",
"desktop_binary_version_min=1.0.0",
"desktop_binary_version_max=1.0.1",
"desktop_backend_compat_id=compat-123",
} {
if !strings.Contains(got, needle) {
t.Fatalf("VERSION missing %q:\n%s", needle, got)
}
}
if strings.Contains(got, "desktop_unused=temporary") {
t.Fatalf("VERSION still contains removed metadata:\n%s", got)
}
}
func TestRunnerExecutesReleaseFlow(t *testing.T) {
t.Parallel()
root := t.TempDir()
remote := filepath.Join(t.TempDir(), "remote.git")
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
mustRun(t, root, "git", "config", "commit.gpgsign", "false")
mustRun(t, root, "git", "config", "tag.gpgsign", "false")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\ndesktop_backend_change_scope=bindings\ndesktop_release_mode=binary\ndesktop_unused=temporary\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("version=old\n"), 0o644); err != nil {
t.Fatalf("WriteFile(notes.txt): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
mustRun(t, root, "git", "init", "--bare", remote)
mustRun(t, root, "git", "remote", "add", "origin", remote)
mustRun(t, root, "git", "push", "-u", "origin", "HEAD")
binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(bin): %v", err)
}
nixPath := filepath.Join(binDir, "nix")
nixScript := "#!/usr/bin/env bash\nif [[ \"${1-}\" == \"fmt\" ]]; then\n exit 0\nfi\necho \"unexpected nix invocation: $*\" >&2\nexit 1\n"
if err := os.WriteFile(nixPath, []byte(nixScript), 0o755); err != nil {
t.Fatalf("WriteFile(bin/nix): %v", err)
}
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
ReleaseStepsJSON: mustJSON(t, []ReleaseStep{
{Kind: "writeFile", Path: "generated/version.txt", Text: "$FULL_VERSION\n"},
{Kind: "replace", Path: "notes.txt", Regex: "^version=.*$", Replacement: "version=$FULL_VERSION"},
{Kind: "writeFile", Path: "release.tag", Text: "$FULL_TAG\n"},
{Kind: "writeFile", Path: "metadata/scope.txt", Text: "$VERSION_META_DESKTOP_BACKEND_CHANGE_SCOPE\n"},
{Kind: "writeFile", Path: "metadata/mode-before.txt", Text: "$VERSION_META_DESKTOP_RELEASE_MODE\n"},
{Kind: "versionMetaSet", Key: "desktop_release_mode", Value: "codepush"},
{Kind: "versionMetaSet", Key: "desktop_binary_version_min", Value: "1.0.0"},
{Kind: "versionMetaSet", Key: "desktop_binary_version_max", Value: "$FULL_VERSION"},
{Kind: "versionMetaSet", Key: "desktop_backend_compat_id", Value: "compat-123"},
{Kind: "versionMetaUnset", Key: "desktop_unused"},
}),
PostVersion: "printf '%s\\n' \"$FULL_VERSION\" >\"$ROOT_DIR/post-version.txt\"",
Execution: ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
},
Env: append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")),
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
versionFile, err := ReadVersionFile(filepath.Join(root, "VERSION"))
if err != nil {
t.Fatalf("ReadVersionFile(after): %v", err)
}
if got := versionFile.Current().String(); got != "1.0.1" {
t.Fatalf("Current() after release = %q, want 1.0.1", got)
}
assertFileEquals(t, filepath.Join(root, "generated/version.txt"), "1.0.1\n")
assertFileEquals(t, filepath.Join(root, "notes.txt"), "version=1.0.1\n")
assertFileEquals(t, filepath.Join(root, "release.tag"), "v1.0.1\n")
assertFileEquals(t, filepath.Join(root, "metadata/scope.txt"), "bindings\n")
assertFileEquals(t, filepath.Join(root, "metadata/mode-before.txt"), "binary\n")
assertFileEquals(t, filepath.Join(root, "post-version.txt"), "1.0.1\n")
versionBytes, err := os.ReadFile(filepath.Join(root, "VERSION"))
if err != nil {
t.Fatalf("ReadFile(VERSION after): %v", err)
}
versionText := string(versionBytes)
for _, needle := range []string{
"desktop_backend_change_scope=bindings",
"desktop_release_mode=codepush",
"desktop_binary_version_min=1.0.0",
"desktop_binary_version_max=1.0.1",
"desktop_backend_compat_id=compat-123",
} {
if !strings.Contains(versionText, needle) {
t.Fatalf("VERSION missing %q:\n%s", needle, versionText)
}
}
if strings.Contains(versionText, "desktop_unused=temporary") {
t.Fatalf("VERSION still contains removed metadata:\n%s", versionText)
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list", "v1.0.1"))
if tagList != "v1.0.1" {
t.Fatalf("git tag --list v1.0.1 = %q, want v1.0.1", tagList)
}
}
func TestRunnerLeavesChangesUncommittedByDefault(t *testing.T) {
t.Parallel()
root := t.TempDir()
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
binDir := t.TempDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(bin): %v", err)
}
nixPath := filepath.Join(binDir, "nix")
nixScript := "#!/usr/bin/env bash\nif [[ \"${1-}\" == \"fmt\" ]]; then\n exit 0\nfi\necho \"unexpected nix invocation: $*\" >&2\nexit 1\n"
if err := os.WriteFile(nixPath, []byte(nixScript), 0o755); err != nil {
t.Fatalf("WriteFile(bin/nix): %v", err)
}
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
Env: append(os.Environ(), "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH")),
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.1\nstable\n0\n")
status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short"))
if status != "M VERSION" {
t.Fatalf("git status --short = %q, want %q", status, "M VERSION")
}
tagList := strings.TrimSpace(mustOutput(t, root, "git", "tag", "--list"))
if tagList != "" {
t.Fatalf("git tag --list = %q, want empty", tagList)
}
}
func TestRunnerDryRunDoesNotModifyRepo(t *testing.T) {
t.Parallel()
root := t.TempDir()
mustRun(t, root, "git", "init")
mustRun(t, root, "git", "config", "user.name", "Release Test")
mustRun(t, root, "git", "config", "user.email", "release-test@example.com")
if err := os.WriteFile(filepath.Join(root, "flake.nix"), []byte("{ outputs = { self }: {}; }\n"), 0o644); err != nil {
t.Fatalf("WriteFile(flake.nix): %v", err)
}
if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte("1.0.0\nstable\n0\n"), 0o644); err != nil {
t.Fatalf("WriteFile(VERSION): %v", err)
}
mustRun(t, root, "git", "add", "-A")
mustRun(t, root, "git", "commit", "-m", "init")
var stdout strings.Builder
r := &Runner{
Config: Config{
RootDir: root,
AllowedChannels: []string{"alpha", "beta", "rc", "internal"},
Execution: ExecutionOptions{
DryRun: true,
Commit: true,
Tag: true,
Push: true,
},
Stdout: &stdout,
},
}
if err := r.Run([]string{"patch"}); err != nil {
t.Fatalf("Runner.Run: %v", err)
}
assertFileEquals(t, filepath.Join(root, "VERSION"), "1.0.0\nstable\n0\n")
status := strings.TrimSpace(mustOutput(t, root, "git", "status", "--short"))
if status != "" {
t.Fatalf("git status --short = %q, want empty", status)
}
if !strings.Contains(stdout.String(), "Dry run: 1.0.1") {
t.Fatalf("dry-run output missing next version:\n%s", stdout.String())
}
}
func MustParseVersion(t *testing.T, raw string) Version {
t.Helper()
v, err := ParseVersion(raw)
if err != nil {
t.Fatalf("ParseVersion(%q): %v", raw, err)
}
return v
}
func mustJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return string(data)
}
func mustRun(t *testing.T, dir string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(out))
}
}
func mustOutput(t *testing.T, dir string, name string, args ...string) string {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, string(out))
}
return string(out)
}
func assertFileEquals(t *testing.T, path string, want string) {
t.Helper()
gotBytes, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
if got := string(gotBytes); got != want {
t.Fatalf("%s = %q, want %q", path, got, want)
}
}

View File

@@ -0,0 +1,160 @@
package release
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Config struct {
RootDir string
AllowedChannels []string
ReleaseStepsJSON string
PostVersion string
Execution ExecutionOptions
Env []string
Stdout io.Writer
Stderr io.Writer
}
type ExecutionOptions struct {
DryRun bool
Commit bool
Tag bool
Push bool
}
type Runner struct {
Config Config
}
func (o ExecutionOptions) Normalize() ExecutionOptions {
if o.Push {
o.Commit = true
}
if o.Tag {
o.Commit = true
}
return o
}
func (r *Runner) Run(args []string) error {
rootDir, err := r.rootDir()
if err != nil {
return err
}
stdout := writerOrDiscard(r.Config.Stdout)
stderr := writerOrDiscard(r.Config.Stderr)
execution := r.Config.Execution.Normalize()
versionFile, versionPath, err := r.loadVersionFile(rootDir)
if err != nil {
return err
}
nextVersion, err := ResolveNextVersion(versionFile.Version, args, r.Config.AllowedChannels)
if err != nil {
return err
}
if execution.DryRun {
printReleasePlan(stdout, nextVersion, execution, strings.TrimSpace(r.Config.ReleaseStepsJSON) != "", strings.TrimSpace(r.Config.PostVersion) != "")
return nil
}
if err := requireCleanGit(rootDir); err != nil {
return err
}
versionFile.Version = nextVersion
if err := versionFile.Write(versionPath); err != nil {
return err
}
if err := r.runReleaseSteps(rootDir, versionPath, versionFile, nextVersion, stdout, stderr); err != nil {
return err
}
if err := r.runShell(rootDir, versionFile, nextVersion, r.Config.PostVersion, stdout, stderr); err != nil {
return err
}
if err := r.finalizeRelease(rootDir, nextVersion, execution, stdout, stderr); err != nil {
return err
}
return nil
}
func (r *Runner) rootDir() (string, error) {
if r.Config.RootDir != "" {
return r.Config.RootDir, nil
}
rootDir, err := gitOutput("", "rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
return strings.TrimSpace(rootDir), nil
}
func (r *Runner) loadVersionFile(rootDir string) (*VersionFile, string, error) {
versionPath := filepath.Join(rootDir, "VERSION")
if _, err := os.Stat(versionPath); err != nil {
return nil, "", fmt.Errorf("VERSION file not found at %s", versionPath)
}
versionFile, err := ReadVersionFile(versionPath)
if err != nil {
return nil, "", err
}
return versionFile, versionPath, nil
}
func (r *Runner) finalizeRelease(rootDir string, version Version, execution ExecutionOptions, stdout io.Writer, stderr io.Writer) error {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "nix", "fmt"); err != nil {
return err
}
if !execution.Commit {
return nil
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "add", "-A"); err != nil {
return err
}
commitMsg := "chore(release): " + version.Tag()
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "commit", "-m", commitMsg); err != nil {
return err
}
if execution.Tag {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "tag", version.Tag()); err != nil {
return err
}
}
if !execution.Push {
return nil
}
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push"); err != nil {
return err
}
if execution.Tag {
if _, err := runCommand(rootDir, r.Config.Env, stdout, stderr, "git", "push", "--tags"); err != nil {
return err
}
}
return nil
}
func printReleasePlan(stdout io.Writer, version Version, execution ExecutionOptions, hasReleaseSteps bool, hasPostVersion bool) {
fmt.Fprintf(stdout, "Dry run: %s\n", version.String())
fmt.Fprintf(stdout, "Tag: %s\n", version.Tag())
fmt.Fprintf(stdout, "Release steps: %s\n", yesNo(hasReleaseSteps))
fmt.Fprintf(stdout, "Post-version: %s\n", yesNo(hasPostVersion))
fmt.Fprintf(stdout, "nix fmt: yes\n")
fmt.Fprintf(stdout, "git commit: %s\n", yesNo(execution.Commit))
fmt.Fprintf(stdout, "git tag: %s\n", yesNo(execution.Tag))
fmt.Fprintf(stdout, "git push: %s\n", yesNo(execution.Push))
}

View File

@@ -0,0 +1,90 @@
package release
import (
"io"
"strings"
)
const shellPrelude = `
log() { echo "[release] $*" >&2; }
version_meta_get() {
local key="${1-}"
local line
while IFS= read -r line; do
[[ $line == "$key="* ]] && printf '%s\n' "${line#*=}" && return 0
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
return 1
}
version_meta_set() {
local key="${1-}"
local value="${2-}"
local tmp
tmp="$(mktemp)"
awk -v key="$key" -v value="$value" '
NR <= 3 { print; next }
$0 ~ ("^" key "=") { print key "=" value; updated=1; next }
{ print }
END { if (!updated) print key "=" value }
' "$ROOT_DIR/VERSION" >"$tmp"
mv "$tmp" "$ROOT_DIR/VERSION"
export_version_meta_env
}
version_meta_unset() {
local key="${1-}"
local tmp
tmp="$(mktemp)"
awk -v key="$key" '
NR <= 3 { print; next }
$0 ~ ("^" key "=") { next }
{ print }
' "$ROOT_DIR/VERSION" >"$tmp"
mv "$tmp" "$ROOT_DIR/VERSION"
export_version_meta_env
}
export_version_meta_env() {
local line key value env_key
while IFS= read -r line; do
[[ $line == *=* ]] || continue
key="${line%%=*}"
value="${line#*=}"
env_key="$(printf '%s' "$key" | tr -c '[:alnum:]' '_' | tr '[:lower:]' '[:upper:]')"
export "VERSION_META_${env_key}=$value"
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
}
export_version_meta_env
`
func (r *Runner) runShell(rootDir string, versionFile *VersionFile, version Version, script string, stdout io.Writer, stderr io.Writer) error {
if strings.TrimSpace(script) == "" {
return nil
}
env := r.shellEnv(rootDir, versionFile, version)
_, err := runCommand(rootDir, env, stdout, stderr, "bash", "-euo", "pipefail", "-c", shellPrelude+"\n"+script)
return err
}
func (r *Runner) shellEnv(rootDir string, versionFile *VersionFile, version Version) []string {
envMap := buildReleaseEnv(rootDir, versionFile, version, r.Config.Env)
env := make([]string, 0, len(envMap))
for key, value := range envMap {
env = append(env, key+"="+value)
}
return env
}
func sanitizeMetaEnvName(key string) string {
var b strings.Builder
b.WriteString("VERSION_META_")
for _, r := range key {
switch {
case r >= 'a' && r <= 'z':
b.WriteRune(r - 32)
case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
return b.String()
}

View File

@@ -0,0 +1,10 @@
package release
func contains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -0,0 +1,350 @@
package release
import (
"fmt"
"io"
"os"
"strings"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
)
type CommandOption struct {
Title string
Description string
Command string
Args []string
NextVersion Version
Preview string
}
func IsInteractiveTerminal(stdin io.Reader, stdout io.Writer) bool {
in, inOK := stdin.(*os.File)
out, outOK := stdout.(*os.File)
if !inOK || !outOK {
return false
}
return term.IsTerminal(int(in.Fd())) && term.IsTerminal(int(out.Fd()))
}
func SelectCommand(config Config) ([]string, bool, error) {
r := &Runner{Config: config}
rootDir, err := r.rootDir()
if err != nil {
return nil, false, err
}
versionFile, _, err := r.loadVersionFile(rootDir)
if err != nil {
return nil, false, err
}
options := BuildCommandOptions(config, versionFile.Version)
if len(options) == 0 {
return nil, false, fmt.Errorf("no release commands available for current version %s", versionFile.Version.String())
}
model := newCommandPickerModel(versionFile.Version, options)
finalModel, err := tea.NewProgram(model, tea.WithAltScreen()).Run()
if err != nil {
return nil, false, err
}
result := finalModel.(commandPickerModel)
if !result.confirmed {
return nil, false, nil
}
return append([]string(nil), result.selected.Args...), true, nil
}
func BuildCommandOptions(config Config, current Version) []CommandOption {
var options []CommandOption
seen := map[string]struct{}{}
for _, args := range candidateCommandArgs(current, config.AllowedChannels) {
command := formatReleaseCommand(args)
if _, exists := seen[command]; exists {
continue
}
next, err := ResolveNextVersion(current, args, config.AllowedChannels)
if err != nil {
continue
}
options = append(options, CommandOption{
Title: titleForArgs(args),
Description: descriptionForArgs(current, args, next),
Command: command,
Args: append([]string(nil), args...),
NextVersion: next,
Preview: buildPreview(config, current, args, next),
})
seen[command] = struct{}{}
}
return options
}
func candidateCommandArgs(current Version, allowedChannels []string) [][]string {
candidates := [][]string{
{"patch"},
{"minor"},
{"major"},
}
if current.Channel != "stable" {
candidates = append([][]string{{"stable"}}, candidates...)
}
for _, channel := range allowedChannels {
candidates = append(candidates,
[]string{channel},
[]string{"minor", channel},
[]string{"major", channel},
)
}
return candidates
}
func formatReleaseCommand(args []string) string {
return formatReleaseCommandWithExecution(args, ExecutionOptions{})
}
func formatReleaseCommandWithExecution(args []string, execution ExecutionOptions) string {
var parts []string
parts = append(parts, "release")
if execution.DryRun {
parts = append(parts, "--dry-run")
}
if execution.Commit {
parts = append(parts, "--commit")
}
if execution.Tag {
parts = append(parts, "--tag")
}
if execution.Push {
parts = append(parts, "--push")
}
if len(args) == 0 {
return strings.Join(parts, " ")
}
return strings.Join(append(parts, args...), " ")
}
func titleForArgs(args []string) string {
if len(args) == 0 {
return "Patch release"
}
switch len(args) {
case 1:
switch args[0] {
case "patch":
return "Patch release"
case "minor":
return "Minor release"
case "major":
return "Major release"
case "stable":
return "Promote to stable"
default:
return strings.ToUpper(args[0][:1]) + args[0][1:] + " prerelease"
}
case 2:
return capitalize(args[0]) + " " + args[1]
default:
return strings.Join(args, " ")
}
}
func descriptionForArgs(current Version, args []string, next Version) string {
switch len(args) {
case 1:
switch args[0] {
case "patch":
return "Bump patch and keep the current channel."
case "minor":
return "Bump minor and keep the current channel."
case "major":
return "Bump major and keep the current channel."
case "stable":
return "Promote the current prerelease to a stable release."
default:
if current.Channel == args[0] && current.Channel != "stable" {
return "Advance the current prerelease number."
}
return "Switch to the " + args[0] + " channel."
}
case 2:
return fmt.Sprintf("Bump %s and publish to %s.", args[0], args[1])
default:
return "Release " + next.String() + "."
}
}
func buildPreview(config Config, current Version, args []string, next Version) string {
execution := config.Execution.Normalize()
var lines []string
lines = append(lines,
"Command",
" "+formatReleaseCommandWithExecution(args, execution),
"",
"Version",
" Current: "+current.String(),
" Next: "+next.String(),
" Tag: "+next.Tag(),
"",
"Flow",
" Release steps: "+yesNo(strings.TrimSpace(config.ReleaseStepsJSON) != ""),
" Post-version: "+yesNo(strings.TrimSpace(config.PostVersion) != ""),
" nix fmt: yes",
" git commit: "+yesNo(execution.Commit),
" git tag: "+yesNo(execution.Tag),
" git push: "+yesNo(execution.Push),
" dry run: "+yesNo(execution.DryRun),
)
return strings.Join(lines, "\n")
}
func yesNo(v bool) string {
if v {
return "yes"
}
return "no"
}
func capitalize(s string) string {
if s == "" {
return s
}
runes := []rune(s)
first := runes[0]
if first >= 'a' && first <= 'z' {
runes[0] = first - 32
}
return string(runes)
}
type commandPickerModel struct {
current Version
options []CommandOption
cursor int
width int
height int
confirmed bool
selected CommandOption
}
func newCommandPickerModel(current Version, options []CommandOption) commandPickerModel {
return commandPickerModel{
current: current,
options: options,
}
}
func (m commandPickerModel) Init() tea.Cmd {
return nil
}
func (m commandPickerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.options)-1 {
m.cursor++
}
case "enter":
m.confirmed = true
m.selected = m.options[m.cursor]
return m, tea.Quit
}
}
return m, nil
}
func (m commandPickerModel) View() string {
if len(m.options) == 0 {
return "No release commands available.\n"
}
preview := m.options[m.cursor].Preview
header := fmt.Sprintf("Release command picker\nCurrent version: %s\nUse up/down or j/k to choose, Enter to run, q to cancel.\n", m.current.String())
listLines := make([]string, 0, len(m.options)+1)
listLines = append(listLines, "Commands")
for i, option := range m.options {
cursor := " "
if i == m.cursor {
cursor = "> "
}
listLines = append(listLines, fmt.Sprintf("%s%s\n %s", cursor, option.Command, option.Description))
}
list := strings.Join(listLines, "\n")
if m.width >= 100 {
return header + "\n" + renderColumns(list, preview, m.width)
}
return header + "\n" + list + "\n\n" + preview + "\n"
}
func renderColumns(left string, right string, width int) string {
if width < 40 {
return left + "\n\n" + right
}
leftWidth := width / 2
rightWidth := width - leftWidth - 3
leftLines := strings.Split(left, "\n")
rightLines := strings.Split(right, "\n")
maxLines := len(leftLines)
if len(rightLines) > maxLines {
maxLines = len(rightLines)
}
var b strings.Builder
for i := 0; i < maxLines; i++ {
leftLine := ""
if i < len(leftLines) {
leftLine = leftLines[i]
}
rightLine := ""
if i < len(rightLines) {
rightLine = rightLines[i]
}
b.WriteString(padRight(trimRunes(leftLine, leftWidth), leftWidth))
b.WriteString(" | ")
b.WriteString(trimRunes(rightLine, rightWidth))
b.WriteByte('\n')
}
return b.String()
}
func padRight(s string, width int) string {
missing := width - len([]rune(s))
if missing <= 0 {
return s
}
return s + strings.Repeat(" ", missing)
}
func trimRunes(s string, width int) string {
runes := []rune(s)
if len(runes) <= width {
return s
}
if width <= 1 {
return string(runes[:width])
}
if width <= 3 {
return string(runes[:width])
}
return string(runes[:width-3]) + "..."
}

View File

@@ -0,0 +1,91 @@
package release
import (
"strings"
"testing"
)
func TestBuildCommandOptionsForStableVersion(t *testing.T) {
t.Parallel()
current := MustParseVersion(t, "1.0.0")
options := BuildCommandOptions(Config{
AllowedChannels: []string{"alpha", "beta"},
ReleaseStepsJSON: `[{"kind":"writeFile","path":"VERSION.txt","text":"$FULL_VERSION\n"}]`,
PostVersion: "echo post",
Execution: ExecutionOptions{
Commit: true,
Tag: true,
Push: true,
},
}, current)
want := map[string]string{
"release patch": "1.0.1",
"release minor": "1.1.0",
"release major": "2.0.0",
"release alpha": "1.0.1-alpha.1",
"release minor beta": "1.1.0-beta.1",
}
for command, nextVersion := range want {
option, ok := findOptionByCommand(options, command)
if !ok {
t.Fatalf("expected command %q in options", command)
}
if option.NextVersion.String() != nextVersion {
t.Fatalf("%s next version = %q, want %q", command, option.NextVersion.String(), nextVersion)
}
if !strings.Contains(option.Preview, "Release steps: yes") {
t.Fatalf("%s preview missing release steps marker:\n%s", command, option.Preview)
}
if !strings.Contains(option.Preview, "Post-version: yes") {
t.Fatalf("%s preview missing post-version marker:\n%s", command, option.Preview)
}
if !strings.Contains(option.Preview, "git push: yes") {
t.Fatalf("%s preview missing git push marker:\n%s", command, option.Preview)
}
}
}
func TestBuildCommandOptionsForPrereleaseVersion(t *testing.T) {
t.Parallel()
current := MustParseVersion(t, "1.2.3-beta.2")
options := BuildCommandOptions(Config{
AllowedChannels: []string{"alpha", "beta", "rc"},
}, current)
stableOption, ok := findOptionByCommand(options, "release stable")
if !ok {
t.Fatalf("expected release stable option")
}
if stableOption.NextVersion.String() != "1.2.3" {
t.Fatalf("release stable next version = %q, want 1.2.3", stableOption.NextVersion.String())
}
betaOption, ok := findOptionByCommand(options, "release beta")
if !ok {
t.Fatalf("expected release beta option")
}
if betaOption.NextVersion.String() != "1.2.3-beta.3" {
t.Fatalf("release beta next version = %q, want 1.2.3-beta.3", betaOption.NextVersion.String())
}
patchOption, ok := findOptionByCommand(options, "release patch")
if !ok {
t.Fatalf("expected release patch option")
}
if patchOption.NextVersion.String() != "1.2.4-beta.1" {
t.Fatalf("release patch next version = %q, want 1.2.4-beta.1", patchOption.NextVersion.String())
}
}
func findOptionByCommand(options []CommandOption, command string) (CommandOption, bool) {
for _, option := range options {
if option.Command == command {
return option, true
}
}
return CommandOption{}, false
}

View File

@@ -0,0 +1,261 @@
package release
import (
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
var versionPattern = regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([A-Za-z]+)\.([0-9]+))?$`)
type Version struct {
Major int
Minor int
Patch int
Channel string
Prerelease int
}
func ParseVersion(raw string) (Version, error) {
match := versionPattern.FindStringSubmatch(raw)
if match == nil {
return Version{}, fmt.Errorf("invalid version %q (expected x.y.z or x.y.z-channel.N)", raw)
}
major, _ := strconv.Atoi(match[1])
minor, _ := strconv.Atoi(match[2])
patch, _ := strconv.Atoi(match[3])
v := Version{
Major: major,
Minor: minor,
Patch: patch,
Channel: "stable",
}
if match[4] != "" {
pre, _ := strconv.Atoi(match[5])
v.Channel = match[4]
v.Prerelease = pre
}
return v, nil
}
func (v Version) String() string {
if v.Channel == "" || v.Channel == "stable" {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
return fmt.Sprintf("%d.%d.%d-%s.%d", v.Major, v.Minor, v.Patch, v.Channel, v.Prerelease)
}
func (v Version) BaseString() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}
func (v Version) Tag() string {
return "v" + v.String()
}
func (v Version) cmp(other Version) int {
if v.Major != other.Major {
return compareInt(v.Major, other.Major)
}
if v.Minor != other.Minor {
return compareInt(v.Minor, other.Minor)
}
if v.Patch != other.Patch {
return compareInt(v.Patch, other.Patch)
}
if v.Channel == "stable" && other.Channel != "stable" {
return 1
}
if v.Channel != "stable" && other.Channel == "stable" {
return -1
}
if v.Channel == "stable" && other.Channel == "stable" {
return 0
}
if v.Channel != other.Channel {
return comparePrerelease(v.Channel, other.Channel)
}
return compareInt(v.Prerelease, other.Prerelease)
}
func ResolveNextVersion(current Version, args []string, allowedChannels []string) (Version, error) {
currentFull := current.String()
action := ""
rest := args
if len(rest) > 0 {
action = rest[0]
rest = rest[1:]
}
if action == "set" {
return resolveSetVersion(current, currentFull, rest, allowedChannels)
}
part := ""
targetChannel := ""
wasChannelOnly := false
switch action {
case "":
part = "patch"
case "major", "minor", "patch":
part = action
if len(rest) > 0 {
targetChannel = rest[0]
rest = rest[1:]
}
case "stable", "full":
if len(rest) > 0 {
return Version{}, fmt.Errorf("%q takes no second argument", action)
}
targetChannel = "stable"
default:
if contains(allowedChannels, action) {
if len(rest) > 0 {
return Version{}, errors.New("channel-only bump takes no second argument")
}
targetChannel = action
wasChannelOnly = true
} else {
return Version{}, fmt.Errorf("unknown argument %q", action)
}
}
if targetChannel == "" {
targetChannel = current.Channel
}
if err := validateChannel(targetChannel, allowedChannels); err != nil {
return Version{}, err
}
if current.Channel != "stable" && targetChannel == "stable" && action != "stable" && action != "full" {
return Version{}, fmt.Errorf("from prerelease channel %q, promote using 'stable' or 'full' only", current.Channel)
}
if part == "" && wasChannelOnly && current.Channel == "stable" && targetChannel != "stable" {
part = "patch"
}
next := current
oldBase := next.BaseString()
oldChannel := next.Channel
oldPre := next.Prerelease
if part != "" {
bumpVersion(&next, part)
}
if targetChannel == "stable" {
next.Channel = "stable"
next.Prerelease = 0
} else {
if next.BaseString() == oldBase && targetChannel == oldChannel && oldPre > 0 {
next.Prerelease = oldPre + 1
} else {
next.Prerelease = 1
}
next.Channel = targetChannel
}
if next.String() == currentFull {
return Version{}, fmt.Errorf("Version %s is already current; nothing to do.", next.String())
}
return next, nil
}
func resolveSetVersion(current Version, currentFull string, args []string, allowedChannels []string) (Version, error) {
if len(args) == 0 {
return Version{}, errors.New("'set' requires a version argument")
}
next, err := ParseVersion(args[0])
if err != nil {
return Version{}, err
}
if err := validateChannel(next.Channel, allowedChannels); err != nil {
return Version{}, err
}
if current.Channel != "stable" && next.Channel == "stable" {
return Version{}, fmt.Errorf("from prerelease channel %q, promote using 'stable' or 'full' only", current.Channel)
}
switch next.cmp(current) {
case 0:
return Version{}, fmt.Errorf("Version %s is already current; nothing to do.", next.String())
case -1:
return Version{}, fmt.Errorf("%s is lower than current %s", next.String(), currentFull)
}
return next, nil
}
func validateChannel(channel string, allowedChannels []string) error {
if channel == "" || channel == "stable" {
return nil
}
if contains(allowedChannels, channel) {
return nil
}
return fmt.Errorf("unknown channel %q", channel)
}
func bumpVersion(v *Version, part string) {
switch part {
case "major":
v.Major++
v.Minor = 0
v.Patch = 0
case "minor":
v.Minor++
v.Patch = 0
case "patch":
v.Patch++
default:
panic("unknown bump part: " + part)
}
}
func compareInt(left int, right int) int {
switch {
case left > right:
return 1
case left < right:
return -1
default:
return 0
}
}
func comparePrerelease(left string, right string) int {
values := []string{left, right}
sort.Slice(values, func(i int, j int) bool {
return semverLikeLess(values[i], values[j])
})
switch {
case left == right:
return 0
case values[len(values)-1] == left:
return 1
default:
return -1
}
}
func semverLikeLess(left string, right string) bool {
leftParts := strings.FieldsFunc(left, func(r rune) bool { return r == '.' || r == '-' })
rightParts := strings.FieldsFunc(right, func(r rune) bool { return r == '.' || r == '-' })
for i := 0; i < len(leftParts) && i < len(rightParts); i++ {
li, lerr := strconv.Atoi(leftParts[i])
ri, rerr := strconv.Atoi(rightParts[i])
switch {
case lerr == nil && rerr == nil:
if li != ri {
return li < ri
}
default:
if leftParts[i] != rightParts[i] {
return leftParts[i] < rightParts[i]
}
}
}
return len(leftParts) < len(rightParts)
}

View File

@@ -0,0 +1,112 @@
package release
import (
"bytes"
"fmt"
"os"
"strconv"
"strings"
)
type Metadata struct {
lines []string
}
func (m Metadata) Lines() []string {
return append([]string(nil), m.lines...)
}
func (m Metadata) Get(key string) string {
for _, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
return strings.TrimPrefix(line, key+"=")
}
}
return ""
}
func (m *Metadata) Set(key string, value string) {
for i, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
m.lines[i] = key + "=" + value
return
}
}
m.lines = append(m.lines, key+"="+value)
}
func (m *Metadata) Unset(key string) {
filtered := make([]string, 0, len(m.lines))
for _, line := range m.lines {
if strings.HasPrefix(line, key+"=") {
continue
}
filtered = append(filtered, line)
}
m.lines = filtered
}
type VersionFile struct {
Version Version
Metadata Metadata
}
func (f VersionFile) Current() Version {
return f.Version
}
func ReadVersionFile(path string) (*VersionFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
lines := strings.Split(text, "\n")
if len(lines) < 3 {
return nil, fmt.Errorf("invalid VERSION file %q", path)
}
base := strings.TrimSpace(lines[0])
channel := strings.TrimSpace(lines[1])
preRaw := strings.TrimSpace(lines[2])
if channel == "" {
channel = "stable"
}
rawVersion := base
if channel != "stable" {
rawVersion = fmt.Sprintf("%s-%s.%s", base, channel, preRaw)
}
version, err := ParseVersion(rawVersion)
if err != nil {
return nil, err
}
metaLines := make([]string, 0, len(lines)-3)
for _, line := range lines[3:] {
if line == "" {
continue
}
metaLines = append(metaLines, line)
}
return &VersionFile{
Version: version,
Metadata: Metadata{lines: metaLines},
}, nil
}
func (f *VersionFile) Write(path string) error {
channel := f.Version.Channel
pre := strconv.Itoa(f.Version.Prerelease)
if channel == "" || channel == "stable" {
channel = "stable"
pre = "0"
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "%s\n%s\n%s\n", f.Version.BaseString(), channel, pre)
for _, line := range f.Metadata.lines {
fmt.Fprintln(&buf, line)
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}