From 5730c83f7dd2ae409de5efe2d023e46695c78b3e Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 19 Oct 2026 09:21:11 -0700 Subject: [PATCH] fix(release): keep mobile candidates pinned as branches advance (#8454) Mobile candidate publication currently fails if the source branch advances after selection, and the operator can report failure even after the correct immutable tag was created. It also prevents releasing an explicitly selected historical baseline. Add `--commit FULL_SHA` and independently verify in the operator and publisher that the selected commit belongs to the selected source branch's history. Compare immutable SHAs, keep the candidate pinned through publication, and verify the resulting annotated tag instead of requiring the branch to remain stationary. Stable-branch review/check protection and immutable-tag permissions remain enforced. Without `--commit`, selection still defaults to the current source tip. ### Validation - Updated operator and publisher contracts pass: historical main/stable selection, branch advancement during publication, unrelated or missing commits, ancestry lookup failure, and unchanged tag/sequence checks. - Removing ancestry enforcement causes the unrelated-history regression to fail. - Live read-only ancestry verification accepts the original 0.24 baseline. Local publication stops at the existing App-permission check; no tag or build was created. - Full local `just ci` passed. Signed-off-by: Tom Brow --- .../workflows/mobile-release-candidate.yml | 2 +- RELEASING.md | 26 +++++---- scripts/mobile-release-source.sh | 14 +++++ scripts/mobile-release.sh | 57 ++++++++++++------- scripts/publish-mobile-release-candidate.sh | 4 +- ...test-mobile-release-candidate-publisher.sh | 37 +++++++++++- scripts/test-mobile-release-contract.sh | 52 +++++++++++++---- 7 files changed, 143 insertions(+), 49 deletions(-) diff --git a/.github/workflows/mobile-release-candidate.yml b/.github/workflows/mobile-release-candidate.yml index 5f2ecf305a..131e8aab0b 100644 --- a/.github/workflows/mobile-release-candidate.yml +++ b/.github/workflows/mobile-release-candidate.yml @@ -18,7 +18,7 @@ on: default: main type: string target_sha: - description: Exact current source branch commit + description: Pinned commit in the selected source branch history required: true type: string diff --git a/RELEASING.md b/RELEASING.md index 0385dd9e4a..6e251a93d9 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -135,11 +135,13 @@ release notes and require operators to upgrade before distributing these clients 1. **Publish a candidate.** From a clean checkout whose `origin` is the canonical `block/buzz` repository, run `scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches - the exact current `origin/main` commit, derives the next number from exact + a snapshot of `origin/main`, derives the next number from exact remote tags for that marketing version, and publishes an annotated `mobile-vX.Y.Z-rc.N` tag there through the dedicated `buzz-release-bot` GitHub App. It never uses the operator's checked-out commit and never moves - an existing candidate. + an existing candidate. To select a historical commit, add `--commit FULL_SHA`. + Both operator and publisher require that commit to be in the selected source + branch's history. Later branch advances do not change or invalidate the tag. 2. **Build the exact tag.** Enter the candidate tag as `mobile_ref` in the private Buzz mobile Buildkite pipeline. OSS CI deliberately cannot trigger that private pipeline. The tag supplies both source commit and release @@ -168,7 +170,9 @@ scripts/mobile-release.sh candidate 0.25.0 --source-branch mobile-v0.25 The branch must match the marketing version's major and minor components: `mobile-v0.25` accepts 0.25.x, but rejects 0.26.x and 1.25.x. Without the option, the source remains `main`. Both the operator and publisher validate the source; -a missing branch or changed tip fails rather than falling back to `main`. +a missing branch or a commit outside its history fails rather than falling back +to `main`. `--commit FULL_SHA` also works with a stable source branch, whose +review and check protections remain required. The GitHub workflow itself continues to run from reviewed `main`. The private pipeline still builds only the resulting immutable candidate tag. @@ -191,7 +195,8 @@ Never merge `main` wholesale. Remote agents and App Clip are post-conference work. There is no preselected fix list and no requirement to create a stable branch for every minor release. -The initial 0.25.0 candidate uses an empty commit above that baseline to verify +The initial 0.25.0 candidate pins that baseline with `--commit`. The second +candidate uses an empty commit above that baseline to verify that the existing pipeline builds a commit outside `main`. Verify identical source trees, absence of that commit from remote `main`, and the exact tag, commit, marketing version and build number in the uploaded artifacts. Upload @@ -394,14 +399,13 @@ option for a stable release. The command publishes a new immutable RC tag at the remote commit. Continue referring to each tested or shipped platform artifact by its own exact tag. -### `scripts/mobile-release.sh candidate` fails because the source branch moved during publication +### The source branch advances during candidate publication -The App-backed workflow may already have published the requested immutable RC -at the prior source branch tip before the operator command detects the race. Do not -move or delete that tag, and do not treat it as the candidate for the current -source branch. Inspect the run URL from the command output, then rerun -`scripts/mobile-release.sh candidate ` to publish the next RC from the -new current source branch tip, preserving the source-branch option. +Publication remains pinned to the selected SHA. The publisher verifies that it +is an ancestor of a resolved source-branch snapshot, then creates and verifies +an immutable annotated tag for that exact SHA. The operator verifies the tag, +not the branch's later tip. A newer commit does not require retrying or creating +another candidate. Missing or unverifiable ancestry still fails closed. ### A mobile candidate command selects the wrong RC number diff --git a/scripts/mobile-release-source.sh b/scripts/mobile-release-source.sh index 6420ae0d03..bc3321132a 100644 --- a/scripts/mobile-release-source.sh +++ b/scripts/mobile-release-source.sh @@ -42,3 +42,17 @@ require_mobile_release_branch_ruleset() { return 1 } } + +# Compare immutable SHAs, not a mutable branch name. The selected branch may +# advance after this snapshot without changing the authorized candidate source. +require_mobile_release_commit_ancestor() { + local target_sha="$1" source_sha="$2" merge_base + merge_base="$(gh api "repos/block/buzz/compare/$target_sha...$source_sha" --jq .merge_base_commit.sha)" || { + echo "Could not verify candidate source ancestry" >&2 + return 1 + } + [[ "$merge_base" == "$target_sha" ]] || { + echo "Candidate commit $target_sha is not in selected source history at $source_sha" >&2 + return 1 + } +} diff --git a/scripts/mobile-release.sh b/scripts/mobile-release.sh index cd81c50fd1..724c8337a5 100755 --- a/scripts/mobile-release.sh +++ b/scripts/mobile-release.sh @@ -4,10 +4,10 @@ set -euo pipefail usage() { cat >&2 <<'USAGE' usage: - scripts/mobile-release.sh candidate X.Y.Z [--source-branch mobile-vX.Y] + scripts/mobile-release.sh candidate X.Y.Z [--source-branch mobile-vX.Y] [--commit FULL_SHA] candidate Publish the next immutable mobile-vX.Y.Z-rc.N candidate tag at the - exact current remote source commit (main unless --source-branch is set). + selected commit in source history (defaults to the current source tip). USAGE exit 2 } @@ -94,16 +94,12 @@ remote_tag_commit_sha() { } remote_source_commit_sha() { - local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" line advertised_oid fetched_oid commit - line="$(git ls-remote --refs origin "$ref")" || return 1 - [[ -n "$line" && "$line" != *$'\n'* ]] || return 1 - advertised_oid="${line%%$'\t'*}" - git fetch -q --no-tags origin "$ref" - fetched_oid="$(git rev-parse --verify FETCH_HEAD)" - [[ "$fetched_oid" == "$advertised_oid" ]] || \ - fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved while it was being resolved" + local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" fetched_oid commit + # Fetch a single snapshot. A later branch advance does not invalidate it. + git fetch -q --no-tags origin "$ref" || return 1 + fetched_oid="$(git rev-parse --verify FETCH_HEAD)" || return 1 commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || return 1 - [[ "$commit" == "$advertised_oid" ]] || \ + [[ "$commit" == "$fetched_oid" ]] || \ fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH did not resolve directly to a commit" printf '%s' "$commit" } @@ -111,13 +107,31 @@ remote_source_commit_sha() { command="${1:-}" case "$command" in candidate) - [[ "$#" -eq 2 || "$#" -eq 4 ]] || usage + [[ "$#" -ge 2 ]] || usage version="$2" + shift 2 MOBILE_RELEASE_SOURCE_BRANCH=main - if [[ "$#" -eq 4 ]]; then - [[ "$3" == --source-branch ]] || usage - MOBILE_RELEASE_SOURCE_BRANCH="$4" - fi + requested_sha="" + source_option_seen=false + commit_option_seen=false + while [[ "$#" -gt 0 ]]; do + [[ "$#" -ge 2 ]] || usage + case "$1" in + --source-branch) + [[ "$source_option_seen" == false ]] || usage + source_option_seen=true + MOBILE_RELEASE_SOURCE_BRANCH="$2" + ;; + --commit) + [[ "$commit_option_seen" == false ]] || usage + commit_option_seen=true + requested_sha="$2" + [[ "$requested_sha" =~ ^[0-9a-f]{40}$ ]] || fail "--commit requires a full commit SHA" + ;; + *) usage ;; + esac + shift 2 + done require_clean_semver "$version" require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 require_clean_tree @@ -126,7 +140,9 @@ case "$command" in require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 local_head_sha="$(git rev-parse --verify 'HEAD^{commit}')" || fail "HEAD is not a commit" - source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist" + source_tip="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist" + source_sha="${requested_sha:-$source_tip}" + require_mobile_release_commit_ancestor "$source_sha" "$source_tip" || exit 1 next=1 if ! remote_tags="$(git ls-remote --refs --tags origin "refs/tags/mobile-v${version}-rc.*")"; then fail "could not list existing candidates for $version" @@ -161,17 +177,14 @@ case "$command" in gh run watch "$run_id" --repo block/buzz --exit-status --compact || \ fail "App-backed publication failed: $run_url" - current_source_sha="$(remote_source_commit_sha)" || fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH does not exist after publication" - [[ "$current_source_sha" == "$source_sha" ]] || \ - fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $source_sha to $current_source_sha during publication" published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \ fail "publication completed without exact annotated candidate tag $tag" [[ "$published_sha" == "$source_sha" ]] || \ fail "$tag resolved to $published_sha instead of requested commit $source_sha" if [[ "$local_head_sha" != "$source_sha" ]]; then - echo "Note: local HEAD is $local_head_sha; candidate source is current origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_sha." >&2 + echo "Note: local HEAD is $local_head_sha; candidate source is pinned from origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_sha." >&2 fi - printf 'Published %s at origin/%s commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \ + printf 'Published %s at pinned origin/%s commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \ "$tag" "$MOBILE_RELEASE_SOURCE_BRANCH" "$source_sha" ;; diff --git a/scripts/publish-mobile-release-candidate.sh b/scripts/publish-mobile-release-candidate.sh index edeb9ad59b..5119611c67 100755 --- a/scripts/publish-mobile-release-candidate.sh +++ b/scripts/publish-mobile-release-candidate.sh @@ -31,12 +31,12 @@ require_mobile_release_branch_ruleset "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1 source_sha="$(gh api "repos/$repo/git/ref/heads/$MOBILE_RELEASE_SOURCE_BRANCH" --jq .object.sha)" || \ fail "could not resolve $repo $MOBILE_RELEASE_SOURCE_BRANCH" -[[ "$source_sha" == "$target_sha" ]] || \ - fail "$repo $MOBILE_RELEASE_SOURCE_BRANCH moved from requested commit $target_sha to $source_sha" +[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || fail "GitHub returned an invalid source commit SHA" commit_sha="$(gh api "repos/$repo/commits/$target_sha" --jq .sha)" || \ fail "$target_sha is not a commit in $repo" [[ "$commit_sha" == "$target_sha" ]] || \ fail "GitHub resolved requested commit $target_sha as $commit_sha" +require_mobile_release_commit_ancestor "$target_sha" "$source_sha" || exit 1 refs="$( gh api --paginate "repos/$repo/git/matching-refs/tags/mobile-v${version}-rc." --jq '.[].ref' diff --git a/scripts/test-mobile-release-candidate-publisher.sh b/scripts/test-mobile-release-candidate-publisher.sh index 87156a451a..dbfda358b5 100755 --- a/scripts/test-mobile-release-candidate-publisher.sh +++ b/scripts/test-mobile-release-candidate-publisher.sh @@ -35,6 +35,21 @@ case "${1:-}:${2:-}" in api:repos/block/buzz/git/ref/heads/main) printf '%s\n' "$GH_TARGET_SHA" ;; api:repos/block/buzz/git/ref/heads/mobile-v1.2) printf '%s\n' "$GH_STABLE_SHA" ;; api:repos/block/buzz/git/ref/heads/*) printf '%s\n' "$GH_MISMATCH_SHA" ;; + api:repos/block/buzz/compare/*) + [[ "${GH_COMPARE_FAIL:-}" != 1 ]] || exit 1 + pair="${2##*/}" + base="${pair%%...*}"; tip="${pair##*...}" + if [[ -n "${GH_EXPECT_COMPARE:-}" ]]; then + [[ "$pair" == "$GH_EXPECT_COMPARE" ]] || exit 1 + fi + if [[ -n "${GH_COMPARE_BASE_SHA:-}" ]]; then + printf '%s\n' "$GH_COMPARE_BASE_SHA" + elif [[ "$base" == "$tip" ]]; then + printf '%s\n' "$base" + else + printf '%s\n' 9999999999999999999999999999999999999999 + fi + ;; api:repos/block/buzz/commits/*) printf '%s\n' "${2##*/}" ;; api:--paginate) [[ "$3" == "repos/block/buzz/git/matching-refs/tags/mobile-v1.2.3-rc." ]] @@ -116,8 +131,9 @@ if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" exit 1 fi if GH_TARGET_SHA=3333333333333333333333333333333333333333 \ + GH_ANNOTATED_TARGET_SHA=1111111111111111111111111111111111111111 \ "$publisher" 1.2.3 1 1111111111111111111111111111111111111111 main >/dev/null 2>&1; then - echo "publisher accepted a moved main branch" >&2 + echo "publisher accepted a commit outside main history" >&2 exit 1 fi if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\n' \ @@ -164,6 +180,25 @@ if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/ fi GH_ANNOTATED_TARGET_SHA="$GH_STABLE_SHA" "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2 +# Old commits are accepted only when the immutable compare proves ancestry. +old_sha=6666666666666666666666666666666666666666 +for source in main mobile-v1.2; do + tip="$GH_TARGET_SHA" + [[ "$source" != mobile-v1.2 ]] || tip="$GH_STABLE_SHA" + GH_COMPARE_BASE_SHA="$old_sha" GH_EXPECT_COMPARE="$old_sha...$tip" GH_ANNOTATED_TARGET_SHA="$old_sha" \ + "$publisher" 1.2.3 1 "$old_sha" "$source" + before_calls="$(wc -l < "$GH_CALLS")" + for mode in unrelated unavailable; do + if GH_COMPARE_FAIL="$([[ "$mode" == unavailable ]] && echo 1 || echo 0)" \ + GH_COMPARE_BASE_SHA="$GH_MISMATCH_SHA" GH_ANNOTATED_TARGET_SHA="$old_sha" \ + "$publisher" 1.2.3 1 "$old_sha" "$source" >/dev/null 2>&1; then + echo "publisher accepted $mode ancestry for $source" >&2 + exit 1 + fi + done + [[ "$(wc -l < "$GH_CALLS")" == "$before_calls" ]] +done + for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary ''; do if GH_ANNOTATED_TARGET_SHA="$GH_MISMATCH_SHA" "$publisher" 1.2.3 1 "$GH_MISMATCH_SHA" "$source" >/dev/null 2>&1; then echo "publisher accepted mismatched source '$source'" >&2 diff --git a/scripts/test-mobile-release-contract.sh b/scripts/test-mobile-release-contract.sh index ca6b79b9e1..395060e4bd 100755 --- a/scripts/test-mobile-release-contract.sh +++ b/scripts/test-mobile-release-contract.sh @@ -32,7 +32,13 @@ case "${1:-}:${2:-}" in *) exit 2 ;; esac ;; + api:repos/block/buzz/compare/*) + [[ "${GH_COMPARE_FAIL:-}" != 1 ]] || exit 1 + pair="${2##*/}" + git --git-dir="$GH_REMOTE" merge-base "${pair%%...*}" "${pair##*...}" + ;; workflow:run) + printf '%s\n' attempted >> "$GH_WORKFLOW_ATTEMPTS" if [[ "${GH_WORKFLOW_DISPATCH_FAIL:-}" == "1" ]]; then printf '%s\n' "${GH_WORKFLOW_DISPATCH_ERROR:-dispatch failed}" >&2 exit 1 @@ -58,7 +64,7 @@ case "${1:-}:${2:-}" in shift done [[ -n "$version" && -n "$number" && -n "$sha" && -n "$source_branch" ]] || exit 1 - [[ "$(git --git-dir="$GH_REMOTE" rev-parse "refs/heads/$source_branch")" == "$sha" ]] || exit 1 + git --git-dir="$GH_REMOTE" merge-base --is-ancestor "$sha" "refs/heads/$source_branch" || exit 1 printf '%s\t%s\t%s\n' "$version" "$number" "$sha" >> "$GH_WORKFLOW_CAPTURE" if [[ "${GH_WORKFLOW_NO_URL:-}" == "1" ]]; then exit 0 @@ -93,6 +99,7 @@ chmod +x "$bin/gh" export PATH="$bin:$PATH" export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches" +export GH_WORKFLOW_ATTEMPTS="$tmp/workflow-attempts" export GH_WORKTREE="$work" export GH_REMOTE="$remote" @@ -150,7 +157,7 @@ if git -C "$operator" cat-file -e "$remote_main_sha^{commit}" 2>/dev/null; then fail "stale-clone fixture already contains the remote-only commit" fi run_release "$operator" candidate 1.2.3 > "$tmp/stale-output" 2> "$tmp/stale-error" -grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is current origin/main $remote_main_sha." \ +grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is pinned from origin/main $remote_main_sha." \ "$tmp/stale-error" cat "$tmp/stale-output" [[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.1^{commit}')" == \ @@ -262,19 +269,40 @@ if GH_TAG_VERIFY_LIGHTWEIGHT=1 run_release "$operator" candidate 9.9.2 >/dev/nul fi [[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.2-rc.1)" == commit ]] -# The publisher and operator both reject a main-tip race. The immutable tag may -# already exist at the prior tip, but the operator must not report it as a -# current-main candidate. +# Advancing main during publication must not invalidate the pinned tag. pre_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)" -if GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1; then - fail "candidate succeeded after main moved during publication" -fi +GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1 post_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)" [[ "$pre_race_main_sha" != "$post_race_main_sha" ]] -[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.3-rc.1^{commit}')" == \ - "$pre_race_main_sha" ]] -grep -Fq "origin/main moved from requested commit $pre_race_main_sha to $post_race_main_sha during publication" \ - "$tmp/main-race-output" +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.3-rc.1^{commit}')" == "$pre_race_main_sha" ]] +grep -Fq 'Published mobile-v9.9.3-rc.1' "$tmp/main-race-output" + +# Explicit historical source remains pinned even when the source branch advances. +run_release "$operator" candidate 7.7.0 --commit "$remote_main_sha" +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v7.7.0-rc.1^{commit}')" == "$remote_main_sha" ]] +run_release "$operator" candidate 1.2.5 --commit "$remote_main_sha" --source-branch mobile-v1.2 +[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.5-rc.1^{commit}')" == "$remote_main_sha" ]] +before_attempts="$(wc -l < "$GH_WORKFLOW_ATTEMPTS")" +before_dispatches="$(wc -l < "$GH_WORKFLOW_CAPTURE")" +if run_release "$operator" candidate 7.7.1 --commit "$stable_sha" >/dev/null 2>&1; then + fail "accepted a commit outside main history" +fi +if run_release "$operator" candidate 1.2.6 --source-branch mobile-v1.2 --commit "$new_main_sha" >/dev/null 2>&1; then + fail "accepted a main-only commit outside stable history" +fi +for invalid_sha in main HEAD deadbeef 9999999999999999999999999999999999999999 ''; do + if run_release "$operator" candidate 7.7.1 --commit "$invalid_sha" >/dev/null 2>&1; then + fail "accepted invalid or absent commit '$invalid_sha'" + fi +done +if GH_COMPARE_FAIL=1 run_release "$operator" candidate 7.7.1 --commit "$remote_main_sha" >/dev/null 2>&1; then + fail "accepted unavailable ancestry evidence" +fi +if run_release "$operator" candidate 7.7.1 --commit "$remote_main_sha" --commit "$new_main_sha" >/dev/null 2>&1; then + fail "accepted duplicate commit options" +fi +[[ "$(wc -l < "$GH_WORKFLOW_ATTEMPTS")" == "$before_attempts" ]] +[[ "$(wc -l < "$GH_WORKFLOW_CAPTURE")" == "$before_dispatches" ]] # Publishing through a fork is rejected and unsupported gh versions fail before # dispatching any candidate publication.