7 Commits

Author SHA1 Message Date
k3nny 62a702fb89 feat(releaser): release v1.3.0 — release.env dotenv artifact
ci / vet, staticcheck, test, build (push) Successful in 2m48s
release / Build and publish release (push) Successful in 4m35s
Documents the release.env feature: a NEXT_VERSION=<tag> file written
to the repo root on every real release, intended as a GitLab CI dotenv
artifact for passing the version to downstream pipeline jobs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 13:57:06 +02:00
k3nny 2614f23856 feat(releaser): write release.env dotenv artifact on every release
ci / vet, staticcheck, test, build (push) Successful in 3m9s
After the next version is determined and dry-run is confirmed off,
release.env is written to the repository root:

  NEXT_VERSION=<nextTag>

This file is not committed — it is left as an untracked artifact so
GitLab CI can expose it as a dotenv artifact and pass NEXT_VERSION to
downstream jobs (e.g. deploy, notify).

The file is skipped in --dry-run mode. Two tests added:
TestRunReleaseEnv and TestRunReleaseEnvDryRun.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 13:54:38 +02:00
k3nny 12cb3a71af feat(releaser): release v1.2.0 — verbose, colored output, no-v default
ci / vet, staticcheck, test, build (push) Successful in 3m18s
release / Build and publish release (push) Successful in 4m46s
- --verbose flag: config source table, per-commit type analysis, version
  decision explanation; output always to stderr
- Colored structured output: logStep/logDone/logWarn symbols, ▸ verbose
  section headers, TTY-aware ANSI colors (NO_COLOR / TERM=dumb respected)
