8 Commits

Author SHA1 Message Date
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
k3nny 5d0489dd71 feat(releaser): CHANGELOG auto-update, --init, and --changelog-file flags
ci / vet, staticcheck, test, build (push) Successful in 3m30s
release / Build and publish release (push) Successful in 4m39s
- Automatically write/update CHANGELOG.md on every release, grouped by
  Breaking Changes / Added / Fixed; file is created if missing and the
  new section is committed alongside pom.xml in the release commit
- Add --init flag: scaffolds a default .releaser.yml in the repo root
- Add --changelog-file flag: override the default CHANGELOG.md path
- Add CommitFiles() to gitutil so pom.xml and CHANGELOG.md are staged
  in a single commit
- Fix HTTPS push when no token is set: delegate to the git CLI so that
  system credential helpers, SSH agents, and netrc are honoured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:18:27 +02:00
k3nny 6ffe282105 fix(gitutil): fall back to git CLI for push when no token is set
ci / vet, staticcheck, test, build (push) Successful in 4m9s
release / Build and publish release (push) Successful in 5m28s
go-git's HTTPS transport does not use the system credential store,
so pushes to remotes that require credentials fail silently or with
"authentication required" when no token is provided. When token is
empty, delegate to the system git binary so that credential helpers,
SSH agents, and netrc all work as expected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 01:02:37 +02:00
k3nny 0dc6d0747d feat(releaser): add --no-release flag to skip GitLab release creation
ci / vet, staticcheck, test, build (push) Successful in 3m18s
release / Build and publish release (push) Successful in 4m16s
Pushes the commit and tag as normal but exits before calling the GitLab
API. Useful when the project is hosted on a non-GitLab forge (e.g.
Gitea) where the release is handled by a separate CI workflow triggered
on the tag push.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 00:52:11 +02:00
11 changed files with 851 additions and 75 deletions
+25
View File
@@ -3,6 +3,31 @@
All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [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
### Added
- **CHANGELOG.md auto-update** — every release now writes a new dated section to `CHANGELOG.md` (grouped by Breaking Changes / Added / Fixed), committed alongside `pom.xml` in the release commit; file is created if it does not exist
- **`--changelog-file` flag** — override the default `CHANGELOG.md` path (e.g. `--changelog-file CHANGES.md`)
- **`--init` flag** — scaffolds a fully-commented default `.releaser.yml` in the repository root; errors if the file already exists
- **`CommitFiles`** in `gitutil` — internal helper that stages multiple files before a single commit, used to bundle `pom.xml` + `CHANGELOG.md` in one release commit
### Fixed
- **Push without token** — go-git's HTTPS transport does not use the system credential store; when `GITLAB_TOKEN` is unset the push now delegates to the `git` CLI so credential helpers, SSH agents, and `netrc` all work as expected
## [0.4.2] - 2026-07-07
### Fixed
+16 -4
View File
@@ -1,6 +1,6 @@
# releaser
![release](https://img.shields.io/badge/release-v0.4.2-blue.svg)
![release](https://img.shields.io/badge/release-v1.2.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -38,16 +38,22 @@ release/1.2 branch
## Usage
```bash
# Scaffold a default .releaser.yml in the current repository
releaser --init
# Simulate next version (no side effects)
releaser --dry-run
# Full release: bump pom.xml, commit, tag, push, GitLab release
# Full release: update pom.xml + CHANGELOG.md, commit, tag, push, GitLab release
releaser
# Commit and tag locally — skip push and GitLab release
releaser --no-push
# Update pom.xml but stop before committing (review first)
# Push commit and tag but skip creating the GitLab release
releaser --no-release
# Update files but stop before committing (review first)
releaser --no-commit
# … then commit manually and re-run:
releaser --tag-only
@@ -55,6 +61,12 @@ releaser --tag-only
# Explicitly target a branch (useful in detached HEAD CI)
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
@@ -71,7 +83,7 @@ releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
```yaml
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
commit_message: "chore(release): {version} [skip ci]"
author_name: "" # defaults to git config user.name
+9 -3
View File
@@ -55,15 +55,21 @@
- [x] Gitea release workflow (5-platform cross-compilation, release asset upload)
- [x] 96% test coverage with real in-memory git repos and fuzz tests for all parsers
## v0.5 — Changelog
## v0.5 — Changelog
- [ ] `CHANGELOG.md` generation / append (grouped by commit type)
- [ ] `--changelog-file` flag
- [x] `CHANGELOG.md` generation / append (grouped by commit type: Breaking Changes / Added / Fixed)
- [x] `--changelog-file` flag to use a custom filename
- [x] `--init` flag to scaffold a default `.releaser.yml`
- [x] Push falls back to system `git` CLI when no token is set (uses credential helpers, SSH, netrc)
## v1.0 — Production ready
- [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.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)
- [ ] Documentation site
## Future / backlog
+234 -35
View File
@@ -12,6 +12,7 @@ import (
"github.com/spf13/cobra"
"git.k3nny.fr/releaser/internal/branch"
"git.k3nny.fr/releaser/internal/changelog"
"git.k3nny.fr/releaser/internal/commits"
"git.k3nny.fr/releaser/internal/config"
"git.k3nny.fr/releaser/internal/glclient"
@@ -21,6 +22,45 @@ import (
semver "git.k3nny.fr/releaser/internal/version"
)
const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.fr/releaser
# All fields are optional. Uncomment and adjust what you need.
# CLI flags always take precedence over values set here.
git:
# Prefix prepended to every version tag (default: no prefix).
# tag_prefix: "v"
# Regex that identifies release branches. Must contain exactly two capture
# groups: group 1 = major version, group 2 = minor version.
# branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$"
# Template for the version-bump commit message.
# {version} is replaced with the full tag name (e.g. "v1.2.3").
# commit_message: "chore(release): {version} [skip ci]"
# Override the git commit author. When omitted, releaser reads user.name
# and user.email from the repository's git config.
# author_name: ""
# author_email: ""
maven:
# Path to pom.xml, relative to the repository root.
# pom_path: "pom.xml"
gitlab:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com"
# Personal or CI access token with api scope.
# Falls back to the GITLAB_TOKEN environment variable.
# Tip: never commit a real token here — use the environment variable instead.
# token: ""
# Numeric project ID or "namespace/project" path.
# Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables.
# project: ""
`
var (
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
errNothingToRelease = errors.New("nothing to release")
@@ -31,13 +71,17 @@ var exitFn = os.Exit
func newRootCmd() *cobra.Command {
var (
init_ bool
verbose bool
dryRun bool
noPush bool
noRelease bool
noCommit bool
tagOnly bool
branchOverride string
repoPath string
pomOverride string
changelogFile string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
@@ -53,28 +97,36 @@ func newRootCmd() *cobra.Command {
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
patternSet = cmd.Flags().Changed("branch-pattern")
return run(options{
init: init_,
verbose: verbose,
repoPath: repoPath,
branchOverride: branchOverride,
pomOverride: pomOverride,
changelogFile: changelogFile,
tagPrefixFlag: tagPrefixFlag,
tagPrefixSet: tagPrefixSet,
patternFlag: patternFlag,
patternSet: patternSet,
dryRun: dryRun,
noPush: noPush,
noRelease: noRelease,
noCommit: noCommit,
tagOnly: tagOnly,
})
},
}
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(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push")
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)")
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push")
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)")
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(&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")
@@ -92,41 +144,118 @@ 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
}
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 {
path := filepath.Join(absRepo, ".releaser.yml")
if _, err := os.Stat(path); err == nil {
return fmt.Errorf(".releaser.yml already exists in %s — delete it first if you want to reset", absRepo)
}
if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0644); err != nil {
return fmt.Errorf("write .releaser.yml: %w", err)
}
fmt.Printf("created %s\n", path)
return nil
}
func run(o options) error {
logHeader(version)
// --- Config ---
absRepo, err := filepath.Abs(o.repoPath)
if err != nil {
return fmt.Errorf("resolve repo path: %w", err)
}
cfg, err := config.Load(absRepo)
if o.init {
if o.verbose {
logStep("creating .releaser.yml in %s", absRepo)
}
return initConfig(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 ---
@@ -149,6 +278,13 @@ func run(o options) error {
}
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) ---
// Skipped in --no-commit mode: the user intentionally has changes in flight.
if !o.dryRun && !o.noCommit {
@@ -170,17 +306,21 @@ func run(o options) error {
// --- Commit range ---
var messages []string
if lastTag == "" {
fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits")
messages, err = gitutil.AllCommits(repo)
} else {
fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag)
messages, err = gitutil.CommitsSince(repo, lastTag)
}
if err != nil {
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 ---
types := make([]commits.Type, len(messages))
@@ -188,42 +328,100 @@ func run(o options) error {
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)
if !ok {
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
logWarn("no releasable commits found")
return errNothingToRelease
}
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)
if o.dryRun {
fmt.Fprintln(os.Stderr, "dry-run: no changes made")
logStep("dry-run: no changes made")
return nil
}
// --- pom.xml (skipped with --tag-only or when the file does not exist) ---
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
_, statErr := os.Stat(pomPath)
hasPom := !errors.Is(statErr, os.ErrNotExist)
if statErr != nil && hasPom {
return fmt.Errorf("check pom path: %w", statErr)
}
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
if !o.tagOnly {
var filesToCommit []string
if !o.tagOnly && hasPom {
currentPomVersion, err := maven.ReadVersion(pomPath)
if err != nil {
return fmt.Errorf("read pom version: %w", err)
// pom.xml
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
_, statErr := os.Stat(pomPath)
hasPom := !errors.Is(statErr, os.ErrNotExist)
if statErr != nil && hasPom {
return fmt.Errorf("check pom path: %w", statErr)
}
if hasPom {
currentPomVersion, err := maven.ReadVersion(pomPath)
if err != nil {
return fmt.Errorf("read pom version: %w", err)
}
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err)
}
logDone("pom.xml: %s → %s", currentPomVersion, nextVersion)
filesToCommit = append(filesToCommit, cfg.Maven.PomPath)
} else {
logWarn("no pom.xml — skipping version bump")
}
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion)
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err)
// CHANGELOG.md
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
return fmt.Errorf("update changelog: %w", err)
}
logDone("%s updated", o.changelogFile)
filesToCommit = append(filesToCommit, o.changelogFile)
if o.noCommit {
fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
fmt.Printf("files updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
return nil
}
@@ -235,21 +433,18 @@ func run(o options) error {
if cfg.Git.AuthorEmail != "" {
authorEmail = cfg.Git.AuthorEmail
}
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil {
return fmt.Errorf("commit pom.xml: %w", err)
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
return fmt.Errorf("commit: %w", err)
}
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg)
} else if !o.tagOnly && !hasPom {
fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump commit")
logDone("committed: %s", commitMsg)
}
// --- Git tag ---
if err := gitutil.CreateTag(repo, nextTag); err != nil {
return fmt.Errorf("create tag: %w", err)
}
fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag)
logDone("tag: %s", nextTag)
if o.noPush {
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
@@ -257,15 +452,19 @@ func run(o options) error {
}
// --- 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 {
return fmt.Errorf("push: %w", err)
}
fmt.Fprintln(os.Stderr, "info: pushed")
logDone("pushed")
// --- GitLab release ---
if o.noRelease {
fmt.Printf("released %s\n", nextTag)
return nil
}
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)
return nil
}
@@ -280,7 +479,7 @@ func run(o options) error {
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)
return nil
}
+144 -11
View File
@@ -2,10 +2,12 @@ package main
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -226,9 +228,9 @@ func TestRunMissingPom(t *testing.T) {
// Tag must still have been created.
repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v1.2.0")
_, err = repo2.Tag("1.2.0")
if err != nil {
t.Error("expected tag v1.2.0 to be created")
t.Error("expected tag 1.2.0 to be created")
}
}
@@ -248,9 +250,9 @@ func TestRunNoPomAtDefaultPath(t *testing.T) {
}
repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v2.0.0")
_, err = repo2.Tag("2.0.0")
if err != nil {
t.Error("expected tag v2.0.0 to be created")
t.Error("expected tag 2.0.0 to be created")
}
}
@@ -364,12 +366,12 @@ func TestRunDuplicateTag(t *testing.T) {
w.Add("x.go")
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),
// so run() calculates "v1.2.0" as the first-ever version — then
// CreateTag("v1.2.0") fails because the ref already exists.
// so run() calculates "1.2.0" as the first-ever version — then
// CreateTag("1.2.0") fails because the ref already exists.
fakeRef := plumbing.NewHashReference(
plumbing.NewTagReferenceName("v1.2.0"),
plumbing.NewTagReferenceName("1.2.0"),
plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
)
if err := repo.Storer.SetReference(fakeRef); err != nil {
@@ -378,7 +380,7 @@ func TestRunDuplicateTag(t *testing.T) {
err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error: v1.2.0 ref already exists")
t.Fatal("expected error: 1.2.0 ref already exists")
}
}
@@ -429,9 +431,9 @@ func TestRunGitLabError(t *testing.T) {
func TestRunWithPreviousTag(t *testing.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()
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
addFile(t, dir, "x.go", "// fix")
@@ -460,6 +462,25 @@ func TestRunSkipGitLab(t *testing.T) {
}
}
func TestRunNoRelease(t *testing.T) {
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// --no-release skips GitLab release even when credentials are configured
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "test-token")
err := execCmd(t, "--no-release", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-release: unexpected error: %v", err)
}
}
func TestRunMissingToken(t *testing.T) {
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
@@ -568,3 +589,115 @@ func TestMainError(t *testing.T) {
t.Errorf("expected exit code 1 for general error, got %d", gotCode)
}
}
func TestRunInit(t *testing.T) {
dir := t.TempDir()
err := execCmd(t, "--init", "--repo", dir)
if err != nil {
t.Fatalf("--init: unexpected error: %v", err)
}
data, err := os.ReadFile(filepath.Join(dir, ".releaser.yml"))
if err != nil {
t.Fatal("expected .releaser.yml to be created")
}
if len(data) == 0 {
t.Error("expected non-empty .releaser.yml")
}
}
func TestRunInitAlreadyExists(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("existing"), 0644)
err := execCmd(t, "--init", "--repo", dir)
if err == nil {
t.Fatal("expected error when .releaser.yml already exists")
}
}
func TestRunChangelogCreated(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 shiny feature", &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, "CHANGELOG.md"))
if err != nil {
t.Fatal("expected CHANGELOG.md to be created")
}
s := string(data)
if !strings.Contains(s, "## [1.2.0]") {
t.Error("expected version header in CHANGELOG")
}
if !strings.Contains(s, "add shiny feature") {
t.Error("expected feat subject in CHANGELOG")
}
}
func TestRunChangelogFile(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, "--changelog-file", "CHANGES.md")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "CHANGES.md")); err != nil {
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",
"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+"]")
}
}
+101
View File
@@ -0,0 +1,101 @@
package changelog
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
"git.k3nny.fr/releaser/internal/commits"
)
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// Update inserts a new release section into the CHANGELOG file at path.
// If the file does not exist it is created with a standard header.
// Only commits with a releasable type (fix, feat, breaking) produce bullets;
// if none are found the file is left untouched.
func Update(path, tag, version string, messages []string) error {
section := buildSection(version, messages)
if section == "" {
return nil
}
existing := ""
data, err := os.ReadFile(path)
if err == nil {
existing = string(data)
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read %s: %w", path, err)
}
var out string
if existing == "" {
out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" +
section + "\n"
} else {
// Insert above the first ## [ heading so newest release is always at top.
if idx := strings.Index(existing, "\n## ["); idx >= 0 {
out = existing[:idx+1] + section + "\n\n" + existing[idx+1:]
} else {
out = strings.TrimRight(existing, "\n") + "\n\n" + section + "\n"
}
}
return os.WriteFile(path, []byte(out), 0644)
}
func buildSection(version string, messages []string) string {
var breaking, feats, fixes []string
for _, msg := range messages {
t := commits.Parse(msg)
if t == commits.TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := extractSubject(first)
switch t {
case commits.TypeBreaking:
breaking = append(breaking, subject)
case commits.TypeFeat:
feats = append(feats, subject)
case commits.TypeFix:
fixes = append(fixes, subject)
}
}
if len(breaking)+len(feats)+len(fixes) == 0 {
return ""
}
date := time.Now().Format("2006-01-02")
var sb strings.Builder
fmt.Fprintf(&sb, "## [%s] - %s\n", version, date)
writeSection(&sb, "Breaking Changes", breaking)
writeSection(&sb, "Added", feats)
writeSection(&sb, "Fixed", fixes)
return strings.TrimRight(sb.String(), "\n")
}
func writeSection(sb *strings.Builder, title string, items []string) {
if len(items) == 0 {
return
}
fmt.Fprintf(sb, "\n### %s\n", title)
for _, item := range items {
fmt.Fprintf(sb, "- %s\n", item)
}
}
func extractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
+111
View File
@@ -0,0 +1,111 @@
package changelog
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestUpdateNewFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
if err := Update(path, "v1.2.0", "1.2.0", []string{
"feat: add widget",
"fix: off-by-one in parser",
}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
s := string(data)
if !strings.Contains(s, "## [1.2.0]") {
t.Error("expected version header")
}
if !strings.Contains(s, "### Added") {
t.Error("expected Added section")
}
if !strings.Contains(s, "### Fixed") {
t.Error("expected Fixed section")
}
if !strings.Contains(s, "add widget") {
t.Error("expected feat subject")
}
if !strings.Contains(s, "off-by-one in parser") {
t.Error("expected fix subject")
}
}
func TestUpdateExistingFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
// Seed with an older release.
os.WriteFile(path, []byte("# Changelog\n\n## [1.1.0] - 2026-01-01\n\n### Added\n- old stuff\n"), 0644)
if err := Update(path, "v1.2.0", "1.2.0", []string{"feat: new thing"}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
s := string(data)
newIdx := strings.Index(s, "## [1.2.0]")
oldIdx := strings.Index(s, "## [1.1.0]")
if newIdx < 0 || oldIdx < 0 {
t.Fatal("both versions should appear in CHANGELOG")
}
if newIdx > oldIdx {
t.Error("new version should appear before old version")
}
}
func TestUpdateNoReleasableCommits(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
if err := Update(path, "v1.0.1", "1.0.1", []string{
"chore: update deps",
"docs: fix typo",
}); err != nil {
t.Fatal(err)
}
// File should NOT have been created.
if _, err := os.Stat(path); err == nil {
t.Error("file should not be created when there are no releasable commits")
}
}
func TestUpdateBreakingSection(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
if err := Update(path, "v2.0.0", "2.0.0", []string{
"feat!: redesign API",
"fix(core): nil panic",
}); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
s := string(data)
if !strings.Contains(s, "### Breaking Changes") {
t.Error("expected Breaking Changes section")
}
if !strings.Contains(s, "redesign API") {
t.Error("expected breaking subject")
}
}
func TestUpdateReadError(t *testing.T) {
dir := t.TempDir()
// Create a directory where the file should be — ReadFile will error.
os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755)
err := Update(filepath.Join(dir, "CHANGELOG.md"), "v1.0.0", "1.0.0", []string{"fix: something"})
if err == nil {
t.Error("expected error when path is a directory")
}
}
+90 -9
View File
@@ -40,7 +40,7 @@ type GitLabConfig struct {
func defaults() Config {
return Config{
Git: GitConfig{
TagPrefix: "v",
TagPrefix: "",
BranchPattern: branch.DefaultBranchPattern,
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.
// 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"
}
}
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ func TestLoadDefaults(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "v" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v")
if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
}
if cfg.Maven.PomPath != "pom.xml" {
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
+43 -11
View File
@@ -3,6 +3,8 @@ package gitutil
import (
"errors"
"fmt"
"os"
"os/exec"
"sort"
"time"
@@ -194,15 +196,17 @@ func AuthorFromConfig(repo *gogit.Repository) (name, email string) {
return
}
// CommitFile stages filePath (relative to worktree root) and creates a commit.
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
// CommitFiles stages all filePaths (relative to worktree root) and creates a commit.
func CommitFiles(repo *gogit.Repository, filePaths []string, message, authorName, authorEmail string) (plumbing.Hash, error) {
w, err := repo.Worktree()
if err != nil {
return plumbing.ZeroHash, err
}
if _, err := w.Add(filePath); err != nil {
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err)
for _, p := range filePaths {
if _, err := w.Add(p); err != nil {
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", p, err)
}
}
hash, err := w.Commit(message, &gogit.CommitOptions{
@@ -218,6 +222,11 @@ func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEma
return hash, nil
}
// CommitFile stages a single file and creates a commit.
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
return CommitFiles(repo, []string{filePath}, message, authorName, authorEmail)
}
// CreateTag creates a lightweight tag on HEAD.
func CreateTag(repo *gogit.Repository, tagName string) error {
head, err := repo.Head()
@@ -231,9 +240,17 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
}
// Push pushes the given branch and tag to the "origin" remote.
// If token is non-empty, HTTPS basic auth (oauth2/token) is used.
// Passing an empty token lets go-git use the system credential helper or SSH agent.
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
// When token is empty, the system git binary is invoked so that credential helpers,
// SSH agents, and netrc are all available as they would be for a regular git push.
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
if token != "" {
return pushWithGoGit(repo, branchName, tagName, token)
}
return pushWithCLI(repo, branchName, tagName)
}
func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error {
remote, err := repo.Remote("origin")
if err != nil {
return fmt.Errorf("remote origin not found: %w", err)
@@ -244,13 +261,10 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
},
}
if token != "" {
opts.Auth = &githttp.BasicAuth{
Auth: &githttp.BasicAuth{
Username: "oauth2",
Password: token,
}
},
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
@@ -259,6 +273,24 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
return nil
}
func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
wt, err := repo.Worktree()
if err != nil {
return fmt.Errorf("get worktree: %w", err)
}
cmd := exec.Command("git", "-C", wt.Filesystem.Root(), "push", "origin",
fmt.Sprintf("HEAD:refs/heads/%s", branchName),
fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName),
)
cmd.Stdout = os.Stderr // git push status goes to stderr conventionally
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("git push: %w", err)
}
return nil
}
// resolveTagToCommit follows tag objects until it reaches a commit.
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {