Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37db8e97ca | |||
| 2a6a65ce80 | |||
| 3fd8dc2a49 |
+15
-3
@@ -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}
|
||||
@@ -41,9 +54,8 @@
|
||||
- git config user.name "${GITLAB_USER_NAME:-Releaser CI}"
|
||||
|
||||
script:
|
||||
- releaser
|
||||
--branch "$CI_COMMIT_BRANCH"
|
||||
$RELEASER_EXTRA_ARGS
|
||||
# No --branch needed: releaser falls back to CI_COMMIT_BRANCH on detached HEAD.
|
||||
- releaser $RELEASER_EXTRA_ARGS
|
||||
|
||||
artifacts:
|
||||
reports:
|
||||
|
||||
@@ -3,6 +3,33 @@
|
||||
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
|
||||
|
||||
- **CI branch detection fallback** — on detached HEAD (the normal state in CI checkouts), the branch name now falls back to `CI_COMMIT_BRANCH`, then `CI_COMMIT_REF_NAME` (GitLab CI), then `GITHUB_REF_NAME` (GitHub Actions) before erroring; `--branch` is no longer required in CI
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Detached HEAD push** — the go-git push paths (token and SSH agent) used the refspec `refs/heads/<branch>`, a ref that never exists in a detached CI checkout; go-git silently skipped the branch update and pushed only the tag, so the release commit never reached the remote branch. Both paths now push HEAD's commit hash to the branch instead
|
||||
|
||||
### Changed
|
||||
|
||||
- **`.releaser.gitlab-ci.yml` template** — dropped the now-redundant `--branch "$CI_COMMIT_BRANCH"` from the job script
|
||||
|
||||
## [1.8.0] - 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<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.
|
||||
|
||||
@@ -65,15 +65,23 @@ releaser --no-commit
|
||||
# … then commit manually and re-run:
|
||||
releaser --tag-only
|
||||
|
||||
# Explicitly target a branch (useful in detached HEAD CI)
|
||||
# Explicitly target a branch (detached HEAD falls back to
|
||||
# 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
|
||||
|
||||
@@ -177,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:
|
||||
|
||||
+3
-1
@@ -8,7 +8,7 @@
|
||||
- [x] Commit range walker (last tag → HEAD, or full history on first release)
|
||||
- [x] Version bump calculator — returns plain `X.Y.Z` (prefix kept separate)
|
||||
- [x] `--dry-run` flag: print next version and exit
|
||||
- [x] `--branch` flag: override branch detection (detached HEAD in CI)
|
||||
- [x] `--branch` flag: override branch detection (detached HEAD in CI); detached HEAD auto-falls back to `CI_COMMIT_BRANCH` / `CI_COMMIT_REF_NAME` / `GITHUB_REF_NAME` — ✓ shipped v1.9.0, along with detached-HEAD push fix
|
||||
- [x] Exit code 2 when no releasable commits
|
||||
|
||||
## v0.2 — Config + Maven + local git ops ✅
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,12 @@ tasks:
|
||||
generates:
|
||||
- "{{.BIN}}"
|
||||
|
||||
install:
|
||||
desc: installs the binary in /usr/local/bin/
|
||||
cmds:
|
||||
- task: build
|
||||
- sudo cp ./bin/releaser /usr/local/bin/releaser
|
||||
|
||||
run:
|
||||
desc: Build and run releaser (pass args with -- e.g. task run -- --dry-run)
|
||||
deps: [build]
|
||||
|
||||
+178
-1
@@ -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,7 +250,9 @@ 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().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)")
|
||||
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")
|
||||
root.Flags().StringVar(&gradleOverride, "gradle", "", "override gradle.build_file 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.
|
||||
|
||||
@@ -150,6 +150,9 @@ func TestRunDetachedHead(t *testing.T) {
|
||||
// Detach HEAD
|
||||
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, "")
|
||||
}
|
||||
|
||||
err := execCmd(t, "--dry-run", "--repo", dir) // no --branch
|
||||
if err == nil {
|
||||
@@ -157,6 +160,24 @@ func TestRunDetachedHead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDetachedHeadCIBranchEnv(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()})
|
||||
head, _ := repo.Head()
|
||||
repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, head.Hash()))
|
||||
for _, name := range []string{"CI_COMMIT_REF_NAME", "GITHUB_REF_NAME"} {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
t.Setenv("CI_COMMIT_BRANCH", "release/1.2")
|
||||
|
||||
if err := execCmd(t, "--dry-run", "--repo", dir); err != nil {
|
||||
t.Fatalf("detached HEAD with CI_COMMIT_BRANCH should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBadConfig(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("{[invalid"), 0644); err != nil {
|
||||
@@ -484,6 +505,277 @@ 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")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
// Simulate a GitLab CI checkout: detached HEAD, no local branch ref,
|
||||
// branch name only available through the environment.
|
||||
head, _ := repo.Head()
|
||||
repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, head.Hash()))
|
||||
if err := repo.Storer.RemoveReference(plumbing.NewBranchReferenceName("master")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("CI_COMMIT_BRANCH", "release/1.2")
|
||||
t.Setenv("CI_COMMIT_REF_NAME", "")
|
||||
t.Setenv("GITHUB_REF_NAME", "")
|
||||
t.Setenv("GITLAB_TOKEN", "test-token")
|
||||
|
||||
if err := execCmd(t, "--no-release", "--repo", dir); err != nil {
|
||||
t.Fatalf("CI-style detached run: %v", err)
|
||||
}
|
||||
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bare, err := gogit.PlainOpen(remote.Config().URLs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := bare.Reference(plumbing.NewBranchReferenceName("release/1.2"), false); err != nil {
|
||||
t.Errorf("release commit was not pushed to the remote branch: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingToken(t *testing.T) {
|
||||
_, dir := setupRepoWithRemote(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
|
||||
@@ -3,6 +3,23 @@ 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
|
||||
|
||||
- **CI branch detection fallback** — on detached HEAD, the branch name falls back to `CI_COMMIT_BRANCH`, then `CI_COMMIT_REF_NAME` (GitLab CI), then `GITHUB_REF_NAME` (GitHub Actions); `--branch` is no longer required in CI
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Detached HEAD push** — go-git pushes silently skipped the branch update in detached CI checkouts (only the tag was pushed); the branch is now pushed from HEAD's commit hash
|
||||
|
||||
## v1.8.0 — 2026-07-12
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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,9 +94,41 @@ 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
|
||||
|
||||
In CI environments where `git checkout` leaves the repository in detached HEAD state, pass the branch name explicitly:
|
||||
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:
|
||||
|
||||
1. `CI_COMMIT_BRANCH` (GitLab CI, branch pipelines)
|
||||
2. `CI_COMMIT_REF_NAME` (GitLab CI)
|
||||
3. `GITHUB_REF_NAME` (GitHub Actions)
|
||||
|
||||
So on branch pipelines no extra configuration is needed. To override the detected name (or on runners that set none of these variables), pass it explicitly:
|
||||
|
||||
```yaml
|
||||
script:
|
||||
|
||||
@@ -35,7 +35,8 @@ releaser --verbose --dry-run
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--dry-run` | false | Print next version and exit without making any changes |
|
||||
| `--branch <name>` | auto-detected | Override branch name (useful in detached HEAD / CI) |
|
||||
| `--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-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`) |
|
||||
| `--pom <path>` | `pom.xml` | Path to pom.xml relative to repo root |
|
||||
@@ -43,6 +44,7 @@ releaser --verbose --dry-run
|
||||
| `--pyproject <path>` | — | Override `python.pyproject_toml` from config |
|
||||
| `--changelog-file <path>` | `CHANGELOG.md` | Path to changelog file |
|
||||
| `--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-push` | false | Commit and tag locally, skip push and release |
|
||||
| `--no-release` | false | Push branch and tag but skip release creation |
|
||||
|
||||
+57
-12
@@ -42,17 +42,43 @@ func IsWorkingTreeClean(repo *gogit.Repository) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ciBranchEnvVars are checked in order when HEAD is detached: CI runners
|
||||
// (GitLab, GitHub Actions) check out a commit SHA, so the branch name is only
|
||||
// available through the environment. CI_COMMIT_BRANCH is unset on tag and
|
||||
// merge-request pipelines, where CI_COMMIT_REF_NAME would hold a non-branch
|
||||
// ref — a name that fails branch pattern parsing with a clear error.
|
||||
var ciBranchEnvVars = []string{"CI_COMMIT_BRANCH", "CI_COMMIT_REF_NAME", "GITHUB_REF_NAME"}
|
||||
|
||||
// CurrentBranch returns the short branch name from HEAD.
|
||||
// Returns an error when HEAD is detached (common in CI pipelines).
|
||||
// When HEAD is detached (common in CI pipelines), it falls back to the CI
|
||||
// environment variables that carry the branch name, and errors only when
|
||||
// none of them is set.
|
||||
func CurrentBranch(repo *gogit.Repository) (string, error) {
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read HEAD: %w", err)
|
||||
}
|
||||
if !head.Name().IsBranch() {
|
||||
return "", fmt.Errorf("HEAD is detached — use --branch to specify the release branch")
|
||||
}
|
||||
if head.Name().IsBranch() {
|
||||
return head.Name().Short(), nil
|
||||
}
|
||||
for _, name := range ciBranchEnvVars {
|
||||
if v := os.Getenv(name); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -269,6 +295,21 @@ func isSSHURL(u string) bool {
|
||||
return strings.HasPrefix(u, "git@") || strings.HasPrefix(u, "ssh://")
|
||||
}
|
||||
|
||||
// pushRefSpecs builds the refspecs for the release push. The branch source is
|
||||
// HEAD's commit hash rather than refs/heads/<branch>: in CI checkouts HEAD is
|
||||
// detached and the local branch ref does not exist, in which case go-git
|
||||
// silently skips the branch update and pushes only the tag.
|
||||
func pushRefSpecs(repo *gogit.Repository, branchName, tagName string) ([]gitconfig.RefSpec, error) {
|
||||
head, err := repo.Head()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read HEAD: %w", err)
|
||||
}
|
||||
return []gitconfig.RefSpec{
|
||||
gitconfig.RefSpec(fmt.Sprintf("%s:refs/heads/%s", head.Hash(), branchName)),
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error {
|
||||
auth, err := newSSHAgentAuth("git")
|
||||
if err != nil {
|
||||
@@ -280,11 +321,13 @@ func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error
|
||||
return fmt.Errorf("remote origin not found: %w", err)
|
||||
}
|
||||
|
||||
refSpecs, err := pushRefSpecs(repo, branchName, tagName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := &gogit.PushOptions{
|
||||
RefSpecs: []gitconfig.RefSpec{
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
|
||||
},
|
||||
RefSpecs: refSpecs,
|
||||
Auth: auth,
|
||||
}
|
||||
|
||||
@@ -300,11 +343,13 @@ func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) er
|
||||
return fmt.Errorf("remote origin not found: %w", err)
|
||||
}
|
||||
|
||||
refSpecs, err := pushRefSpecs(repo, branchName, tagName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := &gogit.PushOptions{
|
||||
RefSpecs: []gitconfig.RefSpec{
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
|
||||
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
|
||||
},
|
||||
RefSpecs: refSpecs,
|
||||
Auth: &githttp.BasicAuth{
|
||||
Username: "oauth2",
|
||||
Password: token,
|
||||
|
||||
@@ -157,16 +157,29 @@ func TestCurrentBranch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranchDetached(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
// clearCIBranchEnv unsets the CI branch variables so detached-HEAD tests
|
||||
// behave the same on a developer machine and inside an actual CI job.
|
||||
func clearCIBranchEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, name := range ciBranchEnvVars {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
|
||||
// Detach HEAD by replacing the symbolic ref with a hash ref
|
||||
func detachHead(t *testing.T, repo *gogit.Repository) {
|
||||
t.Helper()
|
||||
head, _ := repo.Head()
|
||||
detached := plumbing.NewHashReference(plumbing.HEAD, head.Hash())
|
||||
if err := repo.Storer.SetReference(detached); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranchDetached(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
detachHead(t, repo)
|
||||
clearCIBranchEnv(t)
|
||||
|
||||
_, err := CurrentBranch(repo)
|
||||
if err == nil {
|
||||
@@ -174,6 +187,109 @@ func TestCurrentBranchDetached(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentBranchDetachedCIFallback(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
detachHead(t, repo)
|
||||
|
||||
t.Run("CI_COMMIT_BRANCH wins over CI_COMMIT_REF_NAME", func(t *testing.T) {
|
||||
clearCIBranchEnv(t)
|
||||
t.Setenv("CI_COMMIT_BRANCH", "release/1.2")
|
||||
t.Setenv("CI_COMMIT_REF_NAME", "release/9.9")
|
||||
|
||||
name, err := CurrentBranch(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "release/1.2" {
|
||||
t.Errorf("got %q, want release/1.2", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CI_COMMIT_REF_NAME when CI_COMMIT_BRANCH unset", func(t *testing.T) {
|
||||
clearCIBranchEnv(t)
|
||||
t.Setenv("CI_COMMIT_REF_NAME", "release/3.4")
|
||||
|
||||
name, err := CurrentBranch(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "release/3.4" {
|
||||
t.Errorf("got %q, want release/3.4", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GITHUB_REF_NAME as last resort", func(t *testing.T) {
|
||||
clearCIBranchEnv(t)
|
||||
t.Setenv("GITHUB_REF_NAME", "release/5.6")
|
||||
|
||||
name, err := CurrentBranch(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name != "release/5.6" {
|
||||
t.Errorf("got %q, want release/5.6", name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env ignored when HEAD is on a branch", func(t *testing.T) {
|
||||
attached, attachedDir := newTestRepo(t)
|
||||
addCommit(t, attached, attachedDir, "chore: init", "initial")
|
||||
clearCIBranchEnv(t)
|
||||
t.Setenv("CI_COMMIT_BRANCH", "release/9.9")
|
||||
|
||||
name, err := CurrentBranch(attached)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == "release/9.9" {
|
||||
t.Error("CI env var must not override an attached HEAD")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
@@ -702,6 +818,64 @@ func TestPushWithBareRemote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushDetachedHeadUpdatesRemoteBranch(t *testing.T) {
|
||||
// Regression: in a CI checkout HEAD is detached and refs/heads/<branch>
|
||||
// does not exist locally — go-git silently skipped the branch refspec and
|
||||
// pushed only the tag. The push must carry HEAD's commit to the branch.
|
||||
remoteDir := t.TempDir()
|
||||
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
hash := addCommit(t, repo, dir, "fix: something", "v1")
|
||||
addTag(t, repo, "v1.2.1")
|
||||
detachHead(t, repo)
|
||||
if err := repo.Storer.RemoveReference(plumbing.NewBranchReferenceName("master")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{remoteDir},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Push(repo, "release/1.2", "v1.2.1", "dummy-token"); err != nil {
|
||||
t.Fatalf("Push from detached HEAD failed: %v", err)
|
||||
}
|
||||
|
||||
bare, err := gogit.PlainOpen(remoteDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ref, err := bare.Reference(plumbing.NewBranchReferenceName("release/1.2"), false)
|
||||
if err != nil {
|
||||
t.Fatalf("remote branch ref missing after push: %v", err)
|
||||
}
|
||||
if ref.Hash() != hash {
|
||||
t.Errorf("remote branch at %s, want HEAD commit %s", ref.Hash(), hash)
|
||||
}
|
||||
if _, err := bare.Reference(plumbing.NewTagReferenceName("v1.2.1"), false); err != nil {
|
||||
t.Errorf("remote tag ref missing after push: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithGoGitUnbornHead(t *testing.T) {
|
||||
// Repo with a remote but no commits: pushRefSpecs fails reading HEAD.
|
||||
repo, _ := newTestRepo(t)
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{t.TempDir()},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pushWithGoGit(repo, "master", "v1.0.0", "some-token"); err == nil {
|
||||
t.Error("expected error for unborn HEAD")
|
||||
}
|
||||
}
|
||||
|
||||
// ── IsWorkingTreeClean: w.Status() error path ────────────────────────────────
|
||||
|
||||
func TestIsWorkingTreeCleanCorruptIndex(t *testing.T) {
|
||||
@@ -1040,6 +1214,26 @@ func TestPushWithGoGitNoRemote(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithSSHAgentUnbornHead(t *testing.T) {
|
||||
orig := newSSHAgentAuth
|
||||
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
||||
return &gitssh.PublicKeysCallback{User: user}, nil
|
||||
}
|
||||
defer func() { newSSHAgentAuth = orig }()
|
||||
|
||||
// Repo with a remote but no commits: pushRefSpecs fails reading HEAD.
|
||||
repo, _ := newTestRepo(t)
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{t.TempDir()},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pushWithSSHAgent(repo, "master", "v1.0.0"); err == nil {
|
||||
t.Error("expected error for unborn HEAD")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithGoGitPushFails(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
|
||||
Reference in New Issue
Block a user