feat(releaser): add --verbose flag for configuration and decision tracing
ci / vet, staticcheck, test, build (push) Successful in 3m10s

- Prints a configuration table on startup showing each key, its value,
  and the source (default / config file / env: VARNAME / flag: --name)
- Lists every commit since the last tag with its parsed type and
  the version-bump decision (feat/fix/breaking → patch bump, or ignored)
- Explains the final version choice: highest commit type → next tag
- All verbose output goes to stderr so it never pollutes stdout captures
- Sources tracking wired through config.LoadWithSources and
  ApplyEnvWithSources; LoadWithSources uses a two-pass approach to
  detect which YAML fields were explicitly set vs defaulted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 11:35:22 +02:00
parent 5d0489dd71
commit 46a10c70dc
6 changed files with 246 additions and 11 deletions
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.1.1] - 2026-07-07
### Added
- **`--verbose` flag** — prints configuration table (each key, value, and source: `default` / `config file` / `env: VARNAME` / `flag: --name`), lists every commit since the last tag with its parsed type and version-bump decision, and explains the final version choice; output goes to stderr so it never pollutes scripts that capture stdout
## [1.1.0] - 2026-07-07 ## [1.1.0] - 2026-07-07
### Added ### Added
+4 -1
View File
@@ -1,6 +1,6 @@
# releaser # releaser
![release](https://img.shields.io/badge/release-v1.1.0-blue.svg) ![release](https://img.shields.io/badge/release-v1.1.1-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -64,6 +64,9 @@ releaser --branch release/1.2
# Write changelog to a custom file # Write changelog to a custom file
releaser --changelog-file CHANGES.md releaser --changelog-file CHANGES.md
# Show configuration sources, commit list, and version decision
releaser --verbose --dry-run
# Target a specific pom.xml # Target a specific pom.xml
releaser --pom path/to/pom.xml releaser --pom path/to/pom.xml
+1
View File
@@ -66,6 +66,7 @@
- [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos) - [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos)
- [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64) - [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64)
- [x] ~~`--verbose` flag~~ — ✓ shipped v1.1.1 (shows config sources, commit analysis, version decision)
- [ ] Documentation site - [ ] Documentation site
## Future / backlog ## Future / backlog
+102 -2
View File
@@ -72,6 +72,7 @@ var exitFn = os.Exit
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
var ( var (
init_ bool init_ bool
verbose bool
dryRun bool dryRun bool
noPush bool noPush bool
noRelease bool noRelease bool
@@ -97,6 +98,7 @@ func newRootCmd() *cobra.Command {
patternSet = cmd.Flags().Changed("branch-pattern") patternSet = cmd.Flags().Changed("branch-pattern")
return run(options{ return run(options{
init: init_, init: init_,
verbose: verbose,
repoPath: repoPath, repoPath: repoPath,
branchOverride: branchOverride, branchOverride: branchOverride,
pomOverride: pomOverride, pomOverride: pomOverride,
@@ -115,6 +117,7 @@ func newRootCmd() *cobra.Command {
} }
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit") root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit")
root.Flags().BoolVar(&verbose, "verbose", false, "print configuration sources, commit list, and version decision")
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes") root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release") root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release")
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release") root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
@@ -142,6 +145,7 @@ func main() {
type options struct { type options struct {
init bool init bool
verbose bool
repoPath string repoPath string
branchOverride string branchOverride string
pomOverride string pomOverride string
@@ -157,6 +161,40 @@ type options struct {
tagOnly bool tagOnly bool
} }
// vlog prints a verbose line to stderr, prefixed with "[verbose] ", when verbose is true.
func vlog(verbose bool, format string, args ...any) {
if verbose {
fmt.Fprintf(os.Stderr, "[verbose] "+format+"\n", args...)
}
}
func printVerboseConfig(cfg config.Config, src config.Sources) {
fmt.Fprintln(os.Stderr, "[verbose] configuration:")
rows := []struct{ key, val string }{
{"git.tag_prefix", cfg.Git.TagPrefix},
{"git.branch_pattern", cfg.Git.BranchPattern},
{"git.commit_message", cfg.Git.CommitMessage},
{"git.author_name", cfg.Git.AuthorName},
{"git.author_email", cfg.Git.AuthorEmail},
{"maven.pom_path", cfg.Maven.PomPath},
{"gitlab.url", cfg.GitLab.URL},
{"gitlab.token", func() string {
if cfg.GitLab.Token != "" {
return "(set)"
}
return "(not set)"
}()},
{"gitlab.project", cfg.GitLab.Project},
}
for _, r := range rows {
source := src[r.key]
if source == "" {
source = "default"
}
fmt.Fprintf(os.Stderr, " %-25s = %-40s [%s]\n", r.key, r.val, source)
}
}
func initConfig(absRepo string) error { func initConfig(absRepo string) error {
path := filepath.Join(absRepo, ".releaser.yml") path := filepath.Join(absRepo, ".releaser.yml")
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
@@ -177,24 +215,46 @@ func run(o options) error {
} }
if o.init { if o.init {
vlog(o.verbose, "creating .releaser.yml in %s", absRepo)
return initConfig(absRepo) return initConfig(absRepo)
} }
cfg, err := config.Load(absRepo) var (
cfg config.Config
src config.Sources
)
if o.verbose {
cfg, src, err = config.LoadWithSources(absRepo)
} else {
cfg, err = config.Load(absRepo)
}
if err != nil { if err != nil {
return err return err
} }
cfg.ApplyEnv() cfg.ApplyEnvWithSources(src)
// CLI flags take precedence over config file and env vars // CLI flags take precedence over config file and env vars
if o.tagPrefixSet { if o.tagPrefixSet {
cfg.Git.TagPrefix = o.tagPrefixFlag cfg.Git.TagPrefix = o.tagPrefixFlag
if src != nil {
src["git.tag_prefix"] = "flag: --tag-prefix"
}
} }
if o.pomOverride != "" { if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride cfg.Maven.PomPath = o.pomOverride
if src != nil {
src["maven.pom_path"] = "flag: --pom"
}
} }
if o.patternSet { if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag cfg.Git.BranchPattern = o.patternFlag
if src != nil {
src["git.branch_pattern"] = "flag: --branch-pattern"
}
}
if o.verbose {
printVerboseConfig(cfg, src)
} }
// --- Git --- // --- Git ---
@@ -217,6 +277,8 @@ func run(o options) error {
} }
info.TagPrefix = cfg.Git.TagPrefix info.TagPrefix = cfg.Git.TagPrefix
vlog(o.verbose, "branch: %s → major=%d, minor=%d (pinned by branch)", branchName, info.Major, info.Minor)
// --- Dirty check (before any changes) --- // --- Dirty check (before any changes) ---
// Skipped in --no-commit mode: the user intentionally has changes in flight. // Skipped in --no-commit mode: the user intentionally has changes in flight.
if !o.dryRun && !o.noCommit { if !o.dryRun && !o.noCommit {
@@ -235,6 +297,14 @@ func run(o options) error {
return fmt.Errorf("find latest tag: %w", err) return fmt.Errorf("find latest tag: %w", err)
} }
if o.verbose {
if lastTag == "" {
vlog(true, "last tag: none — scanning all commits")
} else {
vlog(true, "last tag: %s → current patch=%d", lastTag, currentPatch)
}
}
// --- Commit range --- // --- Commit range ---
var messages []string var messages []string
if lastTag == "" { if lastTag == "" {
@@ -256,6 +326,26 @@ func run(o options) error {
types[i] = commits.Parse(msg) types[i] = commits.Parse(msg)
} }
if o.verbose {
vlog(true, "commits analyzed (%d):", len(messages))
for i, msg := range messages {
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
t := types[i]
var decision string
switch t {
case commits.TypeBreaking:
decision = "breaking → patch bump"
case commits.TypeFeat:
decision = "feat → patch bump"
case commits.TypeFix:
decision = "fix → patch bump"
default:
decision = "ignored"
}
fmt.Fprintf(os.Stderr, " %-60s %s\n", first, decision)
}
}
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types) nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
if !ok { if !ok {
fmt.Fprintln(os.Stderr, "info: no releasable commits found") fmt.Fprintln(os.Stderr, "info: no releasable commits found")
@@ -264,6 +354,16 @@ func run(o options) error {
nextTag := info.TagName(nextVersion) nextTag := info.TagName(nextVersion)
if o.verbose {
highestType := commits.TypeNone
for _, t := range types {
if t > highestType {
highestType = t
}
}
vlog(true, "version decision: highest type=%s → next=%s (tag: %s)", highestType, nextVersion, nextTag)
}
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag) fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
if o.dryRun { if o.dryRun {
+44
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"errors" "errors"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -656,3 +657,46 @@ func TestRunChangelogFile(t *testing.T) {
t.Error("expected CHANGES.md to be created") t.Error("expected CHANGES.md to be created")
} }
} }
func TestRunVerbose(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// feat")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("feat: add new thing", &gogit.CommitOptions{Author: testSig()})
// Capture stderr output by redirecting it temporarily.
old := os.Stderr
r, wPipe, _ := os.Pipe()
os.Stderr = wPipe
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
wPipe.Close()
os.Stderr = old
rawBytes, _ := io.ReadAll(r)
output := string(rawBytes)
if err != nil {
t.Fatalf("--verbose: unexpected error: %v", err)
}
checks := []string{
"configuration:",
"git.tag_prefix",
"[default]",
"branch: release/1.2",
"major=1, minor=2",
"commits analyzed",
"feat: add new thing",
"feat → patch bump",
"version decision:",
}
for _, want := range checks {
if !strings.Contains(output, want) {
t.Errorf("--verbose output missing %q\nfull output:\n%s", want, output)
}
}
}
+89 -8
View File
@@ -50,42 +50,123 @@ func defaults() Config {
} }
} }
// Sources records where each config value came from.
// Keys are "section.field" (e.g. "git.tag_prefix").
// Values are one of: "default", "config file", "env: VARNAME", "flag: --flag-name".
type Sources map[string]string
func defaultSources() Sources {
return Sources{
"git.tag_prefix": "default",
"git.branch_pattern": "default",
"git.commit_message": "default",
"git.author_name": "default",
"git.author_email": "default",
"maven.pom_path": "default",
"gitlab.url": "default",
"gitlab.token": "default",
"gitlab.project": "default",
}
}
// Load reads .releaser.yml from dir and merges it over the defaults. // Load reads .releaser.yml from dir and merges it over the defaults.
// Missing file is not an error — defaults are returned as-is. // Missing file is not an error — defaults are returned as-is.
func Load(dir string) (Config, error) { func Load(dir string) (Config, error) {
cfg, _, err := LoadWithSources(dir)
return cfg, err
}
// LoadWithSources is like Load but also returns a Sources map recording where each
// value came from ("default" or "config file").
func LoadWithSources(dir string) (Config, Sources, error) {
cfg := defaults() cfg := defaults()
src := defaultSources()
data, err := os.ReadFile(filepath.Join(dir, filename)) data, err := os.ReadFile(filepath.Join(dir, filename))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return cfg, nil return cfg, src, nil
} }
if err != nil { if err != nil {
return cfg, fmt.Errorf("read %s: %w", filename, err) return cfg, src, fmt.Errorf("read %s: %w", filename, err)
} }
// Unmarshal into cfg (merges over defaults).
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse %s: %w", filename, err) return cfg, src, fmt.Errorf("parse %s: %w", filename, err)
} }
return cfg, nil // Detect which fields the file explicitly set by unmarshaling into a zero overlay.
var overlay Config
_ = yaml.Unmarshal(data, &overlay)
if overlay.Git.TagPrefix != "" {
src["git.tag_prefix"] = "config file"
}
if overlay.Git.BranchPattern != "" {
src["git.branch_pattern"] = "config file"
}
if overlay.Git.CommitMessage != "" {
src["git.commit_message"] = "config file"
}
if overlay.Git.AuthorName != "" {
src["git.author_name"] = "config file"
}
if overlay.Git.AuthorEmail != "" {
src["git.author_email"] = "config file"
}
if overlay.Maven.PomPath != "" {
src["maven.pom_path"] = "config file"
}
if overlay.GitLab.URL != "" {
src["gitlab.url"] = "config file"
}
if overlay.GitLab.Token != "" {
src["gitlab.token"] = "config file"
}
if overlay.GitLab.Project != "" {
src["gitlab.project"] = "config file"
}
return cfg, src, nil
} }
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables. // ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
// Values already set in the config file are never overwritten. // Values already set in the config file are never overwritten.
func (c *Config) ApplyEnv() { func (c *Config) ApplyEnv() {
c.ApplyEnvWithSources(nil)
}
// ApplyEnvWithSources is like ApplyEnv but records the env var name in src for each
// field it fills. src may be nil.
func (c *Config) ApplyEnvWithSources(src Sources) {
if c.GitLab.Token == "" { if c.GitLab.Token == "" {
c.GitLab.Token = os.Getenv("GITLAB_TOKEN") if v := os.Getenv("GITLAB_TOKEN"); v != "" {
c.GitLab.Token = v
if src != nil {
src["gitlab.token"] = "env: GITLAB_TOKEN"
}
}
} }
if c.GitLab.URL == "" { if c.GitLab.URL == "" {
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com") // CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
c.GitLab.URL = os.Getenv("CI_SERVER_URL") if v := os.Getenv("CI_SERVER_URL"); v != "" {
c.GitLab.URL = v
if src != nil {
src["gitlab.url"] = "env: CI_SERVER_URL"
}
}
} }
if c.GitLab.Project == "" { if c.GitLab.Project == "" {
// Prefer numeric ID; fall back to namespace/project path // Prefer numeric ID; fall back to namespace/project path
if id := os.Getenv("CI_PROJECT_ID"); id != "" { if id := os.Getenv("CI_PROJECT_ID"); id != "" {
c.GitLab.Project = id c.GitLab.Project = id
} else { if src != nil {
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH") src["gitlab.project"] = "env: CI_PROJECT_ID"
}
} else if p := os.Getenv("CI_PROJECT_PATH"); p != "" {
c.GitLab.Project = p
if src != nil {
src["gitlab.project"] = "env: CI_PROJECT_PATH"
}
} }
} }
} }