feat(cmd): release v1.10.0 — shallow clone guard and --check preflight
- 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:
@@ -20,6 +20,19 @@
|
|||||||
# api + write_repository
|
# api + write_repository
|
||||||
# (do NOT use CI_JOB_TOKEN — it cannot push tags back)
|
# (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:
|
.releaser:release:
|
||||||
stage: release
|
stage: release
|
||||||
image: ${RELEASER_IMAGE:-registry.example.com/releaser:latest}
|
image: ${RELEASER_IMAGE:-registry.example.com/releaser:latest}
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [1.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
|
## [1.9.0] - 2026-07-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128">
|
<img src="docs/static/images/releaser-logo-128.png" alt="releaser logo" width="128">
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
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)
|
# CI_COMMIT_BRANCH / CI_COMMIT_REF_NAME / GITHUB_REF_NAME automatically)
|
||||||
releaser --branch release/1.2
|
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
|
# Write changelog to a custom file
|
||||||
releaser --changelog-file CHANGES.md
|
releaser --changelog-file CHANGES.md
|
||||||
|
|
||||||
# Show configuration sources, commit list, and version decision
|
# Show configuration sources, commit list, and version decision
|
||||||
releaser --verbose --dry-run
|
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
|
# Target a specific pom.xml
|
||||||
releaser --pom path/to/pom.xml
|
releaser --pom path/to/pom.xml
|
||||||
|
|
||||||
@@ -178,6 +185,7 @@ release:
|
|||||||
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
|
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
|
||||||
variables:
|
variables:
|
||||||
GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope
|
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:
|
script:
|
||||||
- releaser
|
- releaser
|
||||||
artifacts:
|
artifacts:
|
||||||
|
|||||||
@@ -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)
|
- ~~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)
|
- ~~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)
|
- ~~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
|
||||||
|
|||||||
+177
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
gogit "github.com/go-git/go-git/v5"
|
gogit "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"git.k3nny.fr/releaser/internal/branch"
|
"git.k3nny.fr/releaser/internal/branch"
|
||||||
@@ -194,6 +195,8 @@ func newRootCmd() *cobra.Command {
|
|||||||
noRelease bool
|
noRelease bool
|
||||||
noCommit bool
|
noCommit bool
|
||||||
tagOnly bool
|
tagOnly bool
|
||||||
|
allowShallow bool
|
||||||
|
check bool
|
||||||
branchOverride string
|
branchOverride string
|
||||||
repoPath string
|
repoPath string
|
||||||
pomOverride string
|
pomOverride string
|
||||||
@@ -233,6 +236,8 @@ func newRootCmd() *cobra.Command {
|
|||||||
noRelease: noRelease,
|
noRelease: noRelease,
|
||||||
noCommit: noCommit,
|
noCommit: noCommit,
|
||||||
tagOnly: tagOnly,
|
tagOnly: tagOnly,
|
||||||
|
allowShallow: allowShallow,
|
||||||
|
check: check,
|
||||||
releaseEnvFile: releaseEnvFile,
|
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(&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(&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(&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(&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(&repoPath, "repo", ".", "path to git repository")
|
||||||
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config")
|
root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config")
|
||||||
@@ -286,6 +293,8 @@ type options struct {
|
|||||||
noRelease bool
|
noRelease bool
|
||||||
noCommit bool
|
noCommit bool
|
||||||
tagOnly bool
|
tagOnly bool
|
||||||
|
allowShallow bool
|
||||||
|
check bool
|
||||||
releaseEnvFile string
|
releaseEnvFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,6 +463,10 @@ func run(o options) error {
|
|||||||
printVerboseConfig(cfg, src)
|
printVerboseConfig(cfg, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if o.check {
|
||||||
|
return runCheck(o, cfg, absRepo)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Git ---
|
// --- Git ---
|
||||||
repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true})
|
repo, err := gogit.PlainOpenWithOptions(absRepo, &gogit.PlainOpenOptions{DetectDotGit: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -499,6 +512,20 @@ func run(o options) error {
|
|||||||
return fmt.Errorf("find latest tag: %w", err)
|
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 ---
|
// --- Commit range ---
|
||||||
var messages []string
|
var messages []string
|
||||||
if lastTag == "" {
|
if lastTag == "" {
|
||||||
@@ -738,6 +765,156 @@ func run(o options) error {
|
|||||||
return nil
|
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
|
// notifyRelease sends best-effort release notifications to every configured
|
||||||
// target. Failures are logged as warnings, not errors — the release itself
|
// target. Failures are logged as warnings, not errors — the release itself
|
||||||
// already succeeded by the time this runs.
|
// already succeeded by the time this runs.
|
||||||
|
|||||||
@@ -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) {
|
func TestRunCIDetachedHeadPush(t *testing.T) {
|
||||||
repo, dir := setupRepoWithRemote(t)
|
repo, dir := setupRepoWithRemote(t)
|
||||||
addFile(t, dir, "x.go", "// fix")
|
addFile(t, dir, "x.go", "// fix")
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ title: Changelog
|
|||||||
weight: 50
|
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
|
## v1.9.0 — 2026-07-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ release:
|
|||||||
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
|
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
|
||||||
variables:
|
variables:
|
||||||
GITLAB_TOKEN: $RELEASE_TOKEN
|
GITLAB_TOKEN: $RELEASE_TOKEN
|
||||||
|
GIT_DEPTH: 0 # full history — shallow clones hide previous release tags
|
||||||
script:
|
script:
|
||||||
- releaser
|
- releaser
|
||||||
artifacts:
|
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.
|
`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 >}}
|
{{< /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
|
## 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:
|
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:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ releaser --verbose --dry-run
|
|||||||
| Flag | Default | Description |
|
| Flag | Default | Description |
|
||||||
|------|---------|-------------|
|
|------|---------|-------------|
|
||||||
| `--dry-run` | false | Print next version and exit without making any changes |
|
| `--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 <name>` | auto-detected | Override branch name (detached HEAD falls back to `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `GITHUB_REF_NAME`) |
|
| `--branch <name>` | auto-detected | Override branch name (detached HEAD falls back to `CI_COMMIT_BRANCH`, `CI_COMMIT_REF_NAME`, `GITHUB_REF_NAME`) |
|
||||||
| `--branch-pattern <regex>` | `^(?:.*/)?release/(\d+)\.(\d+)$` | Override branch pattern (two capture groups: major, minor) |
|
| `--branch-pattern <regex>` | `^(?:.*/)?release/(\d+)\.(\d+)$` | Override branch pattern (two capture groups: major, minor) |
|
||||||
| `--tag-prefix <prefix>` | `""` | Prefix for version tags (e.g. `v` → `v1.2.3`) |
|
| `--tag-prefix <prefix>` | `""` | Prefix for version tags (e.g. `v` → `v1.2.3`) |
|
||||||
@@ -43,6 +44,7 @@ releaser --verbose --dry-run
|
|||||||
| `--pyproject <path>` | — | Override `python.pyproject_toml` from config |
|
| `--pyproject <path>` | — | Override `python.pyproject_toml` from config |
|
||||||
| `--changelog-file <path>` | `CHANGELOG.md` | Path to changelog file |
|
| `--changelog-file <path>` | `CHANGELOG.md` | Path to changelog file |
|
||||||
| `--release-env-file <path>` | `release.env` | Path for dotenv artifact; pass `""` to disable |
|
| `--release-env-file <path>` | `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-commit` | false | Update version files but stop before committing |
|
||||||
| `--no-push` | false | Commit and tag locally, skip push and release |
|
| `--no-push` | false | Commit and tag locally, skip push and release |
|
||||||
| `--no-release` | false | Push branch and tag but skip release creation |
|
| `--no-release` | false | Push branch and tag but skip release creation |
|
||||||
|
|||||||
@@ -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, ", "))
|
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 {
|
type tagCandidate struct {
|
||||||
name string
|
name string
|
||||||
patch int
|
patch int
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
// ── LatestTag ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestLatestTagNoTags(t *testing.T) {
|
func TestLatestTagNoTags(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user