feat(release): implement release system with GitHub publisher
Add pkg/release package for automated releases: - Config loading from .core/release.yaml - Version detection from git tags with auto-increment - Changelog generation from conventional commits - GitHub publisher using gh CLI CLI commands: - core release - build + publish to GitHub - core release --dry-run - preview without publishing - core release init - interactive config setup - core release changelog - generate changelog - core release version - show/set version Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0237edeb0d
commit
0f072ad353
14 changed files with 2527 additions and 2 deletions
243
cmd/core/cmd/release.go
Normal file
243
cmd/core/cmd/release.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -84,6 +84,7 @@ func Execute() error {
|
|||
AddDoctorCommand(app)
|
||||
AddSearchCommand(app)
|
||||
AddInstallCommand(app)
|
||||
AddReleaseCommand(app)
|
||||
// Run the application
|
||||
return app.Run()
|
||||
}
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
)
|
||||
|
|
|
|||
321
pkg/release/changelog.go
Normal file
321
pkg/release/changelog.go
Normal file
|
|
@ -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])
|
||||
}
|
||||
256
pkg/release/changelog_test.go
Normal file
256
pkg/release/changelog_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
208
pkg/release/config.go
Normal file
208
pkg/release/config.go
Normal file
|
|
@ -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
|
||||
}
|
||||
303
pkg/release/config_test.go
Normal file
303
pkg/release/config_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
233
pkg/release/publishers/github.go
Normal file
233
pkg/release/publishers/github.go
Normal file
|
|
@ -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
|
||||
}
|
||||
167
pkg/release/publishers/github_test.go
Normal file
167
pkg/release/publishers/github_test.go
Normal file
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
65
pkg/release/publishers/publisher.go
Normal file
65
pkg/release/publishers/publisher.go
Normal file
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
216
pkg/release/release.go
Normal file
216
pkg/release/release.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
35
pkg/release/testdata/.core/release.yaml
vendored
Normal file
35
pkg/release/testdata/.core/release.yaml
vendored
Normal file
|
|
@ -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
|
||||
195
pkg/release/version.go
Normal file
195
pkg/release/version.go
Normal file
|
|
@ -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
|
||||
}
|
||||
282
pkg/release/version_test.go
Normal file
282
pkg/release/version_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue