Release/1.0 #1
+29
-2
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# releaser
|
# releaser
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user