- Name + version header printed at the start of every invocation
- Default tag_prefix changed from "v" to "" (bare 1.2.3 tags by default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 12:01:51 +02:00
k3nny 16b25da396 feat(config): change default tag_prefix to empty (no prefix)
ci / vet, staticcheck, test, build (push) Successful in 3m35s
Tags are now bare version numbers by default (e.g. 1.2.3).
Set tag_prefix: "v" in .releaser.yml or pass --tag-prefix v to opt in
to the v-prefixed convention.

Updated all affected tests, the .releaser.yml template comment, and
the README configuration reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:58:29 +02:00
k3nny 6984fcc547 feat(ui): print releaser name and version header on every run
ci / vet, staticcheck, test, build (push) Successful in 3m28s
Adds a logHeader() helper that prints "releaser  v<version>" to stderr
at the start of every invocation, before any other output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:53:22 +02:00
k3nny 153d65bc53 feat(ui): add colored, structured CLI output
ci / vet, staticcheck, test, build (push) Successful in 3m15s
Replace flat "info:" / "warning:" stderr lines with:
- logStep (·), logDone (✓), logWarn (!) prefix symbols
- ANSI colors when stderr is a TTY; auto-disabled via NO_COLOR or TERM=dumb
- Verbose mode uses ▸ section headers (configuration / branch / commits / version)
- Config table source tags colored: dim=[default], bold=[config file],
  cyan=[env:], green=[flag:]
- Commit table in verbose mode: type column colored by kind (cyan=feat,
  green=fix, red=breaking), ignored commits dimmed
- New cmd/ui.go holds all color helpers (paint, logStep, logDone, logWarn,
  logSection, fmtSource); removes the vlog() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:46:42 +02:00
k3nny 46a10c70dc 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>
2026-07-07 11:35:22 +02:00
8 changed files with 433 additions and 41 deletions
+18
View File
@@ -3,6 +3,24 @@
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.3.0] - 2026-07-07
### Added
- **`release.env` dotenv artifact** — on every real release (not `--dry-run`) a `release.env` file is written to the repository root containing `NEXT_VERSION=<tag>`; the file is never committed, allowing GitLab CI to expose it as a dotenv artifact and pass the version to downstream jobs (deploy, notify, etc.)
## [1.2.0] - 2026-07-07
### Added
- **`--verbose` flag** — prints a configuration table (each key, its value, and source: `default` / `config file` / `env: VARNAME` / `flag: --name`), lists every commit since the last tag with its parsed type and bump decision, and shows the final version choice; all output goes to stderr
- **Colored, structured CLI output** — progress lines use `·` / `✓` / `!` prefix symbols; `--verbose` mode uses `▸` section headers (configuration / branch / commits / version); commit type column colored by kind (cyan=feat, green=fix, red=breaking); config source tags colored; respects `NO_COLOR` and `TERM=dumb`; auto-disabled when stderr is not a TTY
- **Name and version header** — `releaser vX.Y.Z` printed to stderr at the start of every invocation
### Changed
- **Default `tag_prefix` is now empty** — tags are bare version numbers (`1.2.3`) by default; add `tag_prefix: "v"` to `.releaser.yml` or pass `--tag-prefix v` to opt in to the `v`-prefixed convention
## [1.1.0] - 2026-07-07 ## [1.1.0] - 2026-07-07
### Added ### Added
+5 -2
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.3.0-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
@@ -80,7 +83,7 @@ releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
```yaml ```yaml
git: git:
tag_prefix: "v" # set to "" for tags without prefix tag_prefix: "" # default: no prefix; set to "v" for v-prefixed tags
branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor
commit_message: "chore(release): {version} [skip ci]" commit_message: "chore(release): {version} [skip ci]"
author_name: "" # defaults to git config user.name author_name: "" # defaults to git config user.name
+5
View File
@@ -66,6 +66,11 @@
- [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.2.0 (shows config sources, commit analysis, version decision)
- [x] ~~Colored, structured CLI output~~ — ✓ shipped v1.2.0 (`·` / `` / `!` symbols, `` section headers in verbose, TTY-aware ANSI colors)
- [x] ~~Name and version header on every run~~ — ✓ shipped v1.2.0
- [x] ~~Default tag prefix changed to empty~~ — ✓ shipped v1.2.0 (bare `1.2.3` tags by default; opt in to `v` prefix via config)
- [x] ~~`release.env` dotenv artifact~~ — ✓ shipped v1.3.0 (`NEXT_VERSION=<tag>` written on every release for GitLab CI downstream jobs)
- [ ] Documentation site - [ ] Documentation site
## Future / backlog ## Future / backlog
+140 -17
View File
@@ -27,7 +27,7 @@ const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.f
# CLI flags always take precedence over values set here. # CLI flags always take precedence over values set here.
git: git:
# Prefix prepended to every version tag. # Prefix prepended to every version tag (default: no prefix).
# tag_prefix: "v" # tag_prefix: "v"
# Regex that identifies release branches. Must contain exactly two capture # Regex that identifies release branches. Must contain exactly two capture
@@ -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,37 @@ type options struct {
tagOnly bool tagOnly bool
} }
func printVerboseConfig(cfg config.Config, src config.Sources) {
logSection("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"
}
val := r.val
if val == "" {
val = paint(ansiDim, "(empty)")
}
fmt.Fprintf(os.Stderr, " %-25s = %-45s %s\n", r.key, val, fmtSource(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 {
@@ -170,6 +205,8 @@ func initConfig(absRepo string) error {
} }
func run(o options) error { func run(o options) error {
logHeader(version)
// --- Config --- // --- Config ---
absRepo, err := filepath.Abs(o.repoPath) absRepo, err := filepath.Abs(o.repoPath)
if err != nil { if err != nil {
@@ -177,24 +214,48 @@ func run(o options) error {
} }
if o.init { if o.init {
if o.verbose {
logStep("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 +278,13 @@ func run(o options) error {
} }
info.TagPrefix = cfg.Git.TagPrefix info.TagPrefix = cfg.Git.TagPrefix
if o.verbose {
logSection("branch")
fmt.Fprintf(os.Stderr, " %s → major=%d, minor=%d %s\n",
paint(ansiBold, branchName), info.Major, info.Minor,
paint(ansiDim, "(pinned by branch)"))
}
// --- 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 {
@@ -238,17 +306,21 @@ func run(o options) error {
// --- Commit range --- // --- Commit range ---
var messages []string var messages []string
if lastTag == "" { if lastTag == "" {
fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits")
messages, err = gitutil.AllCommits(repo) messages, err = gitutil.AllCommits(repo)
} else { } else {
fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag)
messages, err = gitutil.CommitsSince(repo, lastTag) messages, err = gitutil.CommitsSince(repo, lastTag)
} }
if err != nil { if err != nil {
return fmt.Errorf("read commits: %w", err) return fmt.Errorf("read commits: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: %d commit(s) to analyze\n", len(messages)) if !o.verbose {
if lastTag == "" {
logStep("no previous tag — scanning all %d commit(s)", len(messages))
} else {
logStep("last tag: %s (%d commit(s) to analyze)", lastTag, len(messages))
}
}
// --- Version calculation --- // --- Version calculation ---
types := make([]commits.Type, len(messages)) types := make([]commits.Type, len(messages))
@@ -256,21 +328,72 @@ func run(o options) error {
types[i] = commits.Parse(msg) types[i] = commits.Parse(msg)
} }
if o.verbose {
logSection(fmt.Sprintf("commits (%d)", len(messages)))
if lastTag != "" {
fmt.Fprintf(os.Stderr, " since: %s (patch=%d)\n", paint(ansiCyan, lastTag), currentPatch)
}
for i, msg := range messages {
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
if len(first) > 70 {
first = first[:67] + "..."
}
t := types[i]
typeLabel := fmt.Sprintf("%-9s", t.String())
if t == commits.TypeNone {
fmt.Fprintf(os.Stderr, " %s\n", paint(ansiDim, typeLabel+first))
} else {
var col string
switch t {
case commits.TypeBreaking:
col = ansiRed + ansiBold
case commits.TypeFeat:
col = ansiCyan
default: // fix
col = ansiGreen
}
fmt.Fprintf(os.Stderr, " %s %s %s\n",
paint(col, typeLabel), first, paint(ansiDim, "→ patch bump"))
}
}
}
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") logWarn("no releasable commits found")
return errNothingToRelease return errNothingToRelease
} }
nextTag := info.TagName(nextVersion) nextTag := info.TagName(nextVersion)
if o.verbose {
highestType := commits.TypeNone
for _, t := range types {
if t > highestType {
highestType = t
}
}
logSection("version")
fmt.Fprintf(os.Stderr, " highest type: %s → next: %s (tag: %s)\n",
paint(ansiCyan, highestType.String()),
paint(ansiBold, nextVersion),
paint(ansiBold+ansiCyan, 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 {
fmt.Fprintln(os.Stderr, "dry-run: no changes made") logStep("dry-run: no changes made")
return nil return nil
} }
// --- release.env (GitLab CI dotenv artifact) ---
releaseEnvPath := filepath.Join(absRepo, "release.env")
if err := os.WriteFile(releaseEnvPath, []byte("NEXT_VERSION="+nextTag+"\n"), 0644); err != nil {
return fmt.Errorf("write release.env: %w", err)
}
logDone("release.env: NEXT_VERSION=%s", nextTag)
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) --- // --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
if !o.tagOnly { if !o.tagOnly {
var filesToCommit []string var filesToCommit []string
@@ -287,13 +410,13 @@ func run(o options) error {
if err != nil { if err != nil {
return fmt.Errorf("read pom version: %w", err) return fmt.Errorf("read pom version: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion)
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err) return fmt.Errorf("update pom version: %w", err)
} }
logDone("pom.xml: %s → %s", currentPomVersion, nextVersion)
filesToCommit = append(filesToCommit, cfg.Maven.PomPath) filesToCommit = append(filesToCommit, cfg.Maven.PomPath)
} else { } else {
fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump") logWarn("no pom.xml — skipping version bump")
} }
// CHANGELOG.md // CHANGELOG.md
@@ -301,7 +424,7 @@ func run(o options) error {
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
return fmt.Errorf("update changelog: %w", err) return fmt.Errorf("update changelog: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: %s updated\n", o.changelogFile) logDone("%s updated", o.changelogFile)
filesToCommit = append(filesToCommit, o.changelogFile) filesToCommit = append(filesToCommit, o.changelogFile)
if o.noCommit { if o.noCommit {
@@ -321,14 +444,14 @@ func run(o options) error {
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil { if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
return fmt.Errorf("commit: %w", err) return fmt.Errorf("commit: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg) logDone("committed: %s", commitMsg)
} }
// --- Git tag --- // --- Git tag ---
if err := gitutil.CreateTag(repo, nextTag); err != nil { if err := gitutil.CreateTag(repo, nextTag); err != nil {
return fmt.Errorf("create tag: %w", err) return fmt.Errorf("create tag: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag) logDone("tag: %s", nextTag)
if o.noPush { if o.noPush {
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag) fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
@@ -336,11 +459,11 @@ func run(o options) error {
} }
// --- Push --- // --- Push ---
fmt.Fprintln(os.Stderr, "info: pushing commit and tag...") logStep("pushing commit and tag...")
if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil { if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil {
return fmt.Errorf("push: %w", err) return fmt.Errorf("push: %w", err)
} }
fmt.Fprintln(os.Stderr, "info: pushed") logDone("pushed")
// --- GitLab release --- // --- GitLab release ---
if o.noRelease { if o.noRelease {
@@ -348,7 +471,7 @@ func run(o options) error {
return nil return nil
} }
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" { if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation") logWarn("GitLab URL or project not configured — skipping release creation")
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
@@ -363,7 +486,7 @@ func run(o options) error {
return fmt.Errorf("create GitLab release: %w", err) return fmt.Errorf("create GitLab release: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag) logDone("GitLab release created: %s", nextTag)
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
+97 -11
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"
@@ -227,9 +228,9 @@ func TestRunMissingPom(t *testing.T) {
// Tag must still have been created. // Tag must still have been created.
repo2, _ := gogit.PlainOpen(dir) repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v1.2.0") _, err = repo2.Tag("1.2.0")
if err != nil { if err != nil {
t.Error("expected tag v1.2.0 to be created") t.Error("expected tag 1.2.0 to be created")
} }
} }
@@ -249,9 +250,9 @@ func TestRunNoPomAtDefaultPath(t *testing.T) {
} }
repo2, _ := gogit.PlainOpen(dir) repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v2.0.0") _, err = repo2.Tag("2.0.0")
if err != nil { if err != nil {
t.Error("expected tag v2.0.0 to be created") t.Error("expected tag 2.0.0 to be created")
} }
} }
@@ -365,12 +366,12 @@ func TestRunDuplicateTag(t *testing.T) {
w.Add("x.go") w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Pre-create a v1.2.0 ref pointing to a garbage hash. // Pre-create a 1.2.0 ref pointing to a garbage hash.
// LatestTag skips it (resolveTagToCommit fails for garbage hash), // LatestTag skips it (resolveTagToCommit fails for garbage hash),
// so run() calculates "v1.2.0" as the first-ever version — then // so run() calculates "1.2.0" as the first-ever version — then
// CreateTag("v1.2.0") fails because the ref already exists. // CreateTag("1.2.0") fails because the ref already exists.
fakeRef := plumbing.NewHashReference( fakeRef := plumbing.NewHashReference(
plumbing.NewTagReferenceName("v1.2.0"), plumbing.NewTagReferenceName("1.2.0"),
plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
) )
if err := repo.Storer.SetReference(fakeRef); err != nil { if err := repo.Storer.SetReference(fakeRef); err != nil {
@@ -379,7 +380,7 @@ func TestRunDuplicateTag(t *testing.T) {
err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir) err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir)
if err == nil { if err == nil {
t.Fatal("expected error: v1.2.0 ref already exists") t.Fatal("expected error: 1.2.0 ref already exists")
} }
} }
@@ -430,9 +431,9 @@ func TestRunGitLabError(t *testing.T) {
func TestRunWithPreviousTag(t *testing.T) { func TestRunWithPreviousTag(t *testing.T) {
repo, dir := setupRepo(t) repo, dir := setupRepo(t)
// Tag the initial commit as v1.2.0 (simulates a prior release) // Tag the initial commit as 1.2.0 (simulates a prior release)
initialHead, _ := repo.Head() initialHead, _ := repo.Head()
repo.CreateTag("v1.2.0", initialHead.Hash(), nil) repo.CreateTag("1.2.0", initialHead.Hash(), nil)
// Fix commit after the tag — run() will use CommitsSince, not AllCommits // Fix commit after the tag — run() will use CommitsSince, not AllCommits
addFile(t, dir, "x.go", "// fix") addFile(t, dir, "x.go", "// fix")
@@ -656,3 +657,88 @@ func TestRunChangelogFile(t *testing.T) {
t.Error("expected CHANGES.md to be created") t.Error("expected CHANGES.md to be created")
} }
} }
func TestRunReleaseEnv(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, "release.env"))
if err != nil {
t.Fatal("expected release.env to be created")
}
content := strings.TrimSpace(string(data))
if content != "NEXT_VERSION=1.2.0" {
t.Errorf("release.env content = %q, want %q", content, "NEXT_VERSION=1.2.0")
}
}
func TestRunReleaseEnvDryRun(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "release.env")); err == nil {
t.Error("release.env must not be created in --dry-run mode")
}
}
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",
"feat: add new thing",
"patch bump",
"▸ version",
}
for _, want := range checks {
if !strings.Contains(output, want) {
t.Errorf("--verbose output missing %q\nfull output:\n%s", want, output)
}
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"fmt"
"os"
"strings"
)
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiCyan = "\033[36m"
)
var useColor bool
func init() {
fi, err := os.Stderr.Stat()
tty := err == nil && (fi.Mode()&os.ModeCharDevice) != 0
useColor = tty && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
}
func paint(code, s string) string {
if !useColor {
return s
}
return code + s + ansiReset
}
// logStep writes a neutral progress line to stderr.
func logStep(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiDim, "·"), msg)
}
// logDone writes a success completion line to stderr.
func logDone(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiGreen+ansiBold, "✓"), msg)
}
// logWarn writes a warning line to stderr.
func logWarn(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiYellow, "!"), msg)
}
// logHeader prints the tool name and version banner to stderr.
func logHeader(ver string) {
fmt.Fprintf(os.Stderr, "%s %s\n",
paint(ansiBold, "releaser"),
paint(ansiDim, "v"+ver))
}
// logSection writes a bold section header to stderr (used in verbose mode).
func logSection(title string) {
fmt.Fprintf(os.Stderr, "\n%s\n", paint(ansiBold, "▸ "+title))
}
// fmtSource returns a colored "[source]" tag for a config key source.
func fmtSource(src string) string {
switch {
case strings.HasPrefix(src, "env:"):
return paint(ansiCyan, "["+src+"]")
case strings.HasPrefix(src, "flag:"):
return paint(ansiGreen, "["+src+"]")
case src == "default":
return paint(ansiDim, "[default]")
default: // "config file"
return paint(ansiBold, "["+src+"]")
}
}
+90 -9
View File
@@ -40,7 +40,7 @@ type GitLabConfig struct {
func defaults() Config { func defaults() Config {
return Config{ return Config{
Git: GitConfig{ Git: GitConfig{
TagPrefix: "v", TagPrefix: "",
BranchPattern: branch.DefaultBranchPattern, BranchPattern: branch.DefaultBranchPattern,
CommitMessage: "chore(release): {version} [skip ci]", CommitMessage: "chore(release): {version} [skip ci]",
}, },
@@ -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"
}
} }
} }
} }
+2 -2
View File
@@ -11,8 +11,8 @@ func TestLoadDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.Git.TagPrefix != "v" { if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v") t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
} }
if cfg.Maven.PomPath != "pom.xml" { if cfg.Maven.PomPath != "pom.xml" {
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml") t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")