commit 4f46101d4c725d98a538f2c0fe94bb56c5a9d5f3 Author: Ondrej Belusky Date: Thu Feb 12 12:52:34 2026 +0100 move to gitea actions diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..a6ca329 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,60 @@ +name: Gitbump CI +on: + push: + branches: [ master ] + workflow_dispatch: + +jobs: + test: + runs-on: [ubuntu-latest, amd64] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Test & Build + run: | + just vet test + just build-linux-amd64 + /bin/gitbump_linux_amd64 --version + + build: + runs-on: [ubuntu-latest, amd64] + needs: test-build + if: github.ref == 'refs/heads/master' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Bump & Tag + run: go run main.go + + notify-failure: + runs-on: go-executor + needs: [test, build] + if: always() && contains(needs.*.result, 'failure') + steps: + - name: ntfy-failed-notifications + uses: niniyas/ntfy-action@master + with: + url: 'https://ntfy.zlymeda.dynu.net' + topic: ${{ secrets.NTFY_TOPIC || 'errors' }} + priority: 5 + tags: flying_saucer,warning,🚨,🛠️,action,failed + details: Workflow has failed! + actions: 'default' + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60f6eac --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gitbump diff --git a/README.md b/README.md new file mode 100644 index 0000000..907acad --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# gitbump + +Automatically bump version and tag based on commit messages. + +## Usage as Gitea/GitHub Action + +Add this to your workflow: + +```yaml +jobs: + bump: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: '1.23' + - uses: https://gitea.com/your-user/gitbump@master +``` + +### Inputs + +| Input | Description | Default | +|-------|-------------|---------| +| `dry-run` | Show what would be done without making changes | `false` | +| `bump` | Force specific bump type (`major`, `minor`, or `patch`) | | +| `prefix` | Version prefix | `v` | +| `no-push` | Create tag but don't push to remote | `false` | +| `major-patterns` | Comma-separated list for major version bumps | `bump major,breaking change` | +| `minor-patterns` | Comma-separated list for minor version bumps | `bump minor,feat` | +| `patch-patterns` | Comma-separated list for patch version bumps | `bump patch,fix` | +| `chore-patterns` | Comma-separated list for commits that should be ignored | `chore,bump:skip` | + +## TODO +- consider using https://github.com/go-git/go-git +- could read changelog or something to create an annotated tag diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..4a9c11c --- /dev/null +++ b/action.yml @@ -0,0 +1,37 @@ +name: 'Gitbump' +description: 'Automatically bump version and tag based on commit messages' +inputs: + dry-run: + description: 'Show what would be done without making changes' + required: false + default: 'false' + bump: + description: 'Force specific bump type (major, minor, or patch)' + required: false + prefix: + description: 'Version prefix (default "v")' + required: false + default: 'v' + no-push: + description: 'Create tag but dont push to remote' + required: false + default: 'false' + major-patterns: + description: 'Comma-separated list for major version bumps' + required: false + default: 'bump major,breaking change' + minor-patterns: + description: 'Comma-separated list for minor version bumps' + required: false + default: 'bump minor,feat' + patch-patterns: + description: 'Comma-separated list for patch version bumps' + required: false + default: 'bump patch,fix' + chore-patterns: + description: 'Comma-separated list for commits that should be ignored' + required: false + default: 'chore,bump:skip' +runs: + using: 'go' + main: 'main.go' diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f397b14 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module lab.zlymeda.dynu.net/actions/gitbump + +go 1.26 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/justfile b/justfile new file mode 100644 index 0000000..68d479c --- /dev/null +++ b/justfile @@ -0,0 +1,119 @@ +set fallback + +default: + just --choose + +list: + just --list + + +import? 'justfile.custom' + +# Variables with defaults +CGO_ENABLED := env_var_or_default('CGO_ENABLED', '0') +GO111MODULE := env_var_or_default('GO111MODULE', 'on') +GOFLAGS := env_var_or_default('GOFLAGS', '"-mod=mod"') +SUFFIX := env_var_or_default('SUFFIX', '') + +# Determine project name +NAME := if env_var_or_default('GITEA_REPO_NAME', '') != '' { + env_var('GITEA_REPO_NAME') +} else { + file_name(justfile_directory()) +} + +OUTBIN := env_var_or_default('OUTBIN', NAME) + +# Build metadata +VERSION := env_var_or_default('VERSION', `git describe --tags --always --dirty 2> /dev/null || echo dev`) +BUILD_TIME := env_var_or_default('BUILD_TIME', `/bin/date +%FT%T%z`) +PKG := `GOWORK=off go list -m` +LD_FLAGS_OPTIMIZE := env_var_or_default('LD_FLAGS_OPTIMIZE', '-s -w') +LD_FLAGS := '"-X main.Version=' + VERSION + ' -X main.BuildTime=' + BUILD_TIME + ' ' + LD_FLAGS_OPTIMIZE + '"' +GO_OPTS := env_var_or_default('GO_OPTS', '-trimpath') +OPTIMIZE := env_var_or_default('OPTIMIZE', 'false') + +# Display version information +version: + @echo {{NAME}} {{VERSION}} {{BUILD_TIME}} {{PKG}} {{LD_FLAGS}} + +# Build for all platforms +all-build: build-linux-amd64 build-linux-arm64 build-linux-386 + +# Download dependencies +download: + @echo Download go.mod dependencies + go mod download + +# Generate code +generate: + GOWORK=off go generate -mod=mod ./... + +# Run wire dependency injection +wire: + go generate -x ./di + +# Build for specific platform +build GOOS GOARCH: + GOOS={{GOOS}} GOARCH={{GOARCH}} CGO_ENABLED={{CGO_ENABLED}} go build \ + -ldflags {{LD_FLAGS}} {{GO_OPTS}} \ + -mod mod \ + -o bin/{{OUTBIN}}{{SUFFIX}}_{{GOOS}}_{{GOARCH}} \ + ./main.go +build-race: + GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -race \ + -ldflags {{LD_FLAGS}} {{GO_OPTS}} \ + -mod mod \ + -o bin/{{OUTBIN}}{{SUFFIX}}_linux_amd64 \ + ./main.go + +# Install binaries +install: + CGO_ENABLED={{CGO_ENABLED}} go install \ + -ldflags {{LD_FLAGS}} \ + ./... + +# Build for Linux ARM64 +build-linux-arm64: (build "linux" "arm64") + +# Build for Linux AMD64 +build-linux-amd64: (build "linux" "amd64") + +# Build for Linux 386 +build-linux-386: (build "linux" "386") + +# Pre-push checks +prepush: generate vet fmt tidy test + +# Run tests +test: + CGO_ENABLED=0 go build -mod=mod ./... + CGO_ENABLED=1 go test -race -cover -v -mod=mod ./... && echo -e "\033[32mSUCCESS\033[0m" || (echo -e "\033[31mFAILED\033[0m" && exit 1) + +# Run benchmarks +bench: + go test -test.timeout=30m -benchmem -run ^$$ -benchtime=20s -bench . ./... && echo -e "\033[32mSUCCESS\033[0m" || (echo -e "\033[31mFAILED\033[0m" && exit 1) + +# Run go vet +vet: + go vet ./... + +# Format code +fmt: + go fmt ./... + +# Tidy dependencies +tidy: + go mod tidy + +verify: + go mod verify + +# List all modules +go_list: + go list -u -m all + +# Update all dependencies +go_update_all: + go get -t -u ./... + diff --git a/main.go b/main.go new file mode 100644 index 0000000..64fbc60 --- /dev/null +++ b/main.go @@ -0,0 +1,337 @@ +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) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0ff141c --- /dev/null +++ b/main_test.go @@ -0,0 +1,14 @@ +package main + +import "testing" + +func TestParseVersion(t *testing.T) { + major, minor, patch, err := parseVersion("v0.0.0", "v") + if err != nil { + t.Fatal(err) + } + + if major != 0 || minor != 0 || patch != 0 { + t.Fatal("version does not match") + } +}