diff --git a/README.md b/README.md index 9fa1d4a..3ed4564 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ go build -o bin/stamp ./cmd/stamp ## Quick Start -### 1. Create `stamp.toml` at the root of your repository +### 1. Create `.stamp/stamp.toml` ```toml [[projects]] @@ -46,6 +46,8 @@ version = "0.1.0" artifacts = ["dist/my-app-*"] ``` +Because `stamp.toml` lives inside `.stamp/`, git will track the directory without needing a `.gitignore` file in it. + See [`examples/stamp.toml`](examples/stamp.toml) for a fully annotated example. ### 2. Add a stamp file when making a change @@ -108,7 +110,7 @@ Shows all pending stamp files and the projected next version for each project. ### `stamp version` -Consumes all pending stamp files, bumps versions in `stamp.toml`, and prepends a new entry to each project's `CHANGELOG.md`. Then stages and commits the changes via git. +Consumes all pending stamp files, bumps versions in `.stamp/stamp.toml`, and prepends a new entry to each project's `CHANGELOG.md`. Then stages and commits the changes via git. | Flag | Description | |------|-------------| @@ -142,7 +144,7 @@ Posts or updates a PR comment summarising pending stamps. Useful in CI to remind ## Stamp File Format -Stamp files live in `.stamp/` and use Markdown with YAML or TOML frontmatter. +Stamp files live in `.stamp/` alongside `stamp.toml`, and use Markdown with YAML or TOML frontmatter. ### YAML (default) @@ -181,13 +183,12 @@ Short description. | `prepatch` | Pre-release patch — `1.2.3` → `1.2.4-alpha.0` | | `prerelease` | Increment pre-release — `1.2.4-rc.0` → `1.2.4-rc.1` | -## Configuration Reference (`stamp.toml`) +## Configuration Reference (`.stamp/stamp.toml`) ```toml # Global settings (all optional) [config] -base_branch = "main" # Base branch for PR change detection (default: main) -changeset_dir = ".stamp" # Directory for stamp files (default: .stamp) +base_branch = "main" # Base branch for PR change detection (default: main) [[projects]] name = "my-app" @@ -209,8 +210,8 @@ changelog = "CHANGELOG.md" # Relative to path (default: CHANGELOG.md) | Variable | Purpose | |----------|---------| | `STAMP_REPO` | Repository slug `owner/repo` — required for `publish` and `comment` | -| `GITHUB_TOKEN` | GitHub personal access token for releases and PR comments | -| `GITEA_TOKEN` | Gitea access token | +| `GITHUB_TOKEN` | GitHub token for releases and PR comments — automatically provided by the GitHub Actions runner; no manual setup needed | +| `GITEA_TOKEN` | Gitea access token — **must be created manually** (Gitea Actions does not inject one automatically); create a token in your Gitea account settings and store it as a repository secret | | `GITEA_BASE_URL` | Gitea instance URL (e.g. `https://gitea.example.com`) — also enables Gitea mode | ## CI Integration @@ -306,4 +307,4 @@ stamp/ ## License -MIT \ No newline at end of file +MIT diff --git a/cmd/stamp/cmd_add.go b/cmd/stamp/cmd_add.go index 9a53e62..ea912d2 100644 --- a/cmd/stamp/cmd_add.go +++ b/cmd/stamp/cmd_add.go @@ -5,6 +5,7 @@ import ( "fmt" "path/filepath" + "github.com/charmbracelet/huh" "github.com/urfave/cli/v3" "git.thokra.dev/thokra/stamp/internal/changeset" @@ -101,46 +102,61 @@ func addCmd() *cli.Command { } } -// promptAdd runs an interactive prompt to collect add parameters. +// promptAdd runs an interactive TUI prompt to collect add parameters. func promptAdd(cfg *config.Config) ([]string, changeset.BumpType, string, error) { - projectNames := cfg.ProjectNames() - - fmt.Println("Which projects are affected? (enter comma-separated names)") - for _, name := range projectNames { - fmt.Printf(" - %s\n", name) - } - fmt.Print("> ") - - var input string - if _, err := fmt.Scanln(&input); err != nil { - return nil, "", "", fmt.Errorf("reading input: %w", err) - } - - var projects []string - for _, p := range splitComma(input) { - if cfg.FindProject(p) == nil { - return nil, "", "", fmt.Errorf("project %q not found in stamp.toml", p) + projectOptions := make([]huh.Option[string], len(cfg.Projects)) + for i, p := range cfg.Projects { + label := p.Name + if p.PreTag != "" { + label += " (preview: " + p.PreTag + ")" } - projects = append(projects, p) - } - if len(projects) == 0 { - return nil, "", "", fmt.Errorf("at least one project must be specified") + projectOptions[i] = huh.NewOption(label, p.Name) } - fmt.Println("Bump type? (major, minor, patch, premajor, preminor, prepatch, prerelease)") - fmt.Print("> ") + var selectedProjects []string var bumpStr string - if _, err := fmt.Scanln(&bumpStr); err != nil { - return nil, "", "", fmt.Errorf("reading input: %w", err) - } - bumpType := changeset.BumpType(bumpStr) - - fmt.Println("Description of the change:") - fmt.Print("> ") var message string - if _, err := fmt.Scanln(&message); err != nil { - return nil, "", "", fmt.Errorf("reading input: %w", err) + + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Which projects are affected?"). + Options(projectOptions...). + Validate(func(v []string) error { + if len(v) == 0 { + return fmt.Errorf("select at least one project") + } + return nil + }). + Value(&selectedProjects), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("Bump type"). + Options( + huh.NewOption("patch — bug fix (1.2.3 → 1.2.4)", string(changeset.BumpPatch)), + huh.NewOption("minor — new feature (1.2.3 → 1.3.0)", string(changeset.BumpMinor)), + huh.NewOption("major — breaking change (1.2.3 → 2.0.0)", string(changeset.BumpMajor)), + ). + Value(&bumpStr), + ), + huh.NewGroup( + huh.NewText(). + Title("Describe the change"). + CharLimit(500). + Validate(func(v string) error { + if len(v) == 0 { + return fmt.Errorf("description is required") + } + return nil + }). + Value(&message), + ), + ) + + if err := form.Run(); err != nil { + return nil, "", "", err } - return projects, bumpType, message, nil + return selectedProjects, changeset.BumpType(bumpStr), message, nil } diff --git a/cmd/stamp/cmd_preview.go b/cmd/stamp/cmd_preview.go new file mode 100644 index 0000000..8f6e380 --- /dev/null +++ b/cmd/stamp/cmd_preview.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" + + "git.thokra.dev/thokra/stamp/internal/config" +) + +func previewCmd() *cli.Command { + return &cli.Command{ + Name: "preview", + Usage: "manage pre-release mode for a project", + Commands: []*cli.Command{ + { + Name: "enter", + Usage: "put a project into pre-release mode", + ArgsUsage: " ", + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() < 2 { + return fmt.Errorf("usage: stamp preview enter ") + } + projectName := cmd.Args().Get(0) + tag := cmd.Args().Get(1) + + repoRoot, err := findRepoRoot(".") + if err != nil { + return err + } + cfg, err := config.Load(repoRoot) + if err != nil { + return err + } + + project := cfg.FindProject(projectName) + if project == nil { + return fmt.Errorf("project %q not found in stamp.toml", projectName) + } + + if project.PreTag != "" { + return fmt.Errorf("project %q is already in pre-release mode with tag %q — run `stamp preview exit %s` first", + projectName, project.PreTag, projectName) + } + + project.PreTag = tag + + if err := config.Save(repoRoot, cfg); err != nil { + return fmt.Errorf("saving stamp.toml: %w", err) + } + + fmt.Printf("🔬 %s is now in pre-release mode (tag: %s).\n", projectName, tag) + fmt.Printf(" `stamp version` will produce versions like 1.2.3-%s.0\n", tag) + return nil + }, + }, + { + Name: "exit", + Usage: "take a project out of pre-release mode", + ArgsUsage: "", + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return fmt.Errorf("usage: stamp preview exit ") + } + projectName := cmd.Args().First() + + repoRoot, err := findRepoRoot(".") + if err != nil { + return err + } + cfg, err := config.Load(repoRoot) + if err != nil { + return err + } + + project := cfg.FindProject(projectName) + if project == nil { + return fmt.Errorf("project %q not found in stamp.toml", projectName) + } + + if project.PreTag == "" { + return fmt.Errorf("project %q is not in pre-release mode", projectName) + } + + project.PreTag = "" + + if err := config.Save(repoRoot, cfg); err != nil { + return fmt.Errorf("saving stamp.toml: %w", err) + } + + fmt.Printf("✓ %s is no longer in pre-release mode. The next `stamp version` will produce a normal release.\n", projectName) + return nil + }, + }, + }, + } +} diff --git a/cmd/stamp/cmd_version.go b/cmd/stamp/cmd_version.go index 7f5638a..59c4e0f 100644 --- a/cmd/stamp/cmd_version.go +++ b/cmd/stamp/cmd_version.go @@ -50,7 +50,7 @@ func versionCmd() *cli.Command { return nil } - preID := cmd.String("snapshot") + snapshotID := cmd.String("snapshot") projectBumps := semver.ProjectBumps(changesets) now := time.Now() @@ -65,8 +65,32 @@ func versionCmd() *cli.Command { } highest := semver.HighestBump(bumps) + + // Determine the effective pre-release identifier, in priority order: + // 1. --snapshot flag (repo-wide, one-off) + // 2. project.PreTag set by `stamp preview enter` (persistent, per-project) + preID := snapshotID + if preID == "" { + preID = project.PreTag + } + if preID != "" { - highest = changeset.BumpPreRelease + // If the project is already on a pre-release version, just + // increment it. Otherwise promote the highest regular bump + // to its pre-release equivalent so we don't skip a version. + v, _ := semver.CurrentVersion(project.Version) + if v != nil && v.Prerelease() != "" { + highest = changeset.BumpPreRelease + } else { + switch highest { + case changeset.BumpMajor: + highest = changeset.BumpPreMajor + case changeset.BumpMinor: + highest = changeset.BumpPreMinor + default: // patch or anything lower + highest = changeset.BumpPrePatch + } + } } nextVer, err := semver.Bump(project.Version, highest, preID) @@ -105,7 +129,7 @@ func versionCmd() *cli.Command { } // Update stamp.toml with new versions. - cfgPath := filepath.Join(repoRoot, config.ConfigFileName) + cfgPath := config.ConfigPath(repoRoot) if err := config.Save(repoRoot, cfg); err != nil { return fmt.Errorf("saving stamp.toml: %w", err) } diff --git a/cmd/stamp/main.go b/cmd/stamp/main.go index c67e829..95debb2 100644 --- a/cmd/stamp/main.go +++ b/cmd/stamp/main.go @@ -19,6 +19,7 @@ func main() { versionCmd(), publishCmd(), commentCmd(), + previewCmd(), }, } diff --git a/docs/workflow.md b/docs/workflow.md index 9615418..42ab3f0 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -32,9 +32,9 @@ Or build from source: go install git.thokra.dev/thokra/stamp/cmd/stamp@latest ``` -### 2. Create stamp.toml +### 2. Create .stamp/stamp.toml -At the root of your repository: +Inside the `.stamp/` directory at the root of your repository: ```toml [[projects]] @@ -48,6 +48,8 @@ version = "0.1.0" artifacts = ["dist/my-app-*"] ``` +Because `stamp.toml` lives inside `.stamp/`, git will track the directory without needing a `.gitignore` file in it. + See [`examples/stamp.toml`](../examples/stamp.toml) for a fully annotated example. --- @@ -77,6 +79,8 @@ bumps: Added X feature ``` +> Stamp files live alongside `stamp.toml` in the `.stamp/` directory. + Commit and push the stamp file alongside your code changes. ### Checking pending stamps @@ -177,7 +181,7 @@ jobs: ## Stamp File Format -Stamp files live in `.stamp/` and use Markdown with either YAML or TOML frontmatter. +Stamp files live in `.stamp/` alongside `stamp.toml`, and use Markdown with either YAML or TOML frontmatter. ### YAML frontmatter (default) @@ -224,6 +228,6 @@ Short description. | Variable | Purpose | |----------|---------| | `STAMP_REPO` | Repository slug `owner/repo` used by publish and comment | -| `GITHUB_TOKEN` | GitHub personal access token for releases and PR comments | -| `GITEA_TOKEN` | Gitea access token | +| `GITHUB_TOKEN` | GitHub token for releases and PR comments — automatically provided by the GitHub Actions runner; no manual setup needed | +| `GITEA_TOKEN` | Gitea access token — **must be created manually**; create a token in your Gitea account settings and store it as a repository secret | | `GITEA_BASE_URL` | Gitea instance URL (e.g. `https://gitea.example.com`) — also used to detect Gitea mode | diff --git a/examples/stamp.toml b/examples/stamp.toml index 4e44441..ef95092 100644 --- a/examples/stamp.toml +++ b/examples/stamp.toml @@ -1,14 +1,10 @@ -# stamp.toml — annotated example configuration +# .stamp/stamp.toml — annotated example configuration # Global settings (all optional) [config] # The base branch used by `stamp comment` to detect what's changed in a PR. base_branch = "main" -# Directory where stamp files (.md changesets) are stored. -# Default: .stamp -changeset_dir = ".stamp" - # --- Projects --- # Define one [[projects]] block per versioned component. # For single-project repos, define just one block with name = "" (or any name). diff --git a/go.mod b/go.mod index e69b107..0fa120d 100644 --- a/go.mod +++ b/go.mod @@ -13,10 +13,36 @@ require ( require ( github.com/42wim/httpsig v1.2.3 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-fed/httpsig v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-version v1.7.0 // 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/mitchellh/hashstructure/v2 v2.0.2 // 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/crypto v0.39.0 // indirect + golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index e83cd96..5b561d3 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,38 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +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/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +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.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -21,12 +49,33 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +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/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= @@ -34,9 +83,13 @@ golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -44,6 +97,8 @@ golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config/config.go b/internal/config/config.go index b0cf3e2..2995dcf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,8 +8,15 @@ import ( "github.com/BurntSushi/toml" ) -const DefaultChangesetDir = ".stamp" -const ConfigFileName = "stamp.toml" +const ( + DefaultChangesetDir = ".stamp" + ConfigFileName = "stamp.toml" +) + +// ConfigPath returns the path to stamp.toml inside the changeset directory. +func ConfigPath(repoRoot string) string { + return filepath.Join(repoRoot, DefaultChangesetDir, ConfigFileName) +} // Config is the root configuration parsed from stamp.toml. type Config struct { @@ -19,8 +26,7 @@ type Config struct { // GlobalConfig holds repo-level settings. type GlobalConfig struct { - BaseBranch string `toml:"base_branch,omitempty"` - ChangesetDir string `toml:"changeset_dir,omitempty"` + BaseBranch string `toml:"base_branch,omitempty"` } // Project represents a single project in the monorepo. @@ -29,6 +35,7 @@ type Project struct { Path string `toml:"path"` Version string `toml:"version"` Changelog string `toml:"changelog,omitempty"` + PreTag string `toml:"pre_tag,omitempty"` Publish PublishConfig `toml:"publish,omitempty"` } @@ -39,11 +46,9 @@ type PublishConfig struct { Artifacts []string `toml:"artifacts"` } -// ChangesetDir returns the configured changeset directory, defaulting to .stamp. +// ChangesetDir always returns the default changeset directory. +// The config file lives inside this directory, so it is always known. func (c *Config) ChangesetDir() string { - if c.Config.ChangesetDir != "" { - return c.Config.ChangesetDir - } return DefaultChangesetDir } @@ -93,9 +98,9 @@ func (p *PublishConfig) PublishReleases() bool { return p.Releases == nil || *p.Releases } -// Load reads and validates stamp.toml from the given directory. -func Load(dir string) (*Config, error) { - path := filepath.Join(dir, ConfigFileName) +// Load reads and validates stamp.toml from inside the changeset directory. +func Load(repoRoot string) (*Config, error) { + path := ConfigPath(repoRoot) data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading %s: %w", path, err) @@ -113,9 +118,13 @@ func Load(dir string) (*Config, error) { return &cfg, nil } -// Save writes the config back to stamp.toml in the given directory. -func Save(dir string, cfg *Config) error { - path := filepath.Join(dir, ConfigFileName) +// Save writes the config back to stamp.toml inside the changeset directory. +func Save(repoRoot string, cfg *Config) error { + dir := filepath.Join(repoRoot, DefaultChangesetDir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + path := ConfigPath(repoRoot) f, err := os.Create(path) if err != nil { return fmt.Errorf("creating %s: %w", path, err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 560c037..b606ea3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,7 +11,6 @@ import ( const validTOML = ` [config] base_branch = "main" -changeset_dir = ".stamp" [[projects]] name = "my-app" @@ -24,11 +23,20 @@ path = "libs/my-lib" version = "0.1.0" ` -func TestLoad_Valid(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0o644); err != nil { +func writeConfig(t *testing.T, dir, content string) { + t.Helper() + stampDir := filepath.Join(dir, ".stamp") + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(stampDir, "stamp.toml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestLoad_Valid(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, validTOML) cfg, err := config.Load(dir) if err != nil { @@ -42,16 +50,14 @@ func TestLoad_Valid(t *testing.T) { t.Errorf("expected base_branch=main, got %s", cfg.BaseBranch()) } if cfg.ChangesetDir() != ".stamp" { - t.Errorf("expected changeset_dir=.stamp, got %s", cfg.ChangesetDir()) + t.Errorf("expected ChangesetDir=.stamp, got %s", cfg.ChangesetDir()) } } func TestLoad_Defaults(t *testing.T) { dir := t.TempDir() minimal := "[[projects]]\nname = \"app\"\npath = \".\"\nversion = \"1.0.0\"\n" - if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(minimal), 0o644); err != nil { - t.Fatal(err) - } + writeConfig(t, dir, minimal) cfg, err := config.Load(dir) if err != nil { @@ -75,9 +81,7 @@ func TestLoad_MissingFile(t *testing.T) { func TestLoad_NoProjects(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte("[config]\n"), 0o644); err != nil { - t.Fatal(err) - } + writeConfig(t, dir, "[config]\n") _, err := config.Load(dir) if err == nil { t.Fatal("expected error for config with no projects") @@ -97,9 +101,7 @@ name = "app" path = "other" version = "2.0.0" ` - if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(dup), 0o644); err != nil { - t.Fatal(err) - } + writeConfig(t, dir, dup) _, err := config.Load(dir) if err == nil { t.Fatal("expected error for duplicate project name") @@ -108,9 +110,7 @@ version = "2.0.0" func TestFindProject(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0o644); err != nil { - t.Fatal(err) - } + writeConfig(t, dir, validTOML) cfg, _ := config.Load(dir) if p := cfg.FindProject("my-app"); p == nil { diff --git a/internal/semver/semver.go b/internal/semver/semver.go index 9c7c15a..4283b9b 100644 --- a/internal/semver/semver.go +++ b/internal/semver/semver.go @@ -9,6 +9,12 @@ import ( "git.thokra.dev/thokra/stamp/internal/changeset" ) +// CurrentVersion parses a version string and returns the semver object. +// It is exported so callers can inspect pre-release state without re-importing the semver library. +func CurrentVersion(version string) (*goSemver.Version, error) { + return goSemver.NewVersion(version) +} + // Bump computes the next version given the current version string and a bump type. // preID is the pre-release identifier (e.g. "alpha", "beta", "rc") used for pre-* bumps. func Bump(current string, bump changeset.BumpType, preID string) (string, error) {