Skip to content

Realign the release branch on its remote during checkout - #5686

Open
AliSoftware wants to merge 6 commits into
mainfrom
ainfra-2725/reset-release-branch-checkout
Open

Realign the release branch on its remote during checkout#5686
AliSoftware wants to merge 6 commits into
mainfrom
ainfra-2725/reset-release-branch-checkout

Conversation

@AliSoftware

@AliSoftware AliSoftware commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Note

Please do not merge this PR yet. It should wait until wordpress-mobile/release-toolkit#763 is merged and a new version of the release-toolkit gem has been released, so that the fastlane-plugin-wpmreleasetoolkit version can be bumped in Gemfile.lock as part of this same PR. Both halves of AINFRA-2725 then land together.

What does it do?

Part of AINFRA-2725, following the WooCommerce iOS 25.1 release incident. The same change is going out to every mobile product repo.

1. Realign the release branch on the remote (the actual fix)

.buildkite/commands/checkout-release-branch.sh fetched the release branch and checked it out, but never moved the local branch onto the fetched commit:

git fetch origin "$BRANCH_NAME"
git checkout "$BRANCH_NAME"

Buildkite cleans the working copy between jobs, but it can reuse it. A refs/heads/release/x.y left behind by an earlier job on the same agent therefore survives, and git checkout then simply switches to that stale local ref rather than to what was just fetched. Whatever runs next — the version bump, or the GitHub Release draft that finalize_release creates from HEAD — would then be based on the wrong commit.

The fix adds the missing realignment. reset --hard rather than git pull: no extra network round trip, and no merge commit if the refs diverged.

2. Reset to FETCH_HEAD rather than the remote-tracking ref

