/* USAGE storm release --version [options] FLAGS --version Semantic version for the new release (required) --bump Automatically bump the previous version (major|minor|patch) --date Release date (default: today) --clear-changes Delete .changes/*.md files after successful release --dry-run Preview changes without writing files --tag Create an annotated Git tag with release notes --toolchain Update toolchain manifests (path/type or 'interactive') --output-json Output results as JSON --repo Path to the Git repository (default: .) --output Output changelog file path (default: CHANGELOG.md) */ package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object" "github.com/spf13/cobra" "github.com/stormlightlabs/git-storm/internal/changelog" "github.com/stormlightlabs/git-storm/internal/changeset" "github.com/stormlightlabs/git-storm/internal/shared" "github.com/stormlightlabs/git-storm/internal/style" "github.com/stormlightlabs/git-storm/internal/versioning" ) // ReleaseOutput represents the JSON output structure for the release command. type ReleaseOutput struct { Version string `json:"version"` Date string `json:"date"` EntriesCount int `json:"entries_count"` ChangelogPath string `json:"changelog_path"` TagCreated bool `json:"tag_created"` TagName string `json:"tag_name,omitempty"` ChangesCleared bool `json:"changes_cleared"` DeletedCount int `json:"deleted_count,omitempty"` ToolchainsUpdated []string `json:"toolchains_updated,omitempty"` DryRun bool `json:"dry_run"` VersionData *changelog.Version `json:"version_data"` } func releaseCmd() *cobra.Command { var ( version string bumpKind string date string clearChanges bool dryRun bool tag bool toolchains []string outputJSON bool ) c := &cobra.Command{ Use: "release", Short: "Promote unreleased changes into a new changelog version", Long: `Merges all .changes entries into CHANGELOG.md under a new version header. Optionally creates a Git tag and clears the .changes directory.`, RunE: func(cmd *cobra.Command, args []string) error { changelogPath := filepath.Join(repoPath, output) existingChangelog, err := changelog.Parse(changelogPath) if err != nil { return fmt.Errorf("failed to parse changelog: %w", err) } resolvedVersion, err := resolveReleaseVersion(version, bumpKind, existingChangelog) if err != nil { return err } version = resolvedVersion releaseDate := date if releaseDate == "" { releaseDate = time.Now().Format("2006-01-02") } else { if err := changelog.ValidateDate(releaseDate); err != nil { return err } } if !outputJSON { style.Headlinef("Preparing release %s (%s)", version, releaseDate) style.Newline() } changesDir := ".changes" entries, err := changeset.List(changesDir) if err != nil { return fmt.Errorf("failed to read .changes directory: %w", err) } if len(entries) == 0 { return fmt.Errorf("no unreleased changes found in %s", changesDir) } if !outputJSON { style.Println("Found %d unreleased entries", len(entries)) style.Newline() } var entryList []changeset.Entry for _, e := range entries { entryList = append(entryList, e.Entry) } newVersion, err := changelog.Build(entryList, version, releaseDate) if err != nil { return fmt.Errorf("failed to build version: %w", err) } changelog.Merge(existingChangelog, newVersion) releaseOutput := ReleaseOutput{ Version: version, Date: releaseDate, EntriesCount: len(entries), ChangelogPath: changelogPath, DryRun: dryRun, VersionData: newVersion, } if dryRun { if outputJSON { jsonBytes, err := json.MarshalIndent(releaseOutput, "", " ") if err != nil { return fmt.Errorf("failed to marshal output to JSON: %w", err) } fmt.Println(string(jsonBytes)) return nil } style.Headline("Dry-run mode: Preview of CHANGELOG.md") style.Newline() displayVersionPreview(newVersion) style.Newline() style.Println("No files were modified (--dry-run)") if len(toolchains) > 0 { style.Warningf("Skipping toolchain updates (--dry-run)") } return nil } if err := changelog.Write(changelogPath, existingChangelog, repoPath); err != nil { return fmt.Errorf("failed to write CHANGELOG.md: %w", err) } if !outputJSON { style.Addedf("✓ Updated %s", changelogPath) } if clearChanges { deletedCount := 0 for _, entry := range entries { filePath := filepath.Join(changesDir, entry.Filename) if err := os.Remove(filePath); err != nil { if !outputJSON { style.Println("Warning: failed to delete %s: %v", filePath, err) } continue } deletedCount++ } releaseOutput.ChangesCleared = true releaseOutput.DeletedCount = deletedCount if !outputJSON { style.Println("✓ Deleted %d entry files from %s", deletedCount, changesDir) } } if len(toolchains) > 0 { updated, err := updateToolchainTargets(repoPath, version, toolchains) if err != nil { return err } var updatedPaths []string for _, manifest := range updated { updatedPaths = append(updatedPaths, manifest.RelPath) if !outputJSON { style.Addedf("✓ Updated %s", manifest.RelPath) } } releaseOutput.ToolchainsUpdated = updatedPaths } if tag { if err := createReleaseTag(repoPath, version, newVersion); err != nil { return fmt.Errorf("failed to create Git tag: %w", err) } tagName := fmt.Sprintf("v%s", version) releaseOutput.TagCreated = true releaseOutput.TagName = tagName if !outputJSON { style.Newline() style.Addedf("✓ Created Git tag %s", tagName) } } if outputJSON { jsonBytes, err := json.MarshalIndent(releaseOutput, "", " ") if err != nil { return fmt.Errorf("failed to marshal output to JSON: %w", err) } fmt.Println(string(jsonBytes)) return nil } style.Newline() style.Headlinef("Release %s completed successfully", version) return nil }, } c.Flags().StringVar(&version, "version", "", "Semantic version for the new release (e.g., 1.3.0)") c.Flags().StringVar(&bumpKind, "bump", "", "Automatically bump the previous version (major, minor, or patch)") c.Flags().StringVar(&date, "date", "", "Release date in YYYY-MM-DD format (default: today)") c.Flags().BoolVar(&clearChanges, "clear-changes", false, "Delete .changes/*.md files after successful release") c.Flags().BoolVar(&dryRun, "dry-run", false, "Preview changes without writing files") c.Flags().BoolVar(&tag, "tag", false, "Create an annotated Git tag with release notes") c.Flags().StringSliceVar(&toolchains, "toolchain", nil, "Toolchain manifests to update (paths, types, or 'interactive')") c.Flags().BoolVar(&outputJSON, "output-json", false, "Output results as JSON") return c } func resolveReleaseVersion(versionFlag, bumpFlag string, existing *changelog.Changelog) (string, error) { if bumpFlag == "" { if versionFlag == "" { return "", fmt.Errorf("either --version or --bump must be provided") } if err := changelog.ValidateVersion(versionFlag); err != nil { return "", err } return versionFlag, nil } if versionFlag != "" { return "", fmt.Errorf("--version and --bump cannot be used together") } kind, err := versioning.ParseBumpType(bumpFlag) if err != nil { return "", err } var current string if v, ok := versioning.LatestVersion(existing); ok { current = v } return versioning.Next(current, kind) } // createReleaseTag creates an annotated Git tag for the release with changelog entries as the message. func createReleaseTag(repoPath, version string, versionData *changelog.Version) error { repo, err := git.PlainOpen(repoPath) if err != nil { return fmt.Errorf("failed to open repository: %w", err) } head, err := repo.Head() if err != nil { return fmt.Errorf("failed to get HEAD: %w", err) } tagName := fmt.Sprintf("v%s", version) _, err = repo.Tag(tagName) if err == nil { return fmt.Errorf("tag %s already exists", tagName) } tagMessage := buildTagMessage(version, versionData) _, err = repo.CreateTag(tagName, head.Hash(), &git.CreateTagOptions{ Message: tagMessage, Tagger: &object.Signature{ Name: "storm", Email: "noreply@storm", When: time.Now(), }, }) if err != nil { return fmt.Errorf("failed to create tag: %w", err) } return nil } // buildTagMessage formats the version's changelog entries into a tag message. func buildTagMessage(version string, versionData *changelog.Version) string { var builder strings.Builder builder.WriteString(fmt.Sprintf("Release %s\n\n", version)) for i, section := range versionData.Sections { if i > 0 { builder.WriteString("\n") } sectionTitle := shared.TitleCase(section.Type) builder.WriteString(fmt.Sprintf("%s:\n", sectionTitle)) for _, entry := range section.Entries { builder.WriteString(fmt.Sprintf("- %s\n", entry)) } } return builder.String() } // displayVersionPreview shows a formatted preview of the version being released. func displayVersionPreview(version *changelog.Version) { fmt.Printf("## [%s] - %s\n\n", version.Number, version.Date) for i, section := range version.Sections { if i > 0 { fmt.Println() } var sectionTitle string switch section.Type { case "added": sectionTitle = style.StyleAdded.Render("### Added") case "changed": sectionTitle = style.StyleChanged.Render("### Changed") case "deprecated": sectionTitle = "### Deprecated" case "removed": sectionTitle = style.StyleRemoved.Render("### Removed") case "fixed": sectionTitle = style.StyleFixed.Render("### Fixed") case "security": sectionTitle = style.StyleSecurity.Render("### Security") default: sectionTitle = fmt.Sprintf("### %s", section.Type) } fmt.Println(sectionTitle) fmt.Println() for _, entry := range section.Entries { fmt.Printf("- %s\n", entry) } } }