From 4f46101d4c725d98a538f2c0fe94bb56c5a9d5f3 Mon Sep 17 00:00:00 2001 From: Ondrej Belusky Date: Thu, 12 Feb 2026 12:52:34 +0100 Subject: [PATCH] move to gitea actions --- .gitea/workflows/build.yml | 60 +++++++ .gitignore | 1 + README.md | 40 +++++ action.yml | 37 ++++ go.mod | 3 + go.sum | 0 justfile | 119 +++++++++++++ main.go | 337 +++++++++++++++++++++++++++++++++++++ main_test.go | 14 ++ 9 files changed, 611 insertions(+) create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 action.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 justfile create mode 100644 main.go create mode 100644 main_test.go 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") + } +}