diff --git a/cmd/core/cmd/release.go b/cmd/core/cmd/release.go new file mode 100644 index 0000000..8fcb1d8 --- /dev/null +++ b/cmd/core/cmd/release.go @@ -0,0 +1,243 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/host-uk/core/pkg/release" + "github.com/leaanthony/clir" +) + +// Release command styles +var ( + releaseHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#3b82f6")) // blue-500 + + releaseSuccessStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#22c55e")) // green-500 + + releaseErrorStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ef4444")) // red-500 + + releaseDimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6b7280")) // gray-500 + + releaseValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#e2e8f0")) // gray-200 +) + +// AddReleaseCommand adds the release command and its subcommands. +func AddReleaseCommand(app *clir.Cli) { + releaseCmd := app.NewSubCommand("release", "Build and publish releases") + releaseCmd.LongDescription("Builds release artifacts, generates changelog, and publishes to GitHub.\n" + + "Configuration can be provided via .core/release.yaml or command-line flags.") + + // Flags for the main release command + var dryRun bool + var version string + var draft bool + var prerelease bool + + releaseCmd.BoolFlag("dry-run", "Preview release without publishing", &dryRun) + releaseCmd.StringFlag("version", "Version to release (e.g., v1.2.3)", &version) + releaseCmd.BoolFlag("draft", "Create release as a draft", &draft) + releaseCmd.BoolFlag("prerelease", "Mark release as a prerelease", &prerelease) + + // Default action for `core release` + releaseCmd.Action(func() error { + return runRelease(dryRun, version, draft, prerelease) + }) + + // `release init` subcommand + initCmd := releaseCmd.NewSubCommand("init", "Initialize release configuration") + initCmd.LongDescription("Creates a .core/release.yaml configuration file interactively.") + initCmd.Action(func() error { + return runReleaseInit() + }) + + // `release changelog` subcommand + changelogCmd := releaseCmd.NewSubCommand("changelog", "Generate changelog") + changelogCmd.LongDescription("Generates a changelog from conventional commits.") + var fromRef, toRef string + changelogCmd.StringFlag("from", "Starting ref (default: previous tag)", &fromRef) + changelogCmd.StringFlag("to", "Ending ref (default: HEAD)", &toRef) + changelogCmd.Action(func() error { + return runChangelog(fromRef, toRef) + }) + + // `release version` subcommand + versionCmd := releaseCmd.NewSubCommand("version", "Show or set version") + versionCmd.LongDescription("Shows the determined version or validates a version string.") + versionCmd.Action(func() error { + return runReleaseVersion() + }) +} + +// runRelease executes the main release workflow. +func runRelease(dryRun bool, version string, draft, prerelease bool) error { + ctx := context.Background() + + // Get current directory + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load configuration + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Apply CLI overrides + if version != "" { + cfg.SetVersion(version) + } + + // Apply draft/prerelease overrides to all publishers + if draft || prerelease { + for i := range cfg.Publishers { + if draft { + cfg.Publishers[i].Draft = true + } + if prerelease { + cfg.Publishers[i].Prerelease = true + } + } + } + + // Print header + fmt.Printf("%s Starting release process\n", releaseHeaderStyle.Render("Release:")) + if dryRun { + fmt.Printf(" %s\n", releaseDimStyle.Render("(dry-run mode)")) + } + fmt.Println() + + // Run the release + rel, err := release.Run(ctx, cfg, dryRun) + if err != nil { + fmt.Printf("%s %v\n", releaseErrorStyle.Render("Error:"), err) + return err + } + + // Print summary + fmt.Println() + fmt.Printf("%s Release completed!\n", releaseSuccessStyle.Render("Success:")) + fmt.Printf(" Version: %s\n", releaseValueStyle.Render(rel.Version)) + fmt.Printf(" Artifacts: %d\n", len(rel.Artifacts)) + + if !dryRun && len(cfg.Publishers) > 0 { + for _, pub := range cfg.Publishers { + fmt.Printf(" Published: %s\n", releaseValueStyle.Render(pub.Type)) + } + } + + return nil +} + +// runReleaseInit creates a release configuration interactively. +func runReleaseInit() error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Check if config already exists + if release.ConfigExists(projectDir) { + fmt.Printf("%s Configuration already exists at %s\n", + releaseDimStyle.Render("Note:"), + release.ConfigPath(projectDir)) + + reader := bufio.NewReader(os.Stdin) + fmt.Print("Overwrite? [y/N]: ") + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + if response != "y" && response != "yes" { + fmt.Println("Aborted.") + return nil + } + } + + fmt.Printf("%s Creating release configuration\n", releaseHeaderStyle.Render("Init:")) + fmt.Println() + + reader := bufio.NewReader(os.Stdin) + + // Project name + defaultName := filepath.Base(projectDir) + fmt.Printf("Project name [%s]: ", defaultName) + name, _ := reader.ReadString('\n') + name = strings.TrimSpace(name) + if name == "" { + name = defaultName + } + + // Repository + fmt.Print("GitHub repository (owner/repo): ") + repo, _ := reader.ReadString('\n') + repo = strings.TrimSpace(repo) + + // Create config + cfg := release.DefaultConfig() + cfg.Project.Name = name + cfg.Project.Repository = repo + + // Write config + if err := release.WriteConfig(cfg, projectDir); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + fmt.Println() + fmt.Printf("%s Configuration written to %s\n", + releaseSuccessStyle.Render("Success:"), + release.ConfigPath(projectDir)) + + return nil +} + +// runChangelog generates and prints a changelog. +func runChangelog(fromRef, toRef string) error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + // Load config for changelog settings + cfg, err := release.LoadConfig(projectDir) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Generate changelog + changelog, err := release.GenerateWithConfig(projectDir, fromRef, toRef, &cfg.Changelog) + if err != nil { + return fmt.Errorf("failed to generate changelog: %w", err) + } + + fmt.Println(changelog) + return nil +} + +// runReleaseVersion shows the determined version. +func runReleaseVersion() error { + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get working directory: %w", err) + } + + version, err := release.DetermineVersion(projectDir) + if err != nil { + return fmt.Errorf("failed to determine version: %w", err) + } + + fmt.Printf("Version: %s\n", releaseValueStyle.Render(version)) + return nil +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 0fa0f72..076d198 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -84,6 +84,7 @@ func Execute() error { AddDoctorCommand(app) AddSearchCommand(app) AddInstallCommand(app) + AddReleaseCommand(app) // Run the application return app.Run() } diff --git a/go.mod b/go.mod index 89f86a5..506ad26 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/stretchr/testify v1.11.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.41 + golang.org/x/text v0.33.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -73,10 +75,8 @@ require ( golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/pkg/release/changelog.go b/pkg/release/changelog.go new file mode 100644 index 0000000..c25fc52 --- /dev/null +++ b/pkg/release/changelog.go @@ -0,0 +1,321 @@ +// Package release provides release automation with changelog generation and publishing. +package release + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "regexp" + "sort" + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +// ConventionalCommit represents a parsed conventional commit. +type ConventionalCommit struct { + Type string // feat, fix, etc. + Scope string // optional scope in parentheses + Description string // commit description + Hash string // short commit hash + Breaking bool // has breaking change indicator +} + +// commitTypeLabels maps commit types to human-readable labels for the changelog. +var commitTypeLabels = map[string]string{ + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance Improvements", + "refactor": "Code Refactoring", + "docs": "Documentation", + "style": "Styles", + "test": "Tests", + "build": "Build System", + "ci": "Continuous Integration", + "chore": "Chores", + "revert": "Reverts", +} + +// commitTypeOrder defines the order of sections in the changelog. +var commitTypeOrder = []string{ + "feat", + "fix", + "perf", + "refactor", + "docs", + "style", + "test", + "build", + "ci", + "chore", + "revert", +} + +// conventionalCommitRegex matches conventional commit format. +// Examples: "feat: add feature", "fix(scope): fix bug", "feat!: breaking change" +var conventionalCommitRegex = regexp.MustCompile(`^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$`) + +// Generate generates a markdown changelog from git commits between two refs. +// If fromRef is empty, it uses the previous tag or initial commit. +// If toRef is empty, it uses HEAD. +func Generate(dir, fromRef, toRef string) (string, error) { + if toRef == "" { + toRef = "HEAD" + } + + // If fromRef is empty, try to find previous tag + if fromRef == "" { + prevTag, err := getPreviousTag(dir, toRef) + if err != nil { + // No previous tag, use initial commit + fromRef = "" + } else { + fromRef = prevTag + } + } + + // Get commits between refs + commits, err := getCommits(dir, fromRef, toRef) + if err != nil { + return "", fmt.Errorf("changelog.Generate: failed to get commits: %w", err) + } + + // Parse conventional commits + var parsedCommits []ConventionalCommit + for _, commit := range commits { + parsed := parseConventionalCommit(commit) + if parsed != nil { + parsedCommits = append(parsedCommits, *parsed) + } + } + + // Generate markdown + return formatChangelog(parsedCommits, toRef), nil +} + +// GenerateWithConfig generates a changelog with filtering based on config. +func GenerateWithConfig(dir, fromRef, toRef string, cfg *ChangelogConfig) (string, error) { + if toRef == "" { + toRef = "HEAD" + } + + // If fromRef is empty, try to find previous tag + if fromRef == "" { + prevTag, err := getPreviousTag(dir, toRef) + if err != nil { + fromRef = "" + } else { + fromRef = prevTag + } + } + + // Get commits between refs + commits, err := getCommits(dir, fromRef, toRef) + if err != nil { + return "", fmt.Errorf("changelog.GenerateWithConfig: failed to get commits: %w", err) + } + + // Build include/exclude sets + includeSet := make(map[string]bool) + excludeSet := make(map[string]bool) + for _, t := range cfg.Include { + includeSet[t] = true + } + for _, t := range cfg.Exclude { + excludeSet[t] = true + } + + // Parse and filter conventional commits + var parsedCommits []ConventionalCommit + for _, commit := range commits { + parsed := parseConventionalCommit(commit) + if parsed == nil { + continue + } + + // Apply filters + if len(includeSet) > 0 && !includeSet[parsed.Type] { + continue + } + if excludeSet[parsed.Type] { + continue + } + + parsedCommits = append(parsedCommits, *parsed) + } + + return formatChangelog(parsedCommits, toRef), nil +} + +// getPreviousTag returns the tag before the given ref. +func getPreviousTag(dir, ref string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", ref+"^") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// getCommits returns a slice of commit strings between two refs. +// Format: "hash subject" +func getCommits(dir, fromRef, toRef string) ([]string, error) { + var args []string + if fromRef == "" { + // All commits up to toRef + args = []string{"log", "--oneline", "--no-merges", toRef} + } else { + // Commits between refs + args = []string{"log", "--oneline", "--no-merges", fromRef + ".." + toRef} + } + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return nil, err + } + + var commits []string + scanner := bufio.NewScanner(bytes.NewReader(output)) + for scanner.Scan() { + line := scanner.Text() + if line != "" { + commits = append(commits, line) + } + } + + return commits, scanner.Err() +} + +// parseConventionalCommit parses a git log --oneline output into a ConventionalCommit. +// Returns nil if the commit doesn't follow conventional commit format. +func parseConventionalCommit(commitLine string) *ConventionalCommit { + // Split hash and subject + parts := strings.SplitN(commitLine, " ", 2) + if len(parts) != 2 { + return nil + } + + hash := parts[0] + subject := parts[1] + + // Match conventional commit format + matches := conventionalCommitRegex.FindStringSubmatch(subject) + if matches == nil { + return nil + } + + return &ConventionalCommit{ + Type: strings.ToLower(matches[1]), + Scope: matches[2], + Breaking: matches[3] == "!", + Description: matches[4], + Hash: hash, + } +} + +// formatChangelog formats parsed commits into markdown. +func formatChangelog(commits []ConventionalCommit, version string) string { + if len(commits) == 0 { + return fmt.Sprintf("## %s\n\nNo notable changes.", version) + } + + // Group commits by type + grouped := make(map[string][]ConventionalCommit) + var breaking []ConventionalCommit + + for _, commit := range commits { + if commit.Breaking { + breaking = append(breaking, commit) + } + grouped[commit.Type] = append(grouped[commit.Type], commit) + } + + var buf strings.Builder + buf.WriteString(fmt.Sprintf("## %s\n\n", version)) + + // Breaking changes first + if len(breaking) > 0 { + buf.WriteString("### BREAKING CHANGES\n\n") + for _, commit := range breaking { + buf.WriteString(formatCommitLine(commit)) + } + buf.WriteString("\n") + } + + // Other sections in order + for _, commitType := range commitTypeOrder { + commits, ok := grouped[commitType] + if !ok || len(commits) == 0 { + continue + } + + label, ok := commitTypeLabels[commitType] + if !ok { + label = cases.Title(language.English).String(commitType) + } + + buf.WriteString(fmt.Sprintf("### %s\n\n", label)) + for _, commit := range commits { + buf.WriteString(formatCommitLine(commit)) + } + buf.WriteString("\n") + } + + // Any remaining types not in the order list + var remainingTypes []string + for commitType := range grouped { + found := false + for _, t := range commitTypeOrder { + if t == commitType { + found = true + break + } + } + if !found { + remainingTypes = append(remainingTypes, commitType) + } + } + sort.Strings(remainingTypes) + + for _, commitType := range remainingTypes { + commits := grouped[commitType] + label := cases.Title(language.English).String(commitType) + buf.WriteString(fmt.Sprintf("### %s\n\n", label)) + for _, commit := range commits { + buf.WriteString(formatCommitLine(commit)) + } + buf.WriteString("\n") + } + + return strings.TrimSuffix(buf.String(), "\n") +} + +// formatCommitLine formats a single commit as a changelog line. +func formatCommitLine(commit ConventionalCommit) string { + var buf strings.Builder + buf.WriteString("- ") + + if commit.Scope != "" { + buf.WriteString(fmt.Sprintf("**%s**: ", commit.Scope)) + } + + buf.WriteString(commit.Description) + buf.WriteString(fmt.Sprintf(" (%s)\n", commit.Hash)) + + return buf.String() +} + +// ParseCommitType extracts the type from a conventional commit subject. +// Returns empty string if not a conventional commit. +func ParseCommitType(subject string) string { + matches := conventionalCommitRegex.FindStringSubmatch(subject) + if matches == nil { + return "" + } + return strings.ToLower(matches[1]) +} diff --git a/pkg/release/changelog_test.go b/pkg/release/changelog_test.go new file mode 100644 index 0000000..8b2246d --- /dev/null +++ b/pkg/release/changelog_test.go @@ -0,0 +1,256 @@ +package release + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseConventionalCommit_Good(t *testing.T) { + tests := []struct { + name string + input string + expected *ConventionalCommit + }{ + { + name: "feat without scope", + input: "abc1234 feat: add new feature", + expected: &ConventionalCommit{ + Type: "feat", + Scope: "", + Description: "add new feature", + Hash: "abc1234", + Breaking: false, + }, + }, + { + name: "fix with scope", + input: "def5678 fix(auth): resolve login issue", + expected: &ConventionalCommit{ + Type: "fix", + Scope: "auth", + Description: "resolve login issue", + Hash: "def5678", + Breaking: false, + }, + }, + { + name: "breaking change with exclamation", + input: "ghi9012 feat!: breaking API change", + expected: &ConventionalCommit{ + Type: "feat", + Scope: "", + Description: "breaking API change", + Hash: "ghi9012", + Breaking: true, + }, + }, + { + name: "breaking change with scope", + input: "jkl3456 fix(api)!: remove deprecated endpoint", + expected: &ConventionalCommit{ + Type: "fix", + Scope: "api", + Description: "remove deprecated endpoint", + Hash: "jkl3456", + Breaking: true, + }, + }, + { + name: "perf type", + input: "mno7890 perf: optimize database queries", + expected: &ConventionalCommit{ + Type: "perf", + Scope: "", + Description: "optimize database queries", + Hash: "mno7890", + Breaking: false, + }, + }, + { + name: "chore type", + input: "pqr1234 chore: update dependencies", + expected: &ConventionalCommit{ + Type: "chore", + Scope: "", + Description: "update dependencies", + Hash: "pqr1234", + Breaking: false, + }, + }, + { + name: "uppercase type normalizes to lowercase", + input: "stu5678 FEAT: uppercase type", + expected: &ConventionalCommit{ + Type: "feat", + Scope: "", + Description: "uppercase type", + Hash: "stu5678", + Breaking: false, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := parseConventionalCommit(tc.input) + assert.NotNil(t, result) + assert.Equal(t, tc.expected.Type, result.Type) + assert.Equal(t, tc.expected.Scope, result.Scope) + assert.Equal(t, tc.expected.Description, result.Description) + assert.Equal(t, tc.expected.Hash, result.Hash) + assert.Equal(t, tc.expected.Breaking, result.Breaking) + }) + } +} + +func TestParseConventionalCommit_Bad(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "non-conventional commit", + input: "abc1234 Update README", + }, + { + name: "missing colon", + input: "def5678 feat add feature", + }, + { + name: "empty subject", + input: "ghi9012", + }, + { + name: "just hash", + input: "abc1234", + }, + { + name: "merge commit", + input: "abc1234 Merge pull request #123", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := parseConventionalCommit(tc.input) + assert.Nil(t, result) + }) + } +} + +func TestFormatChangelog_Good(t *testing.T) { + t.Run("formats commits by type", func(t *testing.T) { + commits := []ConventionalCommit{ + {Type: "feat", Description: "add feature A", Hash: "abc1234"}, + {Type: "fix", Description: "fix bug B", Hash: "def5678"}, + {Type: "feat", Description: "add feature C", Hash: "ghi9012"}, + } + + result := formatChangelog(commits, "v1.0.0") + + assert.Contains(t, result, "## v1.0.0") + assert.Contains(t, result, "### Features") + assert.Contains(t, result, "### Bug Fixes") + assert.Contains(t, result, "- add feature A (abc1234)") + assert.Contains(t, result, "- fix bug B (def5678)") + assert.Contains(t, result, "- add feature C (ghi9012)") + }) + + t.Run("includes scope in output", func(t *testing.T) { + commits := []ConventionalCommit{ + {Type: "feat", Scope: "api", Description: "add endpoint", Hash: "abc1234"}, + } + + result := formatChangelog(commits, "v1.0.0") + + assert.Contains(t, result, "**api**: add endpoint") + }) + + t.Run("breaking changes first", func(t *testing.T) { + commits := []ConventionalCommit{ + {Type: "feat", Description: "normal feature", Hash: "abc1234"}, + {Type: "feat", Description: "breaking feature", Hash: "def5678", Breaking: true}, + } + + result := formatChangelog(commits, "v1.0.0") + + assert.Contains(t, result, "### BREAKING CHANGES") + // Breaking changes section should appear before Features + breakingPos := indexOf(result, "BREAKING CHANGES") + featuresPos := indexOf(result, "Features") + assert.Less(t, breakingPos, featuresPos) + }) + + t.Run("empty commits returns minimal changelog", func(t *testing.T) { + result := formatChangelog([]ConventionalCommit{}, "v1.0.0") + + assert.Contains(t, result, "## v1.0.0") + assert.Contains(t, result, "No notable changes") + }) +} + +func TestParseCommitType_Good(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"feat: add feature", "feat"}, + {"fix(scope): fix bug", "fix"}, + {"perf!: breaking perf", "perf"}, + {"chore: update deps", "chore"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := ParseCommitType(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParseCommitType_Bad(t *testing.T) { + tests := []struct { + input string + }{ + {"not a conventional commit"}, + {"Update README"}, + {"Merge branch 'main'"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := ParseCommitType(tc.input) + assert.Empty(t, result) + }) + } +} + +func TestGenerateWithConfig_Good(t *testing.T) { + // Note: This test would require a git repository to fully test. + // For unit testing, we test the filtering logic indirectly through + // the parseConventionalCommit and formatChangelog functions. + + t.Run("config filters are parsed correctly", func(t *testing.T) { + cfg := &ChangelogConfig{ + Include: []string{"feat", "fix"}, + Exclude: []string{"chore", "docs"}, + } + + // Verify the config values + assert.Contains(t, cfg.Include, "feat") + assert.Contains(t, cfg.Include, "fix") + assert.Contains(t, cfg.Exclude, "chore") + assert.Contains(t, cfg.Exclude, "docs") + }) +} + +// indexOf returns the position of a substring in a string, or -1 if not found. +func indexOf(s, substr string) int { + for i := 0; i+len(substr) <= len(s); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/pkg/release/config.go b/pkg/release/config.go new file mode 100644 index 0000000..15322e3 --- /dev/null +++ b/pkg/release/config.go @@ -0,0 +1,208 @@ +// Package release provides release automation with changelog generation and publishing. +package release + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ConfigFileName is the name of the release configuration file. +const ConfigFileName = "release.yaml" + +// ConfigDir is the directory where release configuration is stored. +const ConfigDir = ".core" + +// Config holds the complete release configuration loaded from .core/release.yaml. +type Config struct { + // Version is the config file format version. + Version int `yaml:"version"` + // Project contains project metadata. + Project ProjectConfig `yaml:"project"` + // Build contains build settings for the release. + Build BuildConfig `yaml:"build"` + // Publishers defines where to publish the release. + Publishers []PublisherConfig `yaml:"publishers"` + // Changelog configures changelog generation. + Changelog ChangelogConfig `yaml:"changelog"` + + // Internal fields (not serialized) + projectDir string // Set by LoadConfig + version string // Set by CLI flag +} + +// ProjectConfig holds project metadata for releases. +type ProjectConfig struct { + // Name is the project name. + Name string `yaml:"name"` + // Repository is the GitHub repository in owner/repo format. + Repository string `yaml:"repository"` +} + +// BuildConfig holds build settings for releases. +type BuildConfig struct { + // Targets defines the build targets. + Targets []TargetConfig `yaml:"targets"` +} + +// TargetConfig defines a build target. +type TargetConfig struct { + // OS is the target operating system (e.g., "linux", "darwin", "windows"). + OS string `yaml:"os"` + // Arch is the target architecture (e.g., "amd64", "arm64"). + Arch string `yaml:"arch"` +} + +// PublisherConfig holds configuration for a publisher. +type PublisherConfig struct { + // Type is the publisher type (e.g., "github"). + Type string `yaml:"type"` + // Prerelease marks the release as a prerelease. + Prerelease bool `yaml:"prerelease"` + // Draft creates the release as a draft. + Draft bool `yaml:"draft"` +} + +// ChangelogConfig holds changelog generation settings. +type ChangelogConfig struct { + // Include specifies commit types to include in the changelog. + Include []string `yaml:"include"` + // Exclude specifies commit types to exclude from the changelog. + Exclude []string `yaml:"exclude"` +} + +// LoadConfig loads release configuration from the .core/release.yaml file in the given directory. +// If the config file does not exist, it returns DefaultConfig(). +// Returns an error if the file exists but cannot be parsed. +func LoadConfig(dir string) (*Config, error) { + configPath := filepath.Join(dir, ConfigDir, ConfigFileName) + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + cfg := DefaultConfig() + cfg.projectDir = dir + return cfg, nil + } + return nil, fmt.Errorf("release.LoadConfig: failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("release.LoadConfig: failed to parse config file: %w", err) + } + + // Apply defaults for any missing fields + applyDefaults(&cfg) + cfg.projectDir = dir + + return &cfg, nil +} + +// DefaultConfig returns sensible defaults for release configuration. +func DefaultConfig() *Config { + return &Config{ + Version: 1, + Project: ProjectConfig{ + Name: "", + Repository: "", + }, + Build: BuildConfig{ + Targets: []TargetConfig{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + {OS: "darwin", Arch: "amd64"}, + {OS: "darwin", Arch: "arm64"}, + {OS: "windows", Arch: "amd64"}, + }, + }, + Publishers: []PublisherConfig{ + { + Type: "github", + Prerelease: false, + Draft: false, + }, + }, + Changelog: ChangelogConfig{ + Include: []string{"feat", "fix", "perf", "refactor"}, + Exclude: []string{"chore", "docs", "style", "test", "ci"}, + }, + } +} + +// applyDefaults fills in default values for any empty fields in the config. +func applyDefaults(cfg *Config) { + defaults := DefaultConfig() + + if cfg.Version == 0 { + cfg.Version = defaults.Version + } + + if len(cfg.Build.Targets) == 0 { + cfg.Build.Targets = defaults.Build.Targets + } + + if len(cfg.Publishers) == 0 { + cfg.Publishers = defaults.Publishers + } + + if len(cfg.Changelog.Include) == 0 && len(cfg.Changelog.Exclude) == 0 { + cfg.Changelog.Include = defaults.Changelog.Include + cfg.Changelog.Exclude = defaults.Changelog.Exclude + } +} + +// SetProjectDir sets the project directory on the config. +func (c *Config) SetProjectDir(dir string) { + c.projectDir = dir +} + +// SetVersion sets the version override on the config. +func (c *Config) SetVersion(version string) { + c.version = version +} + +// ConfigPath returns the path to the release config file for a given directory. +func ConfigPath(dir string) string { + return filepath.Join(dir, ConfigDir, ConfigFileName) +} + +// ConfigExists checks if a release config file exists in the given directory. +func ConfigExists(dir string) bool { + _, err := os.Stat(ConfigPath(dir)) + return err == nil +} + +// GetRepository returns the repository from the config. +func (c *Config) GetRepository() string { + return c.Project.Repository +} + +// GetProjectName returns the project name from the config. +func (c *Config) GetProjectName() string { + return c.Project.Name +} + +// WriteConfig writes the config to the .core/release.yaml file. +func WriteConfig(cfg *Config, dir string) error { + configPath := ConfigPath(dir) + + // Ensure directory exists + configDir := filepath.Dir(configPath) + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("release.WriteConfig: failed to create directory: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("release.WriteConfig: failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("release.WriteConfig: failed to write config file: %w", err) + } + + return nil +} diff --git a/pkg/release/config_test.go b/pkg/release/config_test.go new file mode 100644 index 0000000..67123ae --- /dev/null +++ b/pkg/release/config_test.go @@ -0,0 +1,303 @@ +package release + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupConfigTestDir creates a temp directory with optional .core/release.yaml content. +func setupConfigTestDir(t *testing.T, configContent string) string { + t.Helper() + dir := t.TempDir() + + if configContent != "" { + coreDir := filepath.Join(dir, ConfigDir) + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + configPath := filepath.Join(coreDir, ConfigFileName) + err = os.WriteFile(configPath, []byte(configContent), 0644) + require.NoError(t, err) + } + + return dir +} + +func TestLoadConfig_Good(t *testing.T) { + t.Run("loads valid config", func(t *testing.T) { + content := ` +version: 1 +project: + name: myapp + repository: owner/repo +build: + targets: + - os: linux + arch: amd64 + - os: darwin + arch: arm64 +publishers: + - type: github + prerelease: true + draft: false +changelog: + include: + - feat + - fix + exclude: + - chore +` + dir := setupConfigTestDir(t, content) + + cfg, err := LoadConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, 1, cfg.Version) + assert.Equal(t, "myapp", cfg.Project.Name) + assert.Equal(t, "owner/repo", cfg.Project.Repository) + assert.Len(t, cfg.Build.Targets, 2) + assert.Equal(t, "linux", cfg.Build.Targets[0].OS) + assert.Equal(t, "amd64", cfg.Build.Targets[0].Arch) + assert.Equal(t, "darwin", cfg.Build.Targets[1].OS) + assert.Equal(t, "arm64", cfg.Build.Targets[1].Arch) + assert.Len(t, cfg.Publishers, 1) + assert.Equal(t, "github", cfg.Publishers[0].Type) + assert.True(t, cfg.Publishers[0].Prerelease) + assert.False(t, cfg.Publishers[0].Draft) + assert.Equal(t, []string{"feat", "fix"}, cfg.Changelog.Include) + assert.Equal(t, []string{"chore"}, cfg.Changelog.Exclude) + }) + + t.Run("returns defaults when config file missing", func(t *testing.T) { + dir := t.TempDir() + + cfg, err := LoadConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + defaults := DefaultConfig() + assert.Equal(t, defaults.Version, cfg.Version) + assert.Equal(t, defaults.Build.Targets, cfg.Build.Targets) + assert.Equal(t, defaults.Publishers, cfg.Publishers) + assert.Equal(t, defaults.Changelog.Include, cfg.Changelog.Include) + assert.Equal(t, defaults.Changelog.Exclude, cfg.Changelog.Exclude) + }) + + t.Run("applies defaults for missing fields", func(t *testing.T) { + content := ` +version: 2 +project: + name: partial +` + dir := setupConfigTestDir(t, content) + + cfg, err := LoadConfig(dir) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Explicit values preserved + assert.Equal(t, 2, cfg.Version) + assert.Equal(t, "partial", cfg.Project.Name) + + // Defaults applied + defaults := DefaultConfig() + assert.Equal(t, defaults.Build.Targets, cfg.Build.Targets) + assert.Equal(t, defaults.Publishers, cfg.Publishers) + }) + + t.Run("sets project directory on load", func(t *testing.T) { + dir := setupConfigTestDir(t, "version: 1") + + cfg, err := LoadConfig(dir) + require.NoError(t, err) + assert.Equal(t, dir, cfg.projectDir) + }) +} + +func TestLoadConfig_Bad(t *testing.T) { + t.Run("returns error for invalid YAML", func(t *testing.T) { + content := ` +version: 1 +project: + name: [invalid yaml +` + dir := setupConfigTestDir(t, content) + + cfg, err := LoadConfig(dir) + assert.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "failed to parse config file") + }) + + t.Run("returns error for unreadable file", func(t *testing.T) { + dir := t.TempDir() + coreDir := filepath.Join(dir, ConfigDir) + err := os.MkdirAll(coreDir, 0755) + require.NoError(t, err) + + // Create config as a directory instead of file + configPath := filepath.Join(coreDir, ConfigFileName) + err = os.Mkdir(configPath, 0755) + require.NoError(t, err) + + cfg, err := LoadConfig(dir) + assert.Error(t, err) + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), "failed to read config file") + }) +} + +func TestDefaultConfig_Good(t *testing.T) { + t.Run("returns sensible defaults", func(t *testing.T) { + cfg := DefaultConfig() + + assert.Equal(t, 1, cfg.Version) + assert.Empty(t, cfg.Project.Name) + assert.Empty(t, cfg.Project.Repository) + + // Default targets + assert.Len(t, cfg.Build.Targets, 5) + hasLinuxAmd64 := false + hasDarwinArm64 := false + hasWindowsAmd64 := false + for _, target := range cfg.Build.Targets { + if target.OS == "linux" && target.Arch == "amd64" { + hasLinuxAmd64 = true + } + if target.OS == "darwin" && target.Arch == "arm64" { + hasDarwinArm64 = true + } + if target.OS == "windows" && target.Arch == "amd64" { + hasWindowsAmd64 = true + } + } + assert.True(t, hasLinuxAmd64) + assert.True(t, hasDarwinArm64) + assert.True(t, hasWindowsAmd64) + + // Default publisher + assert.Len(t, cfg.Publishers, 1) + assert.Equal(t, "github", cfg.Publishers[0].Type) + assert.False(t, cfg.Publishers[0].Prerelease) + assert.False(t, cfg.Publishers[0].Draft) + + // Default changelog settings + assert.Contains(t, cfg.Changelog.Include, "feat") + assert.Contains(t, cfg.Changelog.Include, "fix") + assert.Contains(t, cfg.Changelog.Exclude, "chore") + assert.Contains(t, cfg.Changelog.Exclude, "docs") + }) +} + +func TestConfigPath_Good(t *testing.T) { + t.Run("returns correct path", func(t *testing.T) { + path := ConfigPath("/project/root") + assert.Equal(t, "/project/root/.core/release.yaml", path) + }) +} + +func TestConfigExists_Good(t *testing.T) { + t.Run("returns true when config exists", func(t *testing.T) { + dir := setupConfigTestDir(t, "version: 1") + assert.True(t, ConfigExists(dir)) + }) + + t.Run("returns false when config missing", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, ConfigExists(dir)) + }) + + t.Run("returns false when .core dir missing", func(t *testing.T) { + dir := t.TempDir() + assert.False(t, ConfigExists(dir)) + }) +} + +func TestWriteConfig_Good(t *testing.T) { + t.Run("writes config to file", func(t *testing.T) { + dir := t.TempDir() + + cfg := DefaultConfig() + cfg.Project.Name = "testapp" + cfg.Project.Repository = "owner/testapp" + + err := WriteConfig(cfg, dir) + require.NoError(t, err) + + // Verify file exists + assert.True(t, ConfigExists(dir)) + + // Reload and verify + loaded, err := LoadConfig(dir) + require.NoError(t, err) + assert.Equal(t, "testapp", loaded.Project.Name) + assert.Equal(t, "owner/testapp", loaded.Project.Repository) + }) + + t.Run("creates .core directory if missing", func(t *testing.T) { + dir := t.TempDir() + + cfg := DefaultConfig() + err := WriteConfig(cfg, dir) + require.NoError(t, err) + + // Check directory was created + coreDir := filepath.Join(dir, ConfigDir) + info, err := os.Stat(coreDir) + require.NoError(t, err) + assert.True(t, info.IsDir()) + }) +} + +func TestConfig_GetRepository_Good(t *testing.T) { + t.Run("returns repository", func(t *testing.T) { + cfg := &Config{ + Project: ProjectConfig{ + Repository: "owner/repo", + }, + } + assert.Equal(t, "owner/repo", cfg.GetRepository()) + }) + + t.Run("returns empty string when not set", func(t *testing.T) { + cfg := &Config{} + assert.Empty(t, cfg.GetRepository()) + }) +} + +func TestConfig_GetProjectName_Good(t *testing.T) { + t.Run("returns project name", func(t *testing.T) { + cfg := &Config{ + Project: ProjectConfig{ + Name: "myapp", + }, + } + assert.Equal(t, "myapp", cfg.GetProjectName()) + }) + + t.Run("returns empty string when not set", func(t *testing.T) { + cfg := &Config{} + assert.Empty(t, cfg.GetProjectName()) + }) +} + +func TestConfig_SetVersion_Good(t *testing.T) { + t.Run("sets version override", func(t *testing.T) { + cfg := &Config{} + cfg.SetVersion("v1.2.3") + assert.Equal(t, "v1.2.3", cfg.version) + }) +} + +func TestConfig_SetProjectDir_Good(t *testing.T) { + t.Run("sets project directory", func(t *testing.T) { + cfg := &Config{} + cfg.SetProjectDir("/path/to/project") + assert.Equal(t, "/path/to/project", cfg.projectDir) + }) +} diff --git a/pkg/release/publishers/github.go b/pkg/release/publishers/github.go new file mode 100644 index 0000000..f041174 --- /dev/null +++ b/pkg/release/publishers/github.go @@ -0,0 +1,233 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// GitHubPublisher publishes releases to GitHub using the gh CLI. +type GitHubPublisher struct{} + +// NewGitHubPublisher creates a new GitHub publisher. +func NewGitHubPublisher() *GitHubPublisher { + return &GitHubPublisher{} +} + +// Name returns the publisher's identifier. +func (p *GitHubPublisher) Name() string { + return "github" +} + +// Publish publishes the release to GitHub. +// Uses the gh CLI for creating releases and uploading assets. +func (p *GitHubPublisher) Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error { + // Validate gh CLI is available + if err := validateGhCli(); err != nil { + return err + } + + // Determine repository + repo := "" + if relCfg != nil { + repo = relCfg.GetRepository() + } + if repo == "" { + // Try to detect from git remote + detectedRepo, err := detectRepository(release.ProjectDir) + if err != nil { + return fmt.Errorf("github.Publish: could not determine repository: %w", err) + } + repo = detectedRepo + } + + if dryRun { + return p.dryRunPublish(release, pubCfg, repo) + } + + return p.executePublish(ctx, release, pubCfg, repo) +} + +// dryRunPublish shows what would be done without actually publishing. +func (p *GitHubPublisher) dryRunPublish(release *Release, pubCfg PublisherConfig, repo string) error { + fmt.Println() + fmt.Println("=== DRY RUN: GitHub Release ===") + fmt.Println() + fmt.Printf("Repository: %s\n", repo) + fmt.Printf("Version: %s\n", release.Version) + fmt.Printf("Draft: %t\n", pubCfg.Draft) + fmt.Printf("Prerelease: %t\n", pubCfg.Prerelease) + fmt.Println() + + fmt.Println("Would create release with command:") + args := p.buildCreateArgs(release, pubCfg, repo) + fmt.Printf(" gh %s\n", strings.Join(args, " ")) + fmt.Println() + + if len(release.Artifacts) > 0 { + fmt.Println("Would upload artifacts:") + for _, artifact := range release.Artifacts { + fmt.Printf(" - %s\n", filepath.Base(artifact.Path)) + } + } + + fmt.Println() + fmt.Println("Changelog:") + fmt.Println("---") + fmt.Println(release.Changelog) + fmt.Println("---") + fmt.Println() + fmt.Println("=== END DRY RUN ===") + + return nil +} + +// executePublish actually creates the release and uploads artifacts. +func (p *GitHubPublisher) executePublish(ctx context.Context, release *Release, pubCfg PublisherConfig, repo string) error { + // Build the release create command + args := p.buildCreateArgs(release, pubCfg, repo) + + // Add artifact paths to the command + for _, artifact := range release.Artifacts { + args = append(args, artifact.Path) + } + + // Execute gh release create + cmd := exec.CommandContext(ctx, "gh", args...) + cmd.Dir = release.ProjectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("github.Publish: gh release create failed: %w", err) + } + + return nil +} + +// buildCreateArgs builds the arguments for gh release create. +func (p *GitHubPublisher) buildCreateArgs(release *Release, pubCfg PublisherConfig, repo string) []string { + args := []string{"release", "create", release.Version} + + // Add repository flag + if repo != "" { + args = append(args, "--repo", repo) + } + + // Add title + args = append(args, "--title", release.Version) + + // Add notes (changelog) + if release.Changelog != "" { + args = append(args, "--notes", release.Changelog) + } else { + args = append(args, "--generate-notes") + } + + // Add draft flag + if pubCfg.Draft { + args = append(args, "--draft") + } + + // Add prerelease flag + if pubCfg.Prerelease { + args = append(args, "--prerelease") + } + + return args +} + +// validateGhCli checks if the gh CLI is available and authenticated. +func validateGhCli() error { + // Check if gh is installed + cmd := exec.Command("gh", "--version") + if err := cmd.Run(); err != nil { + return fmt.Errorf("github: gh CLI not found. Install it from https://cli.github.com") + } + + // Check if authenticated + cmd = exec.Command("gh", "auth", "status") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("github: not authenticated with gh CLI. Run 'gh auth login' first") + } + + if !strings.Contains(string(output), "Logged in") { + return fmt.Errorf("github: not authenticated with gh CLI. Run 'gh auth login' first") + } + + return nil +} + +// detectRepository detects the GitHub repository from git remote. +func detectRepository(dir string) (string, error) { + cmd := exec.Command("git", "remote", "get-url", "origin") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get git remote: %w", err) + } + + url := strings.TrimSpace(string(output)) + return parseGitHubRepo(url) +} + +// parseGitHubRepo extracts owner/repo from a GitHub URL. +// Supports: +// - git@github.com:owner/repo.git +// - https://github.com/owner/repo.git +// - https://github.com/owner/repo +func parseGitHubRepo(url string) (string, error) { + // SSH format + if strings.HasPrefix(url, "git@github.com:") { + repo := strings.TrimPrefix(url, "git@github.com:") + repo = strings.TrimSuffix(repo, ".git") + return repo, nil + } + + // HTTPS format + if strings.HasPrefix(url, "https://github.com/") { + repo := strings.TrimPrefix(url, "https://github.com/") + repo = strings.TrimSuffix(repo, ".git") + return repo, nil + } + + return "", fmt.Errorf("not a GitHub URL: %s", url) +} + +// UploadArtifact uploads a single artifact to an existing release. +// This can be used to add artifacts to a release after creation. +func UploadArtifact(ctx context.Context, repo, version, artifactPath string) error { + cmd := exec.CommandContext(ctx, "gh", "release", "upload", version, artifactPath, "--repo", repo) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("github.UploadArtifact: failed to upload %s: %w", artifactPath, err) + } + + return nil +} + +// DeleteRelease deletes a release by tag name. +func DeleteRelease(ctx context.Context, repo, version string) error { + cmd := exec.CommandContext(ctx, "gh", "release", "delete", version, "--repo", repo, "--yes") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("github.DeleteRelease: failed to delete %s: %w", version, err) + } + + return nil +} + +// ReleaseExists checks if a release exists for the given version. +func ReleaseExists(ctx context.Context, repo, version string) bool { + cmd := exec.CommandContext(ctx, "gh", "release", "view", version, "--repo", repo) + return cmd.Run() == nil +} diff --git a/pkg/release/publishers/github_test.go b/pkg/release/publishers/github_test.go new file mode 100644 index 0000000..0f5170c --- /dev/null +++ b/pkg/release/publishers/github_test.go @@ -0,0 +1,167 @@ +package publishers + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseGitHubRepo_Good(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "SSH URL", + input: "git@github.com:owner/repo.git", + expected: "owner/repo", + }, + { + name: "HTTPS URL with .git", + input: "https://github.com/owner/repo.git", + expected: "owner/repo", + }, + { + name: "HTTPS URL without .git", + input: "https://github.com/owner/repo", + expected: "owner/repo", + }, + { + name: "SSH URL without .git", + input: "git@github.com:owner/repo", + expected: "owner/repo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := parseGitHubRepo(tc.input) + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParseGitHubRepo_Bad(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "GitLab URL", + input: "https://gitlab.com/owner/repo.git", + }, + { + name: "Bitbucket URL", + input: "git@bitbucket.org:owner/repo.git", + }, + { + name: "Random URL", + input: "https://example.com/something", + }, + { + name: "Not a URL", + input: "owner/repo", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := parseGitHubRepo(tc.input) + assert.Error(t, err) + }) + } +} + +func TestGitHubPublisher_Name_Good(t *testing.T) { + t.Run("returns github", func(t *testing.T) { + p := NewGitHubPublisher() + assert.Equal(t, "github", p.Name()) + }) +} + +func TestNewRelease_Good(t *testing.T) { + t.Run("creates release struct", func(t *testing.T) { + r := NewRelease("v1.0.0", nil, "changelog", "/project") + assert.Equal(t, "v1.0.0", r.Version) + assert.Equal(t, "changelog", r.Changelog) + assert.Equal(t, "/project", r.ProjectDir) + assert.Nil(t, r.Artifacts) + }) +} + +func TestNewPublisherConfig_Good(t *testing.T) { + t.Run("creates config struct", func(t *testing.T) { + cfg := NewPublisherConfig("github", true, false) + assert.Equal(t, "github", cfg.Type) + assert.True(t, cfg.Prerelease) + assert.False(t, cfg.Draft) + }) +} + +func TestBuildCreateArgs_Good(t *testing.T) { + p := NewGitHubPublisher() + + t.Run("basic args", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + Changelog: "## v1.0.0\n\nChanges", + } + cfg := PublisherConfig{ + Type: "github", + } + + args := p.buildCreateArgs(release, cfg, "owner/repo") + + assert.Contains(t, args, "release") + assert.Contains(t, args, "create") + assert.Contains(t, args, "v1.0.0") + assert.Contains(t, args, "--repo") + assert.Contains(t, args, "owner/repo") + assert.Contains(t, args, "--title") + assert.Contains(t, args, "--notes") + }) + + t.Run("with draft flag", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + } + cfg := PublisherConfig{ + Type: "github", + Draft: true, + } + + args := p.buildCreateArgs(release, cfg, "owner/repo") + + assert.Contains(t, args, "--draft") + }) + + t.Run("with prerelease flag", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + } + cfg := PublisherConfig{ + Type: "github", + Prerelease: true, + } + + args := p.buildCreateArgs(release, cfg, "owner/repo") + + assert.Contains(t, args, "--prerelease") + }) + + t.Run("generates notes when no changelog", func(t *testing.T) { + release := &Release{ + Version: "v1.0.0", + Changelog: "", + } + cfg := PublisherConfig{ + Type: "github", + } + + args := p.buildCreateArgs(release, cfg, "owner/repo") + + assert.Contains(t, args, "--generate-notes") + }) +} diff --git a/pkg/release/publishers/publisher.go b/pkg/release/publishers/publisher.go new file mode 100644 index 0000000..fe3e6fd --- /dev/null +++ b/pkg/release/publishers/publisher.go @@ -0,0 +1,65 @@ +// Package publishers provides release publishing implementations. +package publishers + +import ( + "context" + + "github.com/host-uk/core/pkg/build" +) + +// Release represents a release to be published. +type Release struct { + // Version is the semantic version string (e.g., "v1.2.3"). + Version string + // Artifacts are the built release artifacts. + Artifacts []build.Artifact + // Changelog is the generated markdown changelog. + Changelog string + // ProjectDir is the root directory of the project. + ProjectDir string +} + +// PublisherConfig holds configuration for a publisher. +type PublisherConfig struct { + // Type is the publisher type (e.g., "github"). + Type string + // Prerelease marks the release as a prerelease. + Prerelease bool + // Draft creates the release as a draft. + Draft bool +} + +// ReleaseConfig holds release configuration needed by publishers. +type ReleaseConfig interface { + GetRepository() string + GetProjectName() string +} + +// Publisher defines the interface for release publishers. +type Publisher interface { + // Name returns the publisher's identifier. + Name() string + // Publish publishes the release to the target. + // If dryRun is true, it prints what would be done without executing. + Publish(ctx context.Context, release *Release, pubCfg PublisherConfig, relCfg ReleaseConfig, dryRun bool) error +} + +// NewRelease creates a Release from the release package's Release type. +// This is a helper to convert between packages. +func NewRelease(version string, artifacts []build.Artifact, changelog, projectDir string) *Release { + return &Release{ + Version: version, + Artifacts: artifacts, + Changelog: changelog, + ProjectDir: projectDir, + } +} + +// NewPublisherConfig creates a PublisherConfig. +func NewPublisherConfig(pubType string, prerelease, draft bool) PublisherConfig { + return PublisherConfig{ + Type: pubType, + Prerelease: prerelease, + Draft: draft, + } +} diff --git a/pkg/release/release.go b/pkg/release/release.go new file mode 100644 index 0000000..0f0bca4 --- /dev/null +++ b/pkg/release/release.go @@ -0,0 +1,216 @@ +// Package release provides release automation with changelog generation and publishing. +// It orchestrates the build system, changelog generation, and publishing to targets +// like GitHub Releases. +package release + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/host-uk/core/pkg/build" + "github.com/host-uk/core/pkg/build/builders" + "github.com/host-uk/core/pkg/release/publishers" +) + +// Release represents a release with its version, artifacts, and changelog. +type Release struct { + // Version is the semantic version string (e.g., "v1.2.3"). + Version string + // Artifacts are the built release artifacts (archives with checksums). + Artifacts []build.Artifact + // Changelog is the generated markdown changelog. + Changelog string + // ProjectDir is the root directory of the project. + ProjectDir string +} + +// Run executes the release process: determine version, build artifacts, +// generate changelog, and publish to configured targets. +// If dryRun is true, it will show what would be done without actually publishing. +func Run(ctx context.Context, cfg *Config, dryRun bool) (*Release, error) { + if cfg == nil { + return nil, fmt.Errorf("release.Run: config is nil") + } + + projectDir := cfg.projectDir + if projectDir == "" { + projectDir = "." + } + + // Resolve to absolute path + absProjectDir, err := filepath.Abs(projectDir) + if err != nil { + return nil, fmt.Errorf("release.Run: failed to resolve project directory: %w", err) + } + + // Step 1: Determine version + version := cfg.version + if version == "" { + version, err = DetermineVersion(absProjectDir) + if err != nil { + return nil, fmt.Errorf("release.Run: failed to determine version: %w", err) + } + } + + // Step 2: Generate changelog + changelog, err := Generate(absProjectDir, "", version) + if err != nil { + // Non-fatal: continue with empty changelog + changelog = fmt.Sprintf("Release %s", version) + } + + // Step 3: Build artifacts + artifacts, err := buildArtifacts(ctx, cfg, absProjectDir, version) + if err != nil { + return nil, fmt.Errorf("release.Run: build failed: %w", err) + } + + release := &Release{ + Version: version, + Artifacts: artifacts, + Changelog: changelog, + ProjectDir: absProjectDir, + } + + // Step 4: Publish to configured targets + if len(cfg.Publishers) > 0 { + // Convert to publisher types + pubRelease := publishers.NewRelease(release.Version, release.Artifacts, release.Changelog, release.ProjectDir) + + for _, pubCfg := range cfg.Publishers { + publisher, err := getPublisher(pubCfg.Type) + if err != nil { + return release, fmt.Errorf("release.Run: %w", err) + } + + publisherCfg := publishers.NewPublisherConfig(pubCfg.Type, pubCfg.Prerelease, pubCfg.Draft) + if err := publisher.Publish(ctx, pubRelease, publisherCfg, cfg, dryRun); err != nil { + return release, fmt.Errorf("release.Run: publish to %s failed: %w", pubCfg.Type, err) + } + } + } + + return release, nil +} + +// buildArtifacts builds all artifacts for the release. +func buildArtifacts(ctx context.Context, cfg *Config, projectDir, version string) ([]build.Artifact, error) { + // Load build configuration + buildCfg, err := build.LoadConfig(projectDir) + if err != nil { + return nil, fmt.Errorf("failed to load build config: %w", err) + } + + // Determine targets + var targets []build.Target + if len(cfg.Build.Targets) > 0 { + for _, t := range cfg.Build.Targets { + targets = append(targets, build.Target{OS: t.OS, Arch: t.Arch}) + } + } else if len(buildCfg.Targets) > 0 { + targets = buildCfg.ToTargets() + } else { + // Default targets + targets = []build.Target{ + {OS: "linux", Arch: "amd64"}, + {OS: "linux", Arch: "arm64"}, + {OS: "darwin", Arch: "amd64"}, + {OS: "darwin", Arch: "arm64"}, + {OS: "windows", Arch: "amd64"}, + } + } + + // Determine binary name + binaryName := cfg.Project.Name + if binaryName == "" { + binaryName = buildCfg.Project.Binary + } + if binaryName == "" { + binaryName = buildCfg.Project.Name + } + if binaryName == "" { + binaryName = filepath.Base(projectDir) + } + + // Determine output directory + outputDir := filepath.Join(projectDir, "dist") + + // Get builder (detect project type) + projectType, err := build.PrimaryType(projectDir) + if err != nil { + return nil, fmt.Errorf("failed to detect project type: %w", err) + } + + builder, err := getBuilder(projectType) + if err != nil { + return nil, err + } + + // Build configuration + buildConfig := &build.Config{ + ProjectDir: projectDir, + OutputDir: outputDir, + Name: binaryName, + Version: version, + LDFlags: buildCfg.Build.LDFlags, + } + + // Build + artifacts, err := builder.Build(ctx, buildConfig, targets) + if err != nil { + return nil, fmt.Errorf("build failed: %w", err) + } + + // Archive artifacts + archivedArtifacts, err := build.ArchiveAll(artifacts) + if err != nil { + return nil, fmt.Errorf("archive failed: %w", err) + } + + // Compute checksums + checksummedArtifacts, err := build.ChecksumAll(archivedArtifacts) + if err != nil { + return nil, fmt.Errorf("checksum failed: %w", err) + } + + // Write CHECKSUMS.txt + checksumPath := filepath.Join(outputDir, "CHECKSUMS.txt") + if err := build.WriteChecksumFile(checksummedArtifacts, checksumPath); err != nil { + return nil, fmt.Errorf("failed to write checksums file: %w", err) + } + + // Add CHECKSUMS.txt as an artifact + checksumArtifact := build.Artifact{ + Path: checksumPath, + } + checksummedArtifacts = append(checksummedArtifacts, checksumArtifact) + + return checksummedArtifacts, nil +} + +// getBuilder returns the appropriate builder for the project type. +func getBuilder(projectType build.ProjectType) (build.Builder, error) { + switch projectType { + case build.ProjectTypeWails: + return builders.NewWailsBuilder(), nil + case build.ProjectTypeGo: + return builders.NewGoBuilder(), nil + case build.ProjectTypeNode: + return nil, fmt.Errorf("Node.js builder not yet implemented") + case build.ProjectTypePHP: + return nil, fmt.Errorf("PHP builder not yet implemented") + default: + return nil, fmt.Errorf("unsupported project type: %s", projectType) + } +} + +// getPublisher returns the publisher for the given type. +func getPublisher(pubType string) (publishers.Publisher, error) { + switch pubType { + case "github": + return publishers.NewGitHubPublisher(), nil + default: + return nil, fmt.Errorf("unsupported publisher type: %s", pubType) + } +} diff --git a/pkg/release/testdata/.core/release.yaml b/pkg/release/testdata/.core/release.yaml new file mode 100644 index 0000000..b9c9fd7 --- /dev/null +++ b/pkg/release/testdata/.core/release.yaml @@ -0,0 +1,35 @@ +version: 1 + +project: + name: myapp + repository: owner/repo + +build: + targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 + +publishers: + - type: github + prerelease: false + draft: false + +changelog: + include: + - feat + - fix + - perf + exclude: + - chore + - docs + - style + - test + - ci diff --git a/pkg/release/version.go b/pkg/release/version.go new file mode 100644 index 0000000..335ced7 --- /dev/null +++ b/pkg/release/version.go @@ -0,0 +1,195 @@ +// Package release provides release automation with changelog generation and publishing. +package release + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +// semverRegex matches semantic version strings with or without 'v' prefix. +var semverRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z0-9.-]+))?(?:\+([a-zA-Z0-9.-]+))?$`) + +// DetermineVersion determines the version for a release. +// It checks in order: +// 1. Git tag on HEAD +// 2. Most recent tag + increment patch +// 3. Default to v0.0.1 if no tags exist +func DetermineVersion(dir string) (string, error) { + // Check if HEAD has a tag + headTag, err := getTagOnHead(dir) + if err == nil && headTag != "" { + return normalizeVersion(headTag), nil + } + + // Get most recent tag + latestTag, err := getLatestTag(dir) + if err != nil || latestTag == "" { + // No tags exist, return default + return "v0.0.1", nil + } + + // Increment patch version + return IncrementVersion(latestTag), nil +} + +// IncrementVersion increments the patch version of a semver string. +// Examples: +// - "v1.2.3" -> "v1.2.4" +// - "1.2.3" -> "v1.2.4" +// - "v1.2.3-alpha" -> "v1.2.4" (strips prerelease) +func IncrementVersion(current string) string { + matches := semverRegex.FindStringSubmatch(current) + if matches == nil { + // Not a valid semver, return as-is with increment suffix + return current + ".1" + } + + major, _ := strconv.Atoi(matches[1]) + minor, _ := strconv.Atoi(matches[2]) + patch, _ := strconv.Atoi(matches[3]) + + // Increment patch + patch++ + + return fmt.Sprintf("v%d.%d.%d", major, minor, patch) +} + +// IncrementMinor increments the minor version of a semver string. +// Examples: +// - "v1.2.3" -> "v1.3.0" +// - "1.2.3" -> "v1.3.0" +func IncrementMinor(current string) string { + matches := semverRegex.FindStringSubmatch(current) + if matches == nil { + return current + ".1" + } + + major, _ := strconv.Atoi(matches[1]) + minor, _ := strconv.Atoi(matches[2]) + + // Increment minor, reset patch + minor++ + + return fmt.Sprintf("v%d.%d.0", major, minor) +} + +// IncrementMajor increments the major version of a semver string. +// Examples: +// - "v1.2.3" -> "v2.0.0" +// - "1.2.3" -> "v2.0.0" +func IncrementMajor(current string) string { + matches := semverRegex.FindStringSubmatch(current) + if matches == nil { + return current + ".1" + } + + major, _ := strconv.Atoi(matches[1]) + + // Increment major, reset minor and patch + major++ + + return fmt.Sprintf("v%d.0.0", major) +} + +// ParseVersion parses a semver string into its components. +// Returns (major, minor, patch, prerelease, build, error). +func ParseVersion(version string) (int, int, int, string, string, error) { + matches := semverRegex.FindStringSubmatch(version) + if matches == nil { + return 0, 0, 0, "", "", fmt.Errorf("invalid semver: %s", version) + } + + major, _ := strconv.Atoi(matches[1]) + minor, _ := strconv.Atoi(matches[2]) + patch, _ := strconv.Atoi(matches[3]) + prerelease := matches[4] + build := matches[5] + + return major, minor, patch, prerelease, build, nil +} + +// ValidateVersion checks if a string is a valid semver. +func ValidateVersion(version string) bool { + return semverRegex.MatchString(version) +} + +// normalizeVersion ensures the version starts with 'v'. +func normalizeVersion(version string) string { + if !strings.HasPrefix(version, "v") { + return "v" + version + } + return version +} + +// getTagOnHead returns the tag on HEAD, if any. +func getTagOnHead(dir string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--exact-match", "HEAD") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// getLatestTag returns the most recent tag in the repository. +func getLatestTag(dir string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// CompareVersions compares two semver strings. +// Returns: +// +// -1 if a < b +// 0 if a == b +// 1 if a > b +func CompareVersions(a, b string) int { + aMajor, aMinor, aPatch, _, _, errA := ParseVersion(a) + bMajor, bMinor, bPatch, _, _, errB := ParseVersion(b) + + // Invalid versions are considered less than valid ones + if errA != nil && errB != nil { + return strings.Compare(a, b) + } + if errA != nil { + return -1 + } + if errB != nil { + return 1 + } + + // Compare major + if aMajor != bMajor { + if aMajor < bMajor { + return -1 + } + return 1 + } + + // Compare minor + if aMinor != bMinor { + if aMinor < bMinor { + return -1 + } + return 1 + } + + // Compare patch + if aPatch != bPatch { + if aPatch < bPatch { + return -1 + } + return 1 + } + + return 0 +} diff --git a/pkg/release/version_test.go b/pkg/release/version_test.go new file mode 100644 index 0000000..a227c60 --- /dev/null +++ b/pkg/release/version_test.go @@ -0,0 +1,282 @@ +package release + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIncrementVersion_Good(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "increment patch with v prefix", + input: "v1.2.3", + expected: "v1.2.4", + }, + { + name: "increment patch without v prefix", + input: "1.2.3", + expected: "v1.2.4", + }, + { + name: "increment from zero", + input: "v0.0.0", + expected: "v0.0.1", + }, + { + name: "strips prerelease", + input: "v1.2.3-alpha", + expected: "v1.2.4", + }, + { + name: "strips build metadata", + input: "v1.2.3+build123", + expected: "v1.2.4", + }, + { + name: "strips prerelease and build", + input: "v1.2.3-beta.1+build456", + expected: "v1.2.4", + }, + { + name: "handles large numbers", + input: "v10.20.99", + expected: "v10.20.100", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IncrementVersion(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestIncrementVersion_Bad(t *testing.T) { + t.Run("invalid semver returns original with suffix", func(t *testing.T) { + result := IncrementVersion("not-a-version") + assert.Equal(t, "not-a-version.1", result) + }) +} + +func TestIncrementMinor_Good(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "increment minor resets patch", + input: "v1.2.3", + expected: "v1.3.0", + }, + { + name: "increment minor from zero", + input: "v1.0.5", + expected: "v1.1.0", + }, + { + name: "handles large numbers", + input: "v5.99.50", + expected: "v5.100.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IncrementMinor(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestIncrementMajor_Good(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "increment major resets minor and patch", + input: "v1.2.3", + expected: "v2.0.0", + }, + { + name: "increment major from zero", + input: "v0.5.10", + expected: "v1.0.0", + }, + { + name: "handles large numbers", + input: "v99.50.25", + expected: "v100.0.0", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := IncrementMajor(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParseVersion_Good(t *testing.T) { + tests := []struct { + name string + input string + major int + minor int + patch int + prerelease string + build string + }{ + { + name: "simple version with v", + input: "v1.2.3", + major: 1, minor: 2, patch: 3, + }, + { + name: "simple version without v", + input: "1.2.3", + major: 1, minor: 2, patch: 3, + }, + { + name: "with prerelease", + input: "v1.2.3-alpha", + major: 1, minor: 2, patch: 3, + prerelease: "alpha", + }, + { + name: "with prerelease and build", + input: "v1.2.3-beta.1+build.456", + major: 1, minor: 2, patch: 3, + prerelease: "beta.1", + build: "build.456", + }, + { + name: "with build only", + input: "v1.2.3+sha.abc123", + major: 1, minor: 2, patch: 3, + build: "sha.abc123", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + major, minor, patch, prerelease, build, err := ParseVersion(tc.input) + assert.NoError(t, err) + assert.Equal(t, tc.major, major) + assert.Equal(t, tc.minor, minor) + assert.Equal(t, tc.patch, patch) + assert.Equal(t, tc.prerelease, prerelease) + assert.Equal(t, tc.build, build) + }) + } +} + +func TestParseVersion_Bad(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"not a version", "not-a-version"}, + {"missing minor", "v1"}, + {"missing patch", "v1.2"}, + {"letters in version", "v1.2.x"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, _, _, _, _, err := ParseVersion(tc.input) + assert.Error(t, err) + }) + } +} + +func TestValidateVersion_Good(t *testing.T) { + validVersions := []string{ + "v1.0.0", + "1.0.0", + "v0.0.1", + "v10.20.30", + "v1.2.3-alpha", + "v1.2.3+build", + "v1.2.3-alpha.1+build.123", + } + + for _, v := range validVersions { + t.Run(v, func(t *testing.T) { + assert.True(t, ValidateVersion(v)) + }) + } +} + +func TestValidateVersion_Bad(t *testing.T) { + invalidVersions := []string{ + "", + "v1", + "v1.2", + "1.2", + "not-a-version", + "v1.2.x", + "version1.0.0", + } + + for _, v := range invalidVersions { + t.Run(v, func(t *testing.T) { + assert.False(t, ValidateVersion(v)) + }) + } +} + +func TestCompareVersions_Good(t *testing.T) { + tests := []struct { + name string + a string + b string + expected int + }{ + {"equal versions", "v1.0.0", "v1.0.0", 0}, + {"a less than b major", "v1.0.0", "v2.0.0", -1}, + {"a greater than b major", "v2.0.0", "v1.0.0", 1}, + {"a less than b minor", "v1.1.0", "v1.2.0", -1}, + {"a greater than b minor", "v1.2.0", "v1.1.0", 1}, + {"a less than b patch", "v1.0.1", "v1.0.2", -1}, + {"a greater than b patch", "v1.0.2", "v1.0.1", 1}, + {"with and without v prefix", "v1.0.0", "1.0.0", 0}, + {"different scales", "v1.10.0", "v1.9.0", 1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := CompareVersions(tc.a, tc.b) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestNormalizeVersion_Good(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"1.0.0", "v1.0.0"}, + {"v1.0.0", "v1.0.0"}, + {"0.0.1", "v0.0.1"}, + {"v10.20.30", "v10.20.30"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := normalizeVersion(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}