fix(release): refuse to tag unless HEAD is at the remote default tip#553
fix(release): refuse to tag unless HEAD is at the remote default tip#553Benehiko wants to merge 1 commit into
Conversation
The release tool tags whatever HEAD points at and pushes the tag, but the downstream "chore: bump" commit it creates afterwards is only committed locally (gitPushTags pushes tags, never the branch). When the tool is re-run while HEAD sits on such a local-only bump commit, the new tag lands on a commit that is not on the remote default branch. That is how store/v0.0.28 ended up pinned to the "chore: bump store/v0.0.27" commit, shipping a store release that predated — and therefore did not contain — a Linux keychain fix that had already merged via PRs. Before creating any tag, fetch and verify HEAD equals the tip of the remote default branch (origin/HEAD, falling back to main); refuse otherwise. The check is skipped in --dry and --skip-git modes since they create no tags. Adds an integration test (temp repo + bare remote) covering the in-sync, local-only-commit, and pushed cases, and documents the release workflow and guard in docs/releasing.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| func remoteDefaultBranch(ctx context.Context) string { | ||
| out, err := runGit(ctx, "symbolic-ref", "--short", "refs/remotes/origin/HEAD") | ||
| if err != nil { | ||
| return "main" |
There was a problem hiding this comment.
[MEDIUM] Silent fallback to "main" produces confusing errors when the real default branch differs
When git symbolic-ref --short refs/remotes/origin/HEAD fails (unconfigured origin/HEAD, which is common on repos cloned without --remote or before running git remote set-head origin -a), the function silently returns "main". If the repository's actual default branch is "master", "develop", or anything else, the subsequent git fetch origin main will fail with a confusing exit-status error like 'couldn't find remote ref main' — giving the operator no indication that the root cause is an unconfigured origin/HEAD.
Consider logging a diagnostic message, e.g.:
log.Printf("warning: origin/HEAD is not set; assuming default branch is %q — run 'git remote set-head origin -a' to fix", "main")or attempting git remote show origin as a fallback to detect the true default branch.
| assert.Contains(t, err.Error(), "refusing to tag") | ||
| }) | ||
|
|
||
| t.Run("passes again once the commit is pushed", func(t *testing.T) { |
There was a problem hiding this comment.
[MEDIUM] Subtest 3 passes trivially when run in isolation — no coverage of the recovery path
Test_verifyReleaseRef/passes_again_once_the_commit_is_pushed depends on subtest 2 having run first to place a local-only commit. When run individually with -run=Test_verifyReleaseRef/passes_again, the repo is freshly initialised with HEAD already at the remote tip, so verifyReleaseRef trivially succeeds without ever having been in a failing state. The critical sequence (fail → push → pass) is never exercised.
Consider restructuring so subtest 3 explicitly creates the local-only commit itself before asserting failure, then pushes and asserts success:
t.Run("passes again once the commit is pushed", func(t *testing.T) {
// put HEAD ahead of remote
repo.addCommit(t, "temp commit")
require.Error(t, verifyReleaseRef(ctx, repo.dir)) // must fail first
repo.run(t, "push", "origin", "HEAD:main")
require.NoError(t, verifyReleaseRef(ctx, repo.dir)) // then pass
})| bare := filepath.Join(root, "origin.git") | ||
| work := filepath.Join(root, "work") | ||
|
|
||
| runGitIn(t, root, "init", "--bare", "--initial-branch=main", bare) |
There was a problem hiding this comment.
[MEDIUM] git init --initial-branch=main requires Git ≥ 2.28 — breaks tests on Ubuntu 20.04 / RHEL 8
The --initial-branch flag was introduced in Git 2.28.0 (July 2020). Ubuntu 20.04 LTS ships Git 2.25 and RHEL 8 ships Git 2.27, so runGitIn will fail with unknown switch 'initial-branch' on those systems before any test logic runs.
A portable alternative that works on all Git versions:
// Instead of: runGitIn(t, root, "init", "--bare", "--initial-branch=main", bare)
runGitIn(t, root, "init", "--bare", bare)
runGitIn(t, bare, "symbolic-ref", "HEAD", "refs/heads/main")This also aligns with the git init -c core.bare=true approach accepted on older toolchains.
Why
store/v0.0.28was tagged on1de02f4— thechore: bump store/v0.0.27commit — which predates the Linux keychain DH fix (#548) that had already merged via PRs. Consumers bumping tov0.0.28therefore did not get the fix, surfacing downstream (sandboxes, docker-auth) as intermittentunexpected end of JSON inputwhen decoding credentials.Root cause is in the release tool:
BumpModuletags the currentHEADand pushes the tag, then creates thechore: bumpcommit.gitPushTagspushes tags only — the bump commit stays local.HEADis sitting on such a local-only bump commit, the new tag lands on a commit that is not on the remote default branch and misses anything merged afterwards.What
git fetchand verifyHEADequals the tip of the remote default branch (origin/HEAD, falling back tomain); refuse otherwise with an actionable error.--dry/--skip-gitmodes (they create no tags).docs/releasing.mddocuments the workflow and the guard.Note
store/v0.0.28is left in place (already on the GOPROXY; deleting would break anyone pinned to it).store/v0.0.29was cut onmainHEAD with the fix and is the version consumers should use.Test plan
go test ./release/...— passesgolangci-lint run -c ../.golangci.yml ./release/...— 0 issues🤖 Generated with Claude Code