diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..6081f7b --- /dev/null +++ b/SPEC.md @@ -0,0 +1,377 @@ +# Prerelease Support Specification + +This document describes the design for adding prerelease version support to Bumper. + +## Overview + +Prereleases allow release groups to publish unstable versions (alpha, beta, rc, etc.) before graduating to a stable release. This feature integrates seamlessly with existing bump workflows and CI/CD processes. + +## Commands + +### `bumper pre enter --tag ` + +Enters prerelease mode for a release group. + +**Arguments:** +- ``: The release group name +- `--tag `: The prerelease tag (e.g., `alpha`, `beta`, `rc`) + +**Behavior:** +- Validates the group exists and is not already in prerelease mode +- Creates/updates `.bumper/prerelease.toml` with the group's prerelease state +- Does not commit or release anything — purely a state change +- Pending bumps (if any) will be processed on the next `bumper commit` + +**Example:** +```sh +bumper pre enter dashboard --tag alpha +# Output: Entered prerelease for 'dashboard' with tag 'alpha' +# Next commit will produce: 1.3.0-alpha.1 +``` + +### `bumper pre exit ` + +Exits prerelease mode and graduates to a stable release. + +**Arguments:** +- ``: The release group name + +**Behavior:** +1. Validates the group is in prerelease mode +2. Reads all processed bumps from `.bumper/prerelease/` +3. Reads any pending bumps from `.bumper/bump-*.md` +4. Consolidates all notes into a single stable changelog entry +5. Updates version to stable (e.g., `1.3.0-rc.2` → `1.3.0`) +6. Deletes all bump files (both pending and prerelease) +7. Removes the group from `.bumper/prerelease.toml` + +**Example:** +```sh +bumper pre exit dashboard +# Output: Exited prerelease for 'dashboard' +# Released version: 1.3.0 +``` + +### `bumper pre status [group]` + +Shows the current prerelease state. + +**Arguments:** +- `[group]`: Optional. If provided, shows status for that group only. + +**Example:** +```sh +bumper pre status +# Output: +# dashboard: 1.3.0-alpha.2 (tag: alpha, from: 1.2.3) +# api: not in prerelease +``` + +## State Management + +### Prerelease State File + +Prerelease state is tracked in `.bumper/prerelease.toml`: + +```toml +[groups.dashboard] +tag = "alpha" +from_version = "1.2.3" +counter = 2 +``` + +| Field | Description | +|-------|-------------| +| `tag` | The prerelease identifier (alpha, beta, rc, etc.) | +| `from_version` | The stable version when prerelease was entered | +| `counter` | The current prerelease number (1, 2, 3, ...) | + +This file should be committed to version control. + +### Bump File Lifecycle + +During prerelease, bump files follow a different lifecycle: + +``` +.bumper/ +├── config.toml +├── prerelease.toml # prerelease state +├── bump-*.md # pending bumps (not yet released) +└── prerelease/ + └── bump-*.md # processed bumps (released as prerelease) +``` + +**Normal (stable) flow:** +1. Bump files created in `.bumper/bump-*.md` +2. `bumper commit` processes and deletes them + +**Prerelease flow:** +1. Bump files created in `.bumper/bump-*.md` +2. `bumper commit` processes and moves them to `.bumper/prerelease/` +3. On `pre exit`, all files in `.bumper/prerelease/` are consolidated and deleted + +This design enables CI/CD triggers based on file changes: +- Watch `.bumper/bump-*.md` for new pending changes +- Watch `.bumper/prerelease/` for prerelease activity + +## Version Calculation + +### Bump Levels Dictate Version + +During prerelease, bump levels continue to dictate the target version rather than naively incrementing the prerelease counter. + +**Algorithm:** + +1. Get `from_version` from prerelease state (e.g., `1.2.3`) +2. Determine accumulated level from bumps in `.bumper/prerelease/` +3. Determine pending level from bumps in `.bumper/bump-*.md` +4. Take the highest of accumulated and pending levels +5. Calculate base version: `from_version` + highest level +6. If base version changed from previous prerelease → reset counter to 1 +7. Else → increment counter + +**Examples:** + +| Current | Pending Bump | Accumulated | Result | +|---------|--------------|-------------|--------| +| 1.2.3 (stable) | minor | - | 1.3.0-alpha.1 | +| 1.3.0-alpha.1 | patch | minor | 1.3.0-alpha.2 (patch < minor) | +| 1.3.0-alpha.2 | major | minor | 2.0.0-alpha.1 (major > minor, escalates) | +| 2.0.0-alpha.1 | minor | major | 2.0.0-alpha.2 (minor < major) | + +### Tag Progression + +Changing tags resets the counter but preserves the base version: + +```sh +bumper commit # 1.3.0-alpha.1 +bumper commit # 1.3.0-alpha.2 +bumper pre enter dashboard --tag beta # switch to beta (or: bumper pre retag) +bumper commit # 1.3.0-beta.1 +bumper pre enter dashboard --tag rc +bumper commit # 1.3.0-rc.1 +bumper pre exit dashboard # 1.3.0 +``` + +## Changelog Behavior + +### Append-Only Design + +The changelog remains append-only. Prerelease entries are written during the prerelease phase and remain in history. + +**During prerelease (after several commits):** + +```markdown +## 1.3.0-alpha.2 +- Fixed bug in feature X + +## 1.3.0-alpha.1 +- Added feature X +- Refactored Y + +## 1.2.3 +- Previous stable release +``` + +### Consolidated Stable Entry + +On `pre exit`, a consolidated stable entry is prepended containing all changes from the prerelease cycle: + +```markdown +## 1.3.0 +- Added feature X +- Refactored Y +- Fixed bug in feature X +- Final polish ← from pending bumps at exit time + +## 1.3.0-alpha.2 +- Fixed bug in feature X + +## 1.3.0-alpha.1 +- Added feature X +- Refactored Y + +## 1.2.3 +- Previous stable release +``` + +This provides: +- **Full history** for debugging and auditing (prerelease entries preserved) +- **Consolidated view** for stable release consumers (1.3.0 entry has everything) + +## `bumper commit` Behavior + +### No-Op with No Pending Bumps + +`bumper commit` is a no-op when there are no pending bumps. This applies to both stable and prerelease modes. + +### Modified Behavior in Prerelease Mode + +When a group is in prerelease mode, `bumper commit`: + +1. Reads pending bumps from `.bumper/bump-*.md` +2. If no pending bumps → no-op (exit early) +3. Reads processed bumps from `.bumper/prerelease/` for accumulated level +4. Calculates next prerelease version (see Version Calculation) +5. Updates version using `next_cmd` +6. Writes changelog entry using `changelog_cmd` +7. Moves pending bump files to `.bumper/prerelease/` +8. Updates `counter` in `prerelease.toml` + +## CI/CD Integration + +### Workflow Examples + +**Standard release workflow (handles both stable and prerelease):** + +```yaml +# .github/workflows/release.yml +name: Release +on: + push: + branches: [main] + paths: + - '.bumper/bump-*.md' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Release + run: bumper commit --group myapp + # Automatically produces prerelease or stable based on prerelease.toml +``` + +**Enter prerelease (manual trigger):** + +```yaml +# .github/workflows/prerelease-enter.yml +name: Enter Prerelease +on: + workflow_dispatch: + inputs: + group: + description: 'Release group' + required: true + tag: + description: 'Prerelease tag' + required: true + type: choice + options: [alpha, beta, rc] + +jobs: + enter: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Enter prerelease + run: | + bumper pre enter ${{ inputs.group }} --tag ${{ inputs.tag }} + git add .bumper/prerelease.toml + git commit -m "chore: enter ${{ inputs.tag }} prerelease for ${{ inputs.group }}" + git push +``` + +**Exit prerelease (manual trigger):** + +```yaml +# .github/workflows/prerelease-exit.yml +name: Graduate to Stable +on: + workflow_dispatch: + inputs: + group: + description: 'Release group' + required: true + +jobs: + graduate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Graduate to stable + run: | + bumper pre exit ${{ inputs.group }} + git add -A + git commit -m "chore: release stable ${{ inputs.group }}" + git push +``` + +### File-Based Triggers + +The bump file design enables granular CI triggers: + +```yaml +# Trigger on new pending bumps +on: + push: + paths: + - '.bumper/bump-*.md' + +# Trigger on prerelease activity +on: + push: + paths: + - '.bumper/prerelease/**' + +# Trigger on prerelease state changes +on: + push: + paths: + - '.bumper/prerelease.toml' +``` + +## Edge Cases + +### Entering Prerelease with No Pending Bumps + +Allowed. The group enters prerelease mode but no version is released until the next `bumper commit` with pending bumps. + +### Exiting Prerelease with No Processed Bumps + +This would mean `pre enter` was called but no commits were made. `pre exit` should: +- Warn the user +- Clean up state (remove from prerelease.toml) +- Not write a changelog entry or update version + +### Multiple Groups in Prerelease + +Each group can independently be in prerelease mode with different tags: + +```toml +# .bumper/prerelease.toml +[groups.dashboard] +tag = "beta" +from_version = "2.0.0" +counter = 3 + +[groups.api] +tag = "alpha" +from_version = "1.5.0" +counter = 1 +``` + +### Switching Tags Mid-Prerelease + +Use `pre enter` with a new tag (or a dedicated `pre retag` command): + +```sh +bumper pre enter dashboard --tag beta # already in alpha +# Keeps from_version, resets counter, changes tag +``` + +## Summary + +| Aspect | Design Decision | +|--------|-----------------| +| Commands | `pre enter`, `pre exit`, `pre status` | +| State | `.bumper/prerelease.toml` (minimal: tag, from_version, counter) | +| Bump files | Moved to `.bumper/prerelease/` on commit, deleted on exit | +| Version calc | Bump levels dictate version, not naive counter increment | +| Changelog | Append-only; stable entry consolidates all prerelease changes | +| No pending bumps | `bumper commit` is a no-op | +| CI/CD | File-based triggers, no flags needed on commit | diff --git a/cmd/bumper/main.go b/cmd/bumper/main.go index e40acd3..cd24733 100644 --- a/cmd/bumper/main.go +++ b/cmd/bumper/main.go @@ -21,6 +21,7 @@ import ( "github.com/disintegrator/bumper/internal/commands/current" "github.com/disintegrator/bumper/internal/commands/initialize" "github.com/disintegrator/bumper/internal/commands/next" + "github.com/disintegrator/bumper/internal/commands/pre" "github.com/disintegrator/bumper/internal/o11y" ) @@ -38,6 +39,7 @@ func newRootCommand(logger *slog.Logger) *cli.Command { current.NewCommand(logger), next.NewCommand(logger), cat.NewCommand(logger), + pre.NewCommand(logger), builtins.NewCommand(logger), }, } diff --git a/internal/commands/commit/command.go b/internal/commands/commit/command.go index 5e786a2..58a2df8 100644 --- a/internal/commands/commit/command.go +++ b/internal/commands/commit/command.go @@ -39,28 +39,63 @@ func NewCommand(logger *slog.Logger) *cli.Command { cfgGroups := cfg.IndexReleaseGroups() - statuses, err := workspace.CollectBumps(ctx, logger, dir, cfg) + // Load prerelease state + prereleaseState, err := workspace.LoadPrereleaseState(dir) if err != nil { - logger.ErrorContext(ctx, "failed to collect pending bumps", slog.String("dir", dir), slog.String("error", err.Error())) + logger.ErrorContext(ctx, "failed to load prerelease state", slog.String("error", err.Error())) return cmd.Failed(err) } - if err := workspace.DeleteBumps(ctx, dir); err != nil { - logger.ErrorContext(ctx, "failed to delete bump files", slog.String("dir", dir), slog.String("error", err.Error())) + // Collect pending bumps + pendingStatuses, err := workspace.CollectBumps(ctx, logger, dir, cfg) + if err != nil { + logger.ErrorContext(ctx, "failed to collect pending bumps", slog.String("dir", dir), slog.String("error", err.Error())) return cmd.Failed(err) } - if len(statuses) == 0 { + if len(pendingStatuses) == 0 { logger.InfoContext(ctx, "no pending version bumps found", slog.String("dir", dir)) return nil } - entries := lo.Entries(statuses) + // Collect accumulated prerelease bumps (for groups in prerelease) + accumulatedStatuses, err := workspace.CollectPrereleaseBumps(ctx, logger, dir, cfg) + if err != nil { + logger.ErrorContext(ctx, "failed to collect prerelease bumps", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Determine which groups have prerelease pending bumps + prereleaseGroups := make(map[string]bool) + stableGroups := make(map[string]bool) + for groupName := range pendingStatuses { + if prereleaseState.IsInPrerelease(groupName) { + prereleaseGroups[groupName] = true + } else { + stableGroups[groupName] = true + } + } + + // Handle prerelease groups: move bump files to prerelease directory + if len(prereleaseGroups) > 0 { + if err := workspace.MoveBumpsToPrerelease(ctx, dir); err != nil { + logger.ErrorContext(ctx, "failed to move bump files to prerelease", slog.String("error", err.Error())) + return cmd.Failed(err) + } + } else { + // No prerelease groups, delete bump files as before + if err := workspace.DeleteBumps(ctx, dir); err != nil { + logger.ErrorContext(ctx, "failed to delete bump files", slog.String("dir", dir), slog.String("error", err.Error())) + return cmd.Failed(err) + } + } + + entries := lo.Entries(pendingStatuses) slices.SortStableFunc(entries, func(e1, e2 lo.Entry[string, *workspace.ReleaseGroupStatus]) int { return strings.Compare(e1.Key, e2.Key) }) - committedGroups := make([]string, 0, len(statuses)) + committedGroups := make([]string, 0, len(pendingStatuses)) for _, entry := range entries { groupName, status := entry.Key, entry.Value @@ -87,10 +122,41 @@ func NewCommand(logger *slog.Logger) *cli.Command { amendFlags = append(amendFlags, "--patch", entry.Content) } - nextVersion, err := workspace.GetNextVersion(ctx, dir, g, status.Level) - if err != nil { - logger.ErrorContext(ctx, "failed to get next version", slog.String("group", groupName), slog.String("error", err.Error())) - return cmd.Failed(err) + var nextVersion string + + if prereleaseState.IsInPrerelease(groupName) { + // In prerelease mode - calculate prerelease version + groupPrereleaseState := prereleaseState.GetGroupState(groupName) + + // Get accumulated level from prerelease bumps + accumulatedLevel := workspace.BumpLevelNone + if accStatus, ok := accumulatedStatuses[groupName]; ok { + accumulatedLevel = accStatus.Level + } + + // Calculate prerelease version + var newCounter int + nextVersion, newCounter, err = workspace.CalculatePrereleaseVersion( + groupPrereleaseState.FromVersion, + groupPrereleaseState.Tag, + groupPrereleaseState.Counter, + accumulatedLevel, + status.Level, + ) + if err != nil { + logger.ErrorContext(ctx, "failed to calculate prerelease version", slog.String("group", groupName), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Update counter in prerelease state + prereleaseState.SetCounter(groupName, newCounter) + } else { + // Not in prerelease mode - use regular version calculation + nextVersion, err = workspace.GetNextVersion(ctx, dir, g, status.Level) + if err != nil { + logger.ErrorContext(ctx, "failed to get next version", slog.String("group", groupName), slog.String("error", err.Error())) + return cmd.Failed(err) + } } err = commitVersionBump(ctx, dir, g, nextVersion) @@ -108,6 +174,14 @@ func NewCommand(logger *slog.Logger) *cli.Command { committedGroups = append(committedGroups, groupName) } + // Save updated prerelease state if any groups were in prerelease + if len(prereleaseGroups) > 0 { + if err := workspace.SavePrereleaseState(dir, prereleaseState); err != nil { + logger.ErrorContext(ctx, "failed to save prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + } + fmt.Println(strings.Join(committedGroups, "\n")) return nil diff --git a/internal/commands/pre/command.go b/internal/commands/pre/command.go new file mode 100644 index 0000000..f0c5a0a --- /dev/null +++ b/internal/commands/pre/command.go @@ -0,0 +1,19 @@ +package pre + +import ( + "log/slog" + + "github.com/urfave/cli/v3" +) + +func NewCommand(logger *slog.Logger) *cli.Command { + return &cli.Command{ + Name: "pre", + Usage: "Manage prerelease versions", + Commands: []*cli.Command{ + newEnterCommand(logger), + newExitCommand(logger), + newStatusCommand(logger), + }, + } +} diff --git a/internal/commands/pre/enter.go b/internal/commands/pre/enter.go new file mode 100644 index 0000000..5181d02 --- /dev/null +++ b/internal/commands/pre/enter.go @@ -0,0 +1,107 @@ +package pre + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/disintegrator/bumper/internal/cmd" + "github.com/disintegrator/bumper/internal/commands/shared" + "github.com/disintegrator/bumper/internal/workspace" + "github.com/urfave/cli/v3" +) + +func newEnterCommand(logger *slog.Logger) *cli.Command { + return &cli.Command{ + Name: "enter", + Usage: "Enter prerelease mode for a release group", + ArgsUsage: "", + Flags: []cli.Flag{ + shared.NewDirFlag(), + &cli.StringFlag{ + Name: "tag", + Usage: "The prerelease tag (e.g., alpha, beta, rc)", + Required: true, + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + rawdir := shared.DirFlag(c) + dir, err := workspace.GetWd(rawdir) + if err != nil { + logger.ErrorContext(ctx, "workspace directory not found", slog.String("dir", rawdir), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + cfg, err := shared.LoadConfig(ctx, logger, dir) + if err != nil { + return err + } + + groupName := c.Args().First() + if groupName == "" { + err := errors.New("group name is required") + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + tag := c.String("tag") + if tag == "" { + err := errors.New("--tag flag is required") + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + // Validate group exists + cfgGroups := cfg.IndexReleaseGroups() + group, ok := cfgGroups[groupName] + if !ok { + err := fmt.Errorf("release group %q not found", groupName) + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + // Load current prerelease state + prereleaseState, err := workspace.LoadPrereleaseState(dir) + if err != nil { + logger.ErrorContext(ctx, "failed to load prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Get current version + currentVersion, err := workspace.GetCurrentVersion(ctx, dir, group) + if err != nil { + logger.ErrorContext(ctx, "failed to get current version", slog.String("group", groupName), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Check if already in prerelease + existingState := prereleaseState.GetGroupState(groupName) + if existingState != nil { + if existingState.Tag == tag { + fmt.Printf("Group %q is already in prerelease with tag %q\n", groupName, tag) + return nil + } + // Switching tags - keep from_version but reset counter + fmt.Printf("Switching prerelease tag for %q from %q to %q\n", groupName, existingState.Tag, tag) + prereleaseState.EnterPrerelease(groupName, tag, existingState.FromVersion) + } else { + // Get the base version (strip any prerelease info) + fromVersion := fmt.Sprintf("%d.%d.%d", currentVersion.Major(), currentVersion.Minor(), currentVersion.Patch()) + prereleaseState.EnterPrerelease(groupName, tag, fromVersion) + } + + // Save prerelease state + if err := workspace.SavePrereleaseState(dir, prereleaseState); err != nil { + logger.ErrorContext(ctx, "failed to save prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + state := prereleaseState.GetGroupState(groupName) + fmt.Printf("Entered prerelease for %q with tag %q\n", groupName, tag) + fmt.Printf("Next commit will produce: %s-%s.1\n", state.FromVersion, tag) + + return nil + }, + } +} diff --git a/internal/commands/pre/exit.go b/internal/commands/pre/exit.go new file mode 100644 index 0000000..52dabc7 --- /dev/null +++ b/internal/commands/pre/exit.go @@ -0,0 +1,228 @@ +package pre + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + + "github.com/disintegrator/bumper/internal/cmd" + "github.com/disintegrator/bumper/internal/commands/shared" + "github.com/disintegrator/bumper/internal/workspace" + "github.com/urfave/cli/v3" +) + +func newExitCommand(logger *slog.Logger) *cli.Command { + return &cli.Command{ + Name: "exit", + Usage: "Exit prerelease mode and graduate to a stable release", + ArgsUsage: "", + Flags: []cli.Flag{ + shared.NewDirFlag(), + }, + Action: func(ctx context.Context, c *cli.Command) error { + rawdir := shared.DirFlag(c) + dir, err := workspace.GetWd(rawdir) + if err != nil { + logger.ErrorContext(ctx, "workspace directory not found", slog.String("dir", rawdir), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + cfg, err := shared.LoadConfig(ctx, logger, dir) + if err != nil { + return err + } + + groupName := c.Args().First() + if groupName == "" { + err := errors.New("group name is required") + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + // Validate group exists + cfgGroups := cfg.IndexReleaseGroups() + group, ok := cfgGroups[groupName] + if !ok { + err := fmt.Errorf("release group %q not found", groupName) + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + // Load current prerelease state + prereleaseState, err := workspace.LoadPrereleaseState(dir) + if err != nil { + logger.ErrorContext(ctx, "failed to load prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Check if in prerelease + groupState := prereleaseState.GetGroupState(groupName) + if groupState == nil { + err := fmt.Errorf("release group %q is not in prerelease mode", groupName) + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + // Collect processed bumps from prerelease directory + prereleaseStatuses, err := workspace.CollectPrereleaseBumps(ctx, logger, dir, cfg) + if err != nil { + logger.ErrorContext(ctx, "failed to collect prerelease bumps", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Collect pending bumps + pendingStatuses, err := workspace.CollectBumps(ctx, logger, dir, cfg) + if err != nil { + logger.ErrorContext(ctx, "failed to collect pending bumps", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Merge all statuses + mergedStatuses := workspace.MergeStatuses(prereleaseStatuses, pendingStatuses) + + // Check if there are any bumps to consolidate + groupStatus, hasGroupBumps := mergedStatuses[groupName] + if !hasGroupBumps || groupStatus.Level == workspace.BumpLevelNone { + // No bumps were made during prerelease + logger.WarnContext(ctx, "no bumps were made during prerelease, cleaning up state") + prereleaseState.ExitPrerelease(groupName) + if err := workspace.SavePrereleaseState(dir, prereleaseState); err != nil { + logger.ErrorContext(ctx, "failed to save prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + // Clean up any empty prerelease directory + workspace.DeletePrereleaseBumps(ctx, dir) + fmt.Printf("Exited prerelease for %q (no changes were made)\n", groupName) + return nil + } + + // Calculate stable version based on accumulated bump levels + accumulatedLevel := workspace.BumpLevelNone + if accStatus := prereleaseStatuses[groupName]; accStatus != nil { + accumulatedLevel = accStatus.Level + } + pendingLevel := workspace.BumpLevelNone + if pendStatus := pendingStatuses[groupName]; pendStatus != nil { + pendingLevel = pendStatus.Level + } + + stableVersion, _, err := workspace.CalculatePrereleaseVersion( + groupState.FromVersion, + groupState.Tag, + groupState.Counter, + accumulatedLevel, + pendingLevel, + ) + if err != nil { + logger.ErrorContext(ctx, "failed to calculate version", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Extract the stable version (without prerelease suffix) + stableVersion, err = workspace.GetStableVersionFromPrerelease(stableVersion) + if err != nil { + logger.ErrorContext(ctx, "failed to get stable version", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Delete pending bumps + if err := workspace.DeleteBumps(ctx, dir); err != nil { + logger.ErrorContext(ctx, "failed to delete pending bump files", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Delete prerelease bumps + if err := workspace.DeletePrereleaseBumps(ctx, dir); err != nil { + logger.ErrorContext(ctx, "failed to delete prerelease bump files", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Build changelog flags with all accumulated changes + amendFlags := make([]string, 0) + amendFlags = append(amendFlags, "--group", groupName) + + for _, entry := range groupStatus.MajorLogs { + amendFlags = append(amendFlags, "--major", entry.Content) + } + for _, entry := range groupStatus.MinorLogs { + amendFlags = append(amendFlags, "--minor", entry.Content) + } + for _, entry := range groupStatus.PatchLogs { + amendFlags = append(amendFlags, "--patch", entry.Content) + } + + // Commit the stable version + if err := commitVersionBump(ctx, dir, group, stableVersion); err != nil { + logger.ErrorContext(ctx, "failed to commit version bump", slog.String("group", groupName), slog.String("version", stableVersion), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Commit the consolidated changelog + if err := commitChangelog(ctx, dir, group, stableVersion, amendFlags); err != nil { + logger.ErrorContext(ctx, "failed to commit changelog", slog.String("group", groupName), slog.String("version", stableVersion), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // Remove group from prerelease state + prereleaseState.ExitPrerelease(groupName) + if err := workspace.SavePrereleaseState(dir, prereleaseState); err != nil { + logger.ErrorContext(ctx, "failed to save prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + fmt.Printf("Exited prerelease for %q\n", groupName) + fmt.Printf("Released version: %s\n", stableVersion) + + return nil + }, + } +} + +func commitVersionBump(ctx context.Context, dir string, group workspace.ReleaseGroup, versionStr string) error { + if len(group.NextCMD) == 0 { + return errors.New("no next version command defined for release group") + } + + nextProg := group.NextCMD[0] + nextArgs := group.NextCMD[1:] + cmd := exec.CommandContext(ctx, nextProg, nextArgs...) + cmd.Dir = dir + cmd.Env = append( + os.Environ(), + fmt.Sprintf("BUMPER_GROUP=%s", group.Name), + fmt.Sprintf("BUMPER_GROUP_NEXT_VERSION=%s", versionStr), + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("execute next version command: %w", err) + } + + return nil +} + +func commitChangelog(ctx context.Context, dir string, group workspace.ReleaseGroup, versionStr string, flags []string) error { + if len(group.ChangelogCMD) == 0 { + return errors.New("no changelog command defined for release group") + } + + changelogProg := group.ChangelogCMD[0] + changelogArgs := append(group.ChangelogCMD[1:], flags...) + cmd := exec.CommandContext(ctx, changelogProg, changelogArgs...) + cmd.Dir = dir + cmd.Env = append( + os.Environ(), + fmt.Sprintf("BUMPER_GROUP=%s", group.Name), + fmt.Sprintf("BUMPER_GROUP_NEXT_VERSION=%s", versionStr), + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("execute amend changelog command: %w", err) + } + + return nil +} diff --git a/internal/commands/pre/status.go b/internal/commands/pre/status.go new file mode 100644 index 0000000..8e22ca0 --- /dev/null +++ b/internal/commands/pre/status.go @@ -0,0 +1,78 @@ +package pre + +import ( + "context" + "fmt" + "log/slog" + + "github.com/disintegrator/bumper/internal/cmd" + "github.com/disintegrator/bumper/internal/commands/shared" + "github.com/disintegrator/bumper/internal/workspace" + "github.com/urfave/cli/v3" +) + +func newStatusCommand(logger *slog.Logger) *cli.Command { + return &cli.Command{ + Name: "status", + Usage: "Show the current prerelease state", + ArgsUsage: "[group]", + Flags: []cli.Flag{ + shared.NewDirFlag(), + }, + Action: func(ctx context.Context, c *cli.Command) error { + rawdir := shared.DirFlag(c) + dir, err := workspace.GetWd(rawdir) + if err != nil { + logger.ErrorContext(ctx, "workspace directory not found", slog.String("dir", rawdir), slog.String("error", err.Error())) + return cmd.Failed(err) + } + + cfg, err := shared.LoadConfig(ctx, logger, dir) + if err != nil { + return err + } + + // Load current prerelease state + prereleaseState, err := workspace.LoadPrereleaseState(dir) + if err != nil { + logger.ErrorContext(ctx, "failed to load prerelease state", slog.String("error", err.Error())) + return cmd.Failed(err) + } + + // If a specific group was requested + groupName := c.Args().First() + if groupName != "" { + // Validate group exists + cfgGroups := cfg.IndexReleaseGroups() + _, ok := cfgGroups[groupName] + if !ok { + err := fmt.Errorf("release group %q not found", groupName) + logger.ErrorContext(ctx, err.Error()) + return cmd.Failed(err) + } + + state := prereleaseState.GetGroupState(groupName) + if state == nil { + fmt.Printf("%s: not in prerelease\n", groupName) + } else { + currentVersion := fmt.Sprintf("%s-%s.%d", state.FromVersion, state.Tag, max(state.Counter, 1)) + fmt.Printf("%s: %s (tag: %s, from: %s)\n", groupName, currentVersion, state.Tag, state.FromVersion) + } + return nil + } + + // Show status for all groups + for _, group := range cfg.Groups { + state := prereleaseState.GetGroupState(group.Name) + if state == nil { + fmt.Printf("%s: not in prerelease\n", group.Name) + } else { + currentVersion := fmt.Sprintf("%s-%s.%d", state.FromVersion, state.Tag, max(state.Counter, 1)) + fmt.Printf("%s: %s (tag: %s, from: %s)\n", group.Name, currentVersion, state.Tag, state.FromVersion) + } + } + + return nil + }, + } +} diff --git a/internal/workspace/bumps.go b/internal/workspace/bumps.go index 7f6ab7e..57e9892 100644 --- a/internal/workspace/bumps.go +++ b/internal/workspace/bumps.go @@ -232,6 +232,208 @@ func GetCurrentVersion(ctx context.Context, dir string, group ReleaseGroup) (*se return currentSemver, nil } +// CollectPrereleaseBumps collects bumps from the prerelease directory +func CollectPrereleaseBumps(ctx context.Context, logger *slog.Logger, dir string, cfg *Config) (map[string]*ReleaseGroupStatus, error) { + statuses := make(map[string]*ReleaseGroupStatus) + + repo, err := openGitRepository(dir) + switch { + case errors.Is(err, errNoGitRepository): + logger.WarnContext(ctx, "git repository not found", slog.String("dir", dir)) + case err != nil: + logger.WarnContext(ctx, "failed to open git repository", slog.String("dir", dir), slog.String("error", err.Error())) + } + + highestBump := make(map[string]BumpLevel) + for _, g := range cfg.Groups { + highestBump[g.Name] = BumpLevelNone + } + + pattern := filepath.Join(PrereleaseBumpDir(dir), "bump-*.md") + matches, err := filepath.Glob(pattern) + if err != nil { + return nil, fmt.Errorf("glob prerelease bump files: %w", err) + } + + if len(matches) == 0 { + return statuses, nil + } + + gitInfo, err := ResolveGitInfoForBumps(ctx, logger, repo, matches) + if err != nil { + return nil, fmt.Errorf("resolve git info for prerelease bumps: %w", err) + } + + var itererr error + lo.ForEachWhile(matches, func(match string, _ int) bool { + f, err := os.Open(match) + if err != nil { + logger.ErrorContext(ctx, "failed to open prerelease bump file", slog.String("file", match), slog.String("error", err.Error())) + itererr = err + return false + } + defer f.Close() + + content, err := io.ReadAll(f) + if err != nil { + logger.ErrorContext(ctx, "failed to read prerelease bump file", slog.String("file", match), slog.String("error", err.Error())) + itererr = err + return false + } + + frontMatter := make(map[string]string) + message, err := extractFrontMatter(string(content), &frontMatter) + if err != nil { + logger.ErrorContext(ctx, "failed to extract front matter from prerelease bump", slog.String("file", match), slog.String("error", err.Error())) + itererr = err + return false + } + + entry := LogEntry{Content: message, Timestamp: 0, Commit: ""} + gitItem, ok := gitInfo[match] + if ok { + entry.Timestamp = gitItem.When.UnixNano() + entry.Commit = gitItem.SHA[:min(7, len(gitItem.SHA))] + entry.Content = fmt.Sprintf("%s: %s", entry.Commit, entry.Content) + } + + for groupName, level := range frontMatter { + if _, ok := highestBump[groupName]; !ok { + logger.WarnContext(ctx, "skipping prerelease bump for unknown group", slog.String("file", match), slog.String("group", groupName)) + continue + } + + if _, ok := statuses[groupName]; !ok { + statuses[groupName] = &ReleaseGroupStatus{ + Level: BumpLevelNone, + MajorLogs: []LogEntry{}, + MinorLogs: []LogEntry{}, + PatchLogs: []LogEntry{}, + } + } + + switch level { + case "major": + statuses[groupName].Level = max(statuses[groupName].Level, BumpLevelMajor) + statuses[groupName].MajorLogs = append(statuses[groupName].MajorLogs, entry) + case "minor": + statuses[groupName].Level = max(statuses[groupName].Level, BumpLevelMinor) + statuses[groupName].MinorLogs = append(statuses[groupName].MinorLogs, entry) + case "patch": + statuses[groupName].Level = max(statuses[groupName].Level, BumpLevelPatch) + statuses[groupName].PatchLogs = append(statuses[groupName].PatchLogs, entry) + default: + logger.WarnContext(ctx, "unknown level in prerelease bump file front matter", slog.String("file", match), slog.String("group", groupName), slog.String("level", level)) + } + } + + return true + }) + if itererr != nil { + return nil, fmt.Errorf("process prerelease bump files: %w", itererr) + } + + for _, status := range statuses { + slices.SortStableFunc(status.MajorLogs, func(a, b LogEntry) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + slices.SortStableFunc(status.MinorLogs, func(a, b LogEntry) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + slices.SortStableFunc(status.PatchLogs, func(a, b LogEntry) int { + return cmp.Compare(a.Timestamp, b.Timestamp) + }) + } + + return statuses, nil +} + +// MoveBumpsToPrerelease moves pending bump files to the prerelease directory +func MoveBumpsToPrerelease(ctx context.Context, dir string) error { + // Ensure prerelease directory exists + prereleaseDir := PrereleaseBumpDir(dir) + if err := os.MkdirAll(prereleaseDir, 0755); err != nil { + return fmt.Errorf("create prerelease directory: %w", err) + } + + pattern := BumpFilename(dir, "*") + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob bump files: %w", err) + } + + for _, match := range matches { + base := filepath.Base(match) + dest := filepath.Join(prereleaseDir, base) + if err := os.Rename(match, dest); err != nil { + return fmt.Errorf("move bump file %s to prerelease: %w", match, err) + } + } + + return nil +} + +// DeletePrereleaseBumps deletes all bump files in the prerelease directory +func DeletePrereleaseBumps(ctx context.Context, dir string) error { + prereleaseDir := PrereleaseBumpDir(dir) + pattern := filepath.Join(prereleaseDir, "bump-*.md") + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob prerelease bump files: %w", err) + } + + for _, match := range matches { + if err := os.Remove(match); err != nil { + return fmt.Errorf("remove prerelease bump file %s: %w", match, err) + } + } + + // Remove the prerelease directory if empty + entries, err := os.ReadDir(prereleaseDir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read prerelease directory: %w", err) + } + if len(entries) == 0 { + os.Remove(prereleaseDir) + } + + return nil +} + +// MergeStatuses merges two status maps, combining logs from both +func MergeStatuses(a, b map[string]*ReleaseGroupStatus) map[string]*ReleaseGroupStatus { + result := make(map[string]*ReleaseGroupStatus) + + // Copy all from a + for name, status := range a { + result[name] = &ReleaseGroupStatus{ + Level: status.Level, + MajorLogs: append([]LogEntry{}, status.MajorLogs...), + MinorLogs: append([]LogEntry{}, status.MinorLogs...), + PatchLogs: append([]LogEntry{}, status.PatchLogs...), + } + } + + // Merge from b + for name, status := range b { + if existing, ok := result[name]; ok { + existing.Level = max(existing.Level, status.Level) + existing.MajorLogs = append(existing.MajorLogs, status.MajorLogs...) + existing.MinorLogs = append(existing.MinorLogs, status.MinorLogs...) + existing.PatchLogs = append(existing.PatchLogs, status.PatchLogs...) + } else { + result[name] = &ReleaseGroupStatus{ + Level: status.Level, + MajorLogs: append([]LogEntry{}, status.MajorLogs...), + MinorLogs: append([]LogEntry{}, status.MinorLogs...), + PatchLogs: append([]LogEntry{}, status.PatchLogs...), + } + } + } + + return result +} + func GetNextVersion(ctx context.Context, dir string, group ReleaseGroup, level BumpLevel) (string, error) { if len(group.CurrentCMD) == 0 { return "", errors.New("no current version command defined for release group") diff --git a/internal/workspace/paths.go b/internal/workspace/paths.go index 6f919ac..69daa84 100644 --- a/internal/workspace/paths.go +++ b/internal/workspace/paths.go @@ -20,3 +20,15 @@ func VersionFilename(base string) string { func BumpFilename(base string, suffix string) string { return filepath.Join(Dir(base), fmt.Sprintf("bump-%s.md", suffix)) } + +func PrereleaseFilename(base string) string { + return filepath.Join(Dir(base), "prerelease.toml") +} + +func PrereleaseBumpDir(base string) string { + return filepath.Join(Dir(base), "prerelease") +} + +func PrereleaseBumpFilename(base string, suffix string) string { + return filepath.Join(PrereleaseBumpDir(base), fmt.Sprintf("bump-%s.md", suffix)) +} diff --git a/internal/workspace/prerelease.go b/internal/workspace/prerelease.go new file mode 100644 index 0000000..b11e056 --- /dev/null +++ b/internal/workspace/prerelease.go @@ -0,0 +1,202 @@ +package workspace + +import ( + "errors" + "fmt" + "os" + + "github.com/BurntSushi/toml" + "github.com/Masterminds/semver/v3" +) + +// PrereleaseGroupState represents the prerelease state for a single release group +type PrereleaseGroupState struct { + Tag string `toml:"tag"` + FromVersion string `toml:"from_version"` + Counter int `toml:"counter"` +} + +// PrereleaseState represents the prerelease state for all groups +type PrereleaseState struct { + Groups map[string]*PrereleaseGroupState `toml:"groups"` +} + +// NewPrereleaseState creates a new empty prerelease state +func NewPrereleaseState() *PrereleaseState { + return &PrereleaseState{ + Groups: make(map[string]*PrereleaseGroupState), + } +} + +// LoadPrereleaseState loads the prerelease state from the workspace +func LoadPrereleaseState(baseDir string) (*PrereleaseState, error) { + filename := PrereleaseFilename(baseDir) + + _, err := os.Stat(filename) + if errors.Is(err, os.ErrNotExist) { + return NewPrereleaseState(), nil + } + if err != nil { + return nil, fmt.Errorf("stat prerelease file: %w", err) + } + + var state PrereleaseState + _, err = toml.DecodeFile(filename, &state) + if err != nil { + return nil, fmt.Errorf("decode prerelease file: %w", err) + } + + if state.Groups == nil { + state.Groups = make(map[string]*PrereleaseGroupState) + } + + return &state, nil +} + +// SavePrereleaseState saves the prerelease state to the workspace +func SavePrereleaseState(baseDir string, state *PrereleaseState) error { + filename := PrereleaseFilename(baseDir) + + // If no groups in prerelease, remove the file + if len(state.Groups) == 0 { + err := os.Remove(filename) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove prerelease file: %w", err) + } + return nil + } + + f, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("open prerelease file: %w", err) + } + defer f.Close() + + if err := toml.NewEncoder(f).Encode(state); err != nil { + return fmt.Errorf("encode prerelease file: %w", err) + } + + return nil +} + +// IsInPrerelease checks if a group is in prerelease mode +func (s *PrereleaseState) IsInPrerelease(groupName string) bool { + _, ok := s.Groups[groupName] + return ok +} + +// GetGroupState returns the prerelease state for a group, or nil if not in prerelease +func (s *PrereleaseState) GetGroupState(groupName string) *PrereleaseGroupState { + return s.Groups[groupName] +} + +// EnterPrerelease enters prerelease mode for a group +func (s *PrereleaseState) EnterPrerelease(groupName string, tag string, fromVersion string) { + existing := s.Groups[groupName] + if existing != nil && existing.Tag == tag { + // Same tag, keep counter + return + } + + s.Groups[groupName] = &PrereleaseGroupState{ + Tag: tag, + FromVersion: fromVersion, + Counter: 0, // Will be set to 1 on first commit + } +} + +// ExitPrerelease exits prerelease mode for a group +func (s *PrereleaseState) ExitPrerelease(groupName string) { + delete(s.Groups, groupName) +} + +// IncrementCounter increments the prerelease counter for a group +func (s *PrereleaseState) IncrementCounter(groupName string) { + if state := s.Groups[groupName]; state != nil { + state.Counter++ + } +} + +// ResetCounter resets the prerelease counter for a group (used when base version escalates) +func (s *PrereleaseState) ResetCounter(groupName string) { + if state := s.Groups[groupName]; state != nil { + state.Counter = 1 + } +} + +// SetCounter sets the prerelease counter for a group +func (s *PrereleaseState) SetCounter(groupName string, counter int) { + if state := s.Groups[groupName]; state != nil { + state.Counter = counter + } +} + +// CalculatePrereleaseVersion calculates the next prerelease version based on accumulated and pending bumps +func CalculatePrereleaseVersion(fromVersion string, tag string, currentCounter int, accumulatedLevel BumpLevel, pendingLevel BumpLevel) (string, int, error) { + fromSemver, err := semver.NewVersion(fromVersion) + if err != nil { + return "", 0, fmt.Errorf("parse from_version: %w", err) + } + + // Take the highest of accumulated and pending levels + highestLevel := max(accumulatedLevel, pendingLevel) + + // Calculate the base version by applying highest level to from_version + var baseVersion semver.Version + switch highestLevel { + case BumpLevelMajor: + baseVersion = fromSemver.IncMajor() + case BumpLevelMinor: + baseVersion = fromSemver.IncMinor() + case BumpLevelPatch: + baseVersion = fromSemver.IncPatch() + default: + // If no bump level, use from_version + patch as default + baseVersion = fromSemver.IncPatch() + } + + // Calculate what the previous base version was (if any) + var prevBaseVersion *semver.Version + if currentCounter > 0 { + // We had a previous prerelease, calculate what its base was + switch accumulatedLevel { + case BumpLevelMajor: + v := fromSemver.IncMajor() + prevBaseVersion = &v + case BumpLevelMinor: + v := fromSemver.IncMinor() + prevBaseVersion = &v + case BumpLevelPatch: + v := fromSemver.IncPatch() + prevBaseVersion = &v + } + } + + // Determine the new counter + newCounter := 1 + if prevBaseVersion != nil && prevBaseVersion.Equal(&baseVersion) { + // Base version unchanged, increment counter + newCounter = currentCounter + 1 + } + + // Create the prerelease version string + prereleaseVersion := fmt.Sprintf("%d.%d.%d-%s.%d", + baseVersion.Major(), + baseVersion.Minor(), + baseVersion.Patch(), + tag, + newCounter, + ) + + return prereleaseVersion, newCounter, nil +} + +// GetStableVersionFromPrerelease extracts the stable version from a prerelease version +func GetStableVersionFromPrerelease(prereleaseVersion string) (string, error) { + v, err := semver.NewVersion(prereleaseVersion) + if err != nil { + return "", fmt.Errorf("parse prerelease version: %w", err) + } + + return fmt.Sprintf("%d.%d.%d", v.Major(), v.Minor(), v.Patch()), nil +}