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 <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-19 09:21:11 -07:00
committed by GitHub
parent 5b433d9292
commit 5730c83f7d
7 changed files with 143 additions and 49 deletions

View File

@@ -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

View File

@@ -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 <version>` 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

View File

@@ -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
}
}

View File

@@ -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"
;;

View File

@@ -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'

View File

@@ -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

View File

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