feat(releaser): release v1.5.0 — Node.js, multi-module Maven, configurable bump rules
- Add internal/node package: reads/writes package.json version (single or multi-path via node.package_json / node.package_jsons) - Add maven.pom_paths support: update multiple pom.xml files in one release commit; pom_paths overrides pom_path; --pom flag clears pom_paths - Add git.bump_rules config: per-type control of which version component bumps (breaking/feat/fix accept "patch" or "minor"); wired through version.Next() as a new sixth parameter - Extract injectable function vars (absPath, gitAllCommits, gitCommitsSince, gitCommitFiles) to enable error-path testing without interfaces - Achieve 100% per-package statement coverage across all 12 packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
+83
-19
@@ -16,21 +16,61 @@ 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"`
|
||||
}
|
||||
|
||||
type GitConfig struct {
|
||||
TagPrefix string `yaml:"tag_prefix"`
|
||||
BranchPattern string `yaml:"branch_pattern"`
|
||||
CommitMessage string `yaml:"commit_message"`
|
||||
AuthorName string `yaml:"author_name"`
|
||||
AuthorEmail string `yaml:"author_email"`
|
||||
ReleasableTypes []string `yaml:"releasable_types"`
|
||||
TagPrefix string `yaml:"tag_prefix"`
|
||||
BranchPattern string `yaml:"branch_pattern"`
|
||||
CommitMessage string `yaml:"commit_message"`
|
||||
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 {
|
||||
@@ -64,18 +104,24 @@ type Sources map[string]string
|
||||
|
||||
func defaultSources() Sources {
|
||||
return Sources{
|
||||
"git.tag_prefix": "default",
|
||||
"git.branch_pattern": "default",
|
||||
"git.commit_message": "default",
|
||||
"git.author_name": "default",
|
||||
"git.author_email": "default",
|
||||
"git.releasable_types": "default",
|
||||
"maven.pom_path": "default",
|
||||
"gitlab.url": "default",
|
||||
"gitlab.token": "default",
|
||||
"gitlab.project": "default",
|
||||
"github.token": "default",
|
||||
"github.repo": "default",
|
||||
"git.tag_prefix": "default",
|
||||
"git.branch_pattern": "default",
|
||||
"git.commit_message": "default",
|
||||
"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",
|
||||
"github.token": "default",
|
||||
"github.repo": "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
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
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