feat(releaser): initial release v0.4.0
ci / vet, staticcheck, test, build (push) Failing after 3m26s
release / Build and publish release (push) Successful in 4m28s

Complete GitFlow release automation tool for Conventional Commits workflows:

- Core pipeline: branch parsing, tag discovery, commit analysis, version bump
- Maven pom.xml read/write, git commit/tag, HTTPS push with token auth
- GitLab release creation via API with auto-generated release notes
- Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab)
- CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern
- Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template
- Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows
- Taskfile with build/test/cov/lint/fuzz/ci/docker tasks
- 96% test coverage with real in-memory git repos and fuzz tests for all parsers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:07:53 +02:00
commit f0723e706a
30 changed files with 3959 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
gogit "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
"git.k3nny.fr/releaser/internal/branch"
"git.k3nny.fr/releaser/internal/commits"
"git.k3nny.fr/releaser/internal/config"
"git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version"
)
var (
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
errNothingToRelease = errors.New("nothing to release")
)
// exitFn is a variable so tests can intercept os.Exit calls.
var exitFn = os.Exit
func newRootCmd() *cobra.Command {
var (
dryRun bool
noPush bool
noCommit bool
tagOnly bool
branchOverride string
repoPath string
pomOverride string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
)
root := &cobra.Command{
Use: "releaser",
Short: "GitFlow release automation for Conventional Commits",
Version: version,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
patternSet = cmd.Flags().Changed("branch-pattern")
return run(options{
repoPath: repoPath,
branchOverride: branchOverride,
pomOverride: pomOverride,
tagPrefixFlag: tagPrefixFlag,
tagPrefixSet: tagPrefixSet,
patternFlag: patternFlag,
patternSet: patternSet,
dryRun: dryRun,
noPush: noPush,
noCommit: noCommit,
tagOnly: tagOnly,
})
},
}
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().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(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
return root
}
func main() {
if err := newRootCmd().Execute(); err != nil {
if errors.Is(err, errNothingToRelease) {
exitFn(2)
return
}
exitFn(1)
}
}
type options struct {
repoPath string
branchOverride string
pomOverride string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
dryRun bool
noPush bool
noCommit bool
tagOnly bool
}
func run(o options) error {
// --- Config ---
absRepo, err := filepath.Abs(o.repoPath)
if err != nil {
return fmt.Errorf("resolve repo path: %w", err)
}
cfg, err := config.Load(absRepo)
if err != nil {
return err
}
cfg.ApplyEnv()
// CLI flags take precedence over config file and env vars
if o.tagPrefixSet {
cfg.Git.TagPrefix = o.tagPrefixFlag
}
if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride
}
if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag
}
// --- Git ---
repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true})
if err != nil {
return fmt.Errorf("open repository: %w", err)
}
branchName := o.branchOverride
if branchName == "" {
branchName, err = gitutil.CurrentBranch(repo)
if err != nil {
return err
}
}
info, err := branch.Parse(branchName, cfg.Git.BranchPattern)
if err != nil {
return err
}
info.TagPrefix = cfg.Git.TagPrefix
// --- Dirty check (before any changes) ---
// Skipped in --no-commit mode: the user intentionally has changes in flight.
if !o.dryRun && !o.noCommit {
clean, err := gitutil.IsWorkingTreeClean(repo)
if err != nil {
return fmt.Errorf("check working tree: %w", err)
}
if !clean {
return fmt.Errorf("working tree has uncommitted changes — commit or stash them before releasing")
}
}
// --- Tag discovery ---
lastTag, currentPatch, err := gitutil.LatestTag(repo, info)
if err != nil {
return fmt.Errorf("find latest tag: %w", err)
}
// --- 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))
// --- Version calculation ---
types := make([]commits.Type, len(messages))
for i, msg := range messages {
types[i] = commits.Parse(msg)
}
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
if !ok {
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
return errNothingToRelease
}
nextTag := info.TagName(nextVersion)
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
if o.dryRun {
fmt.Fprintln(os.Stderr, "dry-run: no changes made")
return nil
}
// --- pom.xml (skipped with --tag-only) ---
if !o.tagOnly {
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
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)
}
if o.noCommit {
fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
return nil
}
// --- Git commit ---
authorName, authorEmail := gitutil.AuthorFromConfig(repo)
if cfg.Git.AuthorName != "" {
authorName = cfg.Git.AuthorName
}
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)
}
fmt.Fprintf(os.Stderr, "info: committed: %s\n", 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)
if o.noPush {
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
return nil
}
// --- Push ---
fmt.Fprintln(os.Stderr, "info: 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")
// --- GitLab release ---
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation")
fmt.Printf("released %s\n", nextTag)
return nil
}
if cfg.GitLab.Token == "" {
return fmt.Errorf("GITLAB_TOKEN not set — required for release creation")
}
releaseNotes := notes.Generate(nextTag, messages)
gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project)
if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create GitLab release: %w", err)
}
fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag)
fmt.Printf("released %s\n", nextTag)
return nil
}
+541
View File
@@ -0,0 +1,541 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
gogit "github.com/go-git/go-git/v5"
gitcfg "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
// ── helpers ──────────────────────────────────────────────────────────────────
func testSig() *object.Signature {
return &object.Signature{Name: "CI", Email: "ci@example.com", When: time.Now()}
}
// setupRepo creates a temporary git repo with pom.xml committed.
func setupRepo(t *testing.T) (repo *gogit.Repository, dir string) {
t.Helper()
dir = t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writePom(t, dir, "1.2.3")
commitAll(t, repo, dir, "chore: init")
return repo, dir
}
// setupRepoWithRemote adds a bare remote so Push can succeed.
func setupRepoWithRemote(t *testing.T) (repo *gogit.Repository, dir string) {
t.Helper()
repo, dir = setupRepo(t)
remoteDir := t.TempDir()
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
t.Fatal(err)
}
if _, err := repo.CreateRemote(&gitcfg.RemoteConfig{
Name: "origin",
URLs: []string{remoteDir},
}); err != nil {
t.Fatal(err)
}
return repo, dir
}
func writePom(t *testing.T, dir, version string) {
t.Helper()
pom := `<?xml version="1.0"?>
<project>
<version>` + version + `</version>
</project>`
if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pom), 0644); err != nil {
t.Fatal(err)
}
}
func commitAll(t *testing.T, repo *gogit.Repository, dir, msg string) plumbing.Hash {
t.Helper()
w, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
// stage everything
if _, err := w.Add("."); err != nil {
t.Fatal(err)
}
h, err := w.Commit(msg, &gogit.CommitOptions{Author: testSig()})
if err != nil {
t.Fatalf("commit %q: %v", msg, err)
}
return h
}
func addFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// ── newRootCmd integration tests ──────────────────────────────────────────────
func execCmd(t *testing.T, args ...string) error {
t.Helper()
cmd := newRootCmd()
cmd.SetArgs(args)
return cmd.Execute()
}
func TestRunDryRun(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "src.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("dry-run: unexpected error: %v", err)
}
}
func TestRunNothingToRelease(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "src.go", "chore")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("chore: update deps", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if !errors.Is(err, errNothingToRelease) {
t.Fatalf("expected errNothingToRelease, got %v", err)
}
}
func TestRunNotAGitRepo(t *testing.T) {
dir := t.TempDir()
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for non-git directory")
}
}
func TestRunBadBranchName(t *testing.T) {
_, dir := setupRepo(t)
err := execCmd(t, "--dry-run", "--branch", "main", "--repo", dir)
if err == nil {
t.Fatal("expected error: 'main' doesn't match release pattern")
}
}
func TestRunDetachedHead(t *testing.T) {
repo, dir := setupRepo(t)
// Detach HEAD
head, _ := repo.Head()
repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, head.Hash()))
err := execCmd(t, "--dry-run", "--repo", dir) // no --branch
if err == nil {
t.Fatal("expected error for detached HEAD without --branch")
}
}
func TestRunBadConfig(t *testing.T) {
_, dir := setupRepo(t)
if err := os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("{[invalid"), 0644); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for invalid .releaser.yml")
}
}
func TestRunDirtyTree(t *testing.T) {
_, dir := setupRepo(t)
// Modify a tracked file without staging
os.WriteFile(filepath.Join(dir, "pom.xml"), []byte("<project><version>dirty</version></project>"), 0644)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for dirty working tree")
}
}
func TestRunFlagOverrides(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "src.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
// --tag-prefix "" --branch-pattern with defaults via flags
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir,
"--tag-prefix", "rel-", "--branch-pattern", `^(?:.*/)?release/(\d+)\.(\d+)$`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunPomOverride(t *testing.T) {
_, dir := setupRepo(t)
// Write a second pom at a custom path
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
writePom(t, filepath.Join(dir, "sub"), "1.2.3")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("sub/pom.xml")
w.Commit("chore: add sub pom", &gogit.CommitOptions{Author: testSig()})
addFile(t, dir, "x.go", "// fix")
w.Add("x.go")
w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir, "--pom", "sub/pom.xml")
if err != nil {
t.Fatalf("--pom override: unexpected error: %v", err)
}
}
func TestRunMissingPom(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: patch", &gogit.CommitOptions{Author: testSig()})
// Use --pom to point to a non-existent file; keeps the working tree clean.
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml")
if err == nil {
t.Fatal("expected error for missing pom.xml")
}
}
func TestRunPomNoVersion(t *testing.T) {
_, dir := setupRepo(t)
// Write a pom with no <version> tag as an untracked file (tree stays clean)
os.WriteFile(filepath.Join(dir, "no-version.xml"), []byte("<project></project>"), 0644)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "no-version.xml")
if err == nil {
t.Fatal("expected error when pom has no <version>")
}
}
func TestRunPomReadOnly(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: patch something", &gogit.CommitOptions{Author: testSig()})
// Make pom.xml read-only: ReadVersion succeeds (readable), WriteVersion fails (not writable)
os.Chmod(filepath.Join(dir, "pom.xml"), 0444)
defer os.Chmod(filepath.Join(dir, "pom.xml"), 0644)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when pom.xml is read-only")
}
}
func TestRunAuthorFromConfig(t *testing.T) {
_, dir := setupRepo(t)
// Write .releaser.yml as untracked so the working tree stays clean
cfg := "git:\n author_name: ReleaserBot\n author_email: bot@example.com\n"
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte(cfg), 0644)
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()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("run with author_name/author_email config: %v", err)
}
}
func TestRunNoPush(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: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-push: unexpected error: %v", err)
}
// Verify pom.xml was updated
data, _ := os.ReadFile(filepath.Join(dir, "pom.xml"))
if string(data) == "" {
t.Error("pom.xml should have been updated")
}
}
func TestRunNoCommit(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: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-commit", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-commit: unexpected error: %v", err)
}
}
func TestRunTagOnly(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: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--tag-only --no-push: unexpected error: %v", err)
}
}
func TestRunDuplicateTag(t *testing.T) {
_, dir := setupRepo(t)
repo, _ := gogit.PlainOpen(dir)
// Add a fix commit so there are releasable changes
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
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.
// 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.
fakeRef := plumbing.NewHashReference(
plumbing.NewTagReferenceName("v1.2.0"),
plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
)
if err := repo.Storer.SetReference(fakeRef); err != nil {
t.Fatal(err)
}
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")
}
}
func TestRunPushFails(t *testing.T) {
_, dir := setupRepo(t)
repo, _ := gogit.PlainOpen(dir)
// Remote exists but points to a non-existent directory → push fails
repo.CreateRemote(&gitcfg.RemoteConfig{
Name: "origin",
URLs: []string{"/nonexistent/path/to/bare/repo"},
})
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected push error for invalid remote URL")
}
}
func TestRunGitLabError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message":"Tag already has a release"}`))
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w2, _ := repo.Worktree()
w2.Add("x.go")
w2.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("CI_SERVER_URL", srv.URL)
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "test-token")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when GitLab API returns 422")
}
}
func TestRunWithPreviousTag(t *testing.T) {
repo, dir := setupRepo(t)
// Tag the initial commit as v1.2.0 (simulates a prior release)
initialHead, _ := repo.Head()
repo.CreateTag("v1.2.0", initialHead.Hash(), nil)
// Fix commit after the tag — run() will use CommitsSince, not AllCommits
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("run with previous tag: %v", err)
}
}
func TestRunSkipGitLab(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 GITLAB_TOKEN / URL set → GitLab release is skipped
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("expected skip-gitlab success, got: %v", err)
}
}
func TestRunMissingToken(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()})
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when GITLAB_TOKEN is not set but GitLab URL is configured")
}
}
func TestRunWithGitLab(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w2, _ := repo.Worktree()
w2.Add("x.go")
w2.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("CI_SERVER_URL", srv.URL)
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "test-token")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("full release with mock GitLab: unexpected error: %v", err)
}
}
// ── main() tests via exitFn ───────────────────────────────────────────────────
func TestMainSuccess(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()})
old := os.Args
os.Args = []string{"releaser", "--dry-run", "--branch", "release/1.2", "--repo", dir}
defer func() { os.Args = old }()
called := false
oldFn := exitFn
exitFn = func(code int) { called = true }
defer func() { exitFn = oldFn }()
main()
if called {
t.Error("exitFn should not have been called on success")
}
}
func TestMainNothingToRelease(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "chore")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("chore: bump deps", &gogit.CommitOptions{Author: testSig()})
old := os.Args
os.Args = []string{"releaser", "--branch", "release/1.2", "--repo", dir}
defer func() { os.Args = old }()
var gotCode int
oldFn := exitFn
exitFn = func(code int) { gotCode = code }
defer func() { exitFn = oldFn }()
main()
if gotCode != 2 {
t.Errorf("expected exit code 2 for nothing-to-release, got %d", gotCode)
}
}
func TestMainError(t *testing.T) {
old := os.Args
os.Args = []string{"releaser", "--branch", "release/1.2", "--repo", t.TempDir()} // not a git repo
defer func() { os.Args = old }()
var gotCode int
oldFn := exitFn
exitFn = func(code int) { gotCode = code }
defer func() { exitFn = oldFn }()
main()
if gotCode != 1 {
t.Errorf("expected exit code 1 for general error, got %d", gotCode)
}
}