git fetch origin <branch> always writes FETCH_HEAD, but it only updates refs/remotes/origin/<branch> when the remote's fetch refspec covers that branch. With Buildkite's default +refs/heads/*:refs/remotes/origin/* the two are equivalent — but on a clone whose refspec was narrowed after origin/<branch> already existed, the fetch leaves that ref stale and reset --hard "origin/$BRANCH_NAME" lands on the old commit: the very failure this script exists to prevent, reintroduced through the back door. Resetting to FETCH_HEAD drops the refspec dependency entirely.

3. Standardize the script across all repos

Slightly tangent to the issue, but bundled deliberately: the script had drifted into five different shapes across the repos — argument required via ${1?…} or ${1:?…}, argument plus a BUILDKITE_BRANCH fallback, argument with a hand-rolled usage check, the same under a different variable name, and (in one repo) no argument at all, reading RELEASE_VERSION from the environment. Having rolled the same one-line fix out thirteen times this week, that divergence is pure friction.

All repos now share one canonical script. The resolution order is a superset of what every repo did before — argument, then RELEASE_VERSION from the environment, then the release/* branch the build runs on — so no call site needed changing. The BUILDKITE_BRANCH fallback only fires on a branch matching ^release/, and derives the branch name back from that same value, so it cannot select a branch other than the one the build was already triggered on.

It also closes two latent bugs: under bash -eu, both [[ -z "${RELEASE_VERSION}" ]] on an unset variable and a bare RELEASE_VERSION=$1 with no arguments abort with unbound variable before their intended usage message can print. And an argument that is passed but empty — what happens when a pipeline forwards an unset $RELEASE_VERSION — no longer resolves to a bare release/: it falls through to the environment variable, then to the release/* branch the build runs on, and finally to a clear error if none of those yields a version.

Testing instructions

No behaviour change on a fresh checkout, which is the normal case: the local branch is already at the fetched commit, so the reset is a no-op. The argument-resolution logic was exercised across all combinations (argument / empty argument / environment variable / release/* branch / trunk / feature branch / unset), and the resulting script passes shellcheck in every repo. The next release build exercising this script is the real check.

🤖 Generated with Claude Code

`checkout-release-branch.sh` fetched the release branch then checked it out, but never moved the local branch to the fetched commit. Buildkite cleans the working copy between jobs, yet can reuse it — so a `refs/heads/release/x.y` left behind by an earlier job on the same agent survives, and `git checkout` then just switches to that stale local ref instead of the freshly fetched remote one. Anything running afterwards, such as the version bump and the GitHub Release draft created by `finalize_release`, would target the wrong commit.

Adding `git reset --hard "origin/$BRANCH_NAME"` after the checkout makes the branch match the remote unconditionally. `reset --hard` rather than `git pull`: it needs no extra network round trip and cannot produce a merge if the local and remote refs have diverged.

This is the second part of AINFRA-2725, a follow-up to the WooCommerce iOS 25.1 release incident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@AliSoftware
AliSoftware requested a review from a team as a code owner July 30, 2026 21:08
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown

Claude finished @AliSoftware's task in 2m 42s —— View job


Code review

  • Read the PR diff against origin/main
  • Read the full script and trace all callers
  • Check for regressions in the removed git pull behaviour
  • Post review

The fix is correct and addresses a real failure mode. git reset --hard "origin/$BRANCH_NAME" after the checkout is the right primitive here, and the rationale for preferring it over git pull (no extra round trip, cannot create a merge commit on divergence) holds. I also confirmed this is the only place in .buildkite/ doing git branch manipulation, so there's no sibling script left with the old pattern.

Two hardening points, both about the same "reused working copy" premise the PR is built on. Neither is a blocker.

1. The reset trusts a remote-tracking ref that git fetch origin <branch> only updates opportunistically

.buildkite/commands/checkout-release-branch.sh:24-28

git fetch origin "$BRANCH_NAME"
git checkout "$BRANCH_NAME"
git reset --hard "origin/$BRANCH_NAME"

A refspec-less git fetch origin <branch> writes FETCH_HEAD unconditionally, but updating refs/remotes/origin/<branch> is a side effect that only happens when the remote's configured fetch refspec covers that branch (git ≥ 1.8.4). With the default +refs/heads/*:refs/remotes/origin/* that Buildkite's checkout sets up, this is fine — which is why the PR works in practice today. But if a reused working copy ever has a narrowed refspec (single-branch clone, mirror/reference clone, a hand-tweaked remote.origin.fetch), origin/$BRANCH_NAME stays stale and reset --hard silently resets to exactly the wrong commit this PR is trying to avoid — a no-op that looks like a fix. Given the whole value of the change is "the reset lands on the fetched commit", it's worth making that guarantee explicit rather than inherited:

git fetch origin "+refs/heads/$BRANCH_NAME:refs/remotes/origin/$BRANCH_NAME"

Worth noting the existing git checkout "$BRANCH_NAME" already depends on the same ref for its DWIM branch creation on a fresh checkout, so this isn't a regression the PR introduces — just an assumption now load-bearing for correctness rather than only for convenience.

2. Under set -e, git checkout runs before the reset and can abort the job first

The script is #!/bin/bash -eu (line 1). If a reused working copy has modified tracked files that differ between the current tip and $BRANCH_NAME, git checkout "$BRANCH_NAME" fails with "Your local changes to the following files would be overwritten by checkout" and the script exits before line 28 ever runs. That's the same class of leftover state the PR is defending against, just tracked-file dirt instead of a stale ref.

Both points collapse into one command that is also shorter than what's there now:

# Buildkite can reuse a working copy where "$BRANCH_NAME" was left dirty or at an older commit by a
# previous job. `-B` (re)points the local branch at the fetched commit and `-f` drops leftover local
# changes — rather than `git pull`, which needs an extra round trip and could merge on divergence.
git fetch origin "+refs/heads/$BRANCH_NAME:refs/remotes/origin/$BRANCH_NAME"
git checkout -f -B "$BRANCH_NAME" "origin/$BRANCH_NAME"

-B creates-or-resets the branch to the start point, subsuming the reset --hard. Discarding local changes is safe for every caller — all four invoke this script as the first step of the job, before secrets are applied or any file is generated:

  • .buildkite/commands/release-build.sh:5
  • .buildkite/release-pipelines/finalize-release.yml:12
  • .buildkite/release-pipelines/finalize-hotfix-release.yml:12
  • .buildkite/release-pipelines/new-beta-release.yml:12

One caveat if you take this: neither -f nor reset --hard removes untracked files. Buildkite's between-job clean normally handles those, so I wouldn't add git clean -ffdx speculatively — just don't read either form as "the tree is pristine".

Fix this →

Checked and clear

  • No upstream-tracking regression from dropping git pull. reset --hard doesn't touch branch.<name>.merge/.remote, so any later refspec-less git push (the fastlane version bump, finalize_release) resolves exactly as before.
  • Security: nothing sensitive touched. $BRANCH_NAME is derived from $1/$BUILDKITE_BRANCH and is consistently quoted; it's passed as a single argv element to git, not through a shell, so there's no injection surface. The ^release/ guard at lines 16-21 already constrains the $BUILDKITE_BRANCH path.
  • Performance: reset --hard is local-only, so this adds no network work — a genuine improvement over git pull.
  • Testing claim in the description holds: on a fresh checkout the local branch is already at the fetched commit, so the reset is a no-op.

Nit, ignore freely: the comment on line 26 quotes "$BRANCH_NAME" as if it were shell code, which reads a little oddly inside a comment where nothing expands.

I didn't run builds or tests — this is a .buildkite shell script with no Gradle or Kotlin surface, and the repo has no shellcheck configuration to run against it. I also couldn't run a local git fixture to demonstrate point 1 empirically (sandbox declined the scratch-directory writes), so that one is reasoned from git's fetch semantics and flagged as conditional on the remote's refspec rather than as an observed failure.
· branch ainfra-2725/reset-release-branch-checkout

@AliSoftware AliSoftware added the [Type] Tooling Related to the Gradle build scripts and the setup or maintenance of the project build process. label Jul 30, 2026
@AliSoftware AliSoftware added this to the 8.18 milestone Jul 30, 2026
@AliSoftware

Copy link
Copy Markdown
Contributor Author

About the 2 points Claude raises:

  1. 👍 The first point seems a decent feedback, I'll investigate in more depth tomorrow.
  2. 👎 Second point is not really a concern, and we don't want to apply the suggested fix, because:
    • We actually want git checkout to fail if the release/ branch does not exist—rather than creating it if it does not, which is what git checkout -B would do.
    • During my chat with Claude on this issue (see also the Slack thread attached to the Linear issue), we explicitly rejected git checkout -B (and Claude was the one who suggested git reset --hard would be better for our use case and needs here) exactly because git checkout -B would have the side-effect of creating the branch if it did not exist (even though, technically, if that were the case, I think the git fetch would have failed first. But still, semantically we don't want to "checkout-or-create", we want to "checkout-and-fail-if-not-exists, then reset") while we want to avoid that behavior in this (unlikely) possibility.

Comment thread .buildkite/commands/checkout-release-branch.sh Outdated
"realign it on the remote" read as though the operation happened *on* the remote, rather than describing what the local branch is realigned against. Say plainly what the reset does instead: force the local branch to the fetched commit.

Wording suggested by @mokagio in review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @AliSoftware's task in 1m 3s —— View job


Re-review of b2fc3bf

  • Diff the new commit against the previously reviewed state
  • Check that mokagio's wording suggestion was applied as given
  • Re-check my earlier point 2 against @AliSoftware's reasoning
  • Confirm the status of my earlier point 1
  • Check the merge gate named in the PR description

The only change since my last pass is the comment wording. It matches mokagio's suggestion verbatim, and the ambiguity they flagged is gone — "force the local branch to the fetched commit" says what the reset does without reading as an operation performed on the remote. .buildkite/commands/checkout-release-branch.sh:24-29 is otherwise unchanged, and it's still the only git branch manipulation anywhere under .buildkite/.

Point 2 (checkout -f -B) — withdrawn, your reasoning is right

"checkout-and-fail-if-not-exists, then reset" is the semantics you want, and plain git checkout is what encodes it. -B would turn a missing release branch into a silently created one — exactly the wrong failure mode for a script whose whole job is to guarantee the build sits on a specific released commit. And you're right that the git fetch on line 24 would already have failed under -eu (fatal: couldn't find remote ref …), so the create-or-reset path is unreachable in practice; but encoding the intent in the command rather than relying on an upstream guard is the better call regardless. Dropping it.

Point 1 (explicit fetch refspec) — still open, as you left it

Line 24 is unchanged, so git reset --hard "origin/$BRANCH_NAME" still relies on refs/remotes/origin/$BRANCH_NAME having been updated as an opportunistic side effect of the refspec-less fetch. Fine under the default +refs/heads/*:refs/remotes/origin/*; the concern is only that a narrowed refspec would make the reset a no-op that looks like the fix. Flagging it as still-outstanding rather than re-arguing it — you said you'd investigate, and this commit wasn't meant to address it.

Two cosmetic notes on the new comment, both ignorable

  • "left at an older commit" — a stale refs/heads/release/x.y isn't necessarily older; if a previous job pushed a version bump and the remote branch was later force-moved, or the leftover ref came from a different release line, it could be unrelated rather than behind. "at a different commit" would cover the general case, and it's the general case the reset --hard actually handles.
  • The wrap is uneven now — line 26 runs to ~110 chars while 27 and 28 are half-empty. Reflowing to three even lines reads better, though nothing enforces this (spotlessCheck doesn't cover .buildkite/**, and there's no shellcheck config in the repo).

Neither is worth a commit on its own; fold them in if you touch the file for point 1.

Merge gate reminder

Gemfile.lock:169 is still fastlane-plugin-wpmreleasetoolkit (14.11.1), so the bump your PR description makes a merge precondition hasn't landed here yet. Still blocked on release-toolkit#763 → gem release → bump in this same PR.

I didn't run builds or tests: the diff is a shell comment in .buildkite/, with no Gradle, Kotlin, or Ruby surface to exercise.
· branch ainfra-2725/reset-release-branch-checkout

AliSoftware and others added 2 commits July 31, 2026 19:41
`git fetch origin <branch>` always writes `FETCH_HEAD`, but it only updates `refs/remotes/origin/<branch>` when the remote's configured fetch refspec covers that branch. With the default `+refs/heads/*:refs/remotes/origin/*` that Buildkite sets up, the two are equivalent — but on a clone whose refspec was narrowed after `origin/<branch>` already existed, the fetch leaves that ref stale and `reset --hard "origin/$BRANCH_NAME"` silently lands on the old commit: exactly the failure this script is meant to prevent, reintroduced through the back door.

Resetting to `FETCH_HEAD` removes the dependency on the refspec entirely — it is whatever the line above just fetched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The script had drifted into five different shapes across the repos: argument required via `${1?…}` or `${1:?…}`, argument plus a `BUILDKITE_BRANCH` fallback, argument with a hand-rolled usage check, the same under a different variable name, and — in one repo — no argument at all, reading `RELEASE_VERSION` from the environment. Having rolled the same one-line fix out thirteen times this week, the divergence is pure friction, so this settles on a single canonical version.

The resolution order is a superset of what every repo did before — argument, then `RELEASE_VERSION` from the environment, then the `release/*` branch the build runs on — so no call site needed changing. The `BUILDKITE_BRANCH` fallback only ever fires on a branch matching `^release/`, and it derives the branch name back from that same value, so it cannot select a branch other than the one the build was already triggered on.

It also closes two latent bugs. Under `bash -eu`, `[[ -z "${RELEASE_VERSION}" ]]` on an unset variable and a bare `RELEASE_VERSION=$1` with no arguments both abort with `unbound variable` before their intended usage message can print. And an argument that is passed but empty — which happens when a pipeline forwards an unset `$RELEASE_VERSION` — is now a hard error everywhere, rather than resolving to `release/` or silently falling through to the current branch.

The redundant `echo '--- :git: Checkout Release Branch'` in simplenote-android's pipelines is dropped, since the canonical script prints that group header itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @AliSoftware's task in 4m 10s —— View job


Re-review of c7e24f5 + cb5363c

  • Diff the two new commits against the previously reviewed state
  • Check whether the FETCH_HEAD switch closes my earlier point 1
  • Re-verify the script end to end and its callers
  • Check the merge gate named in the PR description

Two commits since my last pass. c7e24f5 closes my point 1; cb5363c is a larger rewrite than the PR description covers, and it contains one behaviour change worth an explicit sign-off rather than an implicit one.

Point 1 — closed, and FETCH_HEAD is the better fix

.buildkite/commands/checkout-release-branch.sh:34-40. Resetting to FETCH_HEAD is stronger than the explicit-refspec fetch I'd suggested: instead of making origin/$BRANCH_NAME trustworthy, it stops depending on it. git fetch origin <branch> writes FETCH_HEAD unconditionally, including when the fetch was a no-op, so line 40 lands on the commit line 34 just fetched regardless of remote.origin.fetch.

git checkout on line 35 still DWIMs off refs/remotes/origin/$BRANCH_NAME on a working copy with no local branch — but that residual dependency now fails loudly (fatal: invalid reference under -eu) instead of silently seating the build on a stale commit, and if the ref merely exists-but-stale, line 40 corrects it. That's the right shape.

One maintenance note, not a defect: FETCH_HEAD is repository-global positional state, so lines 34 and 40 are now coupled by adjacency. Any future git fetch, git submodule update, or git pull inserted between them silently redirects the reset. A comment or FETCHED_COMMIT="$(git rev-parse FETCH_HEAD)" right after the fetch would pin it, though at 6 lines apart I wouldn't bother today.

The "superset" claim in cb5363c doesn't hold for this repo

.buildkite/commands/checkout-release-branch.sh:20-31. The commit message says the new resolution order "is a superset of what every repo did before … so no call site needed changing". It's a superset for a non-empty argument. For an empty one it is strictly narrower than what pocket-casts-android did before:

# before
if [[ -n "${1:-}" ]]; then          # empty arg → falls through
elif [[ "${BUILDKITE_BRANCH:-}" =~ ^release/ ]]; then   # → recovers here

# after
if [[ $# -gt 0 ]]; then             # empty arg → matches
  RELEASE_VERSION="${1:?…}"         # → hard error, both fallbacks skipped

That case is reachable. All three release pipelines invoke the script as checkout-release-branch.sh "${RELEASE_VERSION}", and Buildkite interpolates an unset variable to the empty string at upload time (the same mechanism that makes $CI_TOOLKIT work) — so an operator triggering finalize-release without RELEASE_VERSION passes one empty argument. Previously the script recovered from the release/* branch the build was already on and finalized correctly; now it aborts:

  • .buildkite/release-pipelines/finalize-release.yml:12
  • .buildkite/release-pipelines/finalize-hotfix-release.yml:12
  • .buildkite/release-pipelines/new-beta-release.yml:12

.buildkite/commands/release-build.sh is unaffected — its own ${1:?RELEASE_VERSION parameter missing} on line 4 fires first, so it can never forward an empty argument.

Your commit message calls this out deliberately ("is now a hard error everywhere, rather than … silently falling through to the current branch"), and for a release script I think aborting on ambiguous input is the right call — especially given the incident this PR descends from. So this is a request to make the accept explicit, not to change it. Two things follow from it:

  • The PR description still says "No behaviour change on a fresh checkout". True of the reset, no longer true of the script: on a fresh checkout with an unset RELEASE_VERSION, the job now fails where it used to succeed. Worth a line in the description, since ReleasesV2 operators are the people who'd hit it.
  • The same message argues the BUILDKITE_BRANCH fallback is safe ("cannot select a branch other than the one the build was already triggered on") — which is exactly the case being removed. If the goal is "never guess", ${1:?} is right; if it's "never guess wrong", the old fallback met that bar. Fine either way, but the two halves of the message pull in opposite directions.

Fix this →

Precedence level 2 is dead code in this repo

Line 24's RELEASE_VERSION environment fallback can't be reached from any call site here: all four pass an argument, so [[ $# -gt 0 ]] always wins. That's fine as cross-repo canonicalization — the commit message says one repo was env-only — but the header comment reads as a live three-tier safety net, and in this repo only tier 1 is ever exercised. No change needed; just don't let a future reader assume tiers 2 and 3 are load-bearing here.

Checked and clear

  • Bug fixes in cb5363c verified. Under bash -eu, both [[ -z "${RELEASE_VERSION}" ]] on an unset variable and a bare RELEASE_VERSION=$1 with no arguments do abort with unbound variable before printing their usage message. Neither shape was present in this repo's version, so those are cross-repo fixes landing here for uniformity, not fixes to a live bug.
  • FETCH_HEAD can't be shadowed by a tag. Tag auto-following can only append entries after the requested refspec's, and git rev-parse FETCH_HEAD resolves the first line — so line 40 gets the branch, not a tag. Reasoned from git's fetch semantics; the sandbox again declined the scratch-directory writes for a local fixture, so this is not empirically demonstrated.
  • No upstream-tracking regression. reset --hard doesn't touch branch.<name>.merge/.remote, so the later refspec-less git push from the fastlane version bump and finalize_release resolves as before. No submodules in the repo, so reset --hard not updating submodule worktrees is moot.
  • Security. $BRANCH_NAME derives from $1/RELEASE_VERSION/BUILDKITE_BRANCH, is consistently quoted, and reaches git as a single argv element — no injection surface. Nothing sensitive touched; all callers run this before configure_apply.
  • Still the only git branch manipulation under .buildkite/.
  • Earlier cosmetic notes (the ~110-char line 36, "older commit" vs "different commit", shell-style quoting inside a comment) are unchanged. Genuinely ignorable; I'm not re-arguing them.

Merge gate

Gemfile.lock:169 is still fastlane-plugin-wpmreleasetoolkit (14.11.1), so the bump your description makes a merge precondition hasn't landed. Still blocked on release-toolkit#763 → gem release → bump here.

I didn't run builds or tests: the diff is one .buildkite shell script, with no Gradle, Kotlin, or Ruby surface to exercise, and no shellcheck config in the repo to lint it with. spotlessCheck doesn't cover .buildkite/**. Both Gradle Wrapper Validation runs on this push passed.
· branch ainfra-2725/reset-release-branch-checkout

@AliSoftware

Copy link
Copy Markdown
Contributor Author

@mokagio heads-up — these PRs grew since your review, so they're worth another look rather than assuming they're still the three-line change you saw. Same comment on all 13 repos.

Two commits were added on top of the wording fix you suggested:

1. Reset to FETCH_HEAD rather than origin/$BRANCH_NAME. git fetch origin <branch> always writes FETCH_HEAD, but it only updates refs/remotes/origin/<branch> when the remote's configured fetch refspec covers that branch. With Buildkite's default +refs/heads/*:refs/remotes/origin/* the two are equivalent — but on a clone whose refspec was narrowed after origin/<branch> already existed, the fetch leaves that ref stale and the reset lands on the old commit: the very failure this script exists to prevent, reintroduced through the back door. Resetting to FETCH_HEAD drops the refspec dependency entirely.

2. Standardized the script across all 13 repos. Tangent to the original issue, bundled deliberately. The script had drifted into five different shapes — argument required via ${1?…} or ${1:?…}, argument plus a BUILDKITE_BRANCH fallback, argument with a hand-rolled usage check, the same under a different variable name, and (in simplenote-android) no argument at all, reading RELEASE_VERSION from the environment. Having rolled the same one-line fix out thirteen times this week, that divergence is pure friction.

All 13 now share one canonical script. The resolution order is a superset of what every repo did before — argument, then RELEASE_VERSION from the environment, then the release/* branch the build runs on — so no call site needed changing. The BUILDKITE_BRANCH fallback only fires on a branch matching ^release/, and it derives the branch name back from that same value, so it cannot select a branch other than the one the build was already triggered on.

It also closes two latent bugs: under bash -eu, both [[ -z "${RELEASE_VERSION}" ]] on an unset variable and a bare RELEASE_VERSION=$1 with no arguments abort with unbound variable before their intended usage message can print. And an argument that is passed but empty — what happens when a pipeline forwards an unset $RELEASE_VERSION — is now a hard error everywhere, instead of resolving to release/ or silently falling through to the current branch.

The scripts are byte-identical across all 13 repos and pass shellcheck everywhere; the argument resolution was exercised across every combination (argument / empty argument / environment variable / release/* / trunk / feature branch / unset). Full rationale in the updated PR description.

Worth flagging that the release-toolkit PR also picked up a real fix from your review — the tag-based lookup was preferring a leftover draft over the release that actually owns the tag.

@AliSoftware
AliSoftware requested a review from mokagio July 31, 2026 19:46
Collapse the three-way `if/elif/else` into a plain assignment plus two guards. The `: # Already provided through the pipeline environment` no-op branch existed only to skip reassigning a value that was already correct, which reads oddly for anyone who has not just written it.

The one behavioural difference is that an argument that is passed but empty no longer gets its own dedicated error: it now falls through to the environment variable, then to the `release/*` branch, and finally to the same generic error as the unset case. That is a rare enough situation to not be worth a distinct branch, and when the fallback does catch it, it resolves to the branch the build is already running on, which cannot be a different branch than intended.

Note the nested guard in `${1:-${RELEASE_VERSION:-}}`: written as `${1:-$RELEASE_VERSION}`, the default expression itself dereferences an unset variable, so under `bash -eu` the script would abort with `RELEASE_VERSION: unbound variable` when neither is set — the same latent failure this standardization removed from a couple of the repos.

Also say "a different commit" rather than "an older commit" when describing the reused working copy, since a stale local ref is not necessarily behind the remote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @AliSoftware's task in 3m 30s —— View job


Re-review of 76470ed

  • Diff the new commit against the previously reviewed state
  • Verify the new resolution logic across all input combinations
  • Re-check my earlier "superset" concern against the new shape
  • Confirm the merge gate

One commit since my last pass, and it resolves the substantive point I raised on cb5363c. The three-way if/elif/else collapsing into an assignment plus two guards reads better, and the : # Already provided… no-op branch was the weakest part of the previous shape — good to see it gone.

The "superset" claim now actually holds

.buildkite/commands/checkout-release-branch.sh:20-29. My objection to cb5363c was that [[ $# -gt 0 ]] + ${1:?…} turned an empty argument into a hard error, where this repo previously recovered from BUILDKITE_BRANCH — reachable because all three release pipelines pass "${RELEASE_VERSION}" and Buildkite interpolates an unset variable to the empty string at upload time. Line 20 fixes that:

RELEASE_VERSION="${1:-${RELEASE_VERSION:-}}"

${1:-…} (colon form) treats empty and unset identically, so an empty argument now falls through to the environment and then to the release/* branch, ending at the same generic error as the unset case. Traced against origin/main's version, the resolution is now a genuine superset for every input:

input origin/main this PR
non-empty arg arg arg
empty arg, on release/X X X
empty arg, on trunk error error
no arg, on release/X X X
no arg, RELEASE_VERSION in env error env value (new tier)
nothing available error error

So the note I asked for in the PR description isn't needed any more — the description is accurate again as written.

The nested :- guard is necessary, and the commit message gets the reason exactly right

Worth confirming since it's the kind of thing a future reader may try to "simplify" back: bash evaluates the default word of ${1:-word} lazily, so ${1:-$RELEASE_VERSION} would not abort when an argument is passed — it would abort only when neither $1 nor RELEASE_VERSION is set, i.e. precisely the case that needs to reach the usage error on line 27. Under bash -eu the script would die with RELEASE_VERSION: unbound variable instead. The inner :- is load-bearing and the comment history explains why; nothing to change.

Also confirmed the self-reference on line 20 is safe — the RHS is fully expanded before the assignment — and that reassigning an already-exported RELEASE_VERSION preserves the export attribute, so nothing downstream sees a difference (moot here anyway, since every caller runs this as a subprocess rather than sourcing it).

Two notes on the trade-off you accepted, neither a blocker

Env now outranks the branch fallback for an empty argument. New ordering relative to origin/main, which had no env tier: empty arg + runtime RELEASE_VERSION=X + build on release/Y resolved to Y before and resolves to X now. Unreachable from any call site in this repo — nothing sets RELEASE_VERSION in these jobs' runtime env; it's interpolated into the argument at upload time — and the explicit variable is the better signal if it ever is set. Noting it only because it's the one row where the two versions disagree on a non-error outcome.

Empty argument is now a silent recovery rather than a loud failure. Your commit message justifies this on the grounds that the fallback "resolves to the branch the build is already running on, which cannot be a different branch than intended". That holds for finalize-release.yml and new-beta-release.yml. For .buildkite/release-pipelines/finalize-hotfix-release.yml:12 it rests on ReleasesV2 triggering the job on the hotfix branch (release/X.Y.Z, as created by new_hotfix_release) rather than on the base release/X.Y — if it ever triggered on the base with RELEASE_VERSION unset at upload, the script would now silently finalize the base release instead of erroring. That's origin/main's behaviour restored, not a regression this PR introduces, and I believe the trigger branch is the hotfix branch in practice. Flagging it because this PR descends from a wrong-commit-released incident, and that one call site is the only place cb5363c's stricter variant had a defensible edge. Your call, and I'd accept it as-is.

.buildkite/commands/release-build.sh is unaffected either way — its own ${1:?RELEASE_VERSION parameter missing} on line 4 fires on an empty value first, so it can never forward one.

Checked and clear

  • My earlier point 1 (FETCH_HEAD) stays closed. Lines 32-38 are untouched by this commit. The adjacency coupling between the fetch and the reset is still a maintenance note only, not a defect.
  • "a different commit" replaced "an older commit" on line 34, which was my suggestion — a stale local ref genuinely needn't be behind. Line 34 is now ~114 chars while 35-37 sit around half that; still nothing enforces wrapping here (spotlessCheck doesn't cover .buildkite/**), so still ignorable.
  • Header comment, line 7: "the first of these that is set and non-empty" is accurate for tiers 1-2; tier 3 additionally requires ^release/, and a branch named exactly release/ yields an empty version and lands on the generic error. Correct behaviour, marginally under-described comment. Not worth a commit.
  • Security. Unchanged surface. $BRANCH_NAME derives from $1 / RELEASE_VERSION / BUILDKITE_BRANCH, is consistently quoted, and reaches git as a single argv element — no injection path. All callers still run this before configure_apply, so no secrets are in play. BUILDKITE_BRANCH=release/1.2/3 round-trips to the same branch name, so the strip-and-reprefix can't retarget.
  • Performance. No change; reset --hard remains local-only.
  • Still the only git branch manipulation anywhere under .buildkite/.

Merge gate

Gemfile.lock:169 is still fastlane-plugin-wpmreleasetoolkit (14.11.1), so the bump your description makes a merge precondition hasn't landed. Still blocked on release-toolkit#763 → gem release → bump here.

I did not empirically run the resolution matrix this time: the sandbox declined executing a scratch harness, and shellcheck isn't installed on this runner, so the table above is traced from bash parameter-expansion semantics rather than observed. Your own cross-repo test run covers the same combinations. No builds or tests run — the diff is one .buildkite shell script with no Gradle, Kotlin, or Ruby surface.
· branch ainfra-2725/reset-release-branch-checkout

The two justifications—`reset --hard` over `git pull`, and `FETCH_HEAD` over the remote-tracking ref—were run together in a prose paragraph that wrapped mid-clause, so neither stood out. Split them into bullets under the sentence stating what the reset does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude finished @AliSoftware's task in 49s —— View job


Re-review of d2a3ee1

  • Diff the new commit against the previously reviewed state
  • Re-verify the full script end to end
  • Confirm the merge gate

Comment-only change since 76470ed — the three-sentence rationale is now a lead-in line plus two bullets. git diff 76470ed..HEAD touches nothing but lines 34-37; the executable body of .buildkite/commands/checkout-release-branch.sh is byte-identical.

It reads better. The old form buried two independent "X rather than Y" justifications in one run-on sentence with an awkward mid-clause wrap; splitting them makes each choice scannable on its own line, which is what someone opening this file at 3am during a release actually needs.

Everything I've previously signed off on still holds and I'm not re-arguing any of it:

  • Reset target (line 38): FETCH_HEAD closes my original point 1 — no dependence on remote.origin.fetch covering the branch.
  • Resolution order (lines 20-29): genuine superset of origin/main for every input, including the empty-argument case Buildkite produces from an unset RELEASE_VERSION.
  • git checkout without -B (line 33): deliberate, per your reasoning — fail on a missing release branch rather than silently create it.
  • Security / performance: unchanged surface. $BRANCH_NAME is consistently quoted and reaches git as a single argv element; reset --hard is local-only.
  • Still the only git branch manipulation anywhere under .buildkite/.

Two cosmetic notes, ignorable

  • The bullets are punctuated inconsistently — line 36 has no terminating period, line 37 does. Trivial, but they're a parallel pair.
  • Line 34 (~107 chars) and line 37 (~110) are still the long ones while 35-36 sit near half that. Nothing enforces wrapping here (spotlessCheck doesn't cover .buildkite/**, and there's no shellcheck config in the repo), and the bullet form makes the ragged right read as intentional structure rather than a bad wrap — so this bothers me less than it did on the previous shape.

Not worth a commit on their own.

Merge gate

Gemfile.lock:169 is still fastlane-plugin-wpmreleasetoolkit (14.11.1), so the bump your description makes a merge precondition hasn't landed. Still blocked on release-toolkit#763 → gem release → bump in this same PR.

No builds or tests run: the diff is three shell comment lines, with no Gradle, Kotlin, or Ruby surface to exercise.
· branch ainfra-2725/reset-release-branch-checkout

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

Labels

[Area] Tooling [Type] Tooling Related to the Gradle build scripts and the setup or maintenance of the project build process.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants