0dc6d0747d
Pushes the commit and tag as normal but exits before calling the GitLab API. Useful when the project is hosted on a non-GitLab forge (e.g. Gitea) where the release is handled by a separate CI workflow triggered on the tag push. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
295 lines
8.4 KiB
Go
295 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
gogit "github.com/go-git/go-git/v5"
|
|
"github.com/spf13/cobra"
|
|
|
|
"git.k3nny.fr/releaser/internal/branch"
|
|
"git.k3nny.fr/releaser/internal/commits"
|
|
"git.k3nny.fr/releaser/internal/config"
|
|
"git.k3nny.fr/releaser/internal/glclient"
|
|
"git.k3nny.fr/releaser/internal/gitutil"
|
|
"git.k3nny.fr/releaser/internal/maven"
|
|
"git.k3nny.fr/releaser/internal/notes"
|
|
semver "git.k3nny.fr/releaser/internal/version"
|
|
)
|
|
|
|
var (
|
|
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
|
|
errNothingToRelease = errors.New("nothing to release")
|
|
)
|
|
|
|
// exitFn is a variable so tests can intercept os.Exit calls.
|
|
var exitFn = os.Exit
|
|
|
|
func newRootCmd() *cobra.Command {
|
|
var (
|
|
dryRun bool
|
|
noPush bool
|
|
noRelease bool
|
|
noCommit bool
|
|
tagOnly bool
|
|
branchOverride string
|
|
repoPath string
|
|
pomOverride string
|
|
tagPrefixFlag string
|
|
tagPrefixSet bool
|
|
patternFlag string
|
|
patternSet bool
|
|
)
|
|
|
|
root := &cobra.Command{
|
|
Use: "releaser",
|
|
Short: "GitFlow release automation for Conventional Commits",
|
|
Version: version,
|
|
SilenceUsage: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
|
|
patternSet = cmd.Flags().Changed("branch-pattern")
|
|
return run(options{
|
|
repoPath: repoPath,
|
|
branchOverride: branchOverride,
|
|
pomOverride: pomOverride,
|
|
tagPrefixFlag: tagPrefixFlag,
|
|
tagPrefixSet: tagPrefixSet,
|
|
patternFlag: patternFlag,
|
|
patternSet: patternSet,
|
|
dryRun: dryRun,
|
|
noPush: noPush,
|
|
noRelease: noRelease,
|
|
noCommit: noCommit,
|
|
tagOnly: tagOnly,
|
|
})
|
|
},
|
|
}
|
|
|
|
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
|
|
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release")
|
|
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
|
|
root.Flags().BoolVar(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push")
|
|
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)")
|
|
root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)")
|
|
root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository")
|
|
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config")
|
|
root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
|
|
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
|
|
|
|
return root
|
|
}
|
|
|
|
func main() {
|
|
if err := newRootCmd().Execute(); err != nil {
|
|
if errors.Is(err, errNothingToRelease) {
|
|
exitFn(2)
|
|
return
|
|
}
|
|
exitFn(1)
|
|
}
|
|
}
|
|
|
|
type options struct {
|
|
repoPath string
|
|
branchOverride string
|
|
pomOverride string
|
|
tagPrefixFlag string
|
|
tagPrefixSet bool
|
|
patternFlag string
|
|
patternSet bool
|
|
dryRun bool
|
|
noPush bool
|
|
noRelease bool
|
|
noCommit bool
|
|
tagOnly bool
|
|
}
|
|
|
|
func run(o options) error {
|
|
// --- Config ---
|
|
absRepo, err := filepath.Abs(o.repoPath)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve repo path: %w", err)
|
|
}
|
|
|
|
cfg, err := config.Load(absRepo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.ApplyEnv()
|
|
|
|
// CLI flags take precedence over config file and env vars
|
|
if o.tagPrefixSet {
|
|
cfg.Git.TagPrefix = o.tagPrefixFlag
|
|
}
|
|
if o.pomOverride != "" {
|
|
cfg.Maven.PomPath = o.pomOverride
|
|
}
|
|
if o.patternSet {
|
|
cfg.Git.BranchPattern = o.patternFlag
|
|
}
|
|
|
|
// --- Git ---
|
|
repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true})
|
|
if err != nil {
|
|
return fmt.Errorf("open repository: %w", err)
|
|
}
|
|
|
|
branchName := o.branchOverride
|
|
if branchName == "" {
|
|
branchName, err = gitutil.CurrentBranch(repo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
info, err := branch.Parse(branchName, cfg.Git.BranchPattern)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info.TagPrefix = cfg.Git.TagPrefix
|
|
|
|
// --- Dirty check (before any changes) ---
|
|
// Skipped in --no-commit mode: the user intentionally has changes in flight.
|
|
if !o.dryRun && !o.noCommit {
|
|
clean, err := gitutil.IsWorkingTreeClean(repo)
|
|
if err != nil {
|
|
return fmt.Errorf("check working tree: %w", err)
|
|
}
|
|
if !clean {
|
|
return fmt.Errorf("working tree has uncommitted changes — commit or stash them before releasing")
|
|
}
|
|
}
|
|
|
|
// --- Tag discovery ---
|
|
lastTag, currentPatch, err := gitutil.LatestTag(repo, info)
|
|
if err != nil {
|
|
return fmt.Errorf("find latest tag: %w", err)
|
|
}
|
|
|
|
// --- Commit range ---
|
|
var messages []string
|
|
if lastTag == "" {
|
|
fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits")
|
|
messages, err = gitutil.AllCommits(repo)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag)
|
|
messages, err = gitutil.CommitsSince(repo, lastTag)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read commits: %w", err)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "info: %d commit(s) to analyze\n", len(messages))
|
|
|
|
// --- Version calculation ---
|
|
types := make([]commits.Type, len(messages))
|
|
for i, msg := range messages {
|
|
types[i] = commits.Parse(msg)
|
|
}
|
|
|
|
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
|
|
if !ok {
|
|
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
|
|
return errNothingToRelease
|
|
}
|
|
|
|
nextTag := info.TagName(nextVersion)
|
|
|
|
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
|
|
|
|
if o.dryRun {
|
|
fmt.Fprintln(os.Stderr, "dry-run: no changes made")
|
|
return nil
|
|
}
|
|
|
|
// --- pom.xml (skipped with --tag-only or when the file does not exist) ---
|
|
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
|
_, statErr := os.Stat(pomPath)
|
|
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
|
if statErr != nil && hasPom {
|
|
return fmt.Errorf("check pom path: %w", statErr)
|
|
}
|
|
|
|
if !o.tagOnly && hasPom {
|
|
currentPomVersion, err := maven.ReadVersion(pomPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read pom version: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion)
|
|
|
|
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
|
|
return fmt.Errorf("update pom version: %w", err)
|
|
}
|
|
|
|
if o.noCommit {
|
|
fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
|
|
return nil
|
|
}
|
|
|
|
// --- Git commit ---
|
|
authorName, authorEmail := gitutil.AuthorFromConfig(repo)
|
|
if cfg.Git.AuthorName != "" {
|
|
authorName = cfg.Git.AuthorName
|
|
}
|
|
if cfg.Git.AuthorEmail != "" {
|
|
authorEmail = cfg.Git.AuthorEmail
|
|
}
|
|
|
|
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
|
|
if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil {
|
|
return fmt.Errorf("commit pom.xml: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg)
|
|
} else if !o.tagOnly && !hasPom {
|
|
fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump commit")
|
|
}
|
|
|
|
// --- Git tag ---
|
|
if err := gitutil.CreateTag(repo, nextTag); err != nil {
|
|
return fmt.Errorf("create tag: %w", err)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag)
|
|
|
|
if o.noPush {
|
|
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
|
|
return nil
|
|
}
|
|
|
|
// --- Push ---
|
|
fmt.Fprintln(os.Stderr, "info: pushing commit and tag...")
|
|
if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil {
|
|
return fmt.Errorf("push: %w", err)
|
|
}
|
|
fmt.Fprintln(os.Stderr, "info: pushed")
|
|
|
|
// --- GitLab release ---
|
|
if o.noRelease {
|
|
fmt.Printf("released %s\n", nextTag)
|
|
return nil
|
|
}
|
|
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
|
|
fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation")
|
|
fmt.Printf("released %s\n", nextTag)
|
|
return nil
|
|
}
|
|
if cfg.GitLab.Token == "" {
|
|
return fmt.Errorf("GITLAB_TOKEN not set — required for release creation")
|
|
}
|
|
|
|
releaseNotes := notes.Generate(nextTag, messages)
|
|
gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project)
|
|
|
|
if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
|
|
return fmt.Errorf("create GitLab release: %w", err)
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag)
|
|
fmt.Printf("released %s\n", nextTag)
|
|
return nil
|
|
}
|