feat: modernize
This commit is contained in:
1
packages/release/.gitignore
vendored
Normal file
1
packages/release/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
vendor/
|
||||
135
packages/release/cmd/release/main.go
Normal file
135
packages/release/cmd/release/main.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
release "repo-lib/packages/release/internal/release"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) > 0 && args[0] == "version-meta" {
|
||||
return runVersionMeta(args[1:])
|
||||
}
|
||||
|
||||
releaseArgs, execution, selectMode, err := parseReleaseCLIArgs(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := release.Config{
|
||||
RootDir: os.Getenv("REPO_LIB_RELEASE_ROOT_DIR"),
|
||||
AllowedChannels: splitEnvList("REPO_LIB_RELEASE_CHANNELS"),
|
||||
ReleaseStepsJSON: os.Getenv("REPO_LIB_RELEASE_STEPS_JSON"),
|
||||
PostVersion: os.Getenv("REPO_LIB_RELEASE_POST_VERSION"),
|
||||
Execution: execution,
|
||||
Env: os.Environ(),
|
||||
Stdout: os.Stdout,
|
||||
Stderr: os.Stderr,
|
||||
}
|
||||
|
||||
if shouldRunInteractiveSelector(releaseArgs, selectMode) {
|
||||
if !release.IsInteractiveTerminal(os.Stdin, os.Stdout) {
|
||||
return fmt.Errorf("interactive release selector requires a terminal")
|
||||
}
|
||||
selectedArgs, confirmed, err := release.SelectCommand(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !confirmed {
|
||||
return nil
|
||||
}
|
||||
releaseArgs = selectedArgs
|
||||
}
|
||||
|
||||
r := &release.Runner{Config: config}
|
||||
return r.Run(releaseArgs)
|
||||
}
|
||||
|
||||
func shouldRunInteractiveSelector(args []string, selectMode bool) bool {
|
||||
if selectMode {
|
||||
return true
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return release.IsInteractiveTerminal(os.Stdin, os.Stdout)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseReleaseCLIArgs(args []string) ([]string, release.ExecutionOptions, bool, error) {
|
||||
var releaseArgs []string
|
||||
execution := release.ExecutionOptions{}
|
||||
selectMode := false
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "select":
|
||||
selectMode = true
|
||||
case "--dry-run":
|
||||
execution.DryRun = true
|
||||
case "--commit":
|
||||
execution.Commit = true
|
||||
case "--tag":
|
||||
execution.Tag = true
|
||||
case "--push":
|
||||
execution.Push = true
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return nil, release.ExecutionOptions{}, false, fmt.Errorf("unknown flag %q", arg)
|
||||
}
|
||||
releaseArgs = append(releaseArgs, arg)
|
||||
}
|
||||
}
|
||||
|
||||
if selectMode && len(releaseArgs) > 0 {
|
||||
return nil, release.ExecutionOptions{}, false, fmt.Errorf("select does not take a release argument")
|
||||
}
|
||||
return releaseArgs, execution.Normalize(), selectMode, nil
|
||||
}
|
||||
|
||||
func runVersionMeta(args []string) error {
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("version-meta requires an action and key")
|
||||
}
|
||||
rootDir := os.Getenv("ROOT_DIR")
|
||||
if rootDir == "" {
|
||||
return fmt.Errorf("ROOT_DIR is required")
|
||||
}
|
||||
versionPath := rootDir + "/VERSION"
|
||||
file, err := release.ReadVersionFile(versionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "set":
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("version-meta set requires key and value")
|
||||
}
|
||||
file.Metadata.Set(args[1], args[2])
|
||||
case "unset":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("version-meta unset requires key")
|
||||
}
|
||||
file.Metadata.Unset(args[1])
|
||||
default:
|
||||
return fmt.Errorf("unknown version-meta action %q", args[0])
|
||||
}
|
||||
return file.Write(versionPath)
|
||||
}
|
||||
|
||||
func splitEnvList(name string) []string {
|
||||
raw := strings.Fields(os.Getenv(name))
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
29
packages/release/go.mod
Normal file
29
packages/release/go.mod
Normal file
@@ -0,0 +1,29 @@
|
||||
module repo-lib/packages/release
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
golang.org/x/term v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
)
|
||||
45
packages/release/go.sum
Normal file
45
packages/release/go.sum
Normal file
@@ -0,0 +1,45 @@
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
91
packages/release/internal/release/exec.go
Normal file
91
packages/release/internal/release/exec.go
Normal 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
|
||||
}
|
||||
64
packages/release/internal/release/release_step.go
Normal file
64
packages/release/internal/release/release_step.go
Normal 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)
|
||||
}
|
||||
}
|
||||
39
packages/release/internal/release/release_step_apply.go
Normal file
39
packages/release/internal/release/release_step_apply.go
Normal 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)
|
||||
}
|
||||
}
|
||||
73
packages/release/internal/release/release_step_context.go
Normal file
73
packages/release/internal/release/release_step_context.go
Normal 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)
|
||||
}
|
||||
44
packages/release/internal/release/release_step_replace.go
Normal file
44
packages/release/internal/release/release_step_replace.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
18
packages/release/internal/release/release_step_write_file.go
Normal file
18
packages/release/internal/release/release_step_write_file.go
Normal 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
|
||||
}
|
||||
404
packages/release/internal/release/release_test.go
Normal file
404
packages/release/internal/release/release_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
160
packages/release/internal/release/runner.go
Normal file
160
packages/release/internal/release/runner.go
Normal 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))
|
||||
}
|
||||
90
packages/release/internal/release/shell.go
Normal file
90
packages/release/internal/release/shell.go
Normal 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()
|
||||
}
|
||||
10
packages/release/internal/release/slices.go
Normal file
10
packages/release/internal/release/slices.go
Normal 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
|
||||
}
|
||||
350
packages/release/internal/release/ui.go
Normal file
350
packages/release/internal/release/ui.go
Normal 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]) + "..."
|
||||
}
|
||||
91
packages/release/internal/release/ui_test.go
Normal file
91
packages/release/internal/release/ui_test.go
Normal 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
|
||||
}
|
||||
261
packages/release/internal/release/version.go
Normal file
261
packages/release/internal/release/version.go
Normal 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)
|
||||
}
|
||||
112
packages/release/internal/release/version_file.go
Normal file
112
packages/release/internal/release/version_file.go
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
flake-parts,
|
||||
nixpkgs,
|
||||
treefmt-nix,
|
||||
lefthookNix,
|
||||
@@ -7,6 +8,7 @@
|
||||
}:
|
||||
import ../repo-lib/lib.nix {
|
||||
inherit
|
||||
flake-parts
|
||||
nixpkgs
|
||||
treefmt-nix
|
||||
lefthookNix
|
||||
|
||||
@@ -2,539 +2,18 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(git rev-parse --show-toplevel)"
|
||||
GITLINT_FILE="$ROOT_DIR/.gitlint"
|
||||
START_HEAD=""
|
||||
CREATED_TAG=""
|
||||
VERSION_META_LINES=()
|
||||
VERSION_META_EXPORT_NAMES=()
|
||||
|
||||
# ── logging ────────────────────────────────────────────────────────────────
|
||||
|
||||
log() { echo "[release] $*" >&2; }
|
||||
|
||||
usage() {
|
||||
local cmd
|
||||
cmd="$(basename "$0")"
|
||||
printf '%s\n' \
|
||||
"Usage:" \
|
||||
" ${cmd} [major|minor|patch] [stable|__CHANNEL_LIST__]" \
|
||||
" ${cmd} set <version>" \
|
||||
"" \
|
||||
"Bump types:" \
|
||||
" (none) bump patch, keep current channel" \
|
||||
" major/minor/patch bump the given part, keep current channel" \
|
||||
" stable / full remove prerelease suffix (only opt-in path to promote prerelease -> stable)" \
|
||||
" __CHANNEL_LIST__ switch channel (from stable, auto-bumps patch unless bump is specified)" \
|
||||
"" \
|
||||
"Safety rule:" \
|
||||
" If current version is prerelease (e.g. x.y.z-beta.N), promotion to stable is allowed only via 'stable' or 'full'." \
|
||||
" Commands like '${cmd} set x.y.z' or '${cmd} patch stable' are blocked from prerelease channels." \
|
||||
"" \
|
||||
"Examples:" \
|
||||
" ${cmd} # patch bump on current channel" \
|
||||
" ${cmd} minor # minor bump on current channel" \
|
||||
" ${cmd} beta # from stable: patch bump + beta.1" \
|
||||
" ${cmd} patch beta # patch bump, switch to beta channel" \
|
||||
" ${cmd} rc # switch to rc channel" \
|
||||
" ${cmd} stable # promote prerelease to stable (opt-in)" \
|
||||
" ${cmd} set 1.2.3" \
|
||||
" ${cmd} set 1.2.3-beta.1"
|
||||
}
|
||||
|
||||
# ── git ────────────────────────────────────────────────────────────────────
|
||||
|
||||
require_clean_git() {
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "Error: git working tree is not clean. Commit or stash changes first." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
revert_on_failure() {
|
||||
local status=$?
|
||||
if [[ -n $START_HEAD ]]; then
|
||||
log "Release failed — reverting to $START_HEAD"
|
||||
git reset --hard "$START_HEAD"
|
||||
fi
|
||||
if [[ -n $CREATED_TAG ]]; then
|
||||
git tag -d "$CREATED_TAG" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit $status
|
||||
}
|
||||
|
||||
# ── version parsing ────────────────────────────────────────────────────────
|
||||
|
||||
parse_base_version() {
|
||||
local v="$1"
|
||||
if [[ ! $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
echo "Error: invalid base version '$v' (expected x.y.z)" >&2
|
||||
exit 1
|
||||
fi
|
||||
MAJOR="${BASH_REMATCH[1]}"
|
||||
MINOR="${BASH_REMATCH[2]}"
|
||||
PATCH="${BASH_REMATCH[3]}"
|
||||
}
|
||||
|
||||
parse_full_version() {
|
||||
local v="$1"
|
||||
CHANNEL="stable"
|
||||
PRERELEASE_NUM=""
|
||||
|
||||
if [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)-([a-zA-Z]+)\.([0-9]+)$ ]]; then
|
||||
BASE_VERSION="${BASH_REMATCH[1]}"
|
||||
CHANNEL="${BASH_REMATCH[2]}"
|
||||
PRERELEASE_NUM="${BASH_REMATCH[3]}"
|
||||
elif [[ $v =~ ^([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
BASE_VERSION="${BASH_REMATCH[1]}"
|
||||
else
|
||||
echo "Error: invalid version '$v' (expected x.y.z or x.y.z-channel.N)" >&2
|
||||
exit 1
|
||||
fi
|
||||
parse_base_version "$BASE_VERSION"
|
||||
}
|
||||
|
||||
validate_channel() {
|
||||
local ch="$1"
|
||||
[[ $ch == "stable" ]] && return 0
|
||||
local valid_channels="__CHANNEL_LIST__"
|
||||
for c in $valid_channels; do
|
||||
[[ $ch == "$c" ]] && return 0
|
||||
done
|
||||
echo "Error: unknown channel '$ch'. Valid channels: stable $valid_channels" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
version_cmp() {
|
||||
# Returns: 0 if equal, 1 if v1 > v2, 2 if v1 < v2
|
||||
# Stable > prerelease for same base version
|
||||
local v1="$1" v2="$2"
|
||||
[[ $v1 == "$v2" ]] && return 0
|
||||
|
||||
local base1="" pre1="" base2="" pre2=""
|
||||
if [[ $v1 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
|
||||
base1="${BASH_REMATCH[1]}"
|
||||
pre1="${BASH_REMATCH[2]}"
|
||||
else
|
||||
base1="$v1"
|
||||
fi
|
||||
if [[ $v2 =~ ^([0-9]+\.[0-9]+\.[0-9]+)-(.+)$ ]]; then
|
||||
base2="${BASH_REMATCH[1]}"
|
||||
pre2="${BASH_REMATCH[2]}"
|
||||
else
|
||||
base2="$v2"
|
||||
fi
|
||||
|
||||
if [[ $base1 != "$base2" ]]; then
|
||||
local highest_base
|
||||
highest_base=$(printf '%s\n%s\n' "$base1" "$base2" | sort -V | tail -n1)
|
||||
[[ $highest_base == "$base1" ]] && return 1 || return 2
|
||||
fi
|
||||
|
||||
[[ -z $pre1 && -n $pre2 ]] && return 1 # stable > prerelease
|
||||
[[ -n $pre1 && -z $pre2 ]] && return 2 # prerelease < stable
|
||||
[[ -z $pre1 && -z $pre2 ]] && return 0 # both stable
|
||||
|
||||
local highest_pre
|
||||
highest_pre=$(printf '%s\n%s\n' "$pre1" "$pre2" | sort -V | tail -n1)
|
||||
[[ $highest_pre == "$pre1" ]] && return 1 || return 2
|
||||
}
|
||||
|
||||
bump_base_version() {
|
||||
case "$1" in
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
patch) PATCH=$((PATCH + 1)) ;;
|
||||
*)
|
||||
echo "Error: unknown bump part '$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
BASE_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||
}
|
||||
|
||||
compute_full_version() {
|
||||
if [[ $CHANNEL == "stable" || -z $CHANNEL ]]; then
|
||||
FULL_VERSION="$BASE_VERSION"
|
||||
else
|
||||
FULL_VERSION="${BASE_VERSION}-${CHANNEL}.${PRERELEASE_NUM:-1}"
|
||||
fi
|
||||
FULL_TAG="v$FULL_VERSION"
|
||||
export BASE_VERSION CHANNEL PRERELEASE_NUM FULL_VERSION FULL_TAG
|
||||
}
|
||||
|
||||
meta_env_name() {
|
||||
local key="$1"
|
||||
key="${key//[^[:alnum:]]/_}"
|
||||
key="$(printf '%s' "$key" | tr '[:lower:]' '[:upper:]')"
|
||||
printf 'VERSION_META_%s\n' "$key"
|
||||
}
|
||||
|
||||
clear_version_meta_exports() {
|
||||
local export_name
|
||||
for export_name in "${VERSION_META_EXPORT_NAMES[@]:-}"; do
|
||||
unset "$export_name"
|
||||
done
|
||||
VERSION_META_EXPORT_NAMES=()
|
||||
}
|
||||
|
||||
load_version_metadata() {
|
||||
VERSION_META_LINES=()
|
||||
[[ ! -f "$ROOT_DIR/VERSION" ]] && return 0
|
||||
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
VERSION_META_LINES+=("$line")
|
||||
done < <(tail -n +4 "$ROOT_DIR/VERSION" 2>/dev/null || true)
|
||||
}
|
||||
|
||||
export_version_metadata() {
|
||||
clear_version_meta_exports
|
||||
|
||||
local line key value export_name
|
||||
for line in "${VERSION_META_LINES[@]:-}"; do
|
||||
[[ $line != *=* ]] && continue
|
||||
key="${line%%=*}"
|
||||
value="${line#*=}"
|
||||
[[ -z $key ]] && continue
|
||||
export_name="$(meta_env_name "$key")"
|
||||
printf -v "$export_name" '%s' "$value"
|
||||
export "${export_name?}=$value"
|
||||
VERSION_META_EXPORT_NAMES+=("$export_name")
|
||||
done
|
||||
}
|
||||
|
||||
write_version_file() {
|
||||
local channel_to_write="$1"
|
||||
local n_to_write="$2"
|
||||
{
|
||||
printf '%s\n%s\n%s\n' "$BASE_VERSION" "$channel_to_write" "$n_to_write"
|
||||
local line
|
||||
for line in "${VERSION_META_LINES[@]:-}"; do
|
||||
printf '%s\n' "$line"
|
||||
done
|
||||
} >"$ROOT_DIR/VERSION"
|
||||
}
|
||||
|
||||
version_meta_get() {
|
||||
local key="${1-}"
|
||||
local line
|
||||
for line in "${VERSION_META_LINES[@]:-}"; do
|
||||
if [[ $line == "$key="* ]]; then
|
||||
printf '%s\n' "${line#*=}"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
version_meta_set() {
|
||||
local key="${1-}"
|
||||
local value="${2-}"
|
||||
[[ -z $key ]] && echo "Error: version_meta_set requires a key" >&2 && exit 1
|
||||
|
||||
local updated=0
|
||||
local index
|
||||
for index in "${!VERSION_META_LINES[@]}"; do
|
||||
if [[ ${VERSION_META_LINES[index]} == "$key="* ]]; then
|
||||
VERSION_META_LINES[index]="$key=$value"
|
||||
updated=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $updated -eq 0 ]]; then
|
||||
VERSION_META_LINES+=("$key=$value")
|
||||
fi
|
||||
|
||||
export_version_metadata
|
||||
version_meta_write
|
||||
}
|
||||
|
||||
version_meta_unset() {
|
||||
local key="${1-}"
|
||||
[[ -z $key ]] && echo "Error: version_meta_unset requires a key" >&2 && exit 1
|
||||
|
||||
local filtered=()
|
||||
local line
|
||||
for line in "${VERSION_META_LINES[@]:-}"; do
|
||||
[[ $line == "$key="* ]] && continue
|
||||
filtered+=("$line")
|
||||
done
|
||||
VERSION_META_LINES=("${filtered[@]}")
|
||||
|
||||
export_version_metadata
|
||||
version_meta_write
|
||||
}
|
||||
|
||||
version_meta_write() {
|
||||
local channel_to_write="$CHANNEL"
|
||||
local n_to_write="${PRERELEASE_NUM:-1}"
|
||||
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
|
||||
channel_to_write="stable"
|
||||
n_to_write="0"
|
||||
fi
|
||||
write_version_file "$channel_to_write" "$n_to_write"
|
||||
}
|
||||
|
||||
# ── gitlint ────────────────────────────────────────────────────────────────
|
||||
|
||||
get_gitlint_title_regex() {
|
||||
[[ ! -f $GITLINT_FILE ]] && return 0
|
||||
awk '
|
||||
/^\[title-match-regex\]$/ { in_section=1; next }
|
||||
/^\[/ { in_section=0 }
|
||||
in_section && /^regex=/ { sub(/^regex=/, ""); print; exit }
|
||||
' "$GITLINT_FILE"
|
||||
}
|
||||
|
||||
validate_commit_message() {
|
||||
local msg="$1"
|
||||
local regex
|
||||
regex="$(get_gitlint_title_regex)"
|
||||
if [[ -n $regex && ! $msg =~ $regex ]]; then
|
||||
echo "Error: commit message does not match .gitlint title-match-regex" >&2
|
||||
echo "Regex: $regex" >&2
|
||||
echo "Message: $msg" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── version file generation ────────────────────────────────────────────────
|
||||
|
||||
run_release_steps() {
|
||||
:
|
||||
__RELEASE_STEPS__
|
||||
}
|
||||
|
||||
# ── version source (built-in) ──────────────────────────────────────────────
|
||||
|
||||
# Initializes $ROOT_DIR/VERSION from git tags if it doesn't exist.
|
||||
# Must be called outside of any subshell so log output stays on stderr
|
||||
# and never contaminates the stdout of do_read_version.
|
||||
init_version_file() {
|
||||
if [[ -f "$ROOT_DIR/VERSION" ]]; then
|
||||
load_version_metadata
|
||||
export_version_metadata
|
||||
return 0
|
||||
fi
|
||||
|
||||
local highest_tag=""
|
||||
while IFS= read -r raw_tag; do
|
||||
local tag="${raw_tag#v}"
|
||||
[[ $tag =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$ ]] || continue
|
||||
|
||||
if [[ -z $highest_tag ]]; then
|
||||
highest_tag="$tag"
|
||||
continue
|
||||
fi
|
||||
|
||||
local cmp_status=0
|
||||
version_cmp "$tag" "$highest_tag" || cmp_status=$?
|
||||
[[ $cmp_status -eq 1 ]] && highest_tag="$tag"
|
||||
done < <(git tag --list)
|
||||
|
||||
[[ -z $highest_tag ]] && highest_tag="0.0.1"
|
||||
|
||||
parse_full_version "$highest_tag"
|
||||
local channel_to_write="$CHANNEL"
|
||||
local n_to_write="${PRERELEASE_NUM:-1}"
|
||||
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
|
||||
channel_to_write="stable"
|
||||
n_to_write="0"
|
||||
fi
|
||||
|
||||
VERSION_META_LINES=()
|
||||
write_version_file "$channel_to_write" "$n_to_write"
|
||||
export_version_metadata
|
||||
log "Initialized $ROOT_DIR/VERSION from highest tag: v$highest_tag"
|
||||
}
|
||||
|
||||
do_read_version() {
|
||||
load_version_metadata
|
||||
export_version_metadata
|
||||
|
||||
local base_line channel_line n_line
|
||||
base_line="$(sed -n '1p' "$ROOT_DIR/VERSION" | tr -d '\r')"
|
||||
channel_line="$(sed -n '2p' "$ROOT_DIR/VERSION" | tr -d '\r')"
|
||||
n_line="$(sed -n '3p' "$ROOT_DIR/VERSION" | tr -d '\r')"
|
||||
|
||||
if [[ -z $channel_line || $channel_line == "stable" ]]; then
|
||||
printf '%s\n' "$base_line"
|
||||
else
|
||||
printf '%s-%s.%s\n' "$base_line" "$channel_line" "$n_line"
|
||||
fi
|
||||
}
|
||||
|
||||
do_write_version() {
|
||||
local channel_to_write="$CHANNEL"
|
||||
local n_to_write="${PRERELEASE_NUM:-1}"
|
||||
if [[ $channel_to_write == "stable" || -z $channel_to_write ]]; then
|
||||
channel_to_write="stable"
|
||||
n_to_write="0"
|
||||
fi
|
||||
write_version_file "$channel_to_write" "$n_to_write"
|
||||
export_version_metadata
|
||||
}
|
||||
|
||||
# ── user-provided hook ─────────────────────────────────────────────────────
|
||||
|
||||
do_post_version() {
|
||||
:
|
||||
__POST_VERSION__
|
||||
}
|
||||
|
||||
# ── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
[[ ${1-} == "-h" || ${1-} == "--help" ]] && usage && exit 0
|
||||
|
||||
require_clean_git
|
||||
START_HEAD="$(git rev-parse HEAD)"
|
||||
trap revert_on_failure ERR
|
||||
|
||||
# Initialize VERSION file outside any subshell so log lines never
|
||||
# bleed into the stdout capture below.
|
||||
init_version_file
|
||||
|
||||
local raw_version
|
||||
raw_version="$(do_read_version | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z]+\.[0-9]+)?$' | tail -n1)"
|
||||
if [[ -z $raw_version ]]; then
|
||||
echo "Error: could not determine current version from VERSION source" >&2
|
||||
exit 1
|
||||
fi
|
||||
parse_full_version "$raw_version"
|
||||
compute_full_version
|
||||
local current_full="$FULL_VERSION"
|
||||
|
||||
log "Current: base=$BASE_VERSION channel=$CHANNEL pre=${PRERELEASE_NUM:-}"
|
||||
|
||||
local action="${1-}"
|
||||
shift || true
|
||||
|
||||
if [[ $action == "set" ]]; then
|
||||
local newv="${1-}"
|
||||
local current_channel="$CHANNEL"
|
||||
[[ -z $newv ]] && echo "Error: 'set' requires a version argument" >&2 && exit 1
|
||||
parse_full_version "$newv"
|
||||
validate_channel "$CHANNEL"
|
||||
if [[ $current_channel != "stable" && $CHANNEL == "stable" ]]; then
|
||||
echo "Error: from prerelease channel '$current_channel', promote using 'stable' or 'full' only" >&2
|
||||
exit 1
|
||||
fi
|
||||
compute_full_version
|
||||
local cmp_status=0
|
||||
version_cmp "$FULL_VERSION" "$current_full" || cmp_status=$?
|
||||
case $cmp_status in
|
||||
0)
|
||||
echo "Version $FULL_VERSION is already current; nothing to do." >&2
|
||||
exit 1
|
||||
;;
|
||||
2)
|
||||
echo "Error: $FULL_VERSION is lower than current $current_full" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
else
|
||||
local part="" target_channel="" was_channel_only=0
|
||||
|
||||
case "$action" in
|
||||
"") part="patch" ;;
|
||||
major | minor | patch)
|
||||
part="$action"
|
||||
target_channel="${1-}"
|
||||
;;
|
||||
stable | full)
|
||||
[[ -n ${1-} ]] && echo "Error: '$action' takes no second argument" >&2 && usage && exit 1
|
||||
target_channel="stable"
|
||||
;;
|
||||
*)
|
||||
# check if action is a valid channel
|
||||
local is_channel=0
|
||||
for c in __CHANNEL_LIST__; do
|
||||
[[ $action == "$c" ]] && is_channel=1 && break
|
||||
done
|
||||
if [[ $is_channel == 1 ]]; then
|
||||
[[ -n ${1-} ]] && echo "Error: channel-only bump takes no second argument" >&2 && usage && exit 1
|
||||
target_channel="$action"
|
||||
was_channel_only=1
|
||||
else
|
||||
echo "Error: unknown argument '$action'" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -z $target_channel ]] && target_channel="$CHANNEL"
|
||||
[[ $target_channel == "full" ]] && target_channel="stable"
|
||||
validate_channel "$target_channel"
|
||||
if [[ $CHANNEL != "stable" && $target_channel == "stable" && $action != "stable" && $action != "full" ]]; then
|
||||
echo "Error: from prerelease channel '$CHANNEL', promote using 'stable' or 'full' only" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z $part && $was_channel_only -eq 1 && $CHANNEL == "stable" && $target_channel != "stable" ]]; then
|
||||
part="patch"
|
||||
fi
|
||||
|
||||
local old_base="$BASE_VERSION" old_channel="$CHANNEL" old_pre="$PRERELEASE_NUM"
|
||||
[[ -n $part ]] && bump_base_version "$part"
|
||||
|
||||
if [[ $target_channel == "stable" ]]; then
|
||||
CHANNEL="stable"
|
||||
PRERELEASE_NUM=""
|
||||
else
|
||||
if [[ $BASE_VERSION == "$old_base" && $target_channel == "$old_channel" && -n $old_pre ]]; then
|
||||
PRERELEASE_NUM=$((old_pre + 1))
|
||||
else
|
||||
PRERELEASE_NUM=1
|
||||
fi
|
||||
CHANNEL="$target_channel"
|
||||
fi
|
||||
fi
|
||||
|
||||
compute_full_version
|
||||
if [[ $FULL_VERSION == "$current_full" ]]; then
|
||||
echo "Version $FULL_VERSION is already current; nothing to do." >&2
|
||||
exit 1
|
||||
fi
|
||||
log "Releasing $FULL_VERSION"
|
||||
|
||||
do_write_version
|
||||
log "Updated version source"
|
||||
|
||||
run_release_steps
|
||||
log "Release steps done"
|
||||
|
||||
do_post_version
|
||||
log "Post-version hook done"
|
||||
|
||||
(cd "$ROOT_DIR" && nix fmt)
|
||||
log "Formatted files"
|
||||
|
||||
git add -A
|
||||
local commit_msg="chore(release): v$FULL_VERSION"
|
||||
validate_commit_message "$commit_msg"
|
||||
git commit -m "$commit_msg"
|
||||
log "Created commit"
|
||||
|
||||
git tag "$FULL_TAG"
|
||||
CREATED_TAG="$FULL_TAG"
|
||||
log "Tagged $FULL_TAG"
|
||||
|
||||
git push
|
||||
git push --tags
|
||||
log "Done — released $FULL_TAG"
|
||||
|
||||
trap - ERR
|
||||
}
|
||||
|
||||
main "$@"
|
||||
REPO_LIB_RELEASE_ROOT_DIR="$(git rev-parse --show-toplevel)"
|
||||
export REPO_LIB_RELEASE_ROOT_DIR
|
||||
export REPO_LIB_RELEASE_CHANNELS='__CHANNEL_LIST__'
|
||||
REPO_LIB_RELEASE_STEPS_JSON="$(cat <<'EOF'
|
||||
__RELEASE_STEPS_JSON__
|
||||
EOF
|
||||
)"
|
||||
export REPO_LIB_RELEASE_STEPS_JSON
|
||||
REPO_LIB_RELEASE_POST_VERSION="$(cat <<'EOF'
|
||||
__POST_VERSION__
|
||||
EOF
|
||||
)"
|
||||
export REPO_LIB_RELEASE_POST_VERSION
|
||||
|
||||
exec __RELEASE_RUNNER__ "$@"
|
||||
|
||||
Reference in New Issue
Block a user