7 Commits

Author SHA1 Message Date
k3nny 104167d7d2 docs(releaser): add documentation URL https://releaser.k3nny.fr
ci / vet, staticcheck, test, build (push) Successful in 3m12s
- README.md: add Documentation link below the release badge
- ROADMAP.md: update shipped docs-site entry with live URL
- docs/hugo.toml: set baseURL to the live site address

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 22:56:39 +02:00
k3nny 88ed87b095 build(ci): release v1.6.2 — staticcheck in task ci, SA4006 fix
ci / vet, staticcheck, test, build (push) Successful in 3m5s
ci / vet, staticcheck, test, build (pull_request) Failing after 12s
Add go tool staticcheck ./... to the task ci pipeline (between vet and
test), consistent with the Gitea CI workflow. Fix the SA4006 it surfaced
in TestLatestTagTagsIterFails where err from PlainOpen was assigned but
never read before being overwritten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 21:47:39 +02:00
k3nny 6381a6440b build(ci): add staticcheck to CI pipeline; fix SA4006 in gitutil test
ci / vet, staticcheck, test, build (push) Successful in 3m5s
Add `go tool staticcheck ./...` between vet and test in the `task ci`
pipeline. Fix the SA4006 finding it surfaced: the PlainOpen error in
TestLatestTagTagsIterFails was assigned but never read (the nil check was
lost when the skip logic was simplified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 21:45:49 +02:00
k3nny 4f933bf130 fix(ci): release v1.6.1 — skip chmod tests when running as root
ci / vet, staticcheck, test, build (push) Failing after 3m0s
Four tests relied on os.Chmod to force error paths but were failing in
Docker CI (golang:1.26-alpine runs as root, where chmod restrictions have
no effect). Added os.Getuid() == 0 skip guards to:
- internal/gradle: TestWriteVersionReadOnly
- internal/gitutil: TestLatestTagTagsIterFails (replaced broken skip logic
  that only fired if Chmod itself errored)
- cmd: TestInitConfigWriteFails, TestRunLatestTagFails

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 21:38:26 +02:00
k3nny a4cd00cc4d test(ci): skip chmod-dependent tests when running as root
ci / vet, staticcheck, test, build (push) Failing after 2m54s
Docker CI containers run as root by default (golang:1.26-alpine), where
os.Chmod has no effect and permission restrictions don't apply. Four
tests were failing because they relied on chmod to force error paths:

- gradle: TestWriteVersionReadOnly
- cmd:    TestInitConfigWriteFails, TestRunLatestTagFails
- gitutil: TestLatestTagTagsIterFails (had broken skip logic that only
  fired if Chmod itself errored, which never happens as root)

Replace all four with an upfront os.Getuid() == 0 check so they are
skipped cleanly in root environments and still run (and must pass) on
non-root local development.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 21:10:41 +02:00
k3nny dfdf2b019a 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>
2026-07-11 17:59:13 +02:00
k3nny e2d4214405 docs(releaser): release v1.5.1 — documentation site, fuzzing completeness
ci / vet, staticcheck, test, build (push) Failing after 3m50s
- Add Hugo + Geekdoc documentation site (docs/): installation, CLI
  reference, configuration, CI integration, and changelog pages; explicit
  menu bundle nav so all pages appear in the sidebar on every page
- Add Gitea CI workflow (.gitea/workflows/docs.yml): builds on push to
  main when docs/** changes, deploys minified site to gh-pages branch
- Add docs:setup / docs:serve / docs:build Taskfile tasks; theme bundle
  downloaded at build time (not committed)
- Add FuzzUpdate to internal/changelog and FuzzWriteVersion to
  internal/node to complete fuzz coverage for all file-rewriting packages
- Add fuzzing completeness guidelines to CLAUDE.md: authoritative table,
  exempt-package rationale, seed corpus rules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 17:47:26 +02:00
26 changed files with 1307 additions and 28 deletions
+67
View File
@@ -0,0 +1,67 @@
name: docs
on:
push:
branches:
- main
paths:
- 'docs/**'
- '.gitea/workflows/docs.yml'
vars:
HUGO_VERSION: "0.128.2"
GEEKDOC_VERSION: "v4.1.1"
jobs:
deploy:
name: Build and deploy docs
runs-on: ubuntu-latest
container:
image: alpine:latest
steps:
- name: Install tools
run: apk add --no-cache curl git tar
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
git clone --depth 1 \
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" .
- name: Install Hugo
env:
HUGO_VERSION: ${{ vars.HUGO_VERSION }}
run: |
curl -sSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz" \
| tar xz -C /usr/local/bin hugo
- name: Download Geekdoc theme
env:
GEEKDOC_VERSION: ${{ vars.GEEKDOC_VERSION }}
run: |
mkdir -p docs/themes/geekdoc
curl -sSL "https://github.com/thegeeklab/hugo-geekdoc/releases/download/${GEEKDOC_VERSION}/hugo-geekdoc.tar.gz" \
| tar xz -C docs/themes/geekdoc
- name: Build
run: hugo --source docs --destination public --minify
- name: Deploy to pages branch
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
run: |
cd docs/public
git init
git config user.email "ci@git.k3nny.fr"
git config user.name "Gitea CI"
git add .
git commit -m "deploy docs $(date +%Y-%m-%dT%H:%M:%SZ)"
git push --force \
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" \
HEAD:gh-pages
+4
View File
@@ -6,3 +6,7 @@ releaser-*
coverage.out
coverage.html
# docs build artifacts (downloaded at build time)
/docs/themes/
/docs/public/
+11
View File
@@ -50,6 +50,17 @@ git:
# - "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"
+38
View File
@@ -3,6 +3,44 @@
All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.6.2] - 2026-07-11
### Changed
- **`task ci` now runs `go tool staticcheck ./...`** — runs between `go vet` and `go test`; matches the step already present in the Gitea CI workflow
### Fixed
- **SA4006 in `TestLatestTagTagsIterFails`** — `err` from `gogit.PlainOpen` was assigned then immediately overwritten without being read; added the missing `if err != nil { t.Fatalf(...) }` check
## [1.6.1] - 2026-07-11
### Fixed
- **CI root-permission failures** — four tests that used `os.Chmod` to force error paths were failing in Docker CI (which runs as root, where chmod has no enforcement effect); each now skips with `os.Getuid() == 0`; the gitutil test had broken skip logic that only fired if `Chmod` itself errored — replaced with the same upfront UID check
## [1.6.0] - 2026-07-11
### Added
- **Gradle support** — new `internal/gradle` package; reads and writes the version assignment in `build.gradle` (Groovy DSL, single-quoted) and `build.gradle.kts` (Kotlin DSL, double-quoted); original quote style preserved on write; `regexp.QuoteMeta` ensures version strings with dots or special chars are safe
- **`gradle.build_file` / `gradle.build_files` config** — opt-in, no default; `build_files` list overrides `build_file` for multi-module projects; follows the same multi-value pattern as `maven.pom_paths` and `node.package_jsons`
- **`--gradle <path>` flag** — overrides `gradle.build_file` from config and clears `gradle.build_files`; mirrored in verbose config table as `gradle.paths`
- **`FuzzReadVersion` / `FuzzWriteVersion`** in `internal/gradle` — 100% per-package statement coverage maintained across all 13 packages
## [1.5.1] - 2026-07-11
### Added
- **Documentation site** — Hugo + Geekdoc theme; content covers installation, CLI reference, configuration, CI integration, and changelog; deployed to `gh-pages` via Gitea CI on push to `main`
- **`docs:setup` / `docs:serve` / `docs:build` Taskfile tasks** — `docs:setup` downloads the Geekdoc theme bundle (idempotent); `docs:serve` runs Hugo with live reload; `docs:build` produces a minified static site
- **`FuzzUpdate`** in `internal/changelog` — fuzzes arbitrary existing file content paired with a commit message, covering the `\n## [` insertion logic and idempotency guard
- **`FuzzWriteVersion`** in `internal/node` — mirrors `FuzzReplaceProjectVersion` in `internal/maven`; fuzzes arbitrary JSON content with arbitrary old/new version strings
### Changed
- **CLAUDE.md** — new "Fuzzing" section: authoritative table of which packages require fuzz tests and why, list of exempt packages with rationale, seed corpus guidelines
## [1.5.0] - 2026-07-11
### Added
+97
View File
@@ -0,0 +1,97 @@
# CLAUDE.md — project guidelines for releaser
## Overview
`releaser` is a single-binary Go tool for GitFlow-based release automation. It targets Conventional Commits, versioned release branches (`release/X.Y`), and GitLab / GitHub release creation.
## Architecture
```
cmd/main.go — CLI entrypoint (cobra), run() pipeline, verbose output
internal/branch/ — branch name parser → major/minor
internal/changelog/ — CHANGELOG.md writer
internal/commits/ — Conventional Commits parser (non-strict)
internal/config/ — .releaser.yml loader + env var overlay + source tracking
internal/ghclient/ — minimal GitHub Releases API client
internal/gitutil/ — go-git helpers: tag discovery, commit walker, push
internal/glclient/ — minimal GitLab Releases API client
internal/maven/ — pom.xml version reader/writer
internal/node/ — package.json version reader/writer
internal/notes/ — release notes body generator
internal/version/ — semver next-version calculator
```
## Code conventions
- **No third-party test frameworks** — stdlib `testing` only.
- **No interfaces for mocking** — inject function variables (`var absPath = filepath.Abs`) to test error paths.
- **No comments explaining what** — only comments explaining *why* (hidden constraints, invariants, non-obvious workarounds).
- **No error handling for impossible paths** — trust internal invariants; only validate at system boundaries.
- **No abstractions ahead of need** — three similar lines beats a premature helper.
## Test coverage
**100% per-package statement coverage is required** across all packages. Run:
```bash
go test ./... -cover
```
Every package must show `coverage: 100.0% of statements`.
Strategies used in this project:
- **Error path injection**: override `var absPath`, `var gitAllCommits`, etc. to return injected errors.
- **Filesystem tricks**: `os.Mkdir` where a file is expected (invisible to go-git dirty check; fails os.WriteFile/os.ReadFile); `os.Chmod(..., 0444)` to make files read-only.
- **Null byte paths**: `"path\x00name"` causes `os.Stat` to return `EINVAL` (not `ErrNotExist`), useful for testing stat-error paths that differ from file-not-found.
- **In-memory git repos**: use go-git `PlainInit` + local bare remote for push tests.
- **Direct function calls**: call unexported helpers (e.g. `printVerboseConfig`) directly with crafted inputs to cover branches that are dead via normal CLI flow.
## Fuzzing
**Every package that parses free-form text or reads/writes arbitrary file content must have at least one fuzz test.** Run the full seed corpus with:
```bash
go test -run='^Fuzz' ./...
```
All seed cases must pass. The table below is authoritative — keep it in sync when adding packages or parsers:
| Package | Fuzz target(s) | Why |
|---------|---------------|-----|
| `internal/branch` | `FuzzParse` | parses branch name strings |
| `internal/changelog` | `FuzzUpdate` | rewrites arbitrary existing file content |
| `internal/commits` | `FuzzParse` | parses arbitrary commit message strings |
| `internal/gradle` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary Gradle build file content |
| `internal/glclient` | `FuzzEncodeProjectPath` | encodes arbitrary project path strings |
| `internal/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content |
| `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content |
| `internal/notes` | `FuzzGenerate` | generates notes from arbitrary commit messages |
Packages **not** requiring fuzz tests (no free-form text parsing): `internal/config` (yaml.v3 handles parsing), `internal/ghclient` (HTTP client, no text parsing), `internal/gitutil` (git operations), `internal/version` (typed inputs only), `cmd` (CLI orchestration). When adding a new package, check whether it parses text or rewrites files — if yes, add a row above.
Fuzz seed corpus guidelines:
- Include a realistic happy-path input as the first seed.
- Include empty string, binary/non-UTF-8 bytes (`"\x00\xff"`), and inputs that stress known branches (e.g. existing `## [version]` heading for changelog).
- The fuzz body must never assert on return values — only verify no panic.
## Dependency rules
- **No new external dependencies** unless absolutely necessary. The project deliberately avoids pulling in large ecosystems.
- go-git (`github.com/go-git/go-git/v5`) for all git operations.
- cobra for CLI parsing.
- gopkg.in/yaml.v3 for config.
## Config design
- New config fields go in the appropriate `*Config` struct in `internal/config/config.go`.
- `defaultSources()` must be updated to include every new key.
- `LoadWithSources` overlay detection must cover every new field.
- `printVerboseConfig` in `cmd/main.go` must show every new config value.
## Multi-value config pattern
When a config supports both a single value and multiple values (like `pom_path` / `pom_paths`):
- Single field: `PomPath string`
- Multi field: `PomPaths []string`
- `EffectivePomPaths()` method: `PomPaths` wins if non-empty, else `PomPath` if set, else default.
- `--pom` CLI flag clears `PomPaths` and sets `PomPath` only.
+10 -2
View File
@@ -1,9 +1,11 @@
# releaser
![release](https://img.shields.io/badge/release-v1.5.0-blue.svg)
![release](https://img.shields.io/badge/release-v1.6.2-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
**[Documentation](https://releaser.k3nny.fr)**
## Problem
Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration.
@@ -23,7 +25,7 @@ release/1.2 branch
2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch
3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
4. **Version bump** — increments patch (the minor is owned by the branch)
5. **Release** — updates `pom.xml`, commits, tags, creates GitLab or GitHub release
5. **Release** — updates `pom.xml` / `package.json` / `build.gradle`, commits, tags, creates GitLab or GitHub release
## Version bump rules
@@ -115,6 +117,12 @@ node: # opt-in — no default; om
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gradle: # opt-in — no default; omit to skip
# build_file: "build.gradle" # Groovy or Kotlin DSL; single path
# build_files: # multi-module: list overrides build_file
# - "build.gradle"
# - "module-a/build.gradle"
gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # env GITLAB_TOKEN (never commit this)
+2 -3
View File
@@ -74,7 +74,7 @@
- [x] ~~GitHub release support~~ — ✓ shipped v1.4.0 (`internal/ghclient`, `GITHUB_TOKEN` env, `github.token`/`github.repo` config; GitHub takes precedence over GitLab)
- [x] ~~SSH agent push~~ — ✓ shipped v1.4.0 (go-git `gitssh.NewSSHAgentAuth` for `git@`/`ssh://` remotes)
- [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release)
- [ ] Documentation site
- [x] ~~Documentation site~~ — ✓ shipped v1.5.1 (Hugo + Geekdoc; installation, CLI reference, configuration, CI integration pages; live at https://releaser.k3nny.fr)
## v1.5 — Multi-module, Node.js, configurable bump rules ✅
@@ -85,6 +85,5 @@
## Future / backlog
- Gradle support (`build.gradle` / `build.gradle.kts`)
- ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag)
- Slack / Teams notification on release
- Documentation site
+23 -1
View File
@@ -4,6 +4,7 @@ vars:
BIN: ./bin/releaser
PKG: ./...
FUZZ_TIME: 30s
GEEKDOC_VERSION: "v4.1.1"
tasks:
default:
@@ -76,7 +77,7 @@ tasks:
- go test -run='^Fuzz' {{.PKG}}
ci:
desc: Full CI pipeline — tidy check, vet, test
desc: Full CI pipeline — tidy check, vet, staticcheck, test
cmds:
- task: tidy
- |
@@ -85,6 +86,7 @@ tasks:
exit 1
fi
- task: lint
- go tool staticcheck ./...
- task: test
clean:
@@ -105,3 +107,23 @@ tasks:
TAG: '{{.TAG | default "releaser:dev"}}'
cmds:
- docker run --rm {{.TAG}} {{.CLI_ARGS}}
docs:setup:
desc: Download Geekdoc theme into docs/themes/geekdoc/
cmds:
- mkdir -p docs/themes/geekdoc
- curl -sSL "https://github.com/thegeeklab/hugo-geekdoc/releases/download/{{.GEEKDOC_VERSION}}/hugo-geekdoc.tar.gz" | tar xz -C docs/themes/geekdoc
status:
- test -f docs/themes/geekdoc/theme.toml
docs:serve:
desc: Serve docs locally with live reload (requires hugo)
deps: [docs:setup]
cmds:
- hugo server --source docs
docs:build:
desc: Build docs to docs/public/ (requires hugo)
deps: [docs:setup]
cmds:
- hugo --source docs --destination public --minify
+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 {
+122 -1
View File
@@ -852,6 +852,9 @@ func TestPrintVerboseConfigDirect(t *testing.T) {
// ── initConfig coverage ───────────────────────────────────────────────────────
func TestInitConfigWriteFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping: chmod restrictions do not apply when running as root")
}
dir := t.TempDir()
os.Chmod(dir, 0555)
defer os.Chmod(dir, 0755)
@@ -904,6 +907,9 @@ func TestRunWorkingTreeCheckFails(t *testing.T) {
}
func TestRunLatestTagFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping: chmod restrictions do not apply when running as root")
}
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
@@ -1093,7 +1099,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 +1119,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 +1210,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")
}
}
View File
+24
View File
@@ -0,0 +1,24 @@
---
title: releaser
---
**CI-friendly release automation for GitFlow workflows using Conventional Commits.**
Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration.
`releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` / `package.json` update to GitLab/GitHub tag and release creation.
## How it works
```
release/1.2 branch
└─ last tag: 1.2.3 (or none → start at 1.2.0)
└─ commits since tag → Conventional Commits analysis
└─ next version: 1.2.4
```
1. **Branch parsing** — extracts `major.minor` from branch name (`release/1.2``1.2`)
2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch
3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
4. **Version bump** — increments patch (or minor, if configured via `bump_rules`)
5. **Release** — updates `pom.xml` / `package.json`, commits, tags, creates GitLab or GitHub release
+76
View File
@@ -0,0 +1,76 @@
---
title: Changelog
weight: 50
---
## v1.6.0 — 2026-07-11
### Added
- **Gradle support** — opt-in via `gradle.build_file` (single path) or `gradle.build_files` (list, overrides single); supports both Groovy DSL (`version = '1.2.3'`) and Kotlin DSL (`version = "1.2.3"`); original quote style preserved on write; `--gradle <path>` CLI flag for one-off overrides
## v1.5.1 — 2026-07-11
### Added
- **Documentation site** — Hugo + Geekdoc; installation, CLI reference, configuration, CI integration, and changelog pages; deployed to `gh-pages` via Gitea CI on push to `main`
- **`FuzzUpdate`** in `internal/changelog` and **`FuzzWriteVersion`** in `internal/node` — complete fuzz coverage for all file-rewriting packages
### Changed
- **CLAUDE.md** — fuzzing completeness guidelines with authoritative table
## v1.5.0 — 2026-07-11
### Added
- **Multi-module Maven support** — `maven.pom_paths: [...]` lists multiple `pom.xml` paths; overrides `pom_path`; each path is updated and committed in the same release commit
- **Node.js `package.json` support** — opt-in via `node.package_json` (single path) or `node.package_jsons` (list); version bumped in-place alongside `pom.xml` and `CHANGELOG.md`
- **`git.bump_rules` config** — controls which version component each commit type bumps: `breaking`, `feat`, `fix` each accept `"patch"` (default) or `"minor"`
- **100% per-package statement coverage** across all 12 packages via injectable function vars
### Changed
- **`--pom` flag** now clears `maven.pom_paths` before setting `maven.pom_path`
- **`version.Next()` signature** — accepts a bump-rules map as a sixth parameter; `nil` defaults to all-patch
- **Verbose config table** — now includes `git.bump_rules.*`, `maven.pom_paths`, and `node.paths` rows
## v1.4.0 — 2026-07-11
### Added
- **GitHub release support** — `internal/ghclient` package; configured via `github.token` + `github.repo`; GitHub takes precedence over GitLab when both are configured
- **SSH agent push** — go-git `gitssh.NewSSHAgentAuth` for `git@` / `ssh://` remotes
- **`--release-env-file` flag** — override dotenv artifact path; pass `""` to disable
- **`git.releasable_types` config** — opt-in list of commit types that count as releasable
- **CHANGELOG deduplication guard** — `changelog.Update()` is idempotent; skips write if section already exists
## v1.3.0 — 2026-07-07
### Added
- **`release.env` dotenv artifact** — written on every real release containing `NEXT_VERSION=<tag>`; never committed; exposes the version to downstream GitLab CI jobs
## v1.2.0 — 2026-07-07
### Added
- **`--verbose` flag** — prints config table, commit list with parsed types, and version decision
- **Colored, structured CLI output** — `·` / `✓` / `!` prefix symbols; TTY-aware ANSI colors; respects `NO_COLOR` and `TERM=dumb`
- **Name and version header** on every invocation
### Changed
- **Default `tag_prefix` is now empty** — bare version numbers (`1.2.3`) by default; add `tag_prefix: "v"` to opt in
## v1.1.0 — 2026-07-07
### Added
- **CHANGELOG.md auto-update** — new dated section written on every release, grouped by commit type
- **`--changelog-file` flag** — override changelog path
- **`--init` flag** — scaffolds a fully-commented `.releaser.yml`
## v1.0 and earlier
See the [full CHANGELOG](https://git.k3nny.fr/releaser/src/branch/main/CHANGELOG.md) in the repository.
+109
View File
@@ -0,0 +1,109 @@
---
title: CI Integration
weight: 40
---
## GitLab CI
The simplest setup uses the reusable job template shipped alongside `releaser`:
```yaml
# .gitlab-ci.yml
include:
- project: releaser/releaser
file: .releaser.gitlab-ci.yml
release:
extends: .releaser
variables:
GITLAB_TOKEN: $RELEASE_TOKEN # project/group variable with api + write_repository scope
```
Or write it inline:
```yaml
release:
stage: release
image: registry.example.com/releaser:latest
rules:
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
variables:
GITLAB_TOKEN: $RELEASE_TOKEN
script:
- releaser
artifacts:
reports:
dotenv: release.env # exposes NEXT_VERSION to downstream jobs
```
### Consuming `NEXT_VERSION` downstream
The `release.env` dotenv artifact exports `NEXT_VERSION=<tag>` automatically. Downstream jobs can use it:
```yaml
deploy:
stage: deploy
needs:
- job: release
artifacts: true
script:
- echo "Deploying version $NEXT_VERSION"
```
Disable the dotenv artifact (e.g. for local runs):
```bash
releaser --release-env-file ""
```
Write it to a custom path:
```bash
releaser --release-env-file deploy/version.env
```
## GitHub Actions / Gitea Actions
```yaml
name: release
on:
push:
branches:
- 'release/**'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history needed for tag discovery
- name: Run releaser
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
curl -sSL https://git.k3nny.fr/releaser/releases/latest/download/releaser-linux-amd64 \
-o /usr/local/bin/releaser
chmod +x /usr/local/bin/releaser
releaser
```
{{< hint warning >}}
`fetch-depth: 0` is required. A shallow clone (`--depth 1`) hides the previous tag, causing `releaser` to treat every commit as the first release.
{{< /hint >}}
## Detached HEAD
In CI environments where `git checkout` leaves the repository in detached HEAD state, pass the branch name explicitly:
```yaml
script:
- releaser --branch "$CI_COMMIT_BRANCH"
```
## SSH push
When pushing over SSH (`git@host:...` or `ssh://...` remotes), `releaser` attempts go-git SSH agent auth automatically — no extra configuration needed as long as the CI runner has an SSH agent socket available.
For HTTPS remotes without a token, `releaser` delegates to the system `git` binary so credential helpers and `netrc` work as expected.
+150
View File
@@ -0,0 +1,150 @@
---
title: Configuration
weight: 30
---
`releaser` reads `.releaser.yml` from the repository root. All fields are optional — missing values fall back to the defaults shown below. Run `releaser --init` to scaffold the file with annotations.
## Full reference
```yaml
git:
tag_prefix: "" # default: no prefix; "v" for v1.2.3 style
branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor
commit_message: "chore(release): {version} [skip ci]"
author_name: "" # defaults to git config user.name
author_email: "" # defaults to git config user.email
# Limit which commit types trigger a release (default: all three).
releasable_types:
- fix
- feat
- breaking
# Control which version component each commit type bumps.
# Valid values: "patch" (default) or "minor".
bump_rules:
breaking: "patch"
feat: "patch"
fix: "patch"
maven:
pom_path: "pom.xml" # single pom.xml, relative to repo root
# Multi-module: list overrides pom_path.
# pom_paths:
# - "pom.xml"
# - "module-a/pom.xml"
# - "module-b/pom.xml"
node: # opt-in — omit section to skip
# package_json: "package.json" # single path
# Monorepo: list overrides package_json.
# package_jsons:
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gradle: # opt-in — omit section to skip
# build_file: "build.gradle" # Groovy or Kotlin DSL; single path
# Multi-module: list overrides build_file.
# build_files:
# - "build.gradle"
# - "module-a/build.gradle"
gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # prefer env GITLAB_TOKEN
project: "" # prefer env CI_PROJECT_ID or CI_PROJECT_PATH
github:
token: "" # prefer env GITHUB_TOKEN
repo: "" # "owner/repo" format
```
{{< hint info >}}
When both `github.*` and `gitlab.*` are configured, GitHub takes precedence.
{{< /hint >}}
## Environment variables
| Variable | Used for |
|----------|----------|
| `GITLAB_TOKEN` | GitLab API auth + HTTPS push auth |
| `CI_SERVER_URL` | GitLab instance URL |
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
| `GITHUB_TOKEN` | GitHub API auth |
## Config sources
Run `releaser --verbose --dry-run` to see every config key, its resolved value, and where it came from (`default` / `config file` / `env: VARNAME` / `flag: --name`).
## `git.releasable_types`
By default `fix`, `feat`, and `breaking` commits all trigger a release. Use `releasable_types` to restrict this — for example, on a maintenance branch where you want only bug fixes to release:
```yaml
git:
releasable_types:
- fix
```
## `git.bump_rules`
By default every releasable commit bumps the **patch** component. The `bump_rules` map lets you promote specific types to bump **minor** instead. This is useful on a branch that manages its own minor versioning:
```yaml
git:
bump_rules:
feat: "minor" # feat: commits bump minor, not patch
breaking: "minor" # breaking changes bump minor too
fix: "patch" # fix: stays patch (this is the default)
```
## Multi-module Maven
`pom_paths` accepts a list and overrides `pom_path`. All listed files are updated and committed in the same release commit:
```yaml
maven:
pom_paths:
- "pom.xml"
- "module-a/pom.xml"
- "module-b/pom.xml"
```
The `--pom` CLI flag sets a single path and clears `pom_paths`.
## Node.js support
The `node` section is opt-in — if omitted, no `package.json` is touched. Use `package_jsons` for monorepos:
```yaml
node:
package_jsons:
- "packages/frontend/package.json"
- "packages/backend/package.json"
```
## Gradle support
The `gradle` section is opt-in — if omitted, no build file is touched. Both Groovy DSL (`version = '1.2.3'`) and Kotlin DSL (`version = "1.2.3"`) are supported; the original quote style is preserved on write.
```yaml
gradle:
build_file: "build.gradle"
```
Use `build_files` for multi-module projects:
```yaml
gradle:
build_files:
- "build.gradle"
- "module-a/build.gradle"
- "module-b/build.gradle"
```
The `--gradle <path>` CLI flag sets a single build file path and clears `build_files`.
+59
View File
@@ -0,0 +1,59 @@
---
title: Installation
weight: 10
---
## Pre-built binaries
Download the latest release for your platform from the [Releases page](https://git.k3nny.fr/releaser/releases).
```bash
# Linux (amd64)
curl -sSL https://git.k3nny.fr/releaser/releases/download/v1.5.0/releaser-v1.5.0-linux-amd64 \
-o /usr/local/bin/releaser
chmod +x /usr/local/bin/releaser
```
Available platforms: `linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`, `windows-amd64.exe`.
## Docker
```bash
docker pull git.k3nny.fr/releaser/releaser:latest
# Run in the current repository
docker run --rm \
-v "$PWD:/repo" \
-e GITLAB_TOKEN="$GITLAB_TOKEN" \
git.k3nny.fr/releaser/releaser:latest
```
## Build from source
Requires Go 1.21+.
```bash
git clone https://git.k3nny.fr/releaser/releaser.git
cd releaser
go build -o /usr/local/bin/releaser ./cmd
```
## Verify
```bash
releaser --version
```
## First run
Scaffold a default `.releaser.yml` in your repository root:
```bash
releaser --init
```
Then do a dry run to check the version that would be produced:
```bash
releaser --dry-run
```
+70
View File
@@ -0,0 +1,70 @@
---
title: CLI Reference
weight: 20
---
## Common workflows
```bash
# Scaffold a default .releaser.yml
releaser --init
# Preview next version (no side effects)
releaser --dry-run
# Full release: bump versions, commit, tag, push, create release
releaser
# Commit and tag locally — skip push and release creation
releaser --no-push
# Push commit and tag but skip creating the release
releaser --no-release
# Update files but stop before committing
releaser --no-commit
# ... review changes, then commit manually and re-run:
releaser --tag-only
# Verbose mode: show config sources, commit analysis, version decision
releaser --verbose --dry-run
```
## Flags
| Flag | Default | Description |
|------|---------|-------------|
| `--dry-run` | false | Print next version and exit without making any changes |
| `--branch <name>` | auto-detected | Override branch name (useful in detached HEAD / CI) |
| `--branch-pattern <regex>` | `^(?:.*/)?release/(\d+)\.(\d+)$` | Override branch pattern (two capture groups: major, minor) |
| `--tag-prefix <prefix>` | `""` | Prefix for version tags (e.g. `v``v1.2.3`) |
| `--pom <path>` | `pom.xml` | Path to pom.xml relative to repo root |
| `--gradle <path>` | — | Override `gradle.build_file` from config |
| `--changelog-file <path>` | `CHANGELOG.md` | Path to changelog file |
| `--release-env-file <path>` | `release.env` | Path for dotenv artifact; pass `""` to disable |
| `--no-commit` | false | Update version files but stop before committing |
| `--no-push` | false | Commit and tag locally, skip push and release |
| `--no-release` | false | Push branch and tag but skip release creation |
| `--tag-only` | false | Skip version file updates — tag HEAD and push |
| `--init` | false | Scaffold a default `.releaser.yml` and exit |
| `--verbose` | false | Print config table, commit analysis, and version decision |
## Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Error (config, git, API, etc.) |
| `2` | No releasable commits found — nothing to do |
## Version bump rules
By default all releasable commits bump the **patch** component (minor is pinned to the branch). Override per commit type via `git.bump_rules` in `.releaser.yml`:
| Commit type | Default | Configurable via `bump_rules` |
|-------------|---------|-------------------------------|
| `fix:` | patch | `fix: "minor"` to bump minor |
| `feat:` | patch | `feat: "minor"` to bump minor |
| `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor |
| `chore:`, `docs:`, etc. | none | — |
| unparseable message | none | non-strict: silently ignored |
+16
View File
@@ -0,0 +1,16 @@
main:
- name: Installation
ref: /installation
weight: 10
- name: CLI Reference
ref: /usage
weight: 20
- name: Configuration
ref: /configuration
weight: 30
- name: CI Integration
ref: /ci-integration
weight: 40
- name: Changelog
ref: /changelog
weight: 50
+21
View File
@@ -0,0 +1,21 @@
baseURL = "https://releaser.k3nny.fr/"
title = "releaser"
theme = "geekdoc"
pygmentsUseClasses = true
pygmentsCodeFences = true
[markup]
[markup.goldmark.renderer]
unsafe = true
[markup.tableOfContents]
startLevel = 1
endLevel = 9
[params]
geekdocRepo = "https://git.k3nny.fr/releaser"
geekdocEditPath = "edit/main/docs/content"
geekdocSearch = true
geekdocMenuBundle = true
geekdocBreadcrumb = false
geekdocToC = true
+19
View File
@@ -149,3 +149,22 @@ func TestUpdateIdempotent(t *testing.T) {
t.Error("version heading should appear exactly once after idempotent call")
}
}
// FuzzUpdate verifies Update never panics on arbitrary existing file content or commit messages.
func FuzzUpdate(f *testing.F) {
f.Add("", "feat: add thing")
f.Add("# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n- something\n", "fix: something")
f.Add("some preamble\n", "feat!: breaking change")
f.Add("\n## [2.0.0] - 2026-01-01\n", "feat: another thing")
f.Add("", "chore: no release")
f.Add("", "")
f.Fuzz(func(t *testing.T, existing, message string) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
if existing != "" {
os.WriteFile(path, []byte(existing), 0644) //nolint:errcheck
}
Update(path, "v1.0.0", "1.0.0", []string{message}) //nolint:errcheck
})
}
+26
View File
@@ -17,6 +17,7 @@ type Config struct {
Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"`
Node NodeConfig `yaml:"node"`
Gradle GradleConfig `yaml:"gradle"`
GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"`
}
@@ -73,6 +74,23 @@ func (n NodeConfig) EffectivePaths() []string {
return nil
}
type GradleConfig struct {
BuildFile string `yaml:"build_file"` // single path (opt-in, no default)
BuildFiles []string `yaml:"build_files"` // multiple paths; overrides BuildFile
}
// EffectiveBuildFiles returns the list of Gradle build file paths to process.
// Returns nil when no gradle paths are configured (gradle processing is opt-in).
func (g GradleConfig) EffectiveBuildFiles() []string {
if len(g.BuildFiles) > 0 {
return g.BuildFiles
}
if g.BuildFile != "" {
return []string{g.BuildFile}
}
return nil
}
type GitLabConfig struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
@@ -117,6 +135,8 @@ func defaultSources() Sources {
"maven.pom_paths": "default",
"node.package_json": "default",
"node.package_jsons": "default",
"gradle.build_file": "default",
"gradle.build_files": "default",
"gitlab.url": "default",
"gitlab.token": "default",
"gitlab.project": "default",
@@ -193,6 +213,12 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if len(overlay.Node.PackageJSONs) > 0 {
src["node.package_jsons"] = "config file"
}
if overlay.Gradle.BuildFile != "" {
src["gradle.build_file"] = "config file"
}
if len(overlay.Gradle.BuildFiles) > 0 {
src["gradle.build_files"] = "config file"
}
if overlay.GitLab.URL != "" {
src["gitlab.url"] = "config file"
}
+46
View File
@@ -292,6 +292,52 @@ func TestApplyEnvWithSourcesCIServerURL(t *testing.T) {
}
}
func TestGradleEffectiveBuildFiles(t *testing.T) {
// Neither set → nil (opt-in)
if got := (GradleConfig{}).EffectiveBuildFiles(); got != nil {
t.Errorf("empty config: got %v, want nil", got)
}
// BuildFile only
if got := (GradleConfig{BuildFile: "build.gradle"}).EffectiveBuildFiles(); len(got) != 1 || got[0] != "build.gradle" {
t.Errorf("BuildFile only: got %v", got)
}
// BuildFiles wins over BuildFile
g := GradleConfig{BuildFile: "build.gradle", BuildFiles: []string{"a/build.gradle", "b/build.gradle"}}
if got := g.EffectiveBuildFiles(); len(got) != 2 || got[0] != "a/build.gradle" {
t.Errorf("BuildFiles priority: got %v", got)
}
}
func TestLoadGradleSources(t *testing.T) {
dir := t.TempDir()
content := "gradle:\n build_file: \"build.gradle\"\n"
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, src, err := LoadWithSources(dir)
if err != nil {
t.Fatal(err)
}
if src["gradle.build_file"] != "config file" {
t.Errorf("src[gradle.build_file] = %q, want %q", src["gradle.build_file"], "config file")
}
}
func TestLoadGradleBuildFilesSources(t *testing.T) {
dir := t.TempDir()
content := "gradle:\n build_files:\n - \"a/build.gradle\"\n - \"b/build.gradle\"\n"
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, src, err := LoadWithSources(dir)
if err != nil {
t.Fatal(err)
}
if src["gradle.build_files"] != "config file" {
t.Errorf("src[gradle.build_files] = %q, want %q", src["gradle.build_files"], "config file")
}
}
func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default
+7 -5
View File
@@ -752,19 +752,21 @@ func TestLatestTagTagsIterFails(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
if os.Getuid() == 0 {
t.Skip("skipping: chmod restrictions do not apply when running as root")
}
// Make .git/refs/tags/ unreadable so that go-git's walkReferencesTree
// returns EPERM when it tries to list the directory, triggering the
// Tags() error path. Skip when running as root (chmod has no effect).
// Tags() error path.
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
if err := os.Chmod(tagsDir, 0000); err != nil {
t.Skipf("cannot chmod %s: %v", tagsDir, err)
}
os.Chmod(tagsDir, 0000)
t.Cleanup(func() { os.Chmod(tagsDir, 0755) })
// Reopen so the filesystem storer holds no cached state.
repo2, err := gogit.PlainOpen(dir)
if err != nil {
t.Skipf("PlainOpen failed (likely running as root): %v", err)
t.Fatalf("PlainOpen: %v", err)
}
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
+54
View File
@@ -0,0 +1,54 @@
package gradle
import (
"fmt"
"os"
"regexp"
)
// versionRe matches a Gradle/Kotlin DSL version assignment on its own line.
// Group 1 captures the version string (without quotes).
// Handles both double-quoted (Kotlin/Groovy) and single-quoted (Groovy) forms,
// with or without spaces around =.
var versionRe = regexp.MustCompile(`(?m)^[ \t]*version\s*=\s*["']([^"']+)["']`)
// ReadVersion returns the version value from a build.gradle or build.gradle.kts file.
func ReadVersion(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
m := versionRe.FindStringSubmatch(string(data))
if m == nil {
return "", fmt.Errorf("no version assignment found in %s", path)
}
return m[1], nil
}
// WriteVersion replaces the version assignment in a build.gradle or build.gradle.kts
// file in-place. oldVersion must match what ReadVersion returned. Quote style is preserved.
func WriteVersion(path, oldVersion, newVersion string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
updated, ok := replaceVersion(string(data), oldVersion, newVersion)
if !ok {
return fmt.Errorf("version %q not found in %s", oldVersion, path)
}
return os.WriteFile(path, []byte(updated), 0644)
}
// replaceVersion finds and replaces the first version assignment line in a Gradle build file.
// Quote style (single or double) of the original line is preserved.
// Returns the updated content and true if a replacement was made.
func replaceVersion(content, oldVersion, newVersion string) (string, bool) {
re := regexp.MustCompile(`(?m)^([ \t]*version\s*=\s*)(["'])` + regexp.QuoteMeta(oldVersion) + `["']`)
m := re.FindStringSubmatchIndex(content)
if m == nil {
return content, false
}
prefix := content[m[2]:m[3]] // "version = " etc., preserving whitespace
quote := content[m[4]:m[5]] // " or '
return content[:m[0]] + prefix + quote + newVersion + quote + content[m[1]:], true
}
+183
View File
@@ -0,0 +1,183 @@
package gradle
import (
"os"
"path/filepath"
"strings"
"testing"
)
const gradleGroovy = `plugins {
id 'java'
}
group = 'com.example'
version = '1.2.3'
description = 'My project'
`
const gradleKotlin = `plugins {
kotlin("jvm") version "1.9.0"
}
group = "com.example"
version = "1.2.3"
description = "My project"
`
func writeGradle(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "build.gradle")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
// ── ReadVersion ──────────────────────────────────────────────────────────────
func TestReadVersionGroovy(t *testing.T) {
got, err := ReadVersion(writeGradle(t, gradleGroovy))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionKotlin(t *testing.T) {
got, err := ReadVersion(writeGradle(t, gradleKotlin))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionNoSpaces(t *testing.T) {
got, err := ReadVersion(writeGradle(t, `version="1.0.0"`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.0.0" {
t.Errorf("got %q, want 1.0.0", got)
}
}
func TestReadVersionMissingFile(t *testing.T) {
_, err := ReadVersion(filepath.Join(t.TempDir(), "build.gradle"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestReadVersionNoVersionField(t *testing.T) {
_, err := ReadVersion(writeGradle(t, `group = "com.example"`))
if err == nil {
t.Error("expected error when no version assignment is present")
}
}
// ── WriteVersion ─────────────────────────────────────────────────────────────
func TestWriteVersionKotlin(t *testing.T) {
path := writeGradle(t, gradleKotlin)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), `version = "1.2.4"`) {
t.Errorf("expected updated version; got:\n%s", data)
}
// plugin version declaration must not be touched
if !strings.Contains(string(data), `kotlin("jvm") version "1.9.0"`) {
t.Error("plugin version was incorrectly modified")
}
}
func TestWriteVersionGroovy(t *testing.T) {
path := writeGradle(t, gradleGroovy)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), `version = '1.2.4'`) {
t.Errorf("expected updated version with single quotes; got:\n%s", data)
}
}
func TestWriteVersionMissingFile(t *testing.T) {
err := WriteVersion(filepath.Join(t.TempDir(), "build.gradle"), "1.0.0", "1.0.1")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestWriteVersionNotFound(t *testing.T) {
path := writeGradle(t, gradleKotlin)
err := WriteVersion(path, "9.9.9", "9.9.10")
if err == nil {
t.Error("expected error when old version not found in file")
}
}
func TestWriteVersionReadOnly(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping: chmod restrictions do not apply when running as root")
}
path := writeGradle(t, gradleKotlin)
os.Chmod(path, 0444)
defer os.Chmod(path, 0644)
err := WriteVersion(path, "1.2.3", "1.2.4")
if err == nil {
t.Error("expected error writing to read-only file")
}
}
// ── replaceVersion ────────────────────────────────────────────────────────────
func TestReplaceVersionNotFound(t *testing.T) {
content := `group = "com.example"`
got, ok := replaceVersion(content, "1.0.0", "1.0.1")
if ok {
t.Error("expected ok=false when version not present")
}
if got != content {
t.Error("content should be unchanged when not found")
}
}
// ── fuzz ──────────────────────────────────────────────────────────────────────
// FuzzReadVersion verifies ReadVersion never panics on arbitrary file content.
func FuzzReadVersion(f *testing.F) {
f.Add(gradleGroovy)
f.Add(gradleKotlin)
f.Add(`version="1.0.0"`)
f.Add(`group = "com.example"`)
f.Add("")
f.Add("\x00\xff")
f.Fuzz(func(t *testing.T, content string) {
path := filepath.Join(t.TempDir(), "build.gradle")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
ReadVersion(path) //nolint:errcheck
})
}
// FuzzWriteVersion verifies WriteVersion never panics on arbitrary content or version strings.
func FuzzWriteVersion(f *testing.F) {
f.Add(gradleGroovy, "1.2.3", "1.2.4")
f.Add(gradleKotlin, "1.2.3", "1.2.4")
f.Add(`version="1.0.0"`, "1.0.0", "1.0.1")
f.Add("", "1.0.0", "1.0.1")
f.Add(`version = "1.0.0"`, "", "1.0.1")
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
path := filepath.Join(t.TempDir(), "build.gradle")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
WriteVersion(path, oldVersion, newVersion) //nolint:errcheck
})
}
+15
View File
@@ -82,6 +82,21 @@ func TestWriteVersionNotFound(t *testing.T) {
}
}
// FuzzWriteVersion verifies WriteVersion never panics on arbitrary content or version strings.
func FuzzWriteVersion(f *testing.F) {
f.Add(simplePackage, "1.2.3", "1.2.4")
f.Add(`{"version":"0.0.1"}`, "0.0.1", "0.0.2")
f.Add(`{}`, "1.0.0", "1.0.1")
f.Add("", "1.0.0", "1.0.1")
f.Add(`{"name":"app","version":"1.0.0","version":"dup"}`, "1.0.0", "1.0.1")
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
path := filepath.Join(t.TempDir(), "package.json")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
WriteVersion(path, oldVersion, newVersion) //nolint:errcheck
})
}
// FuzzReadVersion verifies ReadVersion never panics on arbitrary content.
func FuzzReadVersion(f *testing.F) {
f.Add(simplePackage)