2 Commits

Author SHA1 Message Date
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
8 changed files with 451 additions and 39 deletions
+13
View File
@@ -3,6 +3,19 @@
All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.1.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
+12 -3
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.1.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,9 @@ 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
# Target a specific pom.xml
releaser --pom path/to/pom.xml
+5 -3
View File
@@ -55,10 +55,12 @@
- [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
+97 -22
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.
# 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,6 +71,7 @@ var exitFn = os.Exit
func newRootCmd() *cobra.Command {
var (
init_ bool
dryRun bool
noPush bool
noRelease bool
@@ -39,6 +80,7 @@ func newRootCmd() *cobra.Command {
branchOverride string
repoPath string
pomOverride string
changelogFile string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
@@ -54,9 +96,11 @@ func newRootCmd() *cobra.Command {
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
patternSet = cmd.Flags().Changed("branch-pattern")
return run(options{
init: init_,
repoPath: repoPath,
branchOverride: branchOverride,
pomOverride: pomOverride,
changelogFile: changelogFile,
tagPrefixFlag: tagPrefixFlag,
tagPrefixSet: tagPrefixSet,
patternFlag: patternFlag,
@@ -70,14 +114,16 @@ func newRootCmd() *cobra.Command {
},
}
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit")
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release")
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
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(&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")
@@ -95,9 +141,11 @@ func main() {
}
type options struct {
init bool
repoPath string
branchOverride string
pomOverride string
changelogFile string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
@@ -109,6 +157,18 @@ type options struct {
tagOnly bool
}
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 {
// --- Config ---
absRepo, err := filepath.Abs(o.repoPath)
@@ -116,6 +176,10 @@ func run(o options) error {
return fmt.Errorf("resolve repo path: %w", err)
}
if o.init {
return initConfig(absRepo)
}
cfg, err := config.Load(absRepo)
if err != nil {
return err
@@ -207,27 +271,41 @@ func run(o options) error {
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)
}
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)
}
filesToCommit = append(filesToCommit, cfg.Maven.PomPath)
} else {
fmt.Fprintln(os.Stderr, "info: no pom.xml found — 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)
}
fmt.Fprintf(os.Stderr, "info: %s updated\n", 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
}
@@ -239,14 +317,11 @@ 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")
}
// --- Git tag ---
+69
View File
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -587,3 +588,71 @@ 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")
}
}
+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")
}
}
+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) {