From 37db8e97caea3c9caffa07560a3880e7c96046f8 Mon Sep 17 00:00:00 2001 From: k3nny Date: Thu, 16 Jul 2026 22:12:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(cmd):=20release=20v1.10.0=20=E2=80=94=20sh?= =?UTF-8?q?allow=20clone=20guard=20and=20--check=20preflight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .releaser.gitlab-ci.yml | 13 ++ CHANGELOG.md | 13 ++ README.md | 10 +- ROADMAP.md | 2 + cmd/main.go | 177 +++++++++++++++++++++++ cmd/main_test.go | 235 +++++++++++++++++++++++++++++++ docs/content/changelog.md | 7 + docs/content/ci-integration.md | 27 ++++ docs/content/usage.md | 2 + internal/gitutil/gitutil.go | 12 ++ internal/gitutil/gitutil_test.go | 42 ++++++ 11 files changed, 539 insertions(+), 1 deletion(-) diff --git a/.releaser.gitlab-ci.yml b/.releaser.gitlab-ci.yml index ea78d6f..81b64e0 100644 --- a/.releaser.gitlab-ci.yml +++ b/.releaser.gitlab-ci.yml @@ -20,6 +20,19 @@ # api + write_repository # (do NOT use CI_JOB_TOKEN — it cannot push tags back) +# Optional preflight job for merge-request pipelines: validates the release +# environment (branch pattern, remote URL, auth, version files, release +# target) without releasing anything. +.releaser:check: + stage: test + image: ${RELEASER_IMAGE:-registry.example.com/releaser:latest} + variables: + GIT_DEPTH: 0 + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + script: + - releaser --check --branch "release/0.0" + .releaser:release: stage: release image: ${RELEASER_IMAGE:-registry.example.com/releaser:latest} diff --git a/CHANGELOG.md b/CHANGELOG.md index be2f2bd..65e2dd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [1.10.0] - 2026-07-16 + +### Added + +- **`--check` preflight** — validates the release environment without releasing: branch resolution + pattern match, working tree state, tag discovery, shallow clone, remote origin URL parseability (the same parse the push performs), push auth method, configured version files, and release target; reports all problems at once and exits non-zero when any check fails +- **Shallow clone detection** — when the clone is shallow and no previous release tag is found, releaser refuses to release instead of silently restarting versioning at `X.Y.0` (previous tags may sit beyond the fetch depth); a shallow clone whose history includes the latest tag proceeds normally +- **`--allow-shallow` flag** — bypasses the shallow-clone guard for a genuine first release +- **`.releaser:check` CI job template** — optional merge-request-pipeline preflight job in `.releaser.gitlab-ci.yml` + +### Changed + +- **CI examples** — the GitLab CI examples in README and docs now set `GIT_DEPTH: 0`, which the shallow-clone guard would otherwise surface as an error on first release + ## [1.9.0] - 2026-07-16 ### Added diff --git a/README.md b/README.md index 3bfa794..d59dcda 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ releaser logo -![release](https://img.shields.io/badge/release-v1.9.0-blue.svg) +![release](https://img.shields.io/badge/release-v1.10.0-blue.svg) A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. @@ -69,12 +69,19 @@ releaser --tag-only # CI_COMMIT_BRANCH / CI_COMMIT_REF_NAME / GITHUB_REF_NAME automatically) releaser --branch release/1.2 +# First release from a shallow clone (no previous tag exists yet) +releaser --allow-shallow + # Write changelog to a custom file releaser --changelog-file CHANGES.md # Show configuration sources, commit list, and version decision releaser --verbose --dry-run +# Preflight: validate branch, working tree, remote URL, push auth, +# version files, and release target — reports all problems at once +releaser --check + # Target a specific pom.xml releaser --pom path/to/pom.xml @@ -178,6 +185,7 @@ release: - if: $CI_COMMIT_BRANCH =~ /^release\/.+$/ variables: GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope + GIT_DEPTH: 0 # full history — shallow clones hide previous release tags script: - releaser artifacts: diff --git a/ROADMAP.md b/ROADMAP.md index e3fcd73..1ac5b71 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,3 +88,5 @@ - ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag) - ~~Python `pyproject.toml` version bump (`[project].version` and `[tool.poetry].version`; single and multi-path like Maven's `pom_paths`)~~ — ✓ shipped v1.7.0 (`internal/pyproject`; PEP 621 + Poetry; `python.pyproject_tomls`; `--pyproject` flag) - ~~Slack / Teams notification on release~~ — ✓ shipped v1.8.0 (`internal/notify`; Slack, Microsoft Teams, Google Chat, Telegram, and generic webhook; each opt-in via `notify.*` config or env var; best-effort — never fails the release) +- ~~Shallow clone detection~~ — ✓ shipped v1.10.0 (refuses to release when the clone is shallow and no previous tag is found — would silently restart at X.Y.0; `--allow-shallow` escape hatch for a true first release) +- ~~CI preflight~~ — ✓ shipped v1.10.0 as `--check` (branch resolution, working tree, shallow clone, remote URL parse, push auth method, version files, release target; reports all problems at once). Possible later upgrades: online token-scope validation against the GitLab/GitHub API, promotion to a `doctor` subcommand diff --git a/cmd/main.go b/cmd/main.go index 1f52d66..d79e09c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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. diff --git a/cmd/main_test.go b/cmd/main_test.go index 4214233..1b5818f 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -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", "") + + 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") diff --git a/docs/content/changelog.md b/docs/content/changelog.md index 5ca0178..da50f31 100644 --- a/docs/content/changelog.md +++ b/docs/content/changelog.md @@ -3,6 +3,13 @@ title: Changelog weight: 50 --- +## v1.10.0 — 2026-07-16 + +### Added + +- **`--check` preflight** — validates branch, working tree, shallow clone, remote URL, push auth, version files, and release target without releasing; reports all problems at once, non-zero exit on failure +- **Shallow clone detection** — refuses to release when the clone is shallow and no previous tag is found (tags may be beyond the fetch depth); `--allow-shallow` bypasses the guard for a genuine first release + ## v1.9.0 — 2026-07-16 ### Added diff --git a/docs/content/ci-integration.md b/docs/content/ci-integration.md index 4d2a915..5d10322 100644 --- a/docs/content/ci-integration.md +++ b/docs/content/ci-integration.md @@ -29,6 +29,7 @@ release: - if: $CI_COMMIT_BRANCH =~ /^release\/.+$/ variables: GITLAB_TOKEN: $RELEASE_TOKEN + GIT_DEPTH: 0 # full history — shallow clones hide previous release tags script: - releaser artifacts: @@ -93,6 +94,32 @@ jobs: `fetch-depth: 0` is required. A shallow clone (`--depth 1`) hides the previous tag, causing `releaser` to treat every commit as the first release. {{< /hint >}} +## Preflight checks + +`releaser --check` validates the release environment without releasing anything: branch resolution and pattern match, working tree state, shallow clone, remote URL parseability (the same parse the push performs — it catches shell-quoting accidents in `set-url` lines), which push auth would be used, configured version files, and the release target. All problems are reported at once, and the exit code is non-zero if any check fails. + +Note the split with `--dry-run`: dry-run answers *"what version would be released?"* (it analyzes commits and computes the bump); `--check` answers *"will the release plumbing work?"* (everything dry-run never touches). Run `--check` in merge-request pipelines to catch broken CI configuration before it blocks a real release: + +```yaml +release:check: + stage: test + image: registry.example.com/releaser:latest + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + variables: + GIT_DEPTH: 0 + script: + - releaser --check --branch "release/0.0" # any pattern-matching name works for validation +``` + +## Shallow clones + +GitLab CI checks out a shallow clone by default (`GIT_DEPTH: 20`), and shallow clones hide any release tag beyond the fetch depth — tag discovery would silently restart versioning at `X.Y.0`. `releaser` detects this: when the clone is shallow **and** no previous release tag is found, it refuses to release and asks for full history. + +Fix it by fetching full history (`GIT_DEPTH: 0` in GitLab CI, `fetch-depth: 0` in GitHub Actions, or `git fetch --unshallow`). If the project genuinely has no release tag yet, pass `--allow-shallow` to release anyway. + +A shallow clone whose history does include the latest release tag is fine — the version calculation is unaffected, and `releaser` proceeds normally. + ## Detached HEAD CI runners check out a commit SHA, leaving the repository in detached HEAD state. `releaser` detects this and falls back to the branch name from the CI environment, in order: diff --git a/docs/content/usage.md b/docs/content/usage.md index 20dde0b..4d0c9f8 100644 --- a/docs/content/usage.md +++ b/docs/content/usage.md @@ -35,6 +35,7 @@ releaser --verbose --dry-run | Flag | Default | Description | |------|---------|-------------| | `--dry-run` | false | Print next version and exit without making any changes | +| `--check` | false | Preflight: validate branch, working tree, remote URL, push auth, version files, and release target without releasing | | `--branch ` | auto-detected | Override branch name (detached HEAD falls back to `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `GITHUB_REF_NAME`) | | `--branch-pattern ` | `^(?:.*/)?release/(\d+)\.(\d+)$` | Override branch pattern (two capture groups: major, minor) | | `--tag-prefix ` | `""` | Prefix for version tags (e.g. `v` → `v1.2.3`) | @@ -43,6 +44,7 @@ releaser --verbose --dry-run | `--pyproject ` | — | Override `python.pyproject_toml` from config | | `--changelog-file ` | `CHANGELOG.md` | Path to changelog file | | `--release-env-file ` | `release.env` | Path for dotenv artifact; pass `""` to disable | +| `--allow-shallow` | false | Proceed in a shallow clone even when no previous release tag is found | | `--no-commit` | false | Update version files but stop before committing | | `--no-push` | false | Commit and tag locally, skip push and release | | `--no-release` | false | Push branch and tag but skip release creation | diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index fa6012f..2e517fa 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -69,6 +69,18 @@ func CurrentBranch(repo *gogit.Repository) (string, error) { return "", fmt.Errorf("HEAD is detached and no CI branch variable is set (%s) — use --branch to specify the release branch", strings.Join(ciBranchEnvVars, ", ")) } +// IsShallow reports whether the repository is a shallow clone. In shallow CI +// checkouts (GitLab CI defaults to GIT_DEPTH: 20) release tags beyond the +// fetch depth are invisible to LatestTag, which would silently restart +// versioning at X.Y.0. +func IsShallow(repo *gogit.Repository) (bool, error) { + roots, err := repo.Storer.Shallow() + if err != nil { + return false, err + } + return len(roots) > 0, nil +} + type tagCandidate struct { name string patch int diff --git a/internal/gitutil/gitutil_test.go b/internal/gitutil/gitutil_test.go index c2de695..4b4cf15 100644 --- a/internal/gitutil/gitutil_test.go +++ b/internal/gitutil/gitutil_test.go @@ -248,6 +248,48 @@ func TestCurrentBranchDetachedCIFallback(t *testing.T) { }) } +// ── IsShallow ───────────────────────────────────────────────────────────────── + +func TestIsShallow(t *testing.T) { + repo, dir := newTestRepo(t) + hash := addCommit(t, repo, dir, "chore: init", "initial") + + shallow, err := IsShallow(repo) + if err != nil { + t.Fatal(err) + } + if shallow { + t.Error("full clone reported as shallow") + } + + // A shallow clone is marked by .git/shallow listing the boundary commits. + shallowPath := filepath.Join(dir, ".git", "shallow") + if err := os.WriteFile(shallowPath, []byte(hash.String()+"\n"), 0644); err != nil { + t.Fatal(err) + } + shallow, err = IsShallow(repo) + if err != nil { + t.Fatal(err) + } + if !shallow { + t.Error("clone with .git/shallow not reported as shallow") + } +} + +func TestIsShallowReadError(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + // A directory where the shallow file is expected: Open succeeds but + // reading fails, exercising the Storer.Shallow error path. + if err := os.Mkdir(filepath.Join(dir, ".git", "shallow"), 0755); err != nil { + t.Fatal(err) + } + if _, err := IsShallow(repo); err == nil { + t.Error("expected error when shallow file is unreadable") + } +} + // ── LatestTag ───────────────────────────────────────────────────────────────── func TestLatestTagNoTags(t *testing.T) {