1 Commits

Author SHA1 Message Date
k3nny f07220b0c6 feat(releaser): release v1.5.0 — Node.js, multi-module Maven, configurable bump rules
ci / vet, staticcheck, test, build (push) Failing after 4m11s
release / Build and publish release (push) Successful in 5m7s
- Add internal/node package: reads/writes package.json version (single or
  multi-path via node.package_json / node.package_jsons)
- Add maven.pom_paths support: update multiple pom.xml files in one release
  commit; pom_paths overrides pom_path; --pom flag clears pom_paths
- Add git.bump_rules config: per-type control of which version component bumps
  (breaking/feat/fix accept "patch" or "minor"); wired through version.Next()
  as a new sixth parameter
- Extract injectable function vars (absPath, gitAllCommits, gitCommitsSince,
  gitCommitFiles) to enable error-path testing without interfaces
- Achieve 100% per-package statement coverage across all 12 packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 16:59:28 +02:00
17 changed files with 1759 additions and 77 deletions
+29 -2
View File
@@ -19,10 +19,37 @@ git:
# author_name: "" # author_name: ""
# author_email: "" # author_email: ""
maven: # Limit which commit types trigger a release (default: fix, feat, breaking).
# Path to pom.xml, relative to the repository root. # releasable_types:
# - fix
# - feat
# - breaking
# Configure which version component each commit type bumps.
# Valid values: "patch" (default) or "minor".
# bump_rules:
# breaking: "minor"
# feat: "patch"
# fix: "patch"
# maven:
# Single pom.xml path, relative to the repository root.
# pom_path: "pom.xml" # pom_path: "pom.xml"
# Multiple pom.xml paths for multi-module projects (overrides pom_path).
# pom_paths:
# - "pom.xml"
# - "module-a/pom.xml"
# node:
# Single package.json path (node processing is opt-in — no default).
# package_json: "package.json"
# Multiple package.json paths for monorepos (overrides package_json).
# package_jsons:
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gitlab: gitlab:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com" # url: "https://gitlab.example.com"
+15
View File
@@ -3,6 +3,21 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.5.0] - 2026-07-11
### Added
- **Multi-module Maven support** — `maven.pom_paths: [...]` lists multiple `pom.xml` paths (e.g. root + sub-modules); overrides `pom_path`; each path is updated and committed in the same release commit
- **Node.js `package.json` support** — opt-in via `node.package_json` (single path) or `node.package_jsons` (list, overrides the single path); version is bumped in-place alongside `pom.xml` and `CHANGELOG.md`
- **`git.bump_rules` config** — controls which version component each commit type bumps: `breaking`, `feat`, `fix` each accept `"patch"` (default) or `"minor"`; allows e.g. `feat: "minor"` to bump the minor component instead of patch
- **Injectable function vars in `cmd/main.go`** — `absPath`, `gitAllCommits`, `gitCommitsSince`, `gitCommitFiles` are now package-level vars overridable in tests to inject errors, enabling 100% per-package statement coverage across all 12 packages
### Changed
- **`--pom` flag** now clears `maven.pom_paths` before setting `maven.pom_path`, ensuring the CLI flag always wins over a multi-path config file entry
- **`version.Next()` signature** — accepts a `map[commits.Type]semver.BumpLevel` bump-rules map as a sixth parameter; `nil` defaults to all-patch (no change to existing behaviour)
- **Verbose config table** — now includes `git.bump_rules.breaking/feat/fix`, `maven.pom_paths` (effective list), and `node.paths` rows
## [1.4.0] - 2026-07-11 ## [1.4.0] - 2026-07-11
### Added ### Added
+24 -8
View File
@@ -1,6 +1,6 @@
# releaser # releaser
![release](https://img.shields.io/badge/release-v1.4.0-blue.svg) ![release](https://img.shields.io/badge/release-v1.5.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -27,12 +27,14 @@ release/1.2 branch
## Version bump rules ## Version bump rules
| Commit type | Bump | Notes | By default, all releasable commits bump the **patch** component (minor is pinned to the branch). You can override this per commit type via `git.bump_rules` in `.releaser.yml`:
|------------------|---------|--------------------------------------------|
| `fix:` | patch | | | Commit type | Default | Configurable via `bump_rules` |
| `feat:` | patch | minor is pinned to branch | |------------------|---------|------------------------------------------------|
| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary | | `fix:` | patch | `fix: "minor"` to bump minor instead |
| `chore:`, `docs:`, etc. | none | | | `feat:` | patch | `feat: "minor"` to bump minor instead |
| `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor |
| `chore:`, `docs:`, etc. | none | — |
| unparseable msg | none | non-strict mode: silently ignored | | unparseable msg | none | non-strict mode: silently ignored |
## Usage ## Usage
@@ -95,9 +97,23 @@ git:
- fix - fix
- feat - feat
- breaking - breaking
bump_rules: # which version component each type bumps
breaking: "patch" # "minor" to bump minor on breaking changes
feat: "patch"
fix: "patch"
maven: maven:
pom_path: "pom.xml" # relative to repo root pom_path: "pom.xml" # single pom.xml, relative to repo root
# pom_paths: # multi-module: list overrides pom_path
# - "pom.xml"
# - "module-a/pom.xml"
# - "module-b/pom.xml"
node: # opt-in — no default; omit to skip
# package_json: "package.json" # single path
# package_jsons: # monorepo: list overrides package_json
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gitlab: gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL url: "https://gitlab.example.com" # or env CI_SERVER_URL
+8 -3
View File
@@ -76,10 +76,15 @@
- [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release) - [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release)
- [ ] Documentation site - [ ] Documentation site
## v1.5 — Multi-module, Node.js, configurable bump rules ✅
- [x] Multi-module Maven support (`maven.pom_paths: [...]` updates multiple `pom.xml` files in one release)
- [x] `package.json` version bump for Node.js projects (`node.package_json` / `node.package_jsons`)
- [x] Configurable bump rules per commit type (`git.bump_rules.breaking/feat/fix: "minor" | "patch"`)
- [x] 100% per-package statement coverage across all 12 packages
## Future / backlog ## Future / backlog
- Multi-module Maven support (multiple `pom.xml` paths)
- Gradle support (`build.gradle` / `build.gradle.kts`) - Gradle support (`build.gradle` / `build.gradle.kts`)
- `package.json` version bump support (Node.js projects)
- Slack / Teams notification on release - Slack / Teams notification on release
- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch) - Documentation site
+104 -13
View File
@@ -19,6 +19,7 @@ import (
"git.k3nny.fr/releaser/internal/gitutil" "git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/glclient" "git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/maven" "git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/node"
"git.k3nny.fr/releaser/internal/notes" "git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version" semver "git.k3nny.fr/releaser/internal/version"
) )
@@ -50,10 +51,33 @@ git:
# - feat # - feat
# - breaking # - breaking
# Configure which version component each commit type bumps.
# Valid values: "patch" (default) or "minor".
# bump_rules:
# breaking: "minor" # bump minor version instead of patch on breaking changes
# feat: "patch"
# fix: "patch"
maven: maven:
# Path to pom.xml, relative to the repository root. # Single pom.xml path, relative to the repository root.
# pom_path: "pom.xml" # pom_path: "pom.xml"
# Multiple pom.xml paths for multi-module projects (overrides pom_path).
# pom_paths:
# - "pom.xml"
# - "module-a/pom.xml"
# - "module-b/pom.xml"
node:
# Single package.json path (node processing is opt-in — no default).
# package_json: "package.json"
# Multiple package.json paths for monorepos (overrides package_json).
# package_jsons:
# - "package.json"
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gitlab: gitlab:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com" # url: "https://gitlab.example.com"
@@ -84,6 +108,14 @@ var (
// exitFn is a variable so tests can intercept os.Exit calls. // exitFn is a variable so tests can intercept os.Exit calls.
var exitFn = os.Exit var exitFn = os.Exit
// injectable function variables for testing error paths.
var (
absPath = filepath.Abs
gitAllCommits = gitutil.AllCommits
gitCommitsSince = gitutil.CommitsSince
gitCommitFiles = gitutil.CommitFiles
)
// releasePublisher is implemented by both glclient and ghclient. // releasePublisher is implemented by both glclient and ghclient.
type releasePublisher interface { type releasePublisher interface {
CreateRelease(ctx context.Context, tagName, body string) error CreateRelease(ctx context.Context, tagName, body string) error
@@ -215,7 +247,32 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
} }
return strings.Join(cfg.Git.ReleasableTypes, ", ") return strings.Join(cfg.Git.ReleasableTypes, ", ")
}()}, }()},
{"maven.pom_path", cfg.Maven.PomPath}, {"git.bump_rules.breaking", func() string {
if cfg.Git.BumpRules.Breaking == "" {
return "patch"
}
return cfg.Git.BumpRules.Breaking
}()},
{"git.bump_rules.feat", func() string {
if cfg.Git.BumpRules.Feat == "" {
return "patch"
}
return cfg.Git.BumpRules.Feat
}()},
{"git.bump_rules.fix", func() string {
if cfg.Git.BumpRules.Fix == "" {
return "patch"
}
return cfg.Git.BumpRules.Fix
}()},
{"maven.pom_paths", strings.Join(cfg.Maven.EffectivePomPaths(), ", ")},
{"node.paths", func() string {
paths := cfg.Node.EffectivePaths()
if len(paths) == 0 {
return "(not configured)"
}
return strings.Join(paths, ", ")
}()},
{"gitlab.url", cfg.GitLab.URL}, {"gitlab.url", cfg.GitLab.URL},
{"gitlab.token", func() string { {"gitlab.token", func() string {
if cfg.GitLab.Token != "" { if cfg.GitLab.Token != "" {
@@ -257,11 +314,25 @@ func initConfig(absRepo string) error {
return nil return nil
} }
func parseBumpRules(rules config.BumpRulesConfig) map[commits.Type]semver.BumpLevel {
m := map[commits.Type]semver.BumpLevel{}
if rules.Breaking == "minor" {
m[commits.TypeBreaking] = semver.BumpMinor
}
if rules.Feat == "minor" {
m[commits.TypeFeat] = semver.BumpMinor
}
if rules.Fix == "minor" {
m[commits.TypeFix] = semver.BumpMinor
}
return m
}
func run(o options) error { func run(o options) error {
logHeader(version) logHeader(version)
// --- Config --- // --- Config ---
absRepo, err := filepath.Abs(o.repoPath) absRepo, err := absPath(o.repoPath)
if err != nil { if err != nil {
return fmt.Errorf("resolve repo path: %w", err) return fmt.Errorf("resolve repo path: %w", err)
} }
@@ -286,7 +357,8 @@ func run(o options) error {
} }
if o.pomOverride != "" { if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride cfg.Maven.PomPath = o.pomOverride
src["maven.pom_path"] = "flag: --pom" cfg.Maven.PomPaths = nil
src["maven.pom_paths"] = "flag: --pom"
} }
if o.patternSet { if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag cfg.Git.BranchPattern = o.patternFlag
@@ -345,9 +417,9 @@ func run(o options) error {
// --- Commit range --- // --- Commit range ---
var messages []string var messages []string
if lastTag == "" { if lastTag == "" {
messages, err = gitutil.AllCommits(repo) messages, err = gitAllCommits(repo)
} else { } else {
messages, err = gitutil.CommitsSince(repo, lastTag) messages, err = gitCommitsSince(repo, lastTag)
} }
if err != nil { if err != nil {
return fmt.Errorf("read commits: %w", err) return fmt.Errorf("read commits: %w", err)
@@ -398,7 +470,7 @@ func run(o options) error {
} }
releasable := commits.ReleasableSet(cfg.Git.ReleasableTypes) releasable := commits.ReleasableSet(cfg.Git.ReleasableTypes)
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable) nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable, parseBumpRules(cfg.Git.BumpRules))
if !ok { if !ok {
logWarn("no releasable commits found") logWarn("no releasable commits found")
return errNothingToRelease return errNothingToRelease
@@ -440,14 +512,17 @@ func run(o options) error {
if !o.tagOnly { if !o.tagOnly {
var filesToCommit []string var filesToCommit []string
// pom.xml // pom.xml (supports multi-module via pom_paths)
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) anyPom := false
for _, relPomPath := range cfg.Maven.EffectivePomPaths() {
pomPath := filepath.Join(absRepo, relPomPath)
_, statErr := os.Stat(pomPath) _, statErr := os.Stat(pomPath)
hasPom := !errors.Is(statErr, os.ErrNotExist) hasPom := !errors.Is(statErr, os.ErrNotExist)
if statErr != nil && hasPom { if statErr != nil && hasPom {
return fmt.Errorf("check pom path: %w", statErr) return fmt.Errorf("check pom path: %w", statErr)
} }
if hasPom { if hasPom {
anyPom = true
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)
@@ -455,12 +530,28 @@ func run(o options) error {
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err) return fmt.Errorf("update pom version: %w", err)
} }
logDone("pom.xml: %s → %s", currentPomVersion, nextVersion) logDone("%s: %s → %s", relPomPath, currentPomVersion, nextVersion)
filesToCommit = append(filesToCommit, cfg.Maven.PomPath) filesToCommit = append(filesToCommit, relPomPath)
} else { }
}
if !anyPom {
logWarn("no pom.xml — skipping version bump") logWarn("no pom.xml — skipping version bump")
} }
// package.json (opt-in via node.package_json / node.package_jsons)
for _, relPkgPath := range cfg.Node.EffectivePaths() {
pkgPath := filepath.Join(absRepo, relPkgPath)
currentNodeVersion, err := node.ReadVersion(pkgPath)
if err != nil {
return fmt.Errorf("read package.json version: %w", err)
}
if err := node.WriteVersion(pkgPath, currentNodeVersion, nextVersion); err != nil {
return fmt.Errorf("update package.json version: %w", err)
}
logDone("%s: %s → %s", relPkgPath, currentNodeVersion, nextVersion)
filesToCommit = append(filesToCommit, relPkgPath)
}
// CHANGELOG.md // CHANGELOG.md
changelogAbsPath := filepath.Join(absRepo, o.changelogFile) changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
@@ -483,7 +574,7 @@ func run(o options) error {
authorEmail = cfg.Git.AuthorEmail authorEmail = cfg.Git.AuthorEmail
} }
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag) commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil { if _, err := gitCommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
return fmt.Errorf("commit: %w", err) return fmt.Errorf("commit: %w", err)
} }
logDone("committed: %s", commitMsg) logDone("committed: %s", commitMsg)
+458
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -15,6 +16,8 @@ import (
gitcfg "github.com/go-git/go-git/v5/config" 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"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"git.k3nny.fr/releaser/internal/config"
) )
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
@@ -742,3 +745,458 @@ func TestRunVerbose(t *testing.T) {
} }
} }
} }
// ── ui.go coverage ────────────────────────────────────────────────────────────
func TestPaintColor(t *testing.T) {
old := useColor
useColor = true
defer func() { useColor = old }()
got := paint(ansiGreen, "hello")
if !strings.Contains(got, "hello") || !strings.Contains(got, ansiReset) || !strings.Contains(got, ansiGreen) {
t.Errorf("paint with color = %q", got)
}
}
func TestFmtSourceEnv(t *testing.T) {
old := useColor
useColor = false
defer func() { useColor = old }()
got := fmtSource("env: GITLAB_TOKEN")
if got != "[env: GITLAB_TOKEN]" {
t.Errorf("fmtSource env = %q", got)
}
}
func TestFmtSourceFlag(t *testing.T) {
old := useColor
useColor = false
defer func() { useColor = old }()
got := fmtSource("flag: --tag-prefix")
if got != "[flag: --tag-prefix]" {
t.Errorf("fmtSource flag = %q", got)
}
}
func TestFmtSourceConfigFile(t *testing.T) {
old := useColor
useColor = false
defer func() { useColor = old }()
got := fmtSource("config file")
if got != "[config file]" {
t.Errorf("fmtSource config file = %q", got)
}
}
// ── buildPublisher coverage ───────────────────────────────────────────────────
func TestBuildPublisherGitHub(t *testing.T) {
cfg := config.Config{
GitHub: config.GitHubConfig{Token: "ghtoken", Repo: "owner/repo"},
}
pub, err := buildPublisher(cfg)
if err != nil {
t.Fatalf("buildPublisher GitHub: %v", err)
}
if pub == nil {
t.Fatal("expected non-nil publisher for GitHub config")
}
}
func TestBuildPublisherGitLabNoToken(t *testing.T) {
cfg := config.Config{
GitLab: config.GitLabConfig{URL: "https://gitlab.example.com", Project: "42"},
}
_, err := buildPublisher(cfg)
if err == nil {
t.Fatal("expected error when GitLab URL+Project set but token is empty")
}
}
// ── printVerboseConfig coverage ───────────────────────────────────────────────
func TestPrintVerboseConfigDirect(t *testing.T) {
// Use a sparse Sources map (missing keys → source == "" → hits "default" branch).
// Also set non-empty ReleasableTypes and both tokens to cover those branches.
cfg := config.Config{
Git: config.GitConfig{
ReleasableTypes: []string{"fix", "feat"},
},
GitLab: config.GitLabConfig{Token: "secret"},
GitHub: config.GitHubConfig{Token: "ghsecret"},
}
src := config.Sources{} // empty → all lookups return ""
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printVerboseConfig(cfg, src)
w.Close()
os.Stderr = old
out, _ := io.ReadAll(r)
if !strings.Contains(string(out), "fix, feat") {
t.Error("expected releasable types joined in output")
}
if !strings.Contains(string(out), "(set)") {
t.Error("expected '(set)' for configured tokens")
}
}
// ── initConfig coverage ───────────────────────────────────────────────────────
func TestInitConfigWriteFails(t *testing.T) {
dir := t.TempDir()
os.Chmod(dir, 0555)
defer os.Chmod(dir, 0755)
err := initConfig(dir)
if err == nil {
t.Fatal("expected error writing .releaser.yml to read-only directory")
}
}
// ── run() injectable error paths ─────────────────────────────────────────────
func TestRunVerboseInit(t *testing.T) {
dir := t.TempDir()
err := execCmd(t, "--init", "--verbose", "--repo", dir)
if err != nil {
t.Fatalf("--init --verbose: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".releaser.yml")); err != nil {
t.Error("expected .releaser.yml to be created")
}
}
func TestRunAbsPathError(t *testing.T) {
old := absPath
absPath = func(string) (string, error) { return "", fmt.Errorf("injected abs error") }
defer func() { absPath = old }()
err := execCmd(t, "--repo", ".")
if err == nil {
t.Fatal("expected error when filepath.Abs fails")
}
}
func TestRunWorkingTreeCheckFails(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()})
// Corrupt the git index so IsWorkingTreeClean fails.
os.WriteFile(filepath.Join(dir, ".git", "index"), []byte("garbage"), 0644)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for corrupt git index")
}
}
func TestRunLatestTagFails(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()})
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
os.Chmod(tagsDir, 0000)
defer os.Chmod(tagsDir, 0755)
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for unreadable tags directory")
}
}
func TestRunAllCommitsError(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()})
old := gitAllCommits
gitAllCommits = func(_ *gogit.Repository) ([]string, error) {
return nil, fmt.Errorf("injected AllCommits error")
}
defer func() { gitAllCommits = old }()
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error from AllCommits")
}
}
func TestRunCommitsSinceError(t *testing.T) {
repo, dir := setupRepo(t)
head, _ := repo.Head()
repo.CreateTag("1.2.0", head.Hash(), nil)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
old := gitCommitsSince
gitCommitsSince = func(_ *gogit.Repository, _ string) ([]string, error) {
return nil, fmt.Errorf("injected CommitsSince error")
}
defer func() { gitCommitsSince = old }()
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error from CommitsSince")
}
}
// ── verbose commit section coverage ──────────────────────────────────────────
func TestRunVerboseBreakingAndFix(t *testing.T) {
// Covers: verbose "since: lastTag", message truncation (>70 chars),
// TypeBreaking color (ansiRed+ansiBold), TypeFix color (ansiGreen).
repo, dir := setupRepo(t)
head, _ := repo.Head()
repo.CreateTag("1.2.0", head.Hash(), nil)
w, _ := repo.Worktree()
addFile(t, dir, "a.go", "a")
w.Add("a.go")
w.Commit("feat!: redesign the entire public API surface which is a very long commit message header", &gogit.CommitOptions{Author: testSig()})
addFile(t, dir, "b.go", "b")
w.Add("b.go")
w.Commit("fix: correct null pointer in edge case handler", &gogit.CommitOptions{Author: testSig()})
old := os.Stderr
r, wp, _ := os.Pipe()
os.Stderr = wp
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
wp.Close()
os.Stderr = old
io.ReadAll(r)
if err != nil {
t.Fatalf("verbose breaking+fix: unexpected error: %v", err)
}
}
// ── release.env write error ───────────────────────────────────────────────────
func TestRunReleaseEnvWriteFails(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()})
// Empty directory is invisible to git — dirty check passes.
os.Mkdir(filepath.Join(dir, "release.env"), 0755)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when release.env is a directory")
}
}
// ── pom stat error ────────────────────────────────────────────────────────────
func TestRunPomStatError(t *testing.T) {
// A null byte in the path makes os.Stat return EINVAL (not ErrNotExist),
// so hasPom=true and the stat error is propagated.
_, 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, "--branch", "release/1.2", "--repo", dir, "--pom", "pom\x00.xml")
if err == nil {
t.Fatal("expected error for pom path with null byte (EINVAL)")
}
}
// ── changelog update error ────────────────────────────────────────────────────
func TestRunChangelogUpdateFails(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()})
// Empty directory is invisible to git — dirty check passes.
// changelog.Update will fail trying to ReadFile on a directory.
os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when CHANGELOG.md is a directory")
}
}
// ── CommitFiles error ─────────────────────────────────────────────────────────
func TestRunCommitFilesError(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()})
old := gitCommitFiles
gitCommitFiles = func(_ *gogit.Repository, _ []string, _, _, _ string) (plumbing.Hash, error) {
return plumbing.ZeroHash, fmt.Errorf("injected commit error")
}
defer func() { gitCommitFiles = old }()
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error from CommitFiles")
}
}
// ── parseBumpRules coverage ───────────────────────────────────────────────────
func TestParseBumpRules(t *testing.T) {
rules := config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"}
m := parseBumpRules(rules)
if len(m) != 3 {
t.Errorf("expected 3 entries in bump rules map, got %d", len(m))
}
}
// ── printVerboseConfig — bump_rules and node rows ─────────────────────────────
func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
cfg := config.Config{
Git: config.GitConfig{
BumpRules: config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"},
},
Node: config.NodeConfig{PackageJSON: "package.json"},
}
src := config.Sources{}
old := os.Stderr
r, wp, _ := os.Pipe()
os.Stderr = wp
printVerboseConfig(cfg, src)
wp.Close()
os.Stderr = old
out, _ := io.ReadAll(r)
output := string(out)
if !strings.Contains(output, "minor") {
t.Error("expected 'minor' in output for bump_rules")
}
if !strings.Contains(output, "package.json") {
t.Error("expected 'package.json' in output for node.paths")
}
}
// ── node package.json handling ────────────────────────────────────────────────
func writePackageJSON(t *testing.T, dir, ver string) {
t.Helper()
content := fmt.Sprintf(`{"name": "my-app", "version": "%s"}`, ver)
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestRunNodeVersionBump(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writePackageJSON(t, dir, "0.0.0")
commitAll(t, repo, dir, "chore: init")
// .releaser.yml is untracked — go-git IsClean ignores untracked files
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
err = execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("node version bump: unexpected error: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "package.json"))
if !strings.Contains(string(data), `"version": "1.2.0"`) {
t.Errorf("expected version 1.2.0 in package.json, got: %s", data)
}
}
func TestRunNodeReadVersionFails(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
// invalid JSON — ReadVersion will fail
os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{not json`), 0644)
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
err = execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when package.json has invalid JSON")
}
}
func TestRunNodeWriteVersionFails(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writePackageJSON(t, dir, "0.0.0")
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
// Make package.json read-only so WriteVersion fails
os.Chmod(filepath.Join(dir, "package.json"), 0444)
defer os.Chmod(filepath.Join(dir, "package.json"), 0644)
err = execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when package.json is read-only")
}
}
+40
View File
@@ -99,6 +99,28 @@ func TestUpdateBreakingSection(t *testing.T) {
} }
} }
func TestUpdateExistingFileNoHeading(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
// File with content but no ## [ heading — new section appended at bottom.
os.WriteFile(path, []byte("# Changelog\n\nSome preamble.\n"), 0644)
err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"})
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
s := string(data)
if !strings.Contains(s, "## [1.0.0]") {
t.Error("expected version header appended")
}
if !strings.Contains(s, "# Changelog") {
t.Error("expected original content preserved")
}
}
func TestUpdateReadError(t *testing.T) { func TestUpdateReadError(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
// Create a directory where the file should be — ReadFile will error. // Create a directory where the file should be — ReadFile will error.
@@ -109,3 +131,21 @@ func TestUpdateReadError(t *testing.T) {
t.Error("expected error when path is a directory") t.Error("expected error when path is a directory")
} }
} }
func TestUpdateIdempotent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "CHANGELOG.md")
// Pre-seed with the version heading already present.
os.WriteFile(path, []byte("# Changelog\n\n## [1.0.0] - 2026-01-01\n\n- fix: something\n"), 0644)
// Second call must be a no-op (returns nil, file unchanged).
if err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"}); err != nil {
t.Fatalf("idempotent Update should not error, got: %v", err)
}
data, _ := os.ReadFile(path)
if strings.Count(string(data), "## [1.0.0]") != 1 {
t.Error("version heading should appear exactly once after idempotent call")
}
}
+8
View File
@@ -83,6 +83,14 @@ func TestReleasableSet(t *testing.T) {
if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] { if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] {
t.Errorf("fix-only set: %v", only) t.Errorf("fix-only set: %v", only)
} }
onlyFeat := ReleasableSet([]string{"feat"})
if onlyFeat[TypeFix] || !onlyFeat[TypeFeat] || onlyFeat[TypeBreaking] {
t.Errorf("feat-only set: %v", onlyFeat)
}
onlyBreaking := ReleasableSet([]string{"breaking"})
if onlyBreaking[TypeFix] || onlyBreaking[TypeFeat] || !onlyBreaking[TypeBreaking] {
t.Errorf("breaking-only set: %v", onlyBreaking)
}
} }
func TestTypeString(t *testing.T) { func TestTypeString(t *testing.T) {
+65 -1
View File
@@ -16,6 +16,7 @@ const filename = ".releaser.yml"
type Config struct { type Config struct {
Git GitConfig `yaml:"git"` Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"` Maven MavenConfig `yaml:"maven"`
Node NodeConfig `yaml:"node"`
GitLab GitLabConfig `yaml:"gitlab"` GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"` GitHub GitHubConfig `yaml:"github"`
} }
@@ -27,10 +28,49 @@ type GitConfig struct {
AuthorName string `yaml:"author_name"` AuthorName string `yaml:"author_name"`
AuthorEmail string `yaml:"author_email"` AuthorEmail string `yaml:"author_email"`
ReleasableTypes []string `yaml:"releasable_types"` ReleasableTypes []string `yaml:"releasable_types"`
BumpRules BumpRulesConfig `yaml:"bump_rules"`
}
// BumpRulesConfig controls what version component each commit type bumps.
// Valid values: "patch" (default) or "minor".
type BumpRulesConfig struct {
Breaking string `yaml:"breaking"`
Feat string `yaml:"feat"`
Fix string `yaml:"fix"`
} }
type MavenConfig struct { type MavenConfig struct {
PomPath string `yaml:"pom_path"` PomPath string `yaml:"pom_path"` // single path (default: "pom.xml")
PomPaths []string `yaml:"pom_paths"` // multiple paths; overrides PomPath when set
}
// EffectivePomPaths returns the list of pom.xml paths to process.
// PomPaths takes precedence over PomPath; falls back to ["pom.xml"].
func (m MavenConfig) EffectivePomPaths() []string {
if len(m.PomPaths) > 0 {
return m.PomPaths
}
if m.PomPath != "" {
return []string{m.PomPath}
}
return []string{"pom.xml"}
}
type NodeConfig struct {
PackageJSON string `yaml:"package_json"` // single path
PackageJSONs []string `yaml:"package_jsons"` // multiple paths; overrides PackageJSON when set
}
// EffectivePaths returns the list of package.json paths to process.
// Returns nil when no node paths are configured (node processing is opt-in).
func (n NodeConfig) EffectivePaths() []string {
if len(n.PackageJSONs) > 0 {
return n.PackageJSONs
}
if n.PackageJSON != "" {
return []string{n.PackageJSON}
}
return nil
} }
type GitLabConfig struct { type GitLabConfig struct {
@@ -70,7 +110,13 @@ func defaultSources() Sources {
"git.author_name": "default", "git.author_name": "default",
"git.author_email": "default", "git.author_email": "default",
"git.releasable_types": "default", "git.releasable_types": "default",
"git.bump_rules.breaking": "default",
"git.bump_rules.feat": "default",
"git.bump_rules.fix": "default",
"maven.pom_path": "default", "maven.pom_path": "default",
"maven.pom_paths": "default",
"node.package_json": "default",
"node.package_jsons": "default",
"gitlab.url": "default", "gitlab.url": "default",
"gitlab.token": "default", "gitlab.token": "default",
"gitlab.project": "default", "gitlab.project": "default",
@@ -126,9 +172,27 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if len(overlay.Git.ReleasableTypes) > 0 { if len(overlay.Git.ReleasableTypes) > 0 {
src["git.releasable_types"] = "config file" src["git.releasable_types"] = "config file"
} }
if overlay.Git.BumpRules.Breaking != "" {
src["git.bump_rules.breaking"] = "config file"
}
if overlay.Git.BumpRules.Feat != "" {
src["git.bump_rules.feat"] = "config file"
}
if overlay.Git.BumpRules.Fix != "" {
src["git.bump_rules.fix"] = "config file"
}
if overlay.Maven.PomPath != "" { if overlay.Maven.PomPath != "" {
src["maven.pom_path"] = "config file" src["maven.pom_path"] = "config file"
} }
if len(overlay.Maven.PomPaths) > 0 {
src["maven.pom_paths"] = "config file"
}
if overlay.Node.PackageJSON != "" {
src["node.package_json"] = "config file"
}
if len(overlay.Node.PackageJSONs) > 0 {
src["node.package_jsons"] = "config file"
}
if overlay.GitLab.URL != "" { if overlay.GitLab.URL != "" {
src["gitlab.url"] = "config file" src["gitlab.url"] = "config file"
} }
+169
View File
@@ -123,6 +123,175 @@ func TestApplyEnvProjectPathFallback(t *testing.T) {
} }
} }
func TestLoadWithSourcesFullConfig(t *testing.T) {
dir := t.TempDir()
content := `
git:
tag_prefix: "v"
branch_pattern: "^release/(\\d+)$"
commit_message: "release {version}"
author_name: "Bot"
author_email: "bot@example.com"
releasable_types: ["fix", "feat"]
bump_rules:
breaking: "minor"
feat: "patch"
fix: "patch"
maven:
pom_path: "sub/pom.xml"
pom_paths:
- "a/pom.xml"
- "b/pom.xml"
node:
package_json: "frontend/package.json"
package_jsons:
- "pkg-a/package.json"
- "pkg-b/package.json"
gitlab:
url: "https://gitlab.example.com"
token: "gitlab-token"
project: "42"
github:
token: "github-token"
repo: "owner/repo"
`
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
_, src, err := LoadWithSources(dir)
if err != nil {
t.Fatal(err)
}
wantConfigFile := []string{
"git.tag_prefix", "git.branch_pattern", "git.commit_message",
"git.author_name", "git.author_email", "git.releasable_types",
"git.bump_rules.breaking", "git.bump_rules.feat", "git.bump_rules.fix",
"maven.pom_path", "maven.pom_paths",
"node.package_json", "node.package_jsons",
"gitlab.url", "gitlab.token", "gitlab.project",
"github.token", "github.repo",
}
for _, key := range wantConfigFile {
if got := src[key]; got != "config file" {
t.Errorf("src[%q] = %q, want %q", key, got, "config file")
}
}
}
func TestEffectivePomPaths(t *testing.T) {
cases := []struct {
name string
cfg MavenConfig
want []string
}{
{"default", MavenConfig{PomPath: "pom.xml"}, []string{"pom.xml"}},
{"single override", MavenConfig{PomPath: "sub/pom.xml"}, []string{"sub/pom.xml"}},
{"multi overrides single", MavenConfig{PomPath: "pom.xml", PomPaths: []string{"a/pom.xml", "b/pom.xml"}}, []string{"a/pom.xml", "b/pom.xml"}},
{"empty falls back to default", MavenConfig{}, []string{"pom.xml"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.cfg.EffectivePomPaths()
if len(got) != len(c.want) {
t.Fatalf("got %v, want %v", got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("[%d] got %q, want %q", i, got[i], c.want[i])
}
}
})
}
}
func TestNodeEffectivePaths(t *testing.T) {
cases := []struct {
name string
cfg NodeConfig
want []string
}{
{"empty — opt-in, skip by default", NodeConfig{}, nil},
{"single", NodeConfig{PackageJSON: "package.json"}, []string{"package.json"}},
{"multi overrides single", NodeConfig{PackageJSON: "package.json", PackageJSONs: []string{"a/package.json", "b/package.json"}}, []string{"a/package.json", "b/package.json"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := c.cfg.EffectivePaths()
if len(got) != len(c.want) {
t.Fatalf("got %v, want %v", got, c.want)
}
for i := range got {
if got[i] != c.want[i] {
t.Errorf("[%d] got %q, want %q", i, got[i], c.want[i])
}
}
})
}
}
func TestApplyEnvWithSourcesCIProjectID(t *testing.T) {
t.Setenv("CI_PROJECT_ID", "123")
t.Setenv("CI_PROJECT_PATH", "")
cfg := defaults()
src := defaultSources()
cfg.ApplyEnvWithSources(src)
if src["gitlab.project"] != "env: CI_PROJECT_ID" {
t.Errorf("src[gitlab.project] = %q, want %q", src["gitlab.project"], "env: CI_PROJECT_ID")
}
}
func TestApplyEnvWithSourcesGitHubToken(t *testing.T) {
t.Setenv("GITHUB_TOKEN", "ghtoken")
cfg := defaults()
src := defaultSources()
cfg.ApplyEnvWithSources(src)
if src["github.token"] != "env: GITHUB_TOKEN" {
t.Errorf("src[github.token] = %q, want %q", src["github.token"], "env: GITHUB_TOKEN")
}
}
func TestApplyEnvWithSourcesCIProjectPath(t *testing.T) {
t.Setenv("CI_PROJECT_ID", "")
t.Setenv("CI_PROJECT_PATH", "group/project")
cfg := defaults()
src := defaultSources()
cfg.ApplyEnvWithSources(src)
if src["gitlab.project"] != "env: CI_PROJECT_PATH" {
t.Errorf("src[gitlab.project] = %q, want %q", src["gitlab.project"], "env: CI_PROJECT_PATH")
}
}
func TestApplyEnvWithSourcesGitLabToken(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "mytoken")
cfg := defaults()
src := defaultSources()
cfg.ApplyEnvWithSources(src)
if src["gitlab.token"] != "env: GITLAB_TOKEN" {
t.Errorf("src[gitlab.token] = %q, want %q", src["gitlab.token"], "env: GITLAB_TOKEN")
}
}
func TestApplyEnvWithSourcesCIServerURL(t *testing.T) {
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
cfg := defaults()
src := defaultSources()
cfg.ApplyEnvWithSources(src)
if src["gitlab.url"] != "env: CI_SERVER_URL" {
t.Errorf("src[gitlab.url] = %q, want %q", src["gitlab.url"], "env: CI_SERVER_URL")
}
}
func TestLoadPartialOverride(t *testing.T) { func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default // Only override tag_prefix — commit_message should keep its default
+138
View File
@@ -0,0 +1,138 @@
package ghclient
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNew(t *testing.T) {
c := New("tok", "owner/repo")
if c.token != "tok" || c.repo != "owner/repo" || c.httpClient == nil {
t.Fatalf("New fields: token=%q repo=%q httpClient=%v", c.token, c.repo, c.httpClient)
}
}
func TestCreateReleaseSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/releases") {
t.Errorf("path = %q, want .../releases", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
}
var req struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.TagName != "v1.0.0" {
t.Errorf("tag_name = %q, want v1.0.0", req.TagName)
}
w.WriteHeader(http.StatusCreated)
io.WriteString(w, `{}`)
}))
defer srv.Close()
c := New("test-token", "owner/repo")
c.httpClient = srv.Client()
// Override the URL by pointing the client at the test server.
// We can't easily override the URL without a custom transport, so use a
// round-trip wrapper instead.
c.httpClient.Transport = rewriteTransport{base: http.DefaultTransport, target: srv.URL}
if err := c.CreateRelease(context.Background(), "v1.0.0", "release notes"); err != nil {
t.Fatalf("CreateRelease: %v", err)
}
}
func TestCreateReleaseAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
io.WriteString(w, `{"message":"Validation Failed"}`)
}))
defer srv.Close()
c := New("tok", "owner/repo")
c.httpClient = &http.Client{Transport: rewriteTransport{base: http.DefaultTransport, target: srv.URL}}
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
if err == nil {
t.Fatal("expected error for 422 response")
}
if !strings.Contains(err.Error(), "422") {
t.Errorf("error should mention status code: %v", err)
}
}
func TestCreateReleaseAPIErrorNoMessage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
io.WriteString(w, `not json`)
}))
defer srv.Close()
c := New("tok", "owner/repo")
c.httpClient = &http.Client{Transport: rewriteTransport{base: http.DefaultTransport, target: srv.URL}}
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
if err == nil {
t.Fatal("expected error for 500 response")
}
if !strings.Contains(err.Error(), "500") {
t.Errorf("error should mention status code: %v", err)
}
}
func TestCreateReleaseRequestFails(t *testing.T) {
c := New("tok", "owner/repo")
// Use a transport that always fails.
c.httpClient = &http.Client{Transport: alwaysFailTransport{}}
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
if err == nil {
t.Fatal("expected error when HTTP request fails")
}
}
func TestCreateReleaseBadURL(t *testing.T) {
// A repo containing a null byte makes the URL unparseable by http.NewRequestWithContext.
c := New("tok", "owner/repo\x00bad")
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
if err == nil {
t.Fatal("expected error for invalid URL")
}
}
// rewriteTransport redirects all requests to a test server URL.
type rewriteTransport struct {
base http.RoundTripper
target string
}
func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req2 := req.Clone(req.Context())
req2.URL.Scheme = "http"
req2.URL.Host = strings.TrimPrefix(rt.target, "http://")
return rt.base.RoundTrip(req2)
}
// alwaysFailTransport returns an error for every request.
type alwaysFailTransport struct{}
func (alwaysFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
return nil, &testTransportError{"connection refused"}
}
type testTransportError struct{ msg string }
func (e *testTransportError) Error() string { return e.msg }
+27 -14
View File
@@ -85,23 +85,21 @@ func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
return nil return nil
} }
commitHash, err := resolveTagToCommit(repo, ref) tagCommit, err := resolveTagToCommitObj(repo, ref)
if err != nil { if err != nil {
return nil // silently skip malformed tags return nil // silently skip malformed tags
} }
tagCommit, err := repo.CommitObject(commitHash)
if err != nil {
return nil
}
if tagCommit.Hash == headCommit.Hash { if tagCommit.Hash == headCommit.Hash {
candidates = append(candidates, tagCandidate{name, patch}) candidates = append(candidates, tagCandidate{name, patch})
return nil return nil
} }
anc, err := tagCommit.IsAncestor(headCommit) anc, err := tagCommit.IsAncestor(headCommit)
if err != nil || !anc { if err != nil {
return err
}
if !anc {
return nil return nil
} }
@@ -241,6 +239,12 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
return nil return nil
} }
// sshPush is the function used for SSH agent push; replaced in tests to avoid requiring a live agent.
var sshPush = pushWithSSHAgent
// newSSHAgentAuth creates an SSH agent auth method; replaced in tests.
var newSSHAgentAuth = gitssh.NewSSHAgentAuth
// Push pushes the given branch and tag to the "origin" remote. // Push pushes the given branch and tag to the "origin" remote.
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI. // When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
// When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first. // When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first.
@@ -253,7 +257,7 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
if remote, err := repo.Remote("origin"); err == nil { if remote, err := repo.Remote("origin"); err == nil {
urls := remote.Config().URLs urls := remote.Config().URLs
if len(urls) > 0 && isSSHURL(urls[0]) { if len(urls) > 0 && isSSHURL(urls[0]) {
if err := pushWithSSHAgent(repo, branchName, tagName); err == nil { if err := sshPush(repo, branchName, tagName); err == nil {
return nil return nil
} }
} }
@@ -266,7 +270,7 @@ func isSSHURL(u string) bool {
} }
func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error { func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error {
auth, err := gitssh.NewSSHAgentAuth("git") auth, err := newSSHAgentAuth("git")
if err != nil { if err != nil {
return err return err
} }
@@ -331,22 +335,31 @@ func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
return nil return nil
} }
// resolveTagToCommit follows tag objects until it reaches a commit. // resolveTagToCommitObj follows tag objects until it reaches a commit and returns it.
// 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 resolveTagToCommitObj(repo *gogit.Repository, ref *plumbing.Reference) (*object.Commit, error) {
hash := ref.Hash() hash := ref.Hash()
for { for {
obj, err := repo.Object(plumbing.AnyObject, hash) obj, err := repo.Object(plumbing.AnyObject, hash)
if err != nil { if err != nil {
return plumbing.ZeroHash, err return nil, err
} }
switch o := obj.(type) { switch o := obj.(type) {
case *object.Commit: case *object.Commit:
return o.Hash, nil return o, nil
case *object.Tag: case *object.Tag:
hash = o.Target hash = o.Target
default: default:
return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash) return nil, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash)
} }
} }
} }
// resolveTagToCommit follows tag objects until it reaches a commit and returns its hash.
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
c, err := resolveTagToCommitObj(repo, ref)
if err != nil {
return plumbing.ZeroHash, err
}
return c.Hash, nil
}
+407
View File
@@ -1,6 +1,7 @@
package gitutil package gitutil
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
gitconfig "github.com/go-git/go-git/v5/config" gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"git.k3nny.fr/releaser/internal/branch" "git.k3nny.fr/releaser/internal/branch"
) )
@@ -699,3 +701,408 @@ func TestPushWithBareRemote(t *testing.T) {
t.Fatalf("second Push returned unexpected error: %v", err) t.Fatalf("second Push returned unexpected error: %v", err)
} }
} }
// ── IsWorkingTreeClean: w.Status() error path ────────────────────────────────
func TestIsWorkingTreeCleanCorruptIndex(t *testing.T) {
// Use a filesystem repo so we can corrupt the on-disk index.
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
// Overwrite .git/index with garbage so go-git fails to parse it.
indexPath := filepath.Join(dir, ".git", "index")
if err := os.WriteFile(indexPath, []byte("not a valid git index"), 0644); err != nil {
t.Fatal(err)
}
// Reopen — fresh repository object with no cached index.
repo2, err := gogit.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
_, err = IsWorkingTreeClean(repo2)
if err == nil {
t.Error("expected error when git index is corrupt")
}
}
// ── LatestTag: head commit object missing ────────────────────────────────────
func TestLatestTagHeadCommitMissing(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0")
// Detach HEAD to a fake hash that has no backing commit object.
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
t.Fatal(err)
}
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
if err == nil {
t.Error("expected error when HEAD commit object is missing")
}
}
// ── LatestTag: Tags() iterator fails ────────────────────────────────────────
func TestLatestTagTagsIterFails(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// Make .git/refs/tags/ unreadable so that go-git's walkReferencesTree
// returns EPERM when it tries to list the directory, triggering the
// Tags() error path. Skip when running as root (chmod has no effect).
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
if err := os.Chmod(tagsDir, 0000); err != nil {
t.Skipf("cannot chmod %s: %v", tagsDir, err)
}
t.Cleanup(func() { os.Chmod(tagsDir, 0755) })
// Reopen so the filesystem storer holds no cached state.
repo2, err := gogit.PlainOpen(dir)
if err != nil {
t.Skipf("PlainOpen failed (likely running as root): %v", err)
}
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
if err == nil {
t.Error("expected error when refs/tags is unreadable")
}
}
// ── LatestTag: IsAncestor fails → ForEach propagates error ───────────────────
func TestLatestTagIsAncestorFails(t *testing.T) {
// Topology: c0 (base) → c1 (sibling branch, tagged v1.2.0)
// → c2 (master HEAD — diverged from sibling)
// The tag is NOT an ancestor of HEAD. IsAncestor must walk master's history
// all the way back to c0; corrupting c0 makes that walk fail.
repo, dir := newTestRepo(t)
c0 := addCommit(t, repo, dir, "chore: base", "base")
w, _ := repo.Worktree()
if err := w.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("sibling"),
Hash: c0,
Create: true,
}); err != nil {
t.Fatal(err)
}
addCommit(t, repo, dir, "feat: sibling work", "sibling")
addTag(t, repo, "v1.2.0") // tag on the sibling commit (not an ancestor of master)
if err := w.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("master"),
}); err != nil {
t.Fatal(err)
}
addCommit(t, repo, dir, "fix: mainline", "mainline") // HEAD on master
// Corrupt c0 (the common base) so that IsAncestor's commit-graph walk
// fails when it tries to read c0 as a parent of the master HEAD commit.
hashStr := c0.String()
objPath := filepath.Join(dir, ".git", "objects", hashStr[:2], hashStr[2:])
if err := os.Chmod(objPath, 0644); err != nil {
t.Fatalf("chmod object: %v", err)
}
if err := os.WriteFile(objPath, []byte("corrupt"), 0444); err != nil {
t.Fatal(err)
}
repo2, err := gogit.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
// The ForEach callback propagates the IsAncestor error, so LatestTag
// must return a non-nil error (covers the refs.ForEach error path).
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
if err == nil {
t.Error("expected error when commit graph is corrupt during IsAncestor")
}
}
// ── CommitsSince: repo.Head() fails after tag resolve ────────────────────────
func TestCommitsSinceHeadRemoved(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0")
// Remove HEAD so repo.Head() returns ErrReferenceNotFound.
if err := repo.Storer.RemoveReference(plumbing.HEAD); err != nil {
t.Fatal(err)
}
_, err := CommitsSince(repo, "v1.2.0")
if err == nil {
t.Error("expected error when HEAD reference is missing")
}
}
// ── CommitsSince: repo.Log() fails ───────────────────────────────────────────
func TestCommitsSinceFakeHead(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: c2", "v2")
// Point HEAD directly to a non-existent commit hash.
// repo.Head() succeeds (returns the hash) but repo.Log() fails eagerly.
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
t.Fatal(err)
}
_, err := CommitsSince(repo, "v1.2.0")
if err == nil {
t.Error("expected error when HEAD commit object is missing")
}
}
// ── AllCommits: repo.Log() fails ─────────────────────────────────────────────
func TestAllCommitsFakeHead(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// Point HEAD to a non-existent commit hash so repo.Log() fails eagerly.
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
t.Fatal(err)
}
_, err := AllCommits(repo)
if err == nil {
t.Error("expected error when HEAD commit object is missing")
}
}
// ── CommitFiles: w.Commit() fails ────────────────────────────────────────────
func TestCommitFilesUnchanged(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
// test.txt already committed and unchanged — w.Add succeeds, w.Commit fails
// (go-git rejects empty commits when AllowEmptyCommits is false).
_, err := CommitFiles(repo, []string{"test.txt"}, "chore: empty", "Test", "t@t.com")
if err == nil {
t.Error("expected error when committing unchanged file (empty commit)")
}
}
// ── Push: SSH agent success / failure paths ──────────────────────────────────
func TestPushSSHAgentSucceeds(t *testing.T) {
// Mock sshPush so it succeeds without a real SSH agent.
orig := sshPush
sshPush = func(_ *gogit.Repository, _, _ string) error { return nil }
defer func() { sshPush = orig }()
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"git@example.com:owner/repo.git"},
}); err != nil {
t.Fatal(err)
}
if err := Push(repo, "master", "v1.0.0", ""); err != nil {
t.Fatalf("Push with mocked SSH agent should succeed: %v", err)
}
}
func TestPushSSHAgentFailsFallsBackToCLI(t *testing.T) {
// SSH URL remote + sshPush fails → falls through to pushWithCLI.
orig := sshPush
sshPush = func(_ *gogit.Repository, _, _ string) error { return fmt.Errorf("no agent") }
defer func() { sshPush = orig }()
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"git@example.com:owner/repo.git"},
}); err != nil {
t.Fatal(err)
}
// CLI push will fail (no real remote) — we just verify it ran at all.
err := Push(repo, "master", "v1.0.0", "")
if err == nil {
t.Error("expected error after SSH fallback to CLI with unreachable remote")
}
}
// ── pushWithSSHAgent internals ────────────────────────────────────────────────
func TestPushWithSSHAgentAuthFails(t *testing.T) {
orig := newSSHAgentAuth
newSSHAgentAuth = func(_ string) (*gitssh.PublicKeysCallback, error) {
return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
}
defer func() { newSSHAgentAuth = orig }()
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
err := pushWithSSHAgent(repo, "master", "v1.0.0")
if err == nil {
t.Error("expected error when SSH agent auth fails")
}
}
func TestPushWithSSHAgentNoRemote(t *testing.T) {
orig := newSSHAgentAuth
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
return &gitssh.PublicKeysCallback{User: user}, nil
}
defer func() { newSSHAgentAuth = orig }()
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// No remote configured → repo.Remote("origin") fails.
err := pushWithSSHAgent(repo, "master", "v1.0.0")
if err == nil {
t.Error("expected error when no origin remote is configured")
}
}
func TestPushWithSSHAgentPushFails(t *testing.T) {
orig := newSSHAgentAuth
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
return &gitssh.PublicKeysCallback{User: user}, nil
}
defer func() { newSSHAgentAuth = orig }()
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.0.0")
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"/nonexistent/bare/repo"},
}); err != nil {
t.Fatal(err)
}
err := pushWithSSHAgent(repo, "master", "v1.0.0")
if err == nil {
t.Error("expected error when remote push fails")
}
}
func TestPushWithSSHAgentSuccess(t *testing.T) {
remoteDir := t.TempDir()
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
t.Fatal(err)
}
orig := newSSHAgentAuth
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
return &gitssh.PublicKeysCallback{User: user}, nil
}
defer func() { newSSHAgentAuth = orig }()
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.0.0")
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{remoteDir},
}); err != nil {
t.Fatal(err)
}
// Local transport ignores auth — push succeeds regardless of the mock callback.
if err := pushWithSSHAgent(repo, "master", "v1.0.0"); err != nil {
t.Fatalf("expected success pushing to local bare remote: %v", err)
}
}
// ── pushWithGoGit error paths ─────────────────────────────────────────────────
func TestPushWithGoGitNoRemote(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// No remote → repo.Remote("origin") fails inside pushWithGoGit.
err := Push(repo, "master", "v1.0.0", "some-token")
if err == nil {
t.Error("expected error when no origin remote is configured")
}
}
func TestPushWithGoGitPushFails(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.0.0")
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"/nonexistent/bare/repo"},
}); err != nil {
t.Fatal(err)
}
err := Push(repo, "master", "v1.0.0", "some-token")
if err == nil {
t.Error("expected error when remote push fails")
}
}
// ── pushWithCLI: bare repo → no worktree ────────────────────────────────────
func TestPushWithCLIBareRepo(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, true)
if err != nil {
t.Fatal(err)
}
// No token, no SSH URL → goes to pushWithCLI → Worktree() fails for bare repo.
err = Push(repo, "master", "v1.0.0", "")
if err == nil {
t.Error("expected error for bare repo (no worktree)")
}
}
func TestPushWithCLISuccess(t *testing.T) {
// Non-bare repo + local bare remote + no token + no SSH URL → pushWithCLI → success.
repo, dir := newTestRepo(t)
sig := testSig()
wt, _ := repo.Worktree()
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
wt.Add("f.txt")
hash, err := wt.Commit("init", &gogit.CommitOptions{Author: sig})
if err != nil {
t.Fatal(err)
}
if _, err := repo.CreateTag("v1.0.0", hash, nil); err != nil {
t.Fatal(err)
}
remoteDir := t.TempDir()
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
t.Fatal(err)
}
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{remoteDir},
}); err != nil {
t.Fatal(err)
}
// Detect default branch name (go-git uses "master" but git config may differ).
head, _ := repo.Head()
branchName := head.Name().Short()
if err := Push(repo, branchName, "v1.0.0", ""); err != nil {
t.Fatalf("pushWithCLI success: %v", err)
}
}
+42
View File
@@ -0,0 +1,42 @@
package node
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// ReadVersion returns the version field from a package.json file.
func ReadVersion(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read %s: %w", path, err)
}
var pkg struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return "", fmt.Errorf("parse %s: %w", path, err)
}
if pkg.Version == "" {
return "", fmt.Errorf("no version field in %s", path)
}
return pkg.Version, nil
}
// WriteVersion replaces the version field in a package.json file in-place.
// oldVersion must match what ReadVersion returned. Formatting is preserved.
func WriteVersion(path, oldVersion, newVersion string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
old := `"version": "` + oldVersion + `"`
repl := `"version": "` + newVersion + `"`
if !strings.Contains(string(data), old) {
return fmt.Errorf("version %q not found in %s", oldVersion, path)
}
updated := strings.Replace(string(data), old, repl, 1)
return os.WriteFile(path, []byte(updated), 0644)
}
+99
View File
@@ -0,0 +1,99 @@
package node
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeJSON(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "package.json")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
const simplePackage = `{
"name": "my-app",
"version": "1.2.3",
"description": "test"
}`
func TestReadVersion(t *testing.T) {
got, err := ReadVersion(writeJSON(t, simplePackage))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3", got)
}
}
func TestReadVersionMissingFile(t *testing.T) {
_, err := ReadVersion(filepath.Join(t.TempDir(), "package.json"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestReadVersionInvalidJSON(t *testing.T) {
_, err := ReadVersion(writeJSON(t, `{not valid json`))
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestReadVersionNoVersionField(t *testing.T) {
_, err := ReadVersion(writeJSON(t, `{"name":"app"}`))
if err == nil {
t.Error("expected error when version field is absent")
}
}
func TestWriteVersion(t *testing.T) {
path := writeJSON(t, simplePackage)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
if !strings.Contains(string(data), `"version": "1.2.4"`) {
t.Errorf("expected updated version in file; got:\n%s", data)
}
// name and description must be preserved
if !strings.Contains(string(data), `"name": "my-app"`) {
t.Error("name field was lost")
}
}
func TestWriteVersionMissingFile(t *testing.T) {
err := WriteVersion(filepath.Join(t.TempDir(), "package.json"), "1.0.0", "1.0.1")
if err == nil {
t.Error("expected error for missing file")
}
}
func TestWriteVersionNotFound(t *testing.T) {
err := WriteVersion(writeJSON(t, simplePackage), "9.9.9", "9.9.10")
if err == nil {
t.Error("expected error when old version not found")
}
}
// FuzzReadVersion verifies ReadVersion never panics on arbitrary content.
func FuzzReadVersion(f *testing.F) {
f.Add(simplePackage)
f.Add(`{}`)
f.Add(`{"version":"1.0.0"}`)
f.Add(`not json at all`)
f.Add(``)
f.Add("\x00\xff")
f.Fuzz(func(t *testing.T, content string) {
path := filepath.Join(t.TempDir(), "package.json")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
ReadVersion(path) //nolint:errcheck
})
}
+22 -2
View File
@@ -6,18 +6,38 @@ import (
"git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/commits"
) )
// BumpLevel controls which version component is incremented.
type BumpLevel int
const (
BumpPatch BumpLevel = iota
BumpMinor
)
// Next computes the next version string (without tag prefix, e.g. "1.2.4"). // Next computes the next version string (without tag prefix, e.g. "1.2.4").
// currentPatch is -1 when no tag exists yet (first release will be X.Y.0). // currentPatch is -1 when no tag exists yet (first release will be X.Y.0).
// releasable is the set of commit types that trigger a bump; nil defaults to all three. // releasable is the set of commit types that trigger a bump; nil defaults to all three.
// bumpRules maps each type to its bump level; nil defaults to BumpPatch for all.
// Returns ("", false) when there are no releasable commits. // Returns ("", false) when there are no releasable commits.
func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool) (string, bool) { func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool, bumpRules map[commits.Type]BumpLevel) (string, bool) {
if releasable == nil { if releasable == nil {
releasable = commits.ReleasableSet(nil) releasable = commits.ReleasableSet(nil)
} }
found := false
useMinor := false
for _, t := range types { for _, t := range types {
if releasable[t] { if releasable[t] {
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true found = true
if bumpRules[t] == BumpMinor {
useMinor = true
} }
} }
}
if !found {
return "", false return "", false
} }
if useMinor {
return fmt.Sprintf("%d.%d.0", major, minor+1), true
}
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
}
+72 -2
View File
@@ -30,7 +30,7 @@ func TestNext(t *testing.T) {
wantOk: true, wantOk: true,
}, },
{ {
desc: "breaking change still bumps patch on release branch", desc: "breaking change still bumps patch by default",
major: 2, minor: 0, currentPatch: 1, major: 2, minor: 0, currentPatch: 1,
types: []commits.Type{commits.TypeBreaking}, types: []commits.Type{commits.TypeBreaking},
want: "2.0.2", want: "2.0.2",
@@ -61,7 +61,7 @@ func TestNext(t *testing.T) {
for _, c := range cases { for _, c := range cases {
t.Run(c.desc, func(t *testing.T) { t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil) got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, nil)
if ok != c.wantOk { if ok != c.wantOk {
t.Errorf("ok=%v, want %v", ok, c.wantOk) t.Errorf("ok=%v, want %v", ok, c.wantOk)
} }
@@ -71,3 +71,73 @@ func TestNext(t *testing.T) {
}) })
} }
} }
func TestNextMinorBump(t *testing.T) {
rules := map[commits.Type]BumpLevel{
commits.TypeBreaking: BumpMinor,
}
cases := []struct {
desc string
major, minor int
currentPatch int
types []commits.Type
want string
}{
{
desc: "breaking → minor bump",
major: 1, minor: 2, currentPatch: 3,
types: []commits.Type{commits.TypeBreaking},
want: "1.3.0",
},
{
desc: "feat (patch rule) with breaking (minor rule) → minor wins",
major: 1, minor: 2, currentPatch: 3,
types: []commits.Type{commits.TypeFeat, commits.TypeBreaking},
want: "1.3.0",
},
{
desc: "fix only — no minor rule → patch bump",
major: 1, minor: 2, currentPatch: 3,
types: []commits.Type{commits.TypeFix},
want: "1.2.4",
},
{
desc: "first release with minor bump",
major: 2, minor: 0, currentPatch: -1,
types: []commits.Type{commits.TypeBreaking},
want: "2.1.0",
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, rules)
if !ok {
t.Fatal("expected ok=true")
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestNextAllMinorRules(t *testing.T) {
rules := map[commits.Type]BumpLevel{
commits.TypeBreaking: BumpMinor,
commits.TypeFeat: BumpMinor,
commits.TypeFix: BumpMinor,
}
got, ok := Next(1, 4, 2, []commits.Type{commits.TypeFix}, nil, rules)
if !ok || got != "1.5.0" {
t.Errorf("got %q ok=%v, want 1.5.0 true", got, ok)
}
}
func TestNextNilBumpRulesDefaultsToPatch(t *testing.T) {
got, ok := Next(1, 2, 5, []commits.Type{commits.TypeBreaking}, nil, nil)
if !ok || got != "1.2.6" {
t.Errorf("got %q ok=%v, want 1.2.6 true", got, ok)
}
}