4 Commits

Author SHA1 Message Date
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
k3nny 7fdf5ddcf3 fix(maven): skip pom.xml update when file does not exist
ci / vet, staticcheck, test, build (push) Successful in 3m3s
release / Build and publish release (push) Successful in 4m10s
If pom.xml (or the configured maven.pom_path) is absent, releaser now
logs a notice and proceeds to tag and push without failing. This makes
the tool usable in non-Maven projects. An os.Stat error that is not
ErrNotExist (e.g. permission denied) still surfaces as an error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 00:33:57 +02:00
k3nny c92164eb37 fix(ci): fix build step output path conflict
ci / vet, staticcheck, test, build (push) Successful in 2m47s
go build ./cmd/... tried to write a binary named "cmd" which conflicts
with the source directory of the same name; pass -o /dev/null so the
build step only verifies compilation without writing output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 00:18:04 +02:00
6 changed files with 112 additions and 16 deletions
+1 -1
View File
@@ -37,4 +37,4 @@ jobs:
run: go test ./... run: go test ./...
- name: build - name: build
run: go build ./cmd/... run: go build -o /dev/null ./cmd/...
+7
View File
@@ -3,6 +3,13 @@
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/).
## [0.4.2] - 2026-07-07
### Fixed
- **CI build step** — `go build ./cmd/...` failed with "output already exists and is a directory" because Go tried to write a binary named `cmd`, conflicting with the source directory; fixed by passing `-o /dev/null`
- **Optional pom.xml** — releaser no longer fails when `pom.xml` (or the configured `maven.pom_path`) does not exist; it logs a notice and proceeds directly to tag and push, making the tool usable in non-Maven projects
## [0.4.0] - 2026-07-07 ## [0.4.0] - 2026-07-07
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
# releaser # releaser
![release](https://img.shields.io/badge/release-v0.4.0-blue.svg) ![release](https://img.shields.io/badge/release-v0.4.2-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.
+19 -3
View File
@@ -33,6 +33,7 @@ func newRootCmd() *cobra.Command {
var ( var (
dryRun bool dryRun bool
noPush bool noPush bool
noRelease bool
noCommit bool noCommit bool
tagOnly bool tagOnly bool
branchOverride string branchOverride string
@@ -62,6 +63,7 @@ func newRootCmd() *cobra.Command {
patternSet: patternSet, patternSet: patternSet,
dryRun: dryRun, dryRun: dryRun,
noPush: noPush, noPush: noPush,
noRelease: noRelease,
noCommit: noCommit, noCommit: noCommit,
tagOnly: tagOnly, tagOnly: tagOnly,
}) })
@@ -70,6 +72,7 @@ func newRootCmd() *cobra.Command {
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(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push") 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(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)")
root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)") root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)")
@@ -101,6 +104,7 @@ type options struct {
patternSet bool patternSet bool
dryRun bool dryRun bool
noPush bool noPush bool
noRelease bool
noCommit bool noCommit bool
tagOnly bool tagOnly bool
} }
@@ -203,9 +207,15 @@ func run(o options) error {
return nil return nil
} }
// --- pom.xml (skipped with --tag-only) --- // --- pom.xml (skipped with --tag-only or when the file does not exist) ---
if !o.tagOnly { pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
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 !o.tagOnly && hasPom {
currentPomVersion, err := maven.ReadVersion(pomPath) currentPomVersion, err := maven.ReadVersion(pomPath)
if err != nil { if err != nil {
return fmt.Errorf("read pom version: %w", err) return fmt.Errorf("read pom version: %w", err)
@@ -235,6 +245,8 @@ func run(o options) error {
return fmt.Errorf("commit pom.xml: %w", err) return fmt.Errorf("commit pom.xml: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg) 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")
} }
// --- Git tag --- // --- Git tag ---
@@ -256,6 +268,10 @@ func run(o options) error {
fmt.Fprintln(os.Stderr, "info: pushed") fmt.Fprintln(os.Stderr, "info: pushed")
// --- GitLab release --- // --- GitLab release ---
if o.noRelease {
fmt.Printf("released %s\n", nextTag)
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") fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation")
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
+52 -4
View File
@@ -211,6 +211,7 @@ func TestRunPomOverride(t *testing.T) {
} }
func TestRunMissingPom(t *testing.T) { func TestRunMissingPom(t *testing.T) {
// --pom points to a non-existent file: pom update is skipped, tag is still created.
_, dir := setupRepo(t) _, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix") addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir) repo, _ := gogit.PlainOpen(dir)
@@ -218,10 +219,38 @@ func TestRunMissingPom(t *testing.T) {
w.Add("x.go") w.Add("x.go")
w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()}) w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()})
// Use --pom to point to a non-existent file; keeps the working tree clean. err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml") if err != nil {
if err == nil { t.Fatalf("missing pom should be skipped, got error: %v", err)
t.Fatal("expected error for missing pom.xml") }
// Tag must still have been created.
repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v1.2.0")
if err != nil {
t.Error("expected tag v1.2.0 to be created")
}
}
func TestRunNoPomAtDefaultPath(t *testing.T) {
// Repo with no pom.xml at the default path: runs without error, creates tag.
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
addFile(t, dir, "main.go", "package main")
commitAll(t, repo, dir, "fix: initial")
err = execCmd(t, "--no-push", "--branch", "release/2.0", "--repo", dir)
if err != nil {
t.Fatalf("no pom.xml should not be an error: %v", err)
}
repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v2.0.0")
if err != nil {
t.Error("expected tag v2.0.0 to be created")
} }
} }
@@ -431,6 +460,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) { func TestRunMissingToken(t *testing.T) {
_, dir := setupRepoWithRemote(t) _, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix") addFile(t, dir, "x.go", "// fix")
+32 -7
View File
@@ -3,6 +3,8 @@ package gitutil
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"os/exec"
"sort" "sort"
"time" "time"
@@ -231,9 +233,17 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
} }
// Push pushes the given branch and tag to the "origin" remote. // Push pushes the given branch and tag to the "origin" remote.
// If token is non-empty, HTTPS basic auth (oauth2/token) is used. // When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
// Passing an empty token lets go-git use the system credential helper or SSH agent. // 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 { 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") remote, err := repo.Remote("origin")
if err != nil { if err != nil {
return fmt.Errorf("remote origin not found: %w", err) return fmt.Errorf("remote origin not found: %w", err)
@@ -244,13 +254,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/heads/%s:refs/heads/%s", branchName, branchName)),
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)), gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
}, },
} Auth: &githttp.BasicAuth{
if token != "" {
opts.Auth = &githttp.BasicAuth{
Username: "oauth2", Username: "oauth2",
Password: token, Password: token,
} },
} }
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
@@ -259,6 +266,24 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
return nil 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. // resolveTagToCommit follows tag objects until it reaches a commit.
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → 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) { func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {