package main import ( "errors" "flag" "fmt" "log" "os" "os/exec" "regexp" "strconv" "strings" ) var Version = "UNKNOWN" var BuildTime = "UNKNOWN" const DefaultVersion = "v0.0.0" type Config struct { printVersion bool dryRun bool forcedBump string versionPrefix string patterns Patterns noPush bool } type Patterns struct { major []string minor []string patch []string chore []string } func main() { fmt.Printf("version: %s\n", Version) fmt.Printf("built: %s\n", BuildTime) config := parseFlags() if config.printVersion { return } fmt.Println("fetching tags:") if err := fetchTags(); err != nil { log.Fatalf("Error fetching tags: %v", err) } if os.Getenv("DRONE") == "true" || os.Getenv("GITEA_ACTIONS") == "true" || os.Getenv("GITHUB_ACTIONS") == "true" { must(git("config user.email zlymeda@gmail.com")) must(git("config user.name \"gitbump\"")) } tag, err := getLatestTag() if err != nil { log.Fatalf("Error getting latest tag: %v", err) } fmt.Println("latest tag:", tag) commits, err := getCommitsSinceTag(tag) if err != nil { log.Fatalf("Error getting commits: %v", err) } fmt.Println("\nCommits since last tag:") for _, commit := range commits { fmt.Printf("- %s\n", commit) } if len(commits) == 0 { fmt.Println("No commits found since last tag. No version bump needed.") return } major, minor, patch, err := parseVersion(tag, config.versionPrefix) if err != nil { log.Fatalf("Error parsing version: %v", err) } fmt.Println("version:", major, minor, patch) bumpType := getBumpType(commits, config) fmt.Println("bump type:", bumpType) if bumpType == "chore" { fmt.Println("Only chore commits found -> no version bump needed.") return } newVersion := calculateNewVersion(major, minor, patch, bumpType, config.versionPrefix) fmt.Println("new version:", newVersion) if config.dryRun { fmt.Printf("Dry run: Would bump version from %s to %s (bump type: %s)\n", tag, newVersion, bumpType) return } fmt.Printf("Trying to bump version from %s to %s\n", tag, newVersion) err = createAndPushTag(newVersion, config.noPush) if err != nil { log.Fatalf("Error creating/pushing tag: %v", err) } fmt.Printf("Successfully bumped version from %s to %s\n", tag, newVersion) } func parseFlags() Config { config := Config{} flag.BoolVar(&config.printVersion, "version", false, "Just print version") flag.BoolVar(&config.dryRun, "dry-run", getEnvBool("DRY_RUN", false), "Show what would be done without making changes") flag.StringVar(&config.forcedBump, "bump", os.Getenv("BUMP"), "Force specific bump type (major, minor, or patch)") flag.StringVar(&config.versionPrefix, "prefix", getEnv("PREFIX", "v"), "Version prefix (default 'v')") flag.BoolVar(&config.noPush, "no-push", getEnvBool("NO_PUSH", false), "Create tag but don't push to remote") majorPatterns := flag.String("major-patterns", getEnv("MAJOR_PATTERNS", "bump major,breaking change"), "Comma-separated list will be joined with '|' for major version bumps") minorPatterns := flag.String("minor-patterns", getEnv("MINOR_PATTERNS", "bump minor,feat"), "Comma-separated list will be joined with '|' for minor version bumps") patchPatterns := flag.String("patch-patterns", getEnv("PATCH_PATTERNS", "bump patch,fix"), "Comma-separated list will be joined with '|' for patch version bumps") chorePatterns := flag.String("chore-patterns", getEnv("CHORE_PATTERNS", "chore,bump:skip"), "Comma-separated list will be joined with '|' for commits that should be ignored") flag.Parse() config.patterns = Patterns{ major: strings.Split(*majorPatterns, ","), minor: strings.Split(*minorPatterns, ","), patch: strings.Split(*patchPatterns, ","), chore: strings.Split(*chorePatterns, ","), } if config.forcedBump != "" && !contains([]string{"major", "minor", "patch"}, config.forcedBump) { log.Fatalf("Invalid bump type: %s. Must be one of: major, minor, patch", config.forcedBump) } return config } func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value } // Check for Gitea/GitHub Actions input prefix inputKey := "INPUT_" + strings.ReplaceAll(strings.ToUpper(key), "-", "_") if value, ok := os.LookupEnv(inputKey); ok { return value } return fallback } func getEnvBool(key string, fallback bool) bool { if value, ok := os.LookupEnv(key); ok { b, err := strconv.ParseBool(value) if err != nil { return fallback } return b } // Check for Gitea/GitHub Actions input prefix inputKey := "INPUT_" + strings.ReplaceAll(strings.ToUpper(key), "-", "_") if value, ok := os.LookupEnv(inputKey); ok { b, err := strconv.ParseBool(value) if err != nil { return fallback } return b } return fallback } func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } func getLatestTag() (string, error) { output, err := git("describe --tags --abbrev=0") if err != nil { fmt.Println(err) if strings.Contains(err.Error(), "No names found") { return DefaultVersion, nil } return "", err } return output, nil } func fetchTags() error { _, err := git("fetch --tags") return err } func getCommitsSinceTag(tag string) ([]string, error) { var cmd string if tag == DefaultVersion { cmd = "log --pretty=format:%s" } else { cmd = "log --pretty=format:%s " + tag + "..HEAD" } output, err := git(cmd) if err != nil { return nil, err } commits := strings.Split(output, "\n") if len(commits) == 1 && commits[0] == "" { return []string{}, nil } return commits, nil } func parseVersion(tag, prefix string) (int, int, int, error) { tag = strings.TrimPrefix(tag, prefix) parts := strings.Split(tag, ".") if len(parts) != 3 { return 0, 0, 0, fmt.Errorf("invalid version format: %s", tag) } major, err := strconv.Atoi(parts[0]) if err != nil { return 0, 0, 0, err } minor, err := strconv.Atoi(parts[1]) if err != nil { return 0, 0, 0, err } patch, err := strconv.Atoi(parts[2]) if err != nil { return 0, 0, 0, err } return major, minor, patch, nil } func getBumpType(commits []string, config Config) string { // If bump type is forced via flag, use that if config.forcedBump != "" { return config.forcedBump } majorPattern := regexp.MustCompile(`(?i)(` + strings.Join(config.patterns.major, "|") + `)`) minorPattern := regexp.MustCompile(`(?i)(` + strings.Join(config.patterns.minor, "|") + `)`) patchPattern := regexp.MustCompile(`(?i)(` + strings.Join(config.patterns.patch, "|") + `)`) chorePattern := regexp.MustCompile(`(?i)(` + strings.Join(config.patterns.chore, "|") + `)`) chores := 0 for _, commit := range commits { if majorPattern.MatchString(commit) { return "major" } if minorPattern.MatchString(commit) { return "minor" } if patchPattern.MatchString(commit) { return "patch" } if chorePattern.MatchString(commit) { chores++ } } // Default to patch bump if there are any non-chore commits if len(commits) > chores { return "patch" } return "chore" } func calculateNewVersion(major, minor, patch int, bumpType, prefix string) string { switch bumpType { case "major": major++ minor = 0 patch = 0 case "minor": minor++ patch = 0 case "patch": patch++ } return fmt.Sprintf("%s%d.%d.%d", prefix, major, minor, patch) } func createAndPushTag(version string, noPush bool) error { if out, err := git("tag " + version); err != nil { return fmt.Errorf("error creating tag: %v; stdout: %v", err, out) } if noPush { fmt.Println("pushing disabled") return nil } if out, err := git("push origin " + version); err != nil { return fmt.Errorf("error pushing tag: %v; stdout: %v", err, out) } return nil } func git(command string) (string, error) { parts := strings.Split(command, " ") cmd := exec.Command("git", parts...) fmt.Printf("running %s\n", cmd) output, err := cmd.Output() if err != nil { fmt.Println(err) var ee *exec.ExitError if errors.As(err, &ee) { stderr := strings.TrimSpace(string(ee.Stderr)) fmt.Println(stderr) return "", fmt.Errorf("git command: %v: %s", err, stderr) } return "", fmt.Errorf("git command: %v", err) } result := strings.TrimSpace(string(output)) fmt.Printf("output: %s\n", result) return result, nil } func must(_ string, err error) { if err != nil { log.Fatal(err) } }