feat(cmd): release v1.10.0 — shallow clone guard and --check preflight
ci / vet, staticcheck, test, build (push) Successful in 3m46s
docs / Build and deploy docs (push) Failing after 11s
release / Build and publish release (push) Successful in 4m34s

- refuse to release when the clone is shallow and no previous release tag
  is found: tags beyond the fetch depth would silently restart versioning
  at X.Y.0; --allow-shallow bypasses the guard for a true first release
- new --check preflight validates the release environment without
  releasing: branch resolution + pattern, working tree, tag discovery,
  shallow clone, remote origin URL parse (same parse the push performs),
  push auth method, version files, release target — all problems reported
  at once, non-zero exit on failure
- .releaser.gitlab-ci.yml gains an optional .releaser:check MR-pipeline
  job; CI examples now set GIT_DEPTH: 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 22:12:17 +02:00
parent 2a6a65ce80
commit 37db8e97ca
11 changed files with 539 additions and 1 deletions
+177
View File
@@ -9,6 +9,7 @@ import (
"strings"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/spf13/cobra"
"git.k3nny.fr/releaser/internal/branch"
@@ -194,6 +195,8 @@ func newRootCmd() *cobra.Command {
noRelease bool
noCommit bool
tagOnly bool
allowShallow bool
check bool
branchOverride string
repoPath string
pomOverride string
@@ -233,6 +236,8 @@ func newRootCmd() *cobra.Command {
noRelease: noRelease,
noCommit: noCommit,
tagOnly: tagOnly,
allowShallow: allowShallow,
check: check,
releaseEnvFile: releaseEnvFile,
})
},
@@ -245,6 +250,8 @@ func newRootCmd() *cobra.Command {
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the release")
root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push")
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)")
root.Flags().BoolVar(&allowShallow, "allow-shallow", false, "proceed in a shallow clone even when no previous release tag is found")
root.Flags().BoolVar(&check, "check", false, "preflight: validate branch, working tree, remote, auth, version files, and release target without releasing")
root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (detached HEAD falls back to CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, GITHUB_REF_NAME)")
root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository")
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config")
@@ -286,6 +293,8 @@ type options struct {
noRelease bool
noCommit bool
tagOnly bool
allowShallow bool
check bool
releaseEnvFile string
}
@@ -454,6 +463,10 @@ func run(o options) error {
printVerboseConfig(cfg, src)
}
if o.check {
return runCheck(o, cfg, absRepo)
}
// --- Git ---
repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true})
if err != nil {
@@ -499,6 +512,20 @@ func run(o options) error {
return fmt.Errorf("find latest tag: %w", err)
}
// A shallow clone with no tag found is ambiguous: either a genuine first
// release, or the previous tags sit beyond the fetch depth — in which case
// releasing would silently restart at X.Y.0. When a tag was found the
// version math is correct regardless of shallowness.
if lastTag == "" && !o.allowShallow {
shallow, err := gitutil.IsShallow(repo)
if err != nil {
return fmt.Errorf("check shallow clone: %w", err)
}
if shallow {
return fmt.Errorf("no release tag found in a shallow clone — previous tags may be beyond the fetch depth; fetch full history (GIT_DEPTH: 0 in GitLab CI, fetch-depth: 0 in GitHub Actions, or git fetch --unshallow), or pass --allow-shallow if this is truly the first release")
}
}
// --- Commit range ---
var messages []string
if lastTag == "" {
@@ -738,6 +765,156 @@ func run(o options) error {
return nil
}
// runCheck is the --check preflight: it validates everything a release needs
// without writing or pushing anything, and reports every problem at once
// instead of stopping at the first. Purely informational lines use logStep;
// validated checks use logDone; failures use logWarn and fail the run.
func runCheck(o options, cfg config.Config, absRepo string) error {
logSection("check")
failed := 0
fail := func(format string, args ...any) {
logWarn(format, args...)
failed++
}
repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true})
if err != nil {
return fmt.Errorf("open repository: %w", err)
}
// --- branch resolution + pattern match ---
var info branch.Info
branchOK := false
branchName := o.branchOverride
var branchErr error
if branchName == "" {
branchName, branchErr = gitutil.CurrentBranch(repo)
}
if branchErr == nil {
info, branchErr = branch.Parse(branchName, cfg.Git.BranchPattern)
}
if branchErr != nil {
fail("branch: %v", branchErr)
} else {
info.TagPrefix = cfg.Git.TagPrefix
branchOK = true
logDone("branch: %s → major=%d, minor=%d", branchName, info.Major, info.Minor)
}
// --- working tree ---
if clean, err := gitutil.IsWorkingTreeClean(repo); err != nil {
fail("working tree: %v", err)
} else if !clean {
fail("working tree has uncommitted changes")
} else {
logDone("working tree clean")
}
// --- tag discovery + shallow clone ---
if !branchOK {
logStep("tag discovery skipped (branch unresolved)")
} else if lastTag, _, err := gitutil.LatestTag(repo, info); err != nil {
fail("tag discovery: %v", err)
} else if lastTag != "" {
logDone("latest tag: %s", lastTag)
} else if shallow, err := gitutil.IsShallow(repo); err != nil {
fail("shallow check: %v", err)
} else if shallow && !o.allowShallow {
fail("no release tag found in a shallow clone — previous tags may be beyond the fetch depth; fetch full history or pass --allow-shallow")
} else if shallow {
logStep("no previous tag in a shallow clone (--allow-shallow)")
} else {
logDone("no previous tag (first release)")
}
// --- remote + push auth ---
if remote, err := repo.Remote("origin"); err != nil {
fail("remote origin: %v", err)
} else {
url := remote.Config().URLs[0]
// The same parse the push transport performs — catches malformed
// remote URLs (e.g. shell quoting accidents in CI set-url lines).
if _, err := transport.NewEndpoint(url); err != nil {
fail("remote origin URL: %v", err)
} else {
logDone("remote origin: %s", url)
}
switch {
case cfg.GitLab.Token != "":
logStep("push auth: HTTPS token (oauth2)")
case strings.HasPrefix(url, "git@") || strings.HasPrefix(url, "ssh://"):
logStep("push auth: SSH agent, git CLI fallback")
default:
logStep("push auth: git CLI (credential helpers, netrc)")
}
}
// --- version files ---
anyPom := false
for _, relPomPath := range cfg.Maven.EffectivePomPaths() {
pomPath := filepath.Join(absRepo, relPomPath)
_, statErr := os.Stat(pomPath)
if errors.Is(statErr, os.ErrNotExist) {
continue
}
if statErr != nil {
fail("%s: %v", relPomPath, statErr)
continue
}
anyPom = true
if v, err := maven.ReadVersion(pomPath); err != nil {
fail("%s: %v", relPomPath, err)
} else {
logDone("%s: version %s", relPomPath, v)
}
}
if !anyPom {
logStep("no pom.xml — Maven version bump will be skipped")
}
for _, relPkgPath := range cfg.Node.EffectivePaths() {
if v, err := node.ReadVersion(filepath.Join(absRepo, relPkgPath)); err != nil {
fail("%s: %v", relPkgPath, err)
} else {
logDone("%s: version %s", relPkgPath, v)
}
}
for _, relGradlePath := range cfg.Gradle.EffectiveBuildFiles() {
if v, err := gradle.ReadVersion(filepath.Join(absRepo, relGradlePath)); err != nil {
fail("%s: %v", relGradlePath, err)
} else {
logDone("%s: version %s", relGradlePath, v)
}
}
for _, relPyprojectPath := range cfg.Python.EffectivePaths() {
if v, err := pyproject.ReadVersion(filepath.Join(absRepo, relPyprojectPath)); err != nil {
fail("%s: %v", relPyprojectPath, err)
} else {
logDone("%s: version %s", relPyprojectPath, v)
}
}
// --- release target (mirrors buildPublisher precedence) ---
switch {
case cfg.GitHub.Token != "" && cfg.GitHub.Repo != "":
logDone("release target: GitHub (%s)", cfg.GitHub.Repo)
case cfg.GitLab.URL != "" && cfg.GitLab.Project != "":
if cfg.GitLab.Token == "" {
fail("release target: GitLab configured but GITLAB_TOKEN not set")
} else {
logDone("release target: GitLab (%s, project %s)", cfg.GitLab.URL, cfg.GitLab.Project)
}
default:
logStep("no release provider configured — release creation will be skipped")
}
if failed > 0 {
return fmt.Errorf("%d check(s) failed", failed)
}
fmt.Println("all checks passed")
return nil
}
// notifyRelease sends best-effort release notifications to every configured
// target. Failures are logged as warnings, not errors — the release itself
// already succeeded by the time this runs.
+235
View File
@@ -505,6 +505,241 @@ func TestRunNoRelease(t *testing.T) {
}
}
// ── --check preflight ─────────────────────────────────────────────────────────
func TestRunCheckAllPass(t *testing.T) {
repo, dir := setupRepoWithRemote(t)
addFile(t, dir, "package.json", `{"name":"x","version":"1.2.0"}`)
addFile(t, dir, "build.gradle", "version = '1.2.0'\n")
addFile(t, dir, "pyproject.toml", "[project]\nname = \"x\"\nversion = \"1.2.0\"\n")
addFile(t, dir, ".releaser.yml", `node:
package_json: package.json
gradle:
build_file: build.gradle
python:
pyproject_toml: pyproject.toml
`)
commitAll(t, repo, dir, "chore: add version files")
head, _ := repo.Head()
repo.CreateTag("1.2.0", head.Hash(), nil)
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "test-token")
if err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("--check should pass on a healthy repo: %v", err)
}
}
func TestRunCheckFailures(t *testing.T) {
// One run accumulating several failures: unresolvable branch (detached,
// no CI env), dirty tree + unreadable pom, missing configured version
// files, no remote, GitLab target without token.
repo, dir := setupRepo(t)
addFile(t, dir, ".releaser.yml", `node:
package_json: missing/package.json
gradle:
build_file: missing/build.gradle
python:
pyproject_toml: missing/pyproject.toml
`)
commitAll(t, repo, dir, "chore: config")
head, _ := repo.Head()
repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, head.Hash()))
for _, name := range []string{"CI_COMMIT_BRANCH", "CI_COMMIT_REF_NAME", "GITHUB_REF_NAME"} {
t.Setenv(name, "")
}
// Invalid pom left uncommitted: dirties the tree and fails ReadVersion.
addFile(t, dir, "pom.xml", "<not-a-pom/>")
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "")
err := execCmd(t, "--check", "--repo", dir)
if err == nil {
t.Fatal("expected --check to fail")
}
}
func TestRunCheckMalformedRemoteURL(t *testing.T) {
// The shell-quoting accident from real life: quotes stored inside the URL.
repo, dir := setupRepo(t)
if _, err := repo.CreateRemote(&gitcfg.RemoteConfig{
Name: "origin",
URLs: []string{`"https://oauth2:${TOKEN}@example.com/group/proj.git"`},
}); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected --check to flag the malformed remote URL")
}
}
func TestRunCheckSSHRemoteFirstRelease(t *testing.T) {
repo, dir := setupRepo(t)
if _, err := repo.CreateRemote(&gitcfg.RemoteConfig{
Name: "origin",
URLs: []string{"git@example.com:group/proj.git"},
}); err != nil {
t.Fatal(err)
}
// SSH auth line + "no previous tag (first release)" + no release provider
// + absent pom ("no pom.xml" note) — all informational, none failing.
if err := execCmd(t, "--check", "--pom", "missing.xml", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("--check on clean SSH-remote repo: %v", err)
}
}
func TestRunCheckShallowNoTag(t *testing.T) {
repo, dir := setupRepoWithRemote(t)
head, _ := repo.Head()
if err := os.WriteFile(filepath.Join(dir, ".git", "shallow"), []byte(head.Hash().String()+"\n"), 0644); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected --check to fail for shallow clone with no tag")
}
if err := execCmd(t, "--check", "--allow-shallow", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("--check with --allow-shallow: %v", err)
}
}
func TestRunCheckShallowReadError(t *testing.T) {
_, dir := setupRepoWithRemote(t)
if err := os.Mkdir(filepath.Join(dir, ".git", "shallow"), 0755); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected --check to fail when the shallow file is unreadable")
}
}
func TestRunCheckLatestTagFails(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("skipping: chmod restrictions do not apply when running as root")
}
_, dir := setupRepoWithRemote(t)
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
os.Chmod(tagsDir, 0000)
defer os.Chmod(tagsDir, 0755)
err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected --check to fail when tag discovery fails")
}
}
func TestRunCheckWorktreeStatusFails(t *testing.T) {
_, dir := setupRepoWithRemote(t)
if err := os.WriteFile(filepath.Join(dir, ".git", "index"), []byte("not a valid git index"), 0644); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected --check to fail when the worktree status is unreadable")
}
}
func TestRunCheckNotARepo(t *testing.T) {
err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", t.TempDir())
if err == nil {
t.Fatal("expected --check to fail outside a git repository")
}
}
func TestRunCheckPomStatError(t *testing.T) {
_, dir := setupRepoWithRemote(t)
// Null byte in the path: os.Stat fails with EINVAL, not ErrNotExist.
err := execCmd(t, "--check", "--pom", "p\x00om.xml", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected --check to fail on a pom path stat error")
}
}
func TestRunCheckGitHubTarget(t *testing.T) {
repo, dir := setupRepoWithRemote(t)
addFile(t, dir, ".releaser.yml", `github:
repo: owner/proj
`)
commitAll(t, repo, dir, "chore: config")
t.Setenv("GITHUB_TOKEN", "gh-token")
if err := execCmd(t, "--check", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("--check with GitHub target: %v", err)
}
}
func TestRunShallowCloneNoTag(t *testing.T) {
repo, dir := setupRepo(t)
addFile(t, dir, "src.go", "// fix")
w, _ := repo.Worktree()
w.Add("src.go")
hash, _ := w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Mark the clone shallow: no tag + shallow must refuse to release.
if err := os.WriteFile(filepath.Join(dir, ".git", "shallow"), []byte(hash.String()+"\n"), 0644); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for shallow clone with no release tag")
}
if err := execCmd(t, "--dry-run", "--allow-shallow", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("--allow-shallow should bypass the shallow guard: %v", err)
}
}
func TestRunShallowCheckFails(t *testing.T) {
repo, dir := setupRepo(t)
addFile(t, dir, "src.go", "// fix")
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Directory where the shallow file is expected → IsShallow read error.
if err := os.Mkdir(filepath.Join(dir, ".git", "shallow"), 0755); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when the shallow check fails")
}
}
func TestRunShallowCloneWithTag(t *testing.T) {
// A tag within the shallow history pins the version — shallowness is fine.
repo, dir := setupRepo(t)
initialHead, _ := repo.Head()
repo.CreateTag("1.2.0", initialHead.Hash(), nil)
addFile(t, dir, "src.go", "// fix")
w, _ := repo.Worktree()
w.Add("src.go")
hash, _ := w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
if err := os.WriteFile(filepath.Join(dir, ".git", "shallow"), []byte(hash.String()+"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir); err != nil {
t.Fatalf("shallow clone with a reachable tag should release: %v", err)
}
}
func TestRunCIDetachedHeadPush(t *testing.T) {
repo, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")