Release/1.0 #1
+29
-2
@@ -19,10 +19,37 @@ git:
|
||||
# author_name: ""
|
||||
# author_email: ""
|
||||
|
||||
maven:
|
||||
# Path to pom.xml, relative to the repository root.
|
||||
# Limit which commit types trigger a release (default: fix, feat, breaking).
|
||||
# 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"
|
||||
|
||||
# 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 instance URL. Falls back to the CI_SERVER_URL environment variable.
|
||||
# url: "https://gitlab.example.com"
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# releaser
|
||||
|
||||

|
||||

|
||||
|
||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
||||
|
||||
@@ -27,12 +27,14 @@ release/1.2 branch
|
||||
|
||||
## Version bump rules
|
||||
|
||||
| Commit type | Bump | Notes |
|
||||
|------------------|---------|--------------------------------------------|
|
||||
| `fix:` | patch | |
|
||||
| `feat:` | patch | minor is pinned to branch |
|
||||
| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary |
|
||||
| `chore:`, `docs:`, etc. | none | |
|
||||
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`:
|
||||
|
||||
| Commit type | Default | Configurable via `bump_rules` |
|
||||
|------------------|---------|------------------------------------------------|
|
||||
| `fix:` | patch | `fix: "minor"` to bump minor instead |
|
||||
| `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 |
|
||||
|
||||
## Usage
|
||||
@@ -95,9 +97,23 @@ git:
|
||||
- fix
|
||||
- feat
|
||||
- breaking
|
||||
bump_rules: # which version component each type bumps
|
||||
breaking: "patch" # "minor" to bump minor on breaking changes
|
||||
feat: "patch"
|
||||
fix: "patch"
|
||||
|
||||
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:
|
||||
url: "https://gitlab.example.com" # or env CI_SERVER_URL
|
||||
|
||||
+8
-3
@@ -76,10 +76,15 @@
|
||||
- [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release)
|
||||
- [ ] 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
|
||||
|
||||
- Multi-module Maven support (multiple `pom.xml` paths)
|
||||
- Gradle support (`build.gradle` / `build.gradle.kts`)
|
||||
- `package.json` version bump support (Node.js projects)
|
||||
- Slack / Teams notification on release
|
||||
- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch)
|
||||
- Documentation site
|
||||
|
||||
+104
-13
@@ -19,6 +19,7 @@ import (
|
||||
"git.k3nny.fr/releaser/internal/gitutil"
|
||||
"git.k3nny.fr/releaser/internal/glclient"
|
||||
"git.k3nny.fr/releaser/internal/maven"
|
||||
"git.k3nny.fr/releaser/internal/node"
|
||||
"git.k3nny.fr/releaser/internal/notes"
|
||||
semver "git.k3nny.fr/releaser/internal/version"
|
||||
)
|
||||
@@ -50,10 +51,33 @@ git:
|
||||
# - feat
|
||||
# - 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:
|
||||
# Path to pom.xml, relative to the repository root.
|
||||
# Single pom.xml path, relative to the repository root.
|
||||
# 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 instance URL. Falls back to the CI_SERVER_URL environment variable.
|
||||
# url: "https://gitlab.example.com"
|
||||
@@ -84,6 +108,14 @@ var (
|
||||
// exitFn is a variable so tests can intercept os.Exit calls.
|
||||
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.
|
||||
type releasePublisher interface {
|
||||
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, ", ")
|
||||
}()},
|
||||
{"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.token", func() string {
|
||||
if cfg.GitLab.Token != "" {
|
||||
@@ -257,11 +314,25 @@ func initConfig(absRepo string) error {
|
||||
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 {
|
||||
logHeader(version)
|
||||
|
||||
// --- Config ---
|
||||
absRepo, err := filepath.Abs(o.repoPath)
|
||||
absRepo, err := absPath(o.repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve repo path: %w", err)
|
||||
}
|
||||
@@ -286,7 +357,8 @@ func run(o options) error {
|
||||
}
|
||||
if 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 {
|
||||
cfg.Git.BranchPattern = o.patternFlag
|
||||
@@ -345,9 +417,9 @@ func run(o options) error {
|
||||
// --- Commit range ---
|
||||
var messages []string
|
||||
if lastTag == "" {
|
||||
messages, err = gitutil.AllCommits(repo)
|
||||
messages, err = gitAllCommits(repo)
|
||||
} else {
|
||||
messages, err = gitutil.CommitsSince(repo, lastTag)
|
||||
messages, err = gitCommitsSince(repo, lastTag)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read commits: %w", err)
|
||||
@@ -398,7 +470,7 @@ func run(o options) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
logWarn("no releasable commits found")
|
||||
return errNothingToRelease
|
||||
@@ -440,14 +512,17 @@ func run(o options) error {
|
||||
if !o.tagOnly {
|
||||
var filesToCommit []string
|
||||
|
||||
// pom.xml
|
||||
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
||||
// pom.xml (supports multi-module via pom_paths)
|
||||
anyPom := false
|
||||
for _, relPomPath := range cfg.Maven.EffectivePomPaths() {
|
||||
pomPath := filepath.Join(absRepo, relPomPath)
|
||||
_, statErr := os.Stat(pomPath)
|
||||
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
||||
if statErr != nil && hasPom {
|
||||
return fmt.Errorf("check pom path: %w", statErr)
|
||||
}
|
||||
if hasPom {
|
||||
anyPom = true
|
||||
currentPomVersion, err := maven.ReadVersion(pomPath)
|
||||
if err != nil {
|
||||
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 {
|
||||
return fmt.Errorf("update pom version: %w", err)
|
||||
}
|
||||
logDone("pom.xml: %s → %s", currentPomVersion, nextVersion)
|
||||
filesToCommit = append(filesToCommit, cfg.Maven.PomPath)
|
||||
} else {
|
||||
logDone("%s: %s → %s", relPomPath, currentPomVersion, nextVersion)
|
||||
filesToCommit = append(filesToCommit, relPomPath)
|
||||
}
|
||||
}
|
||||
if !anyPom {
|
||||
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
|
||||
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
|
||||
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
|
||||
@@ -483,7 +574,7 @@ func run(o options) error {
|
||||
authorEmail = cfg.Git.AuthorEmail
|
||||
}
|
||||
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)
|
||||
}
|
||||
logDone("committed: %s", commitMsg)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
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"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/config"
|
||||
)
|
||||
|
||||
// ── 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
dir := t.TempDir()
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,14 @@ func TestReleasableSet(t *testing.T) {
|
||||
if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] {
|
||||
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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const filename = ".releaser.yml"
|
||||
type Config struct {
|
||||
Git GitConfig `yaml:"git"`
|
||||
Maven MavenConfig `yaml:"maven"`
|
||||
Node NodeConfig `yaml:"node"`
|
||||
GitLab GitLabConfig `yaml:"gitlab"`
|
||||
GitHub GitHubConfig `yaml:"github"`
|
||||
}
|
||||
@@ -27,10 +28,49 @@ type GitConfig struct {
|
||||
AuthorName string `yaml:"author_name"`
|
||||
AuthorEmail string `yaml:"author_email"`
|
||||
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 {
|
||||
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 {
|
||||
@@ -70,7 +110,13 @@ func defaultSources() Sources {
|
||||
"git.author_name": "default",
|
||||
"git.author_email": "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_paths": "default",
|
||||
"node.package_json": "default",
|
||||
"node.package_jsons": "default",
|
||||
"gitlab.url": "default",
|
||||
"gitlab.token": "default",
|
||||
"gitlab.project": "default",
|
||||
@@ -126,9 +172,27 @@ func LoadWithSources(dir string) (Config, Sources, error) {
|
||||
if len(overlay.Git.ReleasableTypes) > 0 {
|
||||
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 != "" {
|
||||
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 != "" {
|
||||
src["gitlab.url"] = "config 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) {
|
||||
dir := t.TempDir()
|
||||
// Only override tag_prefix — commit_message should keep its default
|
||||
|
||||
@@ -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
@@ -85,23 +85,21 @@ func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
commitHash, err := resolveTagToCommit(repo, ref)
|
||||
tagCommit, err := resolveTagToCommitObj(repo, ref)
|
||||
if err != nil {
|
||||
return nil // silently skip malformed tags
|
||||
}
|
||||
|
||||
tagCommit, err := repo.CommitObject(commitHash)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tagCommit.Hash == headCommit.Hash {
|
||||
candidates = append(candidates, tagCandidate{name, patch})
|
||||
return nil
|
||||
}
|
||||
|
||||
anc, err := tagCommit.IsAncestor(headCommit)
|
||||
if err != nil || !anc {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !anc {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -241,6 +239,12 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
|
||||
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.
|
||||
// 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.
|
||||
@@ -253,7 +257,7 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||
if remote, err := repo.Remote("origin"); err == nil {
|
||||
urls := remote.Config().URLs
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -266,7 +270,7 @@ func isSSHURL(u string) bool {
|
||||
}
|
||||
|
||||
func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error {
|
||||
auth, err := gitssh.NewSSHAgentAuth("git")
|
||||
auth, err := newSSHAgentAuth("git")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -331,22 +335,31 @@ func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
|
||||
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).
|
||||
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()
|
||||
for {
|
||||
obj, err := repo.Object(plumbing.AnyObject, hash)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
return nil, err
|
||||
}
|
||||
switch o := obj.(type) {
|
||||
case *object.Commit:
|
||||
return o.Hash, nil
|
||||
return o, nil
|
||||
case *object.Tag:
|
||||
hash = o.Target
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
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/object"
|
||||
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
)
|
||||
@@ -699,3 +701,408 @@ func TestPushWithBareRemote(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -6,18 +6,38 @@ import (
|
||||
"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").
|
||||
// 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.
|
||||
// bumpRules maps each type to its bump level; nil defaults to BumpPatch for all.
|
||||
// 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 {
|
||||
releasable = commits.ReleasableSet(nil)
|
||||
}
|
||||
found := false
|
||||
useMinor := false
|
||||
for _, t := range types {
|
||||
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
|
||||
}
|
||||
if useMinor {
|
||||
return fmt.Sprintf("%d.%d.0", major, minor+1), true
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestNext(t *testing.T) {
|
||||
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,
|
||||
types: []commits.Type{commits.TypeBreaking},
|
||||
want: "2.0.2",
|
||||
@@ -61,7 +61,7 @@ func TestNext(t *testing.T) {
|
||||
|
||||
for _, c := range cases {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user