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

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