feat(gitutil): release v1.9.0 — detached HEAD support in CI
docs / Build and deploy docs (push) Failing after 32s
release / Build and publish release (push) Successful in 5m34s
ci / vet, staticcheck, test, build (push) Failing after 18m8s

- CurrentBranch falls back to CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, then
  GITHUB_REF_NAME when HEAD is detached, so --branch is no longer required
  in GitLab CI / GitHub Actions jobs
- go-git push paths (token and SSH agent) now push HEAD's commit hash to
  the branch instead of refs/heads/<branch>, which never exists in a
  detached CI checkout — go-git silently skipped the branch update and
  pushed only the tag
- .releaser.gitlab-ci.yml template drops the redundant --branch flag
- docs: README, usage, ci-integration, changelog, ROADMAP updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 21:12:24 +02:00
parent 3fd8dc2a49
commit 2a6a65ce80
11 changed files with 298 additions and 26 deletions
+46 -13
View File
@@ -42,17 +42,31 @@ 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
}
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, ", "))
}
type tagCandidate struct {
@@ -269,6 +283,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,12 +309,14 @@ 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)),
},
Auth: auth,
RefSpecs: refSpecs,
Auth: auth,
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
@@ -300,11 +331,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,
+156 -4
View File
@@ -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,67 @@ 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")
}
})
}
// ── LatestTag ─────────────────────────────────────────────────────────────────
func TestLatestTagNoTags(t *testing.T) {
@@ -702,6 +776,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 +1172,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")