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:
Snider 2026-01-28 18:33:11 +00:00
parent 0237edeb0d
commit 0f072ad353
14 changed files with 2527 additions and 2 deletions

243
cmd/core/cmd/release.go Normal file
View 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
}

View file

@ -84,6 +84,7 @@ func Execute() error {
AddDoctorCommand(app) AddDoctorCommand(app)
AddSearchCommand(app) AddSearchCommand(app)
AddInstallCommand(app) AddInstallCommand(app)
AddReleaseCommand(app)
// Run the application // Run the application
return app.Run() return app.Run()
} }

4
go.mod
View file

@ -7,6 +7,8 @@ require (
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.41 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 ( require (
@ -73,10 +75,8 @@ require (
golang.org/x/net v0.49.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.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 golang.org/x/tools v0.40.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

321
pkg/release/changelog.go Normal file
View 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])
}

View 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
View 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
View 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)
})
}

View 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
}

View 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")
})
}

View 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
View 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
View 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
View 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
View 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)
})
}
}