mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Mobile release candidates (`mobile-vX.Y.Z-rc.N`) are currently created
by the `mobile-release-candidate.yml` workflow, using the
`buzz-release-bot` App key that also serves the desktop, relay and chart
lanes. This PR takes mobile off that key. After it lands, the only way
to create a mobile candidate tag is for a person on the mobile release
team to push it.
### What changes
- `scripts/mobile-release.sh candidate` keeps all its existing checks:
version, source branch, clean tree, canonical origin, ancestry and next
rc number. It no longer dispatches a workflow. It now:
- verifies the mobile tag ruleset 26588677 (active, the expected rules,
scoped to `refs/tags/mobile-*` or `refs/tags/mobile-v*`, and bypassable
by the current user), and that the App-only general tag ruleset 14378754
excludes `refs/tags/mobile-*`;
- requires origin's effective push URLs to be exactly its fetch URL, and
fetches a pinned `--commit` missing from a shallow clone;
- creates the annotated tag locally and pushes exactly that ref
(`--no-follow-tags`, explicit refspec);
- confirms through the GitHub API that `block/buzz` holds that exact tag
object, then verifies the remote tag as before.
A rejected push removes the local tag only when origin lacks it, so a
retry starts clean. When the push reports failure but origin has the
tag, the script keeps the local tag and still runs the canonical GitHub
check, reporting success only if it passes. When GitHub can't confirm
the tag, the local tag is kept and the message says how to reconcile it.
- `scripts/release-rulesets.sh`: `require_mobile_release_tag_ruleset`
replaces the App-oriented check. `MOBILE_RELEASE_TAG_RULESET_ID` is
`26588677`.
- Removed `.github/workflows/mobile-release-candidate.yml`,
`scripts/publish-mobile-release-candidate.sh` and its test, and their CI
references. Updated `RELEASING.md` and the auto-tag workflow header
comment.
- The desktop, relay and chart auto-tagging is unchanged.
### Rollout
Tag ruleset 26588677 ("Restrict Mobile Tags") is active with the two
mobile release managers as bypass actors. The App (Integration 4349119)
is temporarily a bypass actor of 26588677 and 19321162 so the current
`main` workflow keeps working; it is removed from both right before this
merges. General tag ruleset 14378754 now excludes `refs/tags/mobile-*`;
without that, rulesets stack and it would reject release managers'
mobile tag pushes. The branch ruleset 19321162 now has the same bypass
actors, so `require_mobile_release_branch_ruleset` no longer requires
"no bypass"; it still checks the review and status check policy. Release
managers must be bypass actors of both rulesets.
### Before merge
- [x] Remove Integration 4349119 (`buzz-release-bot`) from the bypass
actors of rulesets 26588677 and 19321162.
### Validation
- `shellcheck -x scripts/release-rulesets.sh scripts/mobile-release.sh
scripts/mobile-release-source.sh
scripts/test-mobile-release-contract.sh`: clean.
- `scripts/test-mobile-release-contract.sh`: passes. It runs against a
local bare remote with a stubbed `gh` and covers:
- a successful annotated push at the pinned SHA;
- ruleset failures (not bypassable, inactive, wrong rules, wrong scope,
excludes, non-numeric ID), with `refs/tags/mobile-v*` also accepted as
the include, each failing before any tag is created;
- a push rejected by a pre-receive hook, which leaves no local tag and
allows a clean retry;
- `push.followTags=true` with a stray local annotated mobile tag
(exactly one ref published), a push that lands but exits non-zero (tag
kept), and a GitHub API outage after the push (tag kept, rerun publishes
the next rc);
- origin push URLs pointing at a fork (pushurl, a second pushurl,
pushInsteadOf, insteadOf), a shallow clone pinning a historical commit,
and 14378754 lacking the mobile exclusion;
- rc numbering, source branch rules and `--commit` pinning.
- `scripts/test-release-ref-contract.sh` and
`scripts/test-desktop-release-candidate.sh`: pass.
- Live read-only check against real GitHub as a bypass actor: sourced
`scripts/release-rulesets.sh` and `scripts/mobile-release-source.sh`,
then ran `require_mobile_release_tag_ruleset` (including the 14378754
exclusion check) and `require_mobile_release_branch_ruleset
mobile-v0.27`. Both returned success. No tag was created.
- No mobile app test: this is a script and workflow change only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
218 lines
9.1 KiB
Bash
Executable File
218 lines
9.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat >&2 <<'USAGE'
|
|
usage:
|
|
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
|
|
selected commit in source history (defaults to the current source tip).
|
|
You push the annotated tag yourself, so you must be a bypass actor
|
|
of the mobile tag ruleset (currently the mobile release managers).
|
|
USAGE
|
|
exit 2
|
|
}
|
|
|
|
fail() {
|
|
echo "Error: $*" >&2
|
|
exit 1
|
|
}
|
|
|
|
# shellcheck source=scripts/release-rulesets.sh
|
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
|
|
# shellcheck source=scripts/mobile-release-source.sh
|
|
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mobile-release-source.sh"
|
|
|
|
require_gh_minimum_version() {
|
|
local minimum="2.87.0" version
|
|
|
|
command -v gh >/dev/null 2>&1 || fail "gh >= $minimum is required"
|
|
version="$(gh --version 2>/dev/null | awk 'NR == 1 { print $3 }')" || \
|
|
fail "could not determine gh version (gh >= $minimum is required)"
|
|
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
|
|
fail "gh returned invalid version '$version'"
|
|
if ! awk -v current="$version" -v minimum="$minimum" '
|
|
BEGIN {
|
|
split(current, c, ".")
|
|
split(minimum, m, ".")
|
|
for (i = 1; i <= 3; i++) {
|
|
if ((c[i] + 0) > (m[i] + 0)) exit 0
|
|
if ((c[i] + 0) < (m[i] + 0)) exit 1
|
|
}
|
|
exit 0
|
|
}
|
|
'; then
|
|
fail "gh $version is too old; gh >= $minimum is required"
|
|
fi
|
|
}
|
|
|
|
require_clean_semver() {
|
|
[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
|
|
fail "'$1' is not a mobile release version (expected X.Y.Z)"
|
|
}
|
|
|
|
require_clean_tree() {
|
|
git diff --quiet && git diff --cached --quiet && \
|
|
[[ -z "$(git status --short --untracked-files=normal)" ]] || \
|
|
fail "working tree is dirty; commit or stash changes first"
|
|
}
|
|
|
|
require_annotated_tag() {
|
|
local git_dir="$1" object="$2" label="$3"
|
|
if [[ "$(git -C "$git_dir" cat-file -t "$object" 2>/dev/null || true)" != "tag" ]]; then
|
|
echo "$label must be an annotated tag" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
remote_tag_commit_sha() {
|
|
local ref="$1" line advertised_oid tmp fetched_oid commit
|
|
line="$(git ls-remote --refs origin "$ref")" || return 1
|
|
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
|
|
advertised_oid="${line%%$'\t'*}"
|
|
tmp="$(mktemp -d)"
|
|
git -C "$tmp" init -q
|
|
git -C "$tmp" remote add origin "$(git remote get-url origin)"
|
|
if ! git -C "$tmp" fetch -q --depth 1 origin "$ref"; then
|
|
rm -rf "$tmp"
|
|
return 1
|
|
fi
|
|
fetched_oid="$(git -C "$tmp" rev-parse --verify FETCH_HEAD)"
|
|
if [[ "$fetched_oid" != "$advertised_oid" ]]; then
|
|
rm -rf "$tmp"
|
|
fail "$ref moved while it was being resolved"
|
|
fi
|
|
if ! require_annotated_tag "$tmp" FETCH_HEAD "$ref"; then
|
|
rm -rf "$tmp"
|
|
return 1
|
|
fi
|
|
if ! commit="$(git -C "$tmp" rev-parse --verify 'FETCH_HEAD^{commit}')"; then
|
|
rm -rf "$tmp"
|
|
return 1
|
|
fi
|
|
rm -rf "$tmp"
|
|
printf '%s' "$commit"
|
|
}
|
|
|
|
remote_source_commit_sha() {
|
|
local ref="refs/heads/$MOBILE_RELEASE_SOURCE_BRANCH" line oid commit
|
|
# Take a single snapshot of the branch tip from origin itself. A later branch
|
|
# advance does not invalidate it. The shared FETCH_HEAD is never read: a
|
|
# concurrent fetch in this checkout could replace it with another branch.
|
|
line="$(git ls-remote --refs origin "$ref")" || return 1
|
|
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
|
|
oid="${line%%$'\t'*}"
|
|
[[ "$oid" =~ ^[0-9a-f]{40}$ ]] || return 1
|
|
git fetch -q --no-tags origin "$oid" || return 1
|
|
commit="$(git rev-parse --verify "$oid^{commit}")" || return 1
|
|
[[ "$commit" == "$oid" ]] || \
|
|
fail "origin/$MOBILE_RELEASE_SOURCE_BRANCH did not resolve directly to a commit"
|
|
printf '%s' "$commit"
|
|
}
|
|
|
|
command="${1:-}"
|
|
case "$command" in
|
|
candidate)
|
|
[[ "$#" -ge 2 ]] || usage
|
|
version="$2"
|
|
shift 2
|
|
MOBILE_RELEASE_SOURCE_BRANCH=main
|
|
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
|
|
require_canonical_repository || exit 1
|
|
require_gh_minimum_version
|
|
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_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"
|
|
fi
|
|
while IFS=$'\t' read -r _ ref; do
|
|
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
|
|
number="${BASH_REMATCH[1]}"
|
|
(( number >= next )) && next=$((number + 1))
|
|
done <<< "$remote_tags"
|
|
|
|
tag="mobile-v${version}-rc.${next}"
|
|
# Candidate tags are pushed by the operator, who must be a bypass actor of
|
|
# the mobile tag ruleset (the mobile release managers). No GitHub App or
|
|
# workflow is involved. Check before creating anything locally.
|
|
require_mobile_release_tag_ruleset || exit 1
|
|
# A pinned --commit can be a verified ancestor outside a shallow clone's
|
|
# history. Fetch that exact commit so the local tag can point at it.
|
|
if ! git cat-file -e "$source_sha^{commit}" 2>/dev/null; then
|
|
git fetch -q --no-tags origin "$source_sha" || fail "could not fetch source commit $source_sha"
|
|
fi
|
|
# Annotated, never forced; the operator's own tag signing config applies.
|
|
git tag -a -m "Buzz Mobile $version release candidate $next" "$tag" "$source_sha" || \
|
|
fail "could not create local tag $tag"
|
|
# --no-verify: the release must not depend on development pre-push hooks.
|
|
# --no-follow-tags and an explicit refspec: push exactly this one ref, even
|
|
# when push.followTags would add other local annotated tags.
|
|
if ! git push --no-verify --no-follow-tags origin "refs/tags/$tag:refs/tags/$tag"; then
|
|
# Only delete the local tag when origin certainly lacks the candidate.
|
|
if ! remote_line="$(git ls-remote --refs origin "refs/tags/$tag")"; then
|
|
fail "could not push $tag, and could not check whether origin has it. If GitHub shows $tag, use it and don't rerun; otherwise run 'git tag -d $tag' before retrying"
|
|
fi
|
|
if [[ -z "$remote_line" ]]; then
|
|
git tag -d "$tag" >/dev/null
|
|
fail "could not push $tag; you must be a bypass actor of mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID (currently the mobile release managers). The local tag was removed so a retry starts clean"
|
|
fi
|
|
# origin has the tag, but origin may be rewritten to a fork; the checks
|
|
# below decide whether canonical block/buzz has exactly this candidate.
|
|
echo "Note: push of $tag reported failure, but origin has $tag; verifying it on canonical block/buzz." >&2
|
|
fi
|
|
|
|
# git URL rewriting (insteadOf) can send even a canonical-looking origin
|
|
# to a fork, so confirm with GitHub itself that block/buzz now has this
|
|
# exact annotated tag object. An unavailable API and a mismatch both stop
|
|
# here without deleting the local tag.
|
|
local_tag_oid="$(git rev-parse --verify "refs/tags/$tag")" || fail "local tag $tag disappeared"
|
|
if ! canonical_tag_oid="$(gh api "repos/block/buzz/git/ref/tags/$tag" --jq .object.sha)" || \
|
|
[[ "$canonical_tag_oid" != "$local_tag_oid" ]]; then
|
|
# Numbering reads only remote tags, so keeping the local tag doesn't
|
|
# change the next candidate number. It records what was attempted, and
|
|
# after a fork rewrite is fixed it must be deleted before rerunning.
|
|
fail "could not confirm $tag on canonical block/buzz. If GitHub shows $tag, use it and don't rerun. If it doesn't, fix any git url.*.insteadOf rewrite for origin and run 'git tag -d $tag' before retrying"
|
|
fi
|
|
published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \
|
|
fail "push completed without exact annotated candidate tag $tag on origin"
|
|
[[ "$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 pinned from origin/$MOBILE_RELEASE_SOURCE_BRANCH $source_sha." >&2
|
|
fi
|
|
printf 'Published %s at pinned origin/%s commit %s. Use this exact tag in Release Mobile.\n' \
|
|
"$tag" "$MOBILE_RELEASE_SOURCE_BRANCH" "$source_sha"
|
|
;;
|
|
|
|
*) usage ;;
|
|
esac
|