feat(releaser): initial release v0.4.0
ci / vet, staticcheck, test, build (push) Failing after 3m26s
release / Build and publish release (push) Successful in 4m28s

Complete GitFlow release automation tool for Conventional Commits workflows:

- Core pipeline: branch parsing, tag discovery, commit analysis, version bump
- Maven pom.xml read/write, git commit/tag, HTTPS push with token auth
- GitLab release creation via API with auto-generated release notes
- Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab)
- CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern
- Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template
- Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows
- Taskfile with build/test/cov/lint/fuzz/ci/docker tasks
- 96% test coverage with real in-memory git repos and fuzz tests for all parsers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:07:53 +02:00
commit f0723e706a
30 changed files with 3959 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
package maven
import (
"fmt"
"os"
"regexp"
"strings"
)
var parentBlockRe = regexp.MustCompile(`(?s)<parent>.*?</parent>`)
var versionTagRe = regexp.MustCompile(`<version>([^<]+)</version>`)
// ReadVersion returns the project version from a pom.xml file.
// It ignores the <parent> block version and dependency versions.
func ReadVersion(pomPath string) (string, error) {
data, err := os.ReadFile(pomPath)
if err != nil {
return "", fmt.Errorf("read %s: %w", pomPath, err)
}
// Strip <parent>…</parent> before searching so we don't pick up the parent version.
stripped := parentBlockRe.ReplaceAllString(string(data), "")
m := versionTagRe.FindStringSubmatch(stripped)
if m == nil {
return "", fmt.Errorf("no <version> element found in %s", pomPath)
}
return strings.TrimSpace(m[1]), nil
}
// WriteVersion replaces the project version in a pom.xml file in-place.
// oldVersion must match what ReadVersion returned. The <parent> version is never touched.
func WriteVersion(pomPath, oldVersion, newVersion string) error {
data, err := os.ReadFile(pomPath)
if err != nil {
return fmt.Errorf("read %s: %w", pomPath, err)
}
updated := replaceProjectVersion(string(data), oldVersion, newVersion)
if updated == string(data) {
return fmt.Errorf("version %q not found in %s", oldVersion, pomPath)
}
return os.WriteFile(pomPath, []byte(updated), 0644)
}
// replaceProjectVersion replaces the first <version>oldVersion</version> that is not
// inside a <parent> block. This preserves the parent version and dependency versions.
func replaceProjectVersion(content, oldVersion, newVersion string) string {
target := "<version>" + oldVersion + "</version>"
replacement := "<version>" + newVersion + "</version>"
// If there is a <parent> block, start searching after it.
if idx := strings.Index(content, "</parent>"); idx >= 0 {
after := content[idx:]
if pos := strings.Index(after, target); pos >= 0 {
abs := idx + pos
return content[:abs] + replacement + content[abs+len(target):]
}
return content
}
return strings.Replace(content, target, replacement, 1)
}
+213
View File
@@ -0,0 +1,213 @@
package maven
import (
"os"
"path/filepath"
"strings"
"testing"
)
const simplePom = `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.2.3</version>
<name>My App</name>
</project>`
const pomWithParent = `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.2.3</version>
</project>`
const pomWithSnapshot = `<?xml version="1.0" encoding="UTF-8"?>
<project>
<artifactId>myapp</artifactId>
<version>1.2.4-SNAPSHOT</version>
</project>`
func writePom(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pom.xml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
func TestReadVersion(t *testing.T) {
cases := []struct {
name string
content string
want string
}{
{"simple pom", simplePom, "1.2.3"},
{"pom with parent", pomWithParent, "1.2.3"},
{"snapshot version", pomWithSnapshot, "1.2.4-SNAPSHOT"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ReadVersion(writePom(t, c.content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestReadVersionParentNotMatched(t *testing.T) {
// ReadVersion must return the project version, NOT the parent version (3.2.0).
got, err := ReadVersion(writePom(t, pomWithParent))
if err != nil {
t.Fatal(err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3 (parent version must be ignored)", got)
}
}
func TestWriteVersion(t *testing.T) {
cases := []struct {
name string
content string
oldVersion string
newVersion string
wantInFile string
}{
{"simple replace", simplePom, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
{"with parent", pomWithParent, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
{"snapshot to release", pomWithSnapshot, "1.2.4-SNAPSHOT", "1.2.4", "<version>1.2.4</version>"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
path := writePom(t, c.content)
if err := WriteVersion(path, c.oldVersion, c.newVersion); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
content := string(data)
if !contains(content, c.wantInFile) {
t.Errorf("expected %q in updated pom, got:\n%s", c.wantInFile, content)
}
// Parent version must be unchanged in the parent block case.
if c.name == "with parent" && !contains(content, "<version>3.2.0</version>") {
t.Error("parent version was modified")
}
})
}
}
func TestWriteVersionParentPreserved(t *testing.T) {
path := writePom(t, pomWithParent)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatal(err)
}
got, _ := ReadVersion(path)
if got != "1.2.4" {
t.Errorf("project version = %q, want 1.2.4", got)
}
data, _ := os.ReadFile(path)
if !contains(string(data), "<version>3.2.0</version>") {
t.Error("parent version was modified")
}
}
func TestReadVersionMissingFile(t *testing.T) {
_, err := ReadVersion(filepath.Join(t.TempDir(), "nonexistent.xml"))
if err == nil {
t.Error("expected error for missing pom file")
}
}
func TestReadVersionNoVersionTag(t *testing.T) {
_, err := ReadVersion(writePom(t, "<project><artifactId>app</artifactId></project>"))
if err == nil {
t.Error("expected error when no <version> tag present")
}
}
func TestWriteVersionMissingFile(t *testing.T) {
err := WriteVersion(filepath.Join(t.TempDir(), "nosuchfile.xml"), "1.0", "1.1")
if err == nil {
t.Error("expected error for missing pom file")
}
}
func TestWriteVersionNotFound(t *testing.T) {
err := WriteVersion(writePom(t, simplePom), "9.9.9", "9.9.10")
if err == nil {
t.Error("expected error when old version not found in pom")
}
}
func TestReplaceVersionParentNoMatch(t *testing.T) {
// Has </parent> but the target version only appears inside the parent block,
// not after it. replaceProjectVersion should return content unchanged.
content := `<project><parent><version>3.0.0</version></parent></project>`
result := replaceProjectVersion(content, "3.0.0", "4.0.0")
if result != content {
t.Errorf("expected content unchanged; got:\n%s", result)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
// FuzzReadVersion verifies that ReadVersion never panics on arbitrary file content.
func FuzzReadVersion(f *testing.F) {
f.Add(simplePom)
f.Add(pomWithParent)
f.Add(pomWithSnapshot)
f.Add("")
f.Add("<project></project>")
f.Add("<version>1.0</version>")
f.Add("\x00\x01\xff")
f.Fuzz(func(t *testing.T, content string) {
path := filepath.Join(t.TempDir(), "pom.xml")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
// Must not panic regardless of content
ReadVersion(path) //nolint:errcheck
})
}
// FuzzReplaceProjectVersion verifies the internal replacer is robust against arbitrary inputs.
func FuzzReplaceProjectVersion(f *testing.F) {
f.Add(simplePom, "1.2.3", "1.2.4")
f.Add(pomWithParent, "1.2.3", "2.0.0")
f.Add("", "1.0", "2.0")
f.Add("<version>1.0</version>", "1.0", "1.1")
f.Add("\x00", "", "1.0")
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
result := replaceProjectVersion(content, oldVersion, newVersion)
// Result must always be a string (no panic)
// If a replacement happened, oldVersion must not appear where newVersion was inserted
if oldVersion == newVersion {
return
}
if result != content {
// Replacement occurred — newVersion must appear in result
if !strings.Contains(result, "<version>"+newVersion+"</version>") {
t.Errorf("replacement happened but newVersion not found in result")
}
}
})
}