feat(releaser): add --verbose flag for configuration and decision tracing
ci / vet, staticcheck, test, build (push) Successful in 3m10s
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:
@@ -3,6 +3,12 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# releaser
|
||||
|
||||

|
||||

|
||||
|
||||
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
|
||||
releaser --changelog-file CHANGES.md
|
||||
|
||||
# Show configuration sources, commit list, and version decision
|
||||
releaser --verbose --dry-run
|
||||
|
||||
# Target a specific pom.xml
|
||||
releaser --pom path/to/pom.xml
|
||||
|
||||
|
||||
@@ -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] ~~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
|
||||
|
||||
## Future / backlog
|
||||
|
||||
+102
-2
@@ -72,6 +72,7 @@ var exitFn = os.Exit
|
||||
func newRootCmd() *cobra.Command {
|
||||
var (
|
||||
init_ bool
|
||||
verbose bool
|
||||
dryRun bool
|
||||
noPush bool
|
||||
noRelease bool
|
||||
@@ -97,6 +98,7 @@ func newRootCmd() *cobra.Command {
|
||||
patternSet = cmd.Flags().Changed("branch-pattern")
|
||||
return run(options{
|
||||
init: init_,
|
||||
verbose: verbose,
|
||||
repoPath: repoPath,
|
||||
branchOverride: branchOverride,
|
||||
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(&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(&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")
|
||||
@@ -142,6 +145,7 @@ func main() {
|
||||
|
||||
type options struct {
|
||||
init bool
|
||||
verbose bool
|
||||
repoPath string
|
||||
branchOverride string
|
||||
pomOverride string
|
||||
@@ -157,6 +161,40 @@ type options struct {
|
||||
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 {
|
||||
path := filepath.Join(absRepo, ".releaser.yml")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
@@ -177,24 +215,46 @@ func run(o options) error {
|
||||
}
|
||||
|
||||
if o.init {
|
||||
vlog(o.verbose, "creating .releaser.yml in %s", 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 {
|
||||
return err
|
||||
}
|
||||
cfg.ApplyEnv()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
// CLI flags take precedence over config file and env vars
|
||||
if o.tagPrefixSet {
|
||||
cfg.Git.TagPrefix = o.tagPrefixFlag
|
||||
if src != nil {
|
||||
src["git.tag_prefix"] = "flag: --tag-prefix"
|
||||
}
|
||||
}
|
||||
if o.pomOverride != "" {
|
||||
cfg.Maven.PomPath = o.pomOverride
|
||||
if src != nil {
|
||||
src["maven.pom_path"] = "flag: --pom"
|
||||
}
|
||||
}
|
||||
if o.patternSet {
|
||||
cfg.Git.BranchPattern = o.patternFlag
|
||||
if src != nil {
|
||||
src["git.branch_pattern"] = "flag: --branch-pattern"
|
||||
}
|
||||
}
|
||||
|
||||
if o.verbose {
|
||||
printVerboseConfig(cfg, src)
|
||||
}
|
||||
|
||||
// --- Git ---
|
||||
@@ -217,6 +277,8 @@ func run(o options) error {
|
||||
}
|
||||
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) ---
|
||||
// Skipped in --no-commit mode: the user intentionally has changes in flight.
|
||||
if !o.dryRun && !o.noCommit {
|
||||
@@ -235,6 +297,14 @@ func run(o options) error {
|
||||
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 ---
|
||||
var messages []string
|
||||
if lastTag == "" {
|
||||
@@ -256,6 +326,26 @@ func run(o options) error {
|
||||
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)
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
|
||||
@@ -264,6 +354,16 @@ func run(o options) error {
|
||||
|
||||
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)
|
||||
|
||||
if o.dryRun {
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -656,3 +657,46 @@ func TestRunChangelogFile(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
// Missing file is not an error — defaults are returned as-is.
|
||||
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()
|
||||
src := defaultSources()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return cfg, nil
|
||||
return cfg, src, 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 {
|
||||
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.
|
||||
// Values already set in the config file are never overwritten.
|
||||
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 == "" {
|
||||
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 == "" {
|
||||
// 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 == "" {
|
||||
// Prefer numeric ID; fall back to namespace/project path
|
||||
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
|
||||
c.GitLab.Project = id
|
||||
} else {
|
||||
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH")
|
||||
if src != nil {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user