feat(gradle): release v1.6.0 — Gradle build file version bump
ci / vet, staticcheck, test, build (push) Failing after 3m1s
release / Build and publish release (push) Successful in 5m1s

Add internal/gradle package with ReadVersion and WriteVersion for
build.gradle (Groovy DSL, single-quoted) and build.gradle.kts (Kotlin
DSL, double-quoted). Quote style is preserved on write. regexp.QuoteMeta
ensures version strings with dots or special characters are safe.

Config follows the established multi-value pattern:
- gradle.build_file: single path (opt-in, no default)
- gradle.build_files: list for multi-module projects (overrides build_file)
- --gradle flag overrides build_file and clears build_files

100% per-package statement coverage maintained across all 13 packages;
FuzzReadVersion and FuzzWriteVersion added per fuzzing guidelines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:59:13 +02:00
parent e2d4214405
commit dfdf2b019a
14 changed files with 558 additions and 21 deletions
+58 -16
View File
@@ -18,6 +18,7 @@ import (
"git.k3nny.fr/releaser/internal/ghclient"
"git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/gradle"
"git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/node"
"git.k3nny.fr/releaser/internal/notes"
@@ -78,6 +79,17 @@ node:
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gradle:
# Single build.gradle or build.gradle.kts path (opt-in — no default).
# Both Groovy DSL (single-quoted) and Kotlin DSL (double-quoted) are supported.
# build_file: "build.gradle"
# Multiple build files for multi-module projects (overrides build_file).
# build_files:
# - "build.gradle"
# - "module-a/build.gradle"
# - "module-b/build.gradle"
gitlab:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com"
@@ -149,6 +161,7 @@ func newRootCmd() *cobra.Command {
branchOverride string
repoPath string
pomOverride string
gradleOverride string
changelogFile string
tagPrefixFlag string
tagPrefixSet bool
@@ -171,6 +184,7 @@ func newRootCmd() *cobra.Command {
repoPath: repoPath,
branchOverride: branchOverride,
pomOverride: pomOverride,
gradleOverride: gradleOverride,
changelogFile: changelogFile,
tagPrefixFlag: tagPrefixFlag,
tagPrefixSet: tagPrefixSet,
@@ -196,6 +210,7 @@ func newRootCmd() *cobra.Command {
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(&gradleOverride, "gradle", "", "override gradle.build_file from config")
root.Flags().StringVar(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root")
root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
@@ -215,22 +230,23 @@ func main() {
}
type options struct {
init bool
verbose bool
repoPath string
branchOverride string
pomOverride string
changelogFile string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
dryRun bool
noPush bool
noRelease bool
noCommit bool
tagOnly bool
releaseEnvFile string
init bool
verbose bool
repoPath string
branchOverride string
pomOverride string
gradleOverride string
changelogFile string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
dryRun bool
noPush bool
noRelease bool
noCommit bool
tagOnly bool
releaseEnvFile string
}
func printVerboseConfig(cfg config.Config, src config.Sources) {
@@ -273,6 +289,13 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
}
return strings.Join(paths, ", ")
}()},
{"gradle.paths", func() string {
paths := cfg.Gradle.EffectiveBuildFiles()
if len(paths) == 0 {
return "(not configured)"
}
return strings.Join(paths, ", ")
}()},
{"gitlab.url", cfg.GitLab.URL},
{"gitlab.token", func() string {
if cfg.GitLab.Token != "" {
@@ -360,6 +383,11 @@ func run(o options) error {
cfg.Maven.PomPaths = nil
src["maven.pom_paths"] = "flag: --pom"
}
if o.gradleOverride != "" {
cfg.Gradle.BuildFile = o.gradleOverride
cfg.Gradle.BuildFiles = nil
src["gradle.build_files"] = "flag: --gradle"
}
if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag
src["git.branch_pattern"] = "flag: --branch-pattern"
@@ -552,6 +580,20 @@ func run(o options) error {
filesToCommit = append(filesToCommit, relPkgPath)
}
// build.gradle / build.gradle.kts (opt-in via gradle.build_file / gradle.build_files)
for _, relGradlePath := range cfg.Gradle.EffectiveBuildFiles() {
gradlePath := filepath.Join(absRepo, relGradlePath)
currentGradleVersion, err := gradle.ReadVersion(gradlePath)
if err != nil {
return fmt.Errorf("read gradle version: %w", err)
}
if err := gradle.WriteVersion(gradlePath, currentGradleVersion, nextVersion); err != nil {
return fmt.Errorf("update gradle version: %w", err)
}
logDone("%s: %s → %s", relGradlePath, currentGradleVersion, nextVersion)
filesToCommit = append(filesToCommit, relGradlePath)
}
// CHANGELOG.md
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
+116 -1
View File
@@ -1093,7 +1093,8 @@ func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
Git: config.GitConfig{
BumpRules: config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"},
},
Node: config.NodeConfig{PackageJSON: "package.json"},
Node: config.NodeConfig{PackageJSON: "package.json"},
Gradle: config.GradleConfig{BuildFile: "build.gradle"},
}
src := config.Sources{}
@@ -1112,6 +1113,9 @@ func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
if !strings.Contains(output, "package.json") {
t.Error("expected 'package.json' in output for node.paths")
}
if !strings.Contains(output, "build.gradle") {
t.Error("expected 'build.gradle' in output for gradle.paths")
}
}
// ── node package.json handling ────────────────────────────────────────────────
@@ -1200,3 +1204,114 @@ func TestRunNodeWriteVersionFails(t *testing.T) {
t.Fatal("expected error when package.json is read-only")
}
}
// ── gradle build file handling ────────────────────────────────────────────────
func writeGradleFile(t *testing.T, dir, ver string) {
t.Helper()
content := fmt.Sprintf("group = \"com.example\"\nversion = \"%s\"\n", ver)
if err := os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestRunGradleVersionBump(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writeGradleFile(t, dir, "0.0.0")
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("gradle:\n build_file: \"build.gradle\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("gradle version bump: unexpected error: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "build.gradle"))
if !strings.Contains(string(data), `version = "1.2.0"`) {
t.Errorf("expected version 1.2.0 in build.gradle, got: %s", data)
}
}
func TestRunGradleOverrideFlag(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
// Write gradle file at custom path
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0755); err != nil {
t.Fatal(err)
}
content := "version = \"0.0.0\"\n"
os.WriteFile(filepath.Join(dir, "sub", "build.gradle"), []byte(content), 0644)
commitAll(t, repo, dir, "chore: init")
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--gradle", "sub/build.gradle"); err != nil {
t.Fatalf("--gradle flag: unexpected error: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "sub", "build.gradle"))
if !strings.Contains(string(data), `version = "1.2.0"`) {
t.Errorf("expected version 1.2.0, got: %s", data)
}
}
func TestRunGradleReadVersionFails(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
// build.gradle with no version assignment
os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(`group = "com.example"`), 0644)
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("gradle:\n build_file: \"build.gradle\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
if err := execCmd(t, "--branch", "release/1.2", "--repo", dir); err == nil {
t.Fatal("expected error when build.gradle has no version assignment")
}
}
func TestRunGradleWriteVersionFails(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writeGradleFile(t, dir, "0.0.0")
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("gradle:\n build_file: \"build.gradle\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
os.Chmod(filepath.Join(dir, "build.gradle"), 0444)
defer os.Chmod(filepath.Join(dir, "build.gradle"), 0644)
if err := execCmd(t, "--branch", "release/1.2", "--repo", dir); err == nil {
t.Fatal("expected error when build.gradle is read-only")
}
}