feat(releaser): release v1.5.0 — Node.js, multi-module Maven, configurable bump rules
ci / vet, staticcheck, test, build (push) Failing after 4m11s
release / Build and publish release (push) Successful in 5m7s

- Add internal/node package: reads/writes package.json version (single or
  multi-path via node.package_json / node.package_jsons)
- Add maven.pom_paths support: update multiple pom.xml files in one release
  commit; pom_paths overrides pom_path; --pom flag clears pom_paths
- Add git.bump_rules config: per-type control of which version component bumps
  (breaking/feat/fix accept "patch" or "minor"); wired through version.Next()
  as a new sixth parameter
- Extract injectable function vars (absPath, gitAllCommits, gitCommitsSince,
  gitCommitFiles) to enable error-path testing without interfaces
- Achieve 100% per-package statement coverage across all 12 packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:59:28 +02:00
parent 5af107b06d
commit f07220b0c6
17 changed files with 1759 additions and 77 deletions
+116 -25
View File
@@ -19,6 +19,7 @@ import (
"git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/node"
"git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version"
)
@@ -50,10 +51,33 @@ git:
# - feat
# - breaking
# Configure which version component each commit type bumps.
# Valid values: "patch" (default) or "minor".
# bump_rules:
# breaking: "minor" # bump minor version instead of patch on breaking changes
# feat: "patch"
# fix: "patch"
maven:
# Path to pom.xml, relative to the repository root.
# Single pom.xml path, relative to the repository root.
# pom_path: "pom.xml"
# Multiple pom.xml paths for multi-module projects (overrides pom_path).
# pom_paths:
# - "pom.xml"
# - "module-a/pom.xml"
# - "module-b/pom.xml"
node:
# Single package.json path (node processing is opt-in — no default).
# package_json: "package.json"
# Multiple package.json paths for monorepos (overrides package_json).
# package_jsons:
# - "package.json"
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
gitlab:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com"
@@ -84,6 +108,14 @@ var (
// exitFn is a variable so tests can intercept os.Exit calls.
var exitFn = os.Exit
// injectable function variables for testing error paths.
var (
absPath = filepath.Abs
gitAllCommits = gitutil.AllCommits
gitCommitsSince = gitutil.CommitsSince
gitCommitFiles = gitutil.CommitFiles
)
// releasePublisher is implemented by both glclient and ghclient.
type releasePublisher interface {
CreateRelease(ctx context.Context, tagName, body string) error
@@ -215,7 +247,32 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
}
return strings.Join(cfg.Git.ReleasableTypes, ", ")
}()},
{"maven.pom_path", cfg.Maven.PomPath},
{"git.bump_rules.breaking", func() string {
if cfg.Git.BumpRules.Breaking == "" {
return "patch"
}
return cfg.Git.BumpRules.Breaking
}()},
{"git.bump_rules.feat", func() string {
if cfg.Git.BumpRules.Feat == "" {
return "patch"
}
return cfg.Git.BumpRules.Feat
}()},
{"git.bump_rules.fix", func() string {
if cfg.Git.BumpRules.Fix == "" {
return "patch"
}
return cfg.Git.BumpRules.Fix
}()},
{"maven.pom_paths", strings.Join(cfg.Maven.EffectivePomPaths(), ", ")},
{"node.paths", func() string {
paths := cfg.Node.EffectivePaths()
if len(paths) == 0 {
return "(not configured)"
}
return strings.Join(paths, ", ")
}()},
{"gitlab.url", cfg.GitLab.URL},
{"gitlab.token", func() string {
if cfg.GitLab.Token != "" {
@@ -257,11 +314,25 @@ func initConfig(absRepo string) error {
return nil
}
func parseBumpRules(rules config.BumpRulesConfig) map[commits.Type]semver.BumpLevel {
m := map[commits.Type]semver.BumpLevel{}
if rules.Breaking == "minor" {
m[commits.TypeBreaking] = semver.BumpMinor
}
if rules.Feat == "minor" {
m[commits.TypeFeat] = semver.BumpMinor
}
if rules.Fix == "minor" {
m[commits.TypeFix] = semver.BumpMinor
}
return m
}
func run(o options) error {
logHeader(version)
// --- Config ---
absRepo, err := filepath.Abs(o.repoPath)
absRepo, err := absPath(o.repoPath)
if err != nil {
return fmt.Errorf("resolve repo path: %w", err)
}
@@ -286,7 +357,8 @@ func run(o options) error {
}
if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride
src["maven.pom_path"] = "flag: --pom"
cfg.Maven.PomPaths = nil
src["maven.pom_paths"] = "flag: --pom"
}
if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag
@@ -345,9 +417,9 @@ func run(o options) error {
// --- Commit range ---
var messages []string
if lastTag == "" {
messages, err = gitutil.AllCommits(repo)
messages, err = gitAllCommits(repo)
} else {
messages, err = gitutil.CommitsSince(repo, lastTag)
messages, err = gitCommitsSince(repo, lastTag)
}
if err != nil {
return fmt.Errorf("read commits: %w", err)
@@ -398,7 +470,7 @@ func run(o options) error {
}
releasable := commits.ReleasableSet(cfg.Git.ReleasableTypes)
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable)
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable, parseBumpRules(cfg.Git.BumpRules))
if !ok {
logWarn("no releasable commits found")
return errNothingToRelease
@@ -440,27 +512,46 @@ func run(o options) error {
if !o.tagOnly {
var filesToCommit []string
// pom.xml
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
_, statErr := os.Stat(pomPath)
hasPom := !errors.Is(statErr, os.ErrNotExist)
if statErr != nil && hasPom {
return fmt.Errorf("check pom path: %w", statErr)
// pom.xml (supports multi-module via pom_paths)
anyPom := false
for _, relPomPath := range cfg.Maven.EffectivePomPaths() {
pomPath := filepath.Join(absRepo, relPomPath)
_, statErr := os.Stat(pomPath)
hasPom := !errors.Is(statErr, os.ErrNotExist)
if statErr != nil && hasPom {
return fmt.Errorf("check pom path: %w", statErr)
}
if hasPom {
anyPom = true
currentPomVersion, err := maven.ReadVersion(pomPath)
if err != nil {
return fmt.Errorf("read pom version: %w", err)
}
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err)
}
logDone("%s: %s → %s", relPomPath, currentPomVersion, nextVersion)
filesToCommit = append(filesToCommit, relPomPath)
}
}
if hasPom {
currentPomVersion, err := maven.ReadVersion(pomPath)
if err != nil {
return fmt.Errorf("read pom version: %w", err)
}
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err)
}
logDone("pom.xml: %s → %s", currentPomVersion, nextVersion)
filesToCommit = append(filesToCommit, cfg.Maven.PomPath)
} else {
if !anyPom {
logWarn("no pom.xml — skipping version bump")
}
// package.json (opt-in via node.package_json / node.package_jsons)
for _, relPkgPath := range cfg.Node.EffectivePaths() {
pkgPath := filepath.Join(absRepo, relPkgPath)
currentNodeVersion, err := node.ReadVersion(pkgPath)
if err != nil {
return fmt.Errorf("read package.json version: %w", err)
}
if err := node.WriteVersion(pkgPath, currentNodeVersion, nextVersion); err != nil {
return fmt.Errorf("update package.json version: %w", err)
}
logDone("%s: %s → %s", relPkgPath, currentNodeVersion, nextVersion)
filesToCommit = append(filesToCommit, relPkgPath)
}
// CHANGELOG.md
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
@@ -483,7 +574,7 @@ func run(o options) error {
authorEmail = cfg.Git.AuthorEmail
}
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
if _, err := gitCommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
return fmt.Errorf("commit: %w", err)
}
logDone("committed: %s", commitMsg)
+458
View File
@@ -2,6 +2,7 @@ package main
import (
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -15,6 +16,8 @@ import (
gitcfg "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"git.k3nny.fr/releaser/internal/config"
)
// ── helpers ──────────────────────────────────────────────────────────────────
@@ -742,3 +745,458 @@ func TestRunVerbose(t *testing.T) {
}
}
}
// ── ui.go coverage ────────────────────────────────────────────────────────────
func TestPaintColor(t *testing.T) {
old := useColor
useColor = true
defer func() { useColor = old }()
got := paint(ansiGreen, "hello")
if !strings.Contains(got, "hello") || !strings.Contains(got, ansiReset) || !strings.Contains(got, ansiGreen) {
t.Errorf("paint with color = %q", got)
}
}
func TestFmtSourceEnv(t *testing.T) {
old := useColor
useColor = false
defer func() { useColor = old }()
got := fmtSource("env: GITLAB_TOKEN")
if got != "[env: GITLAB_TOKEN]" {
t.Errorf("fmtSource env = %q", got)
}
}
func TestFmtSourceFlag(t *testing.T) {
old := useColor
useColor = false
defer func() { useColor = old }()
got := fmtSource("flag: --tag-prefix")
if got != "[flag: --tag-prefix]" {
t.Errorf("fmtSource flag = %q", got)
}
}
func TestFmtSourceConfigFile(t *testing.T) {
old := useColor
useColor = false
defer func() { useColor = old }()
got := fmtSource("config file")
if got != "[config file]" {
t.Errorf("fmtSource config file = %q", got)
}
}
// ── buildPublisher coverage ───────────────────────────────────────────────────
func TestBuildPublisherGitHub(t *testing.T) {
cfg := config.Config{
GitHub: config.GitHubConfig{Token: "ghtoken", Repo: "owner/repo"},
}
pub, err := buildPublisher(cfg)
if err != nil {
t.Fatalf("buildPublisher GitHub: %v", err)
}
if pub == nil {
t.Fatal("expected non-nil publisher for GitHub config")
}
}
func TestBuildPublisherGitLabNoToken(t *testing.T) {
cfg := config.Config{
GitLab: config.GitLabConfig{URL: "https://gitlab.example.com", Project: "42"},
}
_, err := buildPublisher(cfg)
if err == nil {
t.Fatal("expected error when GitLab URL+Project set but token is empty")
}
}
// ── printVerboseConfig coverage ───────────────────────────────────────────────
func TestPrintVerboseConfigDirect(t *testing.T) {
// Use a sparse Sources map (missing keys → source == "" → hits "default" branch).
// Also set non-empty ReleasableTypes and both tokens to cover those branches.
cfg := config.Config{
Git: config.GitConfig{
ReleasableTypes: []string{"fix", "feat"},
},
GitLab: config.GitLabConfig{Token: "secret"},
GitHub: config.GitHubConfig{Token: "ghsecret"},
}
src := config.Sources{} // empty → all lookups return ""
old := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
printVerboseConfig(cfg, src)
w.Close()
os.Stderr = old
out, _ := io.ReadAll(r)
if !strings.Contains(string(out), "fix, feat") {
t.Error("expected releasable types joined in output")
}
if !strings.Contains(string(out), "(set)") {
t.Error("expected '(set)' for configured tokens")
}
}
// ── initConfig coverage ───────────────────────────────────────────────────────
func TestInitConfigWriteFails(t *testing.T) {
dir := t.TempDir()
os.Chmod(dir, 0555)
defer os.Chmod(dir, 0755)
err := initConfig(dir)
if err == nil {
t.Fatal("expected error writing .releaser.yml to read-only directory")
}
}
// ── run() injectable error paths ─────────────────────────────────────────────
func TestRunVerboseInit(t *testing.T) {
dir := t.TempDir()
err := execCmd(t, "--init", "--verbose", "--repo", dir)
if err != nil {
t.Fatalf("--init --verbose: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".releaser.yml")); err != nil {
t.Error("expected .releaser.yml to be created")
}
}
func TestRunAbsPathError(t *testing.T) {
old := absPath
absPath = func(string) (string, error) { return "", fmt.Errorf("injected abs error") }
defer func() { absPath = old }()
err := execCmd(t, "--repo", ".")
if err == nil {
t.Fatal("expected error when filepath.Abs fails")
}
}
func TestRunWorkingTreeCheckFails(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Corrupt the git index so IsWorkingTreeClean fails.
os.WriteFile(filepath.Join(dir, ".git", "index"), []byte("garbage"), 0644)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for corrupt git index")
}
}
func TestRunLatestTagFails(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
os.Chmod(tagsDir, 0000)
defer os.Chmod(tagsDir, 0755)
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for unreadable tags directory")
}
}
func TestRunAllCommitsError(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
old := gitAllCommits
gitAllCommits = func(_ *gogit.Repository) ([]string, error) {
return nil, fmt.Errorf("injected AllCommits error")
}
defer func() { gitAllCommits = old }()
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error from AllCommits")
}
}
func TestRunCommitsSinceError(t *testing.T) {
repo, dir := setupRepo(t)
head, _ := repo.Head()
repo.CreateTag("1.2.0", head.Hash(), nil)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
old := gitCommitsSince
gitCommitsSince = func(_ *gogit.Repository, _ string) ([]string, error) {
return nil, fmt.Errorf("injected CommitsSince error")
}
defer func() { gitCommitsSince = old }()
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error from CommitsSince")
}
}
// ── verbose commit section coverage ──────────────────────────────────────────
func TestRunVerboseBreakingAndFix(t *testing.T) {
// Covers: verbose "since: lastTag", message truncation (>70 chars),
// TypeBreaking color (ansiRed+ansiBold), TypeFix color (ansiGreen).
repo, dir := setupRepo(t)
head, _ := repo.Head()
repo.CreateTag("1.2.0", head.Hash(), nil)
w, _ := repo.Worktree()
addFile(t, dir, "a.go", "a")
w.Add("a.go")
w.Commit("feat!: redesign the entire public API surface which is a very long commit message header", &gogit.CommitOptions{Author: testSig()})
addFile(t, dir, "b.go", "b")
w.Add("b.go")
w.Commit("fix: correct null pointer in edge case handler", &gogit.CommitOptions{Author: testSig()})
old := os.Stderr
r, wp, _ := os.Pipe()
os.Stderr = wp
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
wp.Close()
os.Stderr = old
io.ReadAll(r)
if err != nil {
t.Fatalf("verbose breaking+fix: unexpected error: %v", err)
}
}
// ── release.env write error ───────────────────────────────────────────────────
func TestRunReleaseEnvWriteFails(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Empty directory is invisible to git — dirty check passes.
os.Mkdir(filepath.Join(dir, "release.env"), 0755)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when release.env is a directory")
}
}
// ── pom stat error ────────────────────────────────────────────────────────────
func TestRunPomStatError(t *testing.T) {
// A null byte in the path makes os.Stat return EINVAL (not ErrNotExist),
// so hasPom=true and the stat error is propagated.
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "pom\x00.xml")
if err == nil {
t.Fatal("expected error for pom path with null byte (EINVAL)")
}
}
// ── changelog update error ────────────────────────────────────────────────────
func TestRunChangelogUpdateFails(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Empty directory is invisible to git — dirty check passes.
// changelog.Update will fail trying to ReadFile on a directory.
os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when CHANGELOG.md is a directory")
}
}
// ── CommitFiles error ─────────────────────────────────────────────────────────
func TestRunCommitFilesError(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
old := gitCommitFiles
gitCommitFiles = func(_ *gogit.Repository, _ []string, _, _, _ string) (plumbing.Hash, error) {
return plumbing.ZeroHash, fmt.Errorf("injected commit error")
}
defer func() { gitCommitFiles = old }()
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error from CommitFiles")
}
}
// ── parseBumpRules coverage ───────────────────────────────────────────────────
func TestParseBumpRules(t *testing.T) {
rules := config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"}
m := parseBumpRules(rules)
if len(m) != 3 {
t.Errorf("expected 3 entries in bump rules map, got %d", len(m))
}
}
// ── printVerboseConfig — bump_rules and node rows ─────────────────────────────
func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
cfg := config.Config{
Git: config.GitConfig{
BumpRules: config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"},
},
Node: config.NodeConfig{PackageJSON: "package.json"},
}
src := config.Sources{}
old := os.Stderr
r, wp, _ := os.Pipe()
os.Stderr = wp
printVerboseConfig(cfg, src)
wp.Close()
os.Stderr = old
out, _ := io.ReadAll(r)
output := string(out)
if !strings.Contains(output, "minor") {
t.Error("expected 'minor' in output for bump_rules")
}
if !strings.Contains(output, "package.json") {
t.Error("expected 'package.json' in output for node.paths")
}
}
// ── node package.json handling ────────────────────────────────────────────────
func writePackageJSON(t *testing.T, dir, ver string) {
t.Helper()
content := fmt.Sprintf(`{"name": "my-app", "version": "%s"}`, ver)
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestRunNodeVersionBump(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writePackageJSON(t, dir, "0.0.0")
commitAll(t, repo, dir, "chore: init")
// .releaser.yml is untracked — go-git IsClean ignores untracked files
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
err = execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("node version bump: unexpected error: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "package.json"))
if !strings.Contains(string(data), `"version": "1.2.0"`) {
t.Errorf("expected version 1.2.0 in package.json, got: %s", data)
}
}
func TestRunNodeReadVersionFails(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
// invalid JSON — ReadVersion will fail
os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{not json`), 0644)
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
err = execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when package.json has invalid JSON")
}
}
func TestRunNodeWriteVersionFails(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writePackageJSON(t, dir, "0.0.0")
commitAll(t, repo, dir, "chore: init")
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
// Make package.json read-only so WriteVersion fails
os.Chmod(filepath.Join(dir, "package.json"), 0444)
defer os.Chmod(filepath.Join(dir, "package.json"), 0644)
err = execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when package.json is read-only")
}
}