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.