Skip to content

fix(release): refuse to tag unless HEAD is at the remote default tip#553

Open
Benehiko wants to merge 1 commit into
mainfrom
fix/release-verify-head
Open

fix(release): refuse to tag unless HEAD is at the remote default tip#553
Benehiko wants to merge 1 commit into
mainfrom
fix/release-verify-head

Conversation

@Benehiko
Copy link
Copy Markdown
Member

@Benehiko Benehiko commented Jun 5, 2026

Why

store/v0.0.28 was tagged on 1de02f4 — the chore: bump store/v0.0.27 commit — which predates the Linux keychain DH fix (#548) that had already merged via PRs. Consumers bumping to v0.0.28 therefore did not get the fix, surfacing downstream (sandboxes, docker-auth) as intermittent unexpected end of JSON input when decoding credentials.

Root cause is in the release tool:

  • BumpModule tags the current HEAD and pushes the tag, then creates the chore: bump commit.
  • gitPushTags pushes tags only — the bump commit stays local.
  • On a re-run, if HEAD is 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

  • Before creating any tag, git fetch and verify HEAD equals the tip of the remote default branch (origin/HEAD, falling back to main); refuse otherwise with an actionable error.
  • Skipped in --dry / --skip-git modes (they create no tags).
  • Integration test (temp repo + bare remote) covering in-sync, local-only-commit, and pushed cases.
  • docs/releasing.md documents the workflow and the guard.

Note

store/v0.0.28 is left in place (already on the GOPROXY; deleting would break anyone pinned to it). store/v0.0.29 was cut on main HEAD with the fix and is the version consumers should use.

Test plan

  • go test ./release/... — passes
  • golangci-lint run -c ../.golangci.yml ./release/... — 0 issues

🤖 Generated with Claude Code

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>
Copy link
Copy Markdown

@docker-agent docker-agent Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessment: 🟡 NEEDS ATTENTION

3 medium-severity findings in the new release guard and its tests.

Comment thread x/release/command.go
func remoteDefaultBranch(ctx context.Context) string {
out, err := runGit(ctx, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")
if err != nil {
return "main"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread x/release/command_test.go
assert.Contains(t, err.Error(), "refusing to tag")
})

t.Run("passes again once the commit is pushed", func(t *testing.T) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
})

Comment thread x/release/command_test.go
bare := filepath.Join(root, "origin.git")
work := filepath.Join(root, "work")

runGitIn(t, root, "init", "--bare", "--initial-branch=main", bare)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant