release(mobile): push candidate tags as the operator, not the App (#9084)

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>
This commit is contained in:
Tom Brow
2026-11-05 16:56:27 -08:00
committed by GitHub
parent fc85b0d154
commit 5d094cd769
10 changed files with 424 additions and 649 deletions

View File

@@ -11,9 +11,10 @@ name: Auto-tag on Release PR Merge
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
# → tag chart-v<v> → helm-chart.yml (helm chart)
#
# Mobile candidate tags do not come from merged PRs. Operators create immutable
# mobile-v<v>-rc.N tags directly from remote main with scripts/mobile-release.sh,
# then hand the exact tag to buzz-releases.
# Mobile candidate tags do not come from merged PRs or from this App. Release
# managers push immutable mobile-v<v>-rc.N tags themselves with
# scripts/mobile-release.sh, gated by a dedicated mobile tag ruleset whose only
# bypass is the mobile release team, then hand the exact tag to buzz-releases.
#
# Release tags are created with a short-lived token from the dedicated
# buzz-release-bot GitHub App. GitHub attributes the ref creation to that

View File

@@ -106,13 +106,10 @@ jobs:
- 'scripts/mobile-release-source.sh'
- 'scripts/mobile-worktree-overrides.sh'
- 'scripts/mobile-worktree-clean.sh'
- 'scripts/publish-mobile-release-candidate.sh'
- 'scripts/release-rulesets.sh'
- 'scripts/test-mobile-release-contract.sh'
- 'scripts/test-mobile-release-candidate-publisher.sh'
- 'scripts/test-mobile-worktree-overrides.sh'
- 'scripts/ci-hermit-retry.sh'
- '.github/workflows/mobile-release-candidate.yml'
- '.github/workflows/ci.yml'
- '.github/workflows/_ci-*.yml'
- name: Select runtime suites conservatively at the PR file-list limit
@@ -136,9 +133,7 @@ jobs:
scripts/test-oss-desktop-promotion.sh
scripts/test-oss-desktop-promotion-behavior.sh
- name: Mobile release contract
run: |
scripts/test-mobile-release-contract.sh
scripts/test-mobile-release-candidate-publisher.sh
run: scripts/test-mobile-release-contract.sh
- name: Desktop instance environment contract
run: scripts/test-desktop-instance-detection.sh
- name: Mobile worktree identity contract

View File

@@ -1,75 +0,0 @@
name: Publish Mobile Release Candidate
run-name: Publish mobile-v${{ inputs.version }}-rc.${{ inputs.candidate_number }}
on:
workflow_dispatch:
inputs:
version:
description: Mobile marketing version (X.Y.Z)
required: true
type: string
candidate_number:
description: Expected next release-candidate number
required: true
type: string
source_branch:
description: Source branch (main or mobile-vMAJOR.MINOR matching version)
required: true
default: main
type: string
target_sha:
description: Pinned commit in the selected source branch history
required: true
type: string
concurrency:
group: mobile-release-candidate-${{ inputs.version }}
cancel-in-progress: false
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require the reviewed workflow from main
env:
DISPATCH_REF: ${{ github.ref }}
run: |
if [ "$DISPATCH_REF" != "refs/heads/main" ]; then
echo "::error::Mobile candidates must be dispatched from main, not $DISPATCH_REF"
exit 1
fi
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Require canonical repository
env:
REPOSITORY: ${{ github.repository }}
run: |
if [ "$REPOSITORY" != "block/buzz" ]; then
echo "::error::Mobile candidate publication is restricted to block/buzz"
exit 1
fi
- name: Create release tagger token
id: release-tagger
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }}
private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }}
permission-contents: write
- name: Publish annotated candidate tag
env:
GH_TOKEN: ${{ steps.release-tagger.outputs.token }}
MOBILE_VERSION: ${{ inputs.version }}
CANDIDATE_NUMBER: ${{ inputs.candidate_number }}
SOURCE_BRANCH: ${{ inputs.source_branch }}
TARGET_SHA: ${{ inputs.target_sha }}
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA" "$SOURCE_BRANCH"

View File

@@ -12,8 +12,9 @@ with an optional stable source branch:
The lanes version independently. Desktop reads its manifests, relay reads its
crate manifest, and mobile derives both source and marketing version from the
exact candidate tag. The mobile handoff to the private `buzz-releases` pipeline
remains manual because OSS CI cannot trigger private CI.
exact candidate tag. Mobile candidate tags are pushed by a human release
manager, not by CI or a GitHub App. The handoff to the private `buzz-releases`
pipeline remains manual because OSS CI cannot trigger private CI.
## Quick Start
@@ -33,6 +34,7 @@ just release-relay
just release-relay 0.4.0
# Publish the next mobile candidate from the exact current remote main commit
# (requires bypass of the mobile tag ruleset, see Mobile below)
scripts/mobile-release.sh candidate 0.5.0
```
@@ -98,11 +100,20 @@ Every push to `main` continues to publish the rolling relay `:main` and
`scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches
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. 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.
`mobile-vX.Y.Z-rc.N` tag at that snapshot with `git tag -a` (your own tag
signing configuration applies) and pushes exactly that one tag to `origin`.
The push is allowed only because you are a bypass actor of the dedicated
mobile tag ruleset [`26588677`](https://github.com/block/buzz/rules/26588677)
for `refs/tags/mobile-*`; bypass is granted to the mobile release managers.
The `buzz-release-bot` App and its key are not involved in mobile. The script never uses the operator's
checked-out commit and never moves an existing candidate. To select a
historical commit, add `--commit FULL_SHA`. The script requires that commit
to be in the selected source branch's history. Later branch advances do not
change or invalidate the tag. If the push is rejected and origin has no
such tag, the script deletes the local tag and fails; check that you are a
bypass actor of the ruleset. If the candidate may already be on GitHub, the
script keeps the local tag and says so: use the published tag rather than
rerunning.
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
@@ -130,12 +141,13 @@ 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;
the source remains `main`. The script validates the source;
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.
to `main`. `--commit FULL_SHA` also works with a stable source branch. Its
review and check protections bind everyone except the mobile release managers,
who bypass them: a release manager can push an unreviewed commit to a stable
branch and tag it as a candidate.
The private pipeline still builds only the resulting immutable candidate tag.
For the October 2026 stabilization period, all distributed mobile builds,
including Company Portal dogfood builds, must come from `mobile-v0.25` until
@@ -301,10 +313,13 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
- The Default `main` ruleset configured for squash-only merging, strict required
checks, stale-review dismissal, and the **Desktop Release Candidate** check
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
active for all tags (`~ALL`), with creation, update, deletion, and
non-fast-forward protections and `buzz-release-bot` as its sole always-bypass
actor
- The `buzz-release-bot` App credentials configured for GitHub Actions
actor. It must exclude `refs/tags/mobile-*`: rulesets stack, so otherwise it
rejects release managers' mobile tag pushes. `scripts/mobile-release.sh`
checks that exclusion before tagging.
- The `buzz-release-bot` App credentials configured for GitHub Actions (desktop,
relay, and chart lanes only; mobile does not use them)
- The following **GitHub Actions variables and secrets** configured for the
desktop release lane:
@@ -318,21 +333,26 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
| `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key |
Mobile candidate publication requires workflow-dispatch access and the existing
release App because strict tag protection denies direct human creation. The App
must be installed on `block/buzz`, have Contents write and Metadata read, and
retain an `always` bypass on the immutable `mobile-v*` tag rules. It does not
require GitHub Releases permissions or repository Administration permission.
Stable sources additionally require active branch ruleset `19321162` covering
`refs/heads/mobile-v*`, with no exclusions or bypass actors, deletion and
force-push prevention, at least one approval, stale-approval dismissal, last-push
approval, resolved review threads, and strict required `Mobile` and `DCO Check`
checks. An administrator must configure and audit the empty bypass list; GitHub
hides that list from non-admin tokens. Both operator and publisher verify the
public policy and their effective lack of branch bypass on every publication.
The publisher validates the App token's effective
`current_user_can_bypass` value rather than reading the ruleset's hidden bypass
actor list.
Mobile candidate publication requires being a bypass actor of the mobile tag
ruleset [`26588677`](https://github.com/block/buzz/rules/26588677) ("Restrict
Mobile Tags"), recorded as `MOBILE_RELEASE_TAG_RULESET_ID` in
`scripts/release-rulesets.sh`. Bypass is granted to the mobile release
managers. The ruleset must be active for `refs/tags/mobile-*` (or the narrower
`refs/tags/mobile-v*`), with creation, update, deletion, and non-fast-forward
protections and no exclusions. The `buzz-release-bot` App must not be a bypass
actor. On every publication the script checks the public policy and that the
caller's effective `current_user_can_bypass` is `always`; GitHub hides the bypass
actor list from non-admin tokens, so an administrator audits that list.
Stable sources additionally require active branch ruleset
[`19321162`](https://github.com/block/buzz/rules/19321162) covering
`refs/heads/mobile-v*`, with no exclusions, deletion and force-push prevention,
at least one approval, stale-approval dismissal, last-push approval, resolved
review threads, and strict required `Mobile` and `DCO Check` checks. The mobile
release managers are also its bypass actors, so they can publish from a stable
branch; the ruleset still forces review and checks on everyone else. Because of
that bypass, a release manager can push an unreviewed commit to a stable branch
and publish it as a candidate; nothing else enforces review for them. The script
verifies the public policy on every publication but does not check bypass.
---
@@ -362,9 +382,9 @@ its own exact tag.
### The source branch advances during candidate publication
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,
Publication remains pinned to the selected SHA. The script verifies that it
is an ancestor of a resolved source-branch snapshot, then creates, pushes, and
verifies an immutable annotated tag for that exact SHA. It 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.
@@ -376,9 +396,10 @@ increasing remote identities.
### A mobile candidate publication is rejected by repository rules
Confirm `buzz-release-bot` remains the sole always-bypass actor for the active
`mobile-v*` ruleset and that its Actions credentials are available. Do not grant
direct human creation or weaken update or deletion protection. Existing
Confirm you are a bypass actor of the active mobile tag ruleset `26588677`
(the mobile release managers). The script removes its local tag after a rejected
push, so retry after an administrator fixes your access. Do not add yourself to
the bypass list, grant the App bypass, or weaken update or deletion protection. Existing
candidate tags must remain immutable.
### Auto-updater reports "no update available"

View File

@@ -13,18 +13,17 @@ require_mobile_release_source() {
fi
}
# The repository administrator must configure this ruleset without bypass actors.
# GitHub hides that list from non-admin tokens; check the caller's effective
# bypass plus every public policy field on each publication.
# The mobile release managers are the bypass actors of this ruleset and of the
# mobile tag ruleset, so they can publish from a stable branch. The ruleset
# still forces review and checks on everyone else. Bypass is deliberately not
# checked here; verify every other public policy field on each publication.
require_mobile_release_branch_ruleset() {
local branch="$1" valid
[[ "$branch" != main ]] || return 0
valid="$(gh api repos/block/buzz/rulesets/19321162 --jq '
.target == "branch" and .enforcement == "active"
and .current_user_can_bypass == "never"
and (.conditions.ref_name.include | index("refs/heads/mobile-v*") != null)
and (.conditions.ref_name.exclude == [])
and ((has("bypass_actors") | not) or .bypass_actors == [])
and any(.rules[]; .type == "deletion")
and any(.rules[]; .type == "non_fast_forward")
and any(.rules[]; .type == "pull_request"
@@ -38,7 +37,7 @@ require_mobile_release_branch_ruleset() {
and ([.parameters.required_status_checks[].context] | index("DCO Check") != null))
')" || { echo "Could not verify mobile stable-branch protection" >&2; return 1; }
[[ "$valid" == true ]] || {
echo "Mobile stable-branch ruleset 19321162 must enforce review and checks on mobile-v* without bypass" >&2
echo "Mobile stable-branch ruleset 19321162 must enforce review and checks on mobile-v*" >&2
return 1
}
}

View File

@@ -8,6 +8,8 @@ usage:
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
}
@@ -94,12 +96,17 @@ remote_tag_commit_sha() {
}
remote_source_commit_sha() {
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" == "$fetched_oid" ]] || \
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"
}
@@ -154,37 +161,55 @@ case "$command" in
done <<< "$remote_tags"
tag="mobile-v${version}-rc.${next}"
workflow="mobile-release-candidate.yml"
if dispatch_output="$(gh workflow run "$workflow" \
--repo block/buzz \
--ref main \
-f "version=$version" \
-f "candidate_number=$next" \
-f "source_branch=$MOBILE_RELEASE_SOURCE_BRANCH" \
-f "target_sha=$source_sha" 2>&1)"; then
:
else
if [[ "$dispatch_output" == *"does not have 'workflow_dispatch' trigger"* ]]; then
fail "$workflow is not available on main yet; merge the release-process change before publishing a candidate"
fi
fail "could not dispatch App-backed publication for $tag: $dispatch_output"
# 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
run_url="$(printf '%s\n' "$dispatch_output" | awk '/^https:\/\/github\.com\/block\/buzz\/actions\/runs\/[0-9]+$/ { if (found) exit 2; found = $0 } END { if (found) print found }')" || \
fail "GitHub returned multiple workflow run URLs for one candidate dispatch"
[[ -n "$run_url" ]] || \
fail "GitHub accepted the candidate dispatch but returned no workflow run URL"
run_id="${run_url##*/}"
gh run watch "$run_id" --repo block/buzz --exit-status --compact || \
fail "App-backed publication failed: $run_url"
# 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 "publication completed without exact annotated candidate tag $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 through buzz-release-bot. Use this exact tag in Release Mobile.\n' \
printf 'Published %s at pinned origin/%s commit %s. Use this exact tag in Release Mobile.\n' \
"$tag" "$MOBILE_RELEASE_SOURCE_BRANCH" "$source_sha"
;;

View File

@@ -1,85 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
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"
[[ "$#" -eq 4 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA SOURCE_BRANCH"
version="$1"
candidate_number="$2"
target_sha="$3"
MOBILE_RELEASE_SOURCE_BRANCH="$4"
repo="${GITHUB_REPOSITORY:-}"
[[ "$repo" == "block/buzz" ]] || fail "candidate publishing is restricted to block/buzz"
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
fail "'$version' is not a mobile release version (expected X.Y.Z)"
[[ "$candidate_number" =~ ^[1-9][0-9]*$ ]] || \
fail "'$candidate_number' is not a candidate number (expected N >= 1)"
[[ "$target_sha" =~ ^[0-9a-f]{40}$ ]] || fail "'$target_sha' is not a full commit SHA"
require_mobile_release_source "$version" "$MOBILE_RELEASE_SOURCE_BRANCH" || exit 1
command -v gh >/dev/null 2>&1 || fail "gh is required"
require_release_tag_ruleset || exit 1
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" =~ ^[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'
)" || fail "could not list existing candidates for $version"
next=1
while IFS= read -r ref; do
[[ -n "$ref" ]] || continue
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
number="${BASH_REMATCH[1]}"
(( number >= next )) && next=$((number + 1))
done <<< "$refs"
[[ "$candidate_number" -eq "$next" ]] || \
fail "candidate sequence changed; expected rc.$candidate_number but next is rc.$next"
tag="mobile-v${version}-rc.${candidate_number}"
message="Buzz Mobile $version release candidate $candidate_number"
tag_object_sha="$(
gh api --method POST "repos/$repo/git/tags" \
-f tag="$tag" \
-f message="$message" \
-f object="$target_sha" \
-f type=commit \
--jq .sha
)" || fail "could not create annotated tag object for $tag"
[[ "$tag_object_sha" =~ ^[0-9a-f]{40}$ ]] || fail "GitHub returned an invalid tag object SHA"
gh api --method POST "repos/$repo/git/refs" \
-f ref="refs/tags/$tag" \
-f sha="$tag_object_sha" \
--silent || fail "could not publish $tag"
published_type="$(gh api "repos/$repo/git/ref/tags/$tag" --jq .object.type)" || \
fail "could not verify published tag $tag"
published_object="$(gh api "repos/$repo/git/ref/tags/$tag" --jq .object.sha)" || \
fail "could not verify published tag $tag"
[[ "$published_type" == "tag" && "$published_object" == "$tag_object_sha" ]] || \
fail "$tag does not reference the expected annotated tag object"
direct_type="$(gh api "repos/$repo/git/tags/$tag_object_sha" --jq .object.type)" || \
fail "could not verify annotated tag object $tag_object_sha"
direct_sha="$(gh api "repos/$repo/git/tags/$tag_object_sha" --jq .object.sha)" || \
fail "could not verify annotated tag object $tag_object_sha"
[[ "$direct_type" == "commit" && "$direct_sha" == "$target_sha" ]] || \
fail "$tag does not point directly to requested commit $target_sha"
printf 'Published %s at %s through buzz-release-bot.\n' "$tag" "$target_sha"

View File

@@ -1,6 +1,13 @@
#!/usr/bin/env bash
readonly RELEASE_TAG_RULESET_ID=14378754
# Tag ruleset ("Restrict Mobile Tags") that protects mobile candidate tags. Its
# bypass actors are the mobile release managers, never the buzz-release-bot App:
# operators push candidate tags themselves.
readonly MOBILE_RELEASE_TAG_RULESET_ID=26588677
# General tag ruleset ("Release", ~ALL tags) whose only bypass actor is the
# App. Rulesets stack, so it must exclude mobile tags or operators can't push
# them even though they bypass the mobile tag ruleset.
readonly GENERAL_RELEASE_TAG_RULESET_ID=14378754
fail_release_ruleset() {
echo "Error: $*" >&2
@@ -19,36 +26,57 @@ require_canonical_repository() {
fail_release_ruleset "origin must point to canonical block/buzz, not '$origin_url'" || return 1
;;
esac
# Tags are pushed with `git push origin`, which honors remote.origin.pushurl
# and url.*.pushInsteadOf. Require exactly one effective push destination,
# the same canonical repository that origin fetches from.
# --all: git push sends to every configured push URL, not just the first.
[[ "$(git remote get-url --push --all origin)" == "$(git remote get-url origin)" ]] || \
fail_release_ruleset "origin pushes somewhere other than it fetches from; remove remote.origin.pushurl or pushInsteadOf so candidate tags reach canonical block/buzz" || return 1
}
require_release_tag_ruleset() {
local ruleset_endpoint state can_bypass rule_types includes excludes
require_mobile_release_tag_ruleset() {
local ruleset_endpoint state target can_bypass rule_types includes excludes
[[ "$MOBILE_RELEASE_TAG_RULESET_ID" =~ ^[0-9]+$ ]] || \
fail_release_ruleset "MOBILE_RELEASE_TAG_RULESET_ID in scripts/release-rulesets.sh must be numeric (got '$MOBILE_RELEASE_TAG_RULESET_ID')" || return 1
command -v gh >/dev/null 2>&1 || fail_release_ruleset "gh is required" || return 1
ruleset_endpoint="repos/block/buzz/rulesets/$RELEASE_TAG_RULESET_ID"
ruleset_endpoint="repos/block/buzz/rulesets/$MOBILE_RELEASE_TAG_RULESET_ID"
state="$(gh api "$ruleset_endpoint" --jq .enforcement)" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID" || return 1
fail_release_ruleset "could not verify mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID" || return 1
[[ "$state" == "active" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID is '$state'" || return 1
fail_release_ruleset "mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID is '$state'" || return 1
# Rulesets carry their target (branch, tag or push) independently of their
# ref patterns; a retargeted ruleset would leave candidate tags unprotected.
target="$(gh api "$ruleset_endpoint" --jq .target)" || \
fail_release_ruleset "could not verify mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID target" || return 1
[[ "$target" == "tag" ]] || \
fail_release_ruleset "mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID must target tags, not '$target'" || return 1
can_bypass="$(gh api "$ruleset_endpoint" --jq .current_user_can_bypass)" || \
fail_release_ruleset "could not verify the release App's tag-ruleset bypass" || return 1
fail_release_ruleset "could not verify your bypass of mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID" || return 1
[[ "$can_bypass" == "always" ]] || \
fail_release_ruleset "release App cannot always bypass Release tag ruleset $RELEASE_TAG_RULESET_ID (reported '$can_bypass')" || return 1
fail_release_ruleset "you cannot bypass mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID (reported '$can_bypass'); you must be a bypass actor of mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID (currently the mobile release managers)" || return 1
rule_types="$(gh api "$ruleset_endpoint" --jq '[.rules[].type] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID rules" || return 1
fail_release_ruleset "could not verify mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID rules" || return 1
[[ "$rule_types" == "creation,deletion,non_fast_forward,update" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected rules: '$rule_types'" || return 1
fail_release_ruleset "mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID has unexpected rules: '$rule_types'" || return 1
includes="$(gh api "$ruleset_endpoint" --jq '[.conditions.ref_name.include[]] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID scope" || return 1
[[ ",$includes," == *",refs/tags/mobile-v*,"* ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID does not include refs/tags/mobile-v*" || return 1
fail_release_ruleset "could not verify mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID scope" || return 1
# Either pattern covers every mobile-vX.Y.Z-rc.N candidate tag.
[[ ",$includes," == *",refs/tags/mobile-*,"* || ",$includes," == *",refs/tags/mobile-v*,"* ]] || \
fail_release_ruleset "mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID must include refs/tags/mobile-* or refs/tags/mobile-v*" || return 1
excludes="$(gh api "$ruleset_endpoint" --jq '[.conditions.ref_name.exclude[]] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID exclusions" || return 1
fail_release_ruleset "could not verify mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID exclusions" || return 1
[[ -z "$excludes" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected exclusions: '$excludes'" || return 1
fail_release_ruleset "mobile tag ruleset $MOBILE_RELEASE_TAG_RULESET_ID has unexpected exclusions: '$excludes'" || return 1
excludes="$(gh api "repos/block/buzz/rulesets/$GENERAL_RELEASE_TAG_RULESET_ID" --jq '[.conditions.ref_name.exclude[]] | join(",")')" || \
fail_release_ruleset "could not verify general tag ruleset $GENERAL_RELEASE_TAG_RULESET_ID exclusions" || return 1
[[ ",$excludes," == *",refs/tags/mobile-*,"* ]] || \
fail_release_ruleset "general tag ruleset $GENERAL_RELEASE_TAG_RULESET_ID must exclude refs/tags/mobile-*, or it rejects your candidate tag push; ask a repository admin" || return 1
}

View File

@@ -1,266 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
publisher="$repo_root/scripts/publish-mobile-release-candidate.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
bin="$tmp/bin"
mkdir -p "$bin"
cat > "$bin/gh" <<'GH'
#!/usr/bin/env bash
set -euo pipefail
record() {
printf '%s\n' "$*" >> "$GH_CALLS"
}
case "${1:-}:${2:-}" in
api:repos/block/buzz/rulesets/19321162)
[[ "${GH_BRANCH_POLICY_FAIL:-}" != 1 ]] || exit 1
policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"never","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}'
printf '%s\n' "${GH_BRANCH_POLICY:-$policy}" | jq -r "$4"
;;
api:repos/block/buzz/rulesets/14378754)
case "$*" in
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
*'.current_user_can_bypass'*) printf '%s\n' "${GH_CURRENT_USER_CAN_BYPASS-always}" ;;
*'[.rules[].type]'*) printf '%s\n' "${GH_TAG_RULE_TYPES:-creation,deletion,non_fast_forward,update}" ;;
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-v*}" ;;
*'.conditions.ref_name.exclude[]'*) printf '%s\n' "${GH_TAG_EXCLUDES:-}" ;;
*) exit 2 ;;
esac
;;
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." ]]
printf '%s' "${GH_EXISTING_REFS:-}"
;;
api:--method)
endpoint="$4"
case "$endpoint" in
repos/block/buzz/git/tags)
record "$*"
printf '%s\n' "$GH_TAG_OBJECT_SHA"
;;
repos/block/buzz/git/refs)
record "$*"
;;
*) exit 2 ;;
esac
;;
api:repos/block/buzz/git/ref/tags/mobile-v1.2.3-rc.*)
if [[ "$*" == *'.object.type'* ]]; then
printf '%s\n' "${GH_PUBLISHED_REF_TYPE:-tag}"
else
printf '%s\n' "${GH_PUBLISHED_REF_SHA:-$GH_TAG_OBJECT_SHA}"
fi
;;
api:repos/block/buzz/git/tags/*)
if [[ "$*" == *'.object.type'* ]]; then
printf '%s\n' "${GH_ANNOTATED_TARGET_TYPE:-commit}"
else
printf '%s\n' "${GH_ANNOTATED_TARGET_SHA:-$GH_TARGET_SHA}"
fi
;;
*)
echo "unexpected gh call: $*" >&2
exit 2
;;
esac
GH
chmod +x "$bin/gh"
export PATH="$bin:$PATH"
export GH_CALLS="$tmp/calls"
export GITHUB_REPOSITORY=block/buzz
export GH_TARGET_SHA=1111111111111111111111111111111111111111
export GH_STABLE_SHA=4444444444444444444444444444444444444444
export GH_MISMATCH_SHA=5555555555555555555555555555555555555555
export GH_TAG_OBJECT_SHA=2222222222222222222222222222222222222222
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main
grep -Fq -- '-f tag=mobile-v1.2.3-rc.1' "$GH_CALLS"
grep -Fq -- '-f message=Buzz Mobile 1.2.3 release candidate 1' "$GH_CALLS"
grep -Fq -- "-f object=$GH_TARGET_SHA" "$GH_CALLS"
grep -Fq -- '-f type=commit' "$GH_CALLS"
grep -Fq -- '-f ref=refs/tags/mobile-v1.2.3-rc.1' "$GH_CALLS"
grep -Fq -- "-f sha=$GH_TAG_OBJECT_SHA" "$GH_CALLS"
if GH_CURRENT_USER_CAN_BYPASS=never "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted an App token without an always bypass" >&2
exit 1
fi
if GH_CURRENT_USER_CAN_BYPASS='' "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a ruleset response without an effective bypass" >&2
exit 1
fi
if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted disabled tag protection" >&2
exit 1
fi
if GH_TAG_RULE_TYPES=creation "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted incomplete tag protection" >&2
exit 1
fi
if GH_TAG_INCLUDES=refs/tags/v\* "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a tag ruleset that excludes mobile candidates" >&2
exit 1
fi
if GH_TAG_EXCLUDES=refs/tags/mobile-v0.0.0 "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted tag ruleset exclusions" >&2
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 commit outside main history" >&2
exit 1
fi
if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\n' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a stale candidate number" >&2
exit 1
fi
if GH_PUBLISHED_REF_TYPE=commit "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a lightweight published tag" >&2
exit 1
fi
if GH_PUBLISHED_REF_SHA=3333333333333333333333333333333333333333 \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted the wrong annotated tag object" >&2
exit 1
fi
if GH_ANNOTATED_TARGET_TYPE=tag "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a nested annotated tag" >&2
exit 1
fi
if GH_ANNOTATED_TARGET_SHA=3333333333333333333333333333333333333333 \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted an annotated tag on the wrong commit" >&2
exit 1
fi
if "$publisher" 01.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a marketing version with a leading zero" >&2
exit 1
fi
if "$publisher" 1.2.3 01 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted a candidate number with a leading zero" >&2
exit 1
fi
if GH_EXISTING_REFS=$'refs/tags/mobile-v1.2.3-rc.1\nrefs/tags/mobile-v1.2.3-rc.7\nrefs/tags/mobile-v1.2.3-rc.08\nrefs/tags/mobile-v1.2.4-rc.99\n' \
"$publisher" 1.2.3 8 "$GH_TARGET_SHA" main >/dev/null; then
:
else
echo "publisher did not sequence from the highest exact candidate" >&2
exit 1
fi
if GITHUB_REPOSITORY=attacker/buzz "$publisher" 1.2.3 1 "$GH_TARGET_SHA" main >/dev/null 2>&1; then
echo "publisher accepted the wrong repository" >&2
exit 1
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
exit 1
fi
done
# Mutate actual policy JSON so these tests exercise the production jq predicate.
python3 - "$publisher" <<'PYPOLICY'
import copy, json, os, subprocess, sys
policy = {'target': 'branch', 'enforcement': 'active', 'current_user_can_bypass': 'never', 'conditions': {'ref_name': {'include': ['refs/heads/mobile-v*'], 'exclude': []}}, 'bypass_actors': [], 'rules': [{'type': 'deletion'}, {'type': 'non_fast_forward'}, {'type': 'pull_request', 'parameters': {'required_approving_review_count': 1, 'dismiss_stale_reviews_on_push': True, 'require_last_push_approval': True, 'required_review_thread_resolution': True}}, {'type': 'required_status_checks', 'parameters': {'strict_required_status_checks_policy': True, 'required_status_checks': [{'context': 'Mobile'}, {'context': 'DCO Check'}]}}]}
mutations = {
"disabled": lambda p: p.update(enforcement="disabled"),
"wrong target": lambda p: p.update(target="tag"),
"wrong scope": lambda p: p["conditions"]["ref_name"].update(include=["refs/heads/mobile-release/*"]),
"excluded branch": lambda p: p["conditions"]["ref_name"].update(exclude=["refs/heads/mobile-v1.2"]),
"caller bypass": lambda p: p.update(current_user_can_bypass="always"),
"other bypass": lambda p: p.update(bypass_actors=[{"actor_id":1}]),
"no deletion guard": lambda p: p["rules"].pop(0),
"no force guard": lambda p: p["rules"].pop(1),
"no review": lambda p: p["rules"].pop(2),
"no checks": lambda p: p["rules"].pop(3),
"no approvals": lambda p: p["rules"][2]["parameters"].update(required_approving_review_count=0),
"stale approvals": lambda p: p["rules"][2]["parameters"].update(dismiss_stale_reviews_on_push=False),
"self approval": lambda p: p["rules"][2]["parameters"].update(require_last_push_approval=False),
"open threads": lambda p: p["rules"][2]["parameters"].update(required_review_thread_resolution=False),
"non-strict checks": lambda p: p["rules"][3]["parameters"].update(strict_required_status_checks_policy=False),
"missing mobile": lambda p: p["rules"][3]["parameters"]["required_status_checks"].pop(0),
"missing DCO": lambda p: p["rules"][3]["parameters"]["required_status_checks"].pop(1),
}
for name, mutate in mutations.items():
value = copy.deepcopy(policy)
mutate(value)
env = dict(os.environ, GH_BRANCH_POLICY=json.dumps(value), GH_ANNOTATED_TARGET_SHA=os.environ["GH_STABLE_SHA"])
before = open(env["GH_CALLS"]).read()
result = subprocess.run([sys.argv[1], "1.2.3", "1", env["GH_STABLE_SHA"], "mobile-v1.2"], env=env, capture_output=True, text=True)
assert result.returncode != 0 and "must enforce review" in result.stderr, (name, result.stderr)
assert open(env["GH_CALLS"]).read() == before, name
PYPOLICY
if GH_BRANCH_POLICY_FAIL=1 "$publisher" 1.2.3 1 "$GH_STABLE_SHA" mobile-v1.2 >/dev/null 2>&1; then
echo "publisher accepted unavailable branch policy" >&2
exit 1
fi
# Exercise the checked-in workflow command with its actual source input binding.
python3 - "$repo_root/.github/workflows/mobile-release-candidate.yml" "$tmp" <<'PYWORKFLOW'
import os
from pathlib import Path
import re
import subprocess
import sys
workflow = Path(sys.argv[1]).read_text()
step = workflow.split(" - name: Publish annotated candidate tag\n", 1)[1]
assert re.search(r"^ SOURCE_BRANCH: \$\{\{ inputs\.source_branch \}\}$", step, re.M), "source input is not bound to SOURCE_BRANCH"
command = re.search(r"^ run: (.+)$", step, re.M).group(1)
root = Path(sys.argv[2]) / "workflow"
(root / "scripts").mkdir(parents=True)
stub = root / "scripts/publish-mobile-release-candidate.sh"
stub.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$@"\n')
stub.chmod(0o755)
env = dict(os.environ, MOBILE_VERSION="1.2.3", CANDIDATE_NUMBER="1", TARGET_SHA="4" * 40, SOURCE_BRANCH="mobile-v1.2")
actual = subprocess.check_output(["bash", "-eu", "-c", command], cwd=root, env=env, text=True).splitlines()
assert actual == ["1.2.3", "1", "4" * 40, "mobile-v1.2"], actual
PYWORKFLOW
echo "mobile release candidate publisher contract passed"

View File

@@ -2,9 +2,20 @@
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/mobile-release.sh"
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
# The stub gh answers for the real mobile tag ruleset ID, so the checked-in ID
# is exercised unchanged. Keep the line so the numeric-guard case below can
# rewrite it in a copy.
id_line='readonly MOBILE_RELEASE_TAG_RULESET_ID='
grep -Eq "^${id_line}26588677\$" "$repo_root/scripts/release-rulesets.sh" || {
echo "release-rulesets.sh must define MOBILE_RELEASE_TAG_RULESET_ID=26588677" >&2
exit 1
}
mkdir -p "$tmp/scripts"
cp "$repo_root/scripts/mobile-release.sh" "$repo_root/scripts/mobile-release-source.sh" \
"$repo_root/scripts/release-rulesets.sh" "$tmp/scripts/"
script="$tmp/scripts/mobile-release.sh"
remote="$tmp/remote.git"
work="$tmp/work"
operator="$tmp/operator"
@@ -19,88 +30,56 @@ case "${1:-}:${2:-}" in
--version:*) printf 'gh version %s (test)\n' "${GH_VERSION:-2.94.0}" ;;
api:repos/block/buzz/rulesets/19321162)
[[ "${GH_BRANCH_POLICY_FAIL:-}" != 1 ]] || exit 1
policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"never","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[],"rules":[{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}'
policy='{"target":"branch","enforcement":"active","current_user_can_bypass":"always","conditions":{"ref_name":{"include":["refs/heads/mobile-v*"],"exclude":[]}},"bypass_actors":[{"actor_type":"User","bypass_mode":"always"}],"rules":[{"type":"creation"},{"type":"update"},{"type":"deletion"},{"type":"non_fast_forward"},{"type":"pull_request","parameters":{"required_approving_review_count":1,"dismiss_stale_reviews_on_push":true,"require_last_push_approval":true,"required_review_thread_resolution":true}},{"type":"required_status_checks","parameters":{"strict_required_status_checks_policy":true,"required_status_checks":[{"context":"Mobile"},{"context":"DCO Check"}]}}]}'
printf '%s\n' "${GH_BRANCH_POLICY:-$policy}" | jq -r "$4"
;;
api:repos/block/buzz/rulesets/14378754)
api:repos/block/buzz/rulesets/26588677)
case "$*" in
*'.enforcement'*) printf '%s\n' "${GH_TAG_RULESET_STATE:-active}" ;;
*'.target'*) printf '%s\n' "${GH_TAG_RULESET_TARGET:-tag}" ;;
*'.current_user_can_bypass'*) printf '%s\n' "${GH_CURRENT_USER_CAN_BYPASS-always}" ;;
*'[.rules[].type]'*) printf '%s\n' "${GH_TAG_RULE_TYPES:-creation,deletion,non_fast_forward,update}" ;;
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-v*}" ;;
*'.conditions.ref_name.include[]'*) printf '%s\n' "${GH_TAG_INCLUDES:-refs/tags/mobile-*}" ;;
*'.conditions.ref_name.exclude[]'*) printf '%s\n' "${GH_TAG_EXCLUDES:-}" ;;
*) exit 2 ;;
esac
;;
api:repos/block/buzz/rulesets/14378754)
printf '%s\n' "${GH_GENERAL_TAG_EXCLUDES-refs/tags/mobile-*}"
;;
api:repos/block/buzz/git/ref/tags/*)
[[ "${GH_TAG_REF_FAIL:-}" != 1 ]] || exit 1
git --git-dir="$GH_REMOTE" rev-parse --verify --quiet "refs/tags/${2#repos/block/buzz/git/ref/tags/}" || exit 1
;;
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
fi
if [[ "${GH_WORKFLOW_WRONG_URL:-}" == "1" ]]; then
printf '%s\n' 'https://github.com/attacker/buzz/actions/runs/999'
exit 0
fi
if [[ "${GH_WORKFLOW_EXTRA_URL:-}" == "1" ]]; then
printf '%s\n' 'https://github.com/block/buzz/actions/runs/998'
fi
version=""
number=""
sha=""
source_branch=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
version=*) version="${1#version=}" ;;
candidate_number=*) number="${1#candidate_number=}" ;;
source_branch=*) source_branch="${1#source_branch=}" ;;
target_sha=*) sha="${1#target_sha=}" ;;
esac
shift
done
[[ -n "$version" && -n "$number" && -n "$sha" && -n "$source_branch" ]] || 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
fi
printf 'https://github.com/block/buzz/actions/runs/%s\n' "$number"
;;
run:watch)
[[ "${GH_WORKFLOW_FAIL:-}" != "1" ]] || exit 1
number="$3"
IFS=$'\t' read -r version expected sha < <(tail -n 1 "$GH_WORKFLOW_CAPTURE")
[[ "$number" == "$expected" ]]
if [[ "${GH_MAIN_MOVE_DURING_WORKFLOW:-}" == "1" ]]; then
printf '%s\n' moved >> "$GH_WORKTREE/file"
git -C "$GH_WORKTREE" commit -qam moved-during-publication
git -C "$GH_WORKTREE" push -q origin main
fi
if [[ "${GH_TAG_VERIFY_LIGHTWEIGHT:-}" == "1" ]]; then
git -C "$GH_WORKTREE" -c tag.gpgSign=false tag \
"mobile-v${version}-rc.${expected}" "$sha"
else
git -C "$GH_WORKTREE" -c tag.gpgSign=false tag -a \
-m "Buzz Mobile $version release candidate $expected" \
"mobile-v${version}-rc.${expected}" "$sha"
fi
git -C "$GH_WORKTREE" -c core.hooksPath=/dev/null push -q \
origin "refs/tags/mobile-v${version}-rc.${expected}"
;;
*) exit 2 ;;
esac
GH
chmod +x "$bin/gh"
# A git wrapper lets one case simulate a push that lands but reports failure.
real_git="$(command -v git)"
cat > "$bin/git" <<GIT
#!/usr/bin/env bash
if [[ "\${GIT_PUSH_LANDS_BUT_FAILS:-}" == 1 && " \$* " == *" push "* ]]; then
"$real_git" "\$@"
exit 1
fi
# Simulates another fetch of main finishing right after the release script's
# own fetch, replacing the checkout's shared FETCH_HEAD.
if [[ "\${GIT_RACING_MAIN_FETCH:-}" == 1 && " \$* " == *" fetch "* ]]; then
"$real_git" "\$@" || exit
exec "$real_git" fetch -q --no-tags origin refs/heads/main
fi
exec "$real_git" "\$@"
GIT
chmod +x "$bin/git"
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"
run_release() {
@@ -149,6 +128,7 @@ git -c "url.file://$remote.insteadOf=$canonical_origin" \
-c protocol.file.allow=always clone -q "$canonical_origin" "$operator"
git -C "$operator" config user.name test
git -C "$operator" config user.email test@example.com
git -C "$operator" config tag.gpgSign false
echo remote-only >> "$work/file"
git -C "$work" commit -qam remote-only
git -C "$work" push -q origin main
@@ -163,7 +143,8 @@ cat "$tmp/stale-output"
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.1^{commit}')" == \
"$remote_main_sha" ]]
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.1)" == tag ]]
grep -Fq $'1.2.3\t1\t' "$GH_WORKFLOW_CAPTURE"
# The operator pushed the tag itself: it exists locally too, annotated.
[[ "$(git -C "$operator" cat-file -t refs/tags/mobile-v1.2.3-rc.1)" == tag ]]
# A stable branch may diverge from main, including an empty release-only commit.
git -C "$work" switch -q -c mobile-v1.2
@@ -180,6 +161,10 @@ if GH_BRANCH_POLICY='{}' run_release "$operator" candidate 1.2.4 --source-branch
fi
run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.4-rc.1^{commit}')" == "$stable_sha" ]]
# A concurrent fetch of main in the same checkout cannot swap the source.
GIT_RACING_MAIN_FETCH=1 run_release "$operator" candidate 1.2.4 --source-branch mobile-v1.2 >/dev/null
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.4-rc.2^{commit}')" == "$stable_sha" ]] || \
fail "a concurrent fetch of main changed the stable candidate's source"
# Existing mismatched refs must be rejected by the guard, not missing-ref errors.
for source in mobile-v1.3 mobile-v2.2 mobile-v01.2 arbitrary; do
git -C "$work" switch -q -c "$source"
@@ -208,7 +193,6 @@ run_release "$operator" candidate 1.2.3
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.2^{commit}')" == \
"$new_main_sha" ]]
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v1.2.3-rc.2)" == tag ]]
grep -Fq $'1.2.3\t2\t' "$GH_WORKFLOW_CAPTURE"
# Sequence from the highest exact remote RC even if there are gaps, and ignore
# malformed or other-version tags.
@@ -222,68 +206,106 @@ run_release "$operator" candidate 1.2.3
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v1.2.3-rc.8^{commit}')" == \
"$new_main_sha" ]]
# Failed or unattributable App-backed publication fails closed without creating
# the expected candidate tag.
if GH_WORKFLOW_DISPATCH_FAIL=1 run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded despite a rejected workflow dispatch"
# Publication pushes exactly one new ref, the annotated candidate tag.
refs_before="$(git --git-dir="$remote" for-each-ref --format='%(refname) %(objectname)')"
run_release "$operator" candidate 9.9.1 > "$tmp/one-ref-output"
grep -Fq 'Published mobile-v9.9.1-rc.1' "$tmp/one-ref-output"
refs_after="$(git --git-dir="$remote" for-each-ref --format='%(refname) %(objectname)')"
[[ "$(diff <(printf '%s\n' "$refs_before") <(printf '%s\n' "$refs_after") | grep -c '^[<>]')" == 1 ]]
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.1-rc.1)" == tag ]]
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v9.9.1-rc.1^{commit}')" == \
"$(git --git-dir="$remote" rev-parse refs/heads/main)" ]]
if grep -Eq 'buzz-release-bot|workflow' "$tmp/one-ref-output"; then
fail "publication output still refers to the App or a workflow"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.7-rc.1; then
fail "rejected workflow dispatch created a candidate tag"
fi
if GH_WORKFLOW_DISPATCH_FAIL=1 \
GH_WORKFLOW_DISPATCH_ERROR="does not have 'workflow_dispatch' trigger" \
run_release "$operator" candidate 9.9.6 > "$tmp/missing-workflow-output" 2>&1; then
fail "candidate succeeded without the publication workflow on main"
fi
grep -Fq 'merge the release-process change before publishing a candidate' \
"$tmp/missing-workflow-output"
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.6-rc.1; then
fail "missing publication workflow created a candidate tag"
fi
if GH_WORKFLOW_FAIL=1 run_release "$operator" candidate 9.9.9 >/dev/null 2>&1; then
fail "candidate succeeded despite a failed App-backed workflow"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.9-rc.1; then
fail "failed App-backed workflow created a candidate tag"
fi
if GH_WORKFLOW_NO_URL=1 run_release "$operator" candidate 9.9.8 >/dev/null 2>&1; then
fail "candidate succeeded without a workflow run URL"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.8-rc.1; then
fail "URL-less dispatch created a candidate tag"
fi
if GH_WORKFLOW_WRONG_URL=1 run_release "$operator" candidate 9.9.5 >/dev/null 2>&1; then
fail "candidate accepted a workflow run URL from another repository"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.5-rc.1; then
fail "wrong-repository workflow URL created a candidate tag"
fi
if GH_WORKFLOW_EXTRA_URL=1 run_release "$operator" candidate 9.9.4 >/dev/null 2>&1; then
fail "candidate accepted multiple workflow run URLs"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.4-rc.1; then
fail "ambiguous workflow URLs created a candidate tag"
fi
if GH_TAG_VERIFY_LIGHTWEIGHT=1 run_release "$operator" candidate 9.9.2 >/dev/null 2>&1; then
fail "candidate accepted a lightweight published tag"
fi
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.2-rc.1)" == commit ]]
# Advancing main during publication must not invalidate the pinned tag.
pre_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
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 'Published mobile-v9.9.3-rc.1' "$tmp/main-race-output"
assert_no_candidate_tag() {
local version="$1"
if git --git-dir="$remote" show-ref --verify --quiet "refs/tags/mobile-v$version-rc.1"; then
fail "mobile-v$version-rc.1 exists on the remote"
fi
[[ -z "$(git -C "$operator" tag -l "mobile-v$version-rc.*")" ]] || \
fail "a local mobile-v$version-rc tag was left behind"
}
# A tag ruleset the operator cannot bypass fails before any tag is created.
# An absent bypass value is not accepted as success either.
if GH_CURRENT_USER_CAN_BYPASS=never run_release "$operator" candidate 9.9.7 > "$tmp/no-bypass-output" 2>&1; then
fail "candidate succeeded without bypass of the mobile tag ruleset"
fi
grep -Fq 'bypass actor of mobile tag ruleset 26588677' "$tmp/no-bypass-output"
assert_no_candidate_tag 9.9.7
if GH_CURRENT_USER_CAN_BYPASS='' run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded with an empty bypass report"
fi
assert_no_candidate_tag 9.9.7
# Any other ruleset drift also fails before a tag is created.
if GH_TAG_RULESET_STATE=disabled run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded with an inactive mobile tag ruleset"
fi
if GH_TAG_RULESET_TARGET=branch run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded with a mobile ruleset that targets branches"
fi
if GH_TAG_RULE_TYPES=creation,deletion run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded with weakened mobile tag rules"
fi
if GH_TAG_INCLUDES='refs/tags/desktop-v*' run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded with a ruleset that does not cover mobile tags"
fi
if GH_TAG_EXCLUDES='refs/tags/mobile-v9*' run_release "$operator" candidate 9.9.7 >/dev/null 2>&1; then
fail "candidate succeeded with a ruleset that excludes mobile tags"
fi
assert_no_candidate_tag 9.9.7
# The App-only general tag ruleset must exclude mobile tags, or the operator's
# push would be rejected.
if GH_GENERAL_TAG_EXCLUDES='' run_release "$operator" candidate 9.9.7 > "$tmp/general-output" 2>&1; then
fail "candidate succeeded while the general tag ruleset still covers mobile tags"
fi
grep -Fq 'general tag ruleset 14378754 must exclude refs/tags/mobile-*' "$tmp/general-output"
assert_no_candidate_tag 9.9.7
# The narrower refs/tags/mobile-v* include also covers candidate tags.
GH_TAG_INCLUDES='refs/tags/mobile-v*' run_release "$operator" candidate 9.9.8 >/dev/null
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.8-rc.1)" == tag ]]
# A non-numeric ruleset ID fails loudly before any tag is created.
mkdir -p "$tmp/garbage-scripts"
cp "$tmp/scripts/mobile-release.sh" "$tmp/scripts/mobile-release-source.sh" "$tmp/garbage-scripts/"
sed "s/^$id_line.*/${id_line}not-a-number/" "$repo_root/scripts/release-rulesets.sh" \
> "$tmp/garbage-scripts/release-rulesets.sh"
if (cd "$operator" && "$tmp/garbage-scripts/mobile-release.sh" candidate 9.9.7 > "$tmp/garbage-output" 2>&1); then
fail "candidate succeeded with a non-numeric mobile tag ruleset ID"
fi
grep -Fq 'must be numeric' "$tmp/garbage-output"
assert_no_candidate_tag 9.9.7
# A rejected push (the operator is not in the bypass team) fails, explains why,
# and removes the local tag so a retry is clean.
# A machine-wide core.hooksPath would shadow the bare remote's own hooks.
git --git-dir="$remote" config core.hooksPath "$remote/hooks"
cat > "$remote/hooks/pre-receive" <<'HOOK'
#!/usr/bin/env bash
echo "remote: rejected by repository rule (test)" >&2
exit 1
HOOK
chmod +x "$remote/hooks/pre-receive"
if run_release "$operator" candidate 9.9.6 > "$tmp/push-rejected-output" 2>&1; then
fail "candidate succeeded despite a rejected tag push"
fi
grep -Fq 'bypass actor of mobile tag ruleset 26588677' "$tmp/push-rejected-output"
assert_no_candidate_tag 9.9.6
rm "$remote/hooks/pre-receive"
# The retry after the rejection is clean and gets the same candidate number.
run_release "$operator" candidate 9.9.6 >/dev/null
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v9.9.6-rc.1)" == tag ]]
# 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")"
tags_before="$(git --git-dir="$remote" for-each-ref refs/tags)"
if run_release "$operator" candidate 7.7.1 --commit "$stable_sha" >/dev/null 2>&1; then
fail "accepted a commit outside main history"
fi
@@ -301,11 +323,11 @@ 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" ]]
[[ "$(git --git-dir="$remote" for-each-ref refs/tags)" == "$tags_before" ]]
[[ -z "$(git -C "$operator" tag -l 'mobile-v7.7.1-rc.*' 'mobile-v1.2.6-rc.*')" ]]
# Publishing through a fork is rejected and unsupported gh versions fail before
# dispatching any candidate publication.
# publishing any candidate tag.
fork_operator="$tmp/fork-operator"
git clone -q "$remote" "$fork_operator"
git -C "$fork_operator" config user.name test
@@ -313,14 +335,118 @@ git -C "$fork_operator" config user.email test@example.com
if (cd "$fork_operator" && "$script" candidate 2.0.0 >/dev/null 2>&1); then
fail "noncanonical origin was accepted"
fi
before_dispatches="$(wc -l < "$GH_WORKFLOW_CAPTURE")"
# A canonical fetch URL with a fork push destination (pushurl or
# pushInsteadOf) is rejected before any tag is created locally or anywhere.
fork_remote="$tmp/fork.git"
git clone -q --bare "$remote" "$fork_remote"
fork_tags_before="$(git --git-dir="$fork_remote" for-each-ref refs/tags)"
git -C "$operator" config remote.origin.pushurl "file://$fork_remote"
if run_release "$operator" candidate 2.0.1 > "$tmp/pushurl-output" 2>&1; then
fail "candidate succeeded with origin pushing to a fork"
fi
git -C "$operator" config --unset remote.origin.pushurl
grep -Fq 'origin pushes somewhere other than it fetches from' "$tmp/pushurl-output"
# A second pushurl after a canonical first one would also receive the tag.
git -C "$operator" config --add remote.origin.pushurl "$canonical_origin"
git -C "$operator" config --add remote.origin.pushurl "file://$fork_remote"
if run_release "$operator" candidate 2.0.1 > "$tmp/pushurl-output" 2>&1; then
fail "candidate succeeded with an extra push URL pointing at a fork"
fi
git -C "$operator" config --unset-all remote.origin.pushurl
grep -Fq 'origin pushes somewhere other than it fetches from' "$tmp/pushurl-output"
git -C "$operator" config "url.file://$fork_remote.pushInsteadOf" "$canonical_origin"
if run_release "$operator" candidate 2.0.1 >/dev/null 2>&1; then
fail "candidate succeeded with pushInsteadOf pointing at a fork"
fi
git -C "$operator" config --unset "url.file://$fork_remote.pushInsteadOf"
assert_no_candidate_tag 2.0.1
[[ "$(git --git-dir="$fork_remote" for-each-ref refs/tags)" == "$fork_tags_before" ]]
# An insteadOf rewrite sends both fetch and push to the fork. GitHub then has
# no tag, so publication fails loudly and keeps the local tag as evidence.
git -C "$operator" config --unset "url.file://$remote.insteadOf"
git -C "$operator" config "url.file://$fork_remote.insteadOf" "$canonical_origin"
if (cd "$operator" && "$script" candidate 2.0.1 > "$tmp/insteadof-output" 2>&1); then
fail "candidate reported success after publishing into a fork"
fi
git -C "$operator" config --unset "url.file://$fork_remote.insteadOf"
grep -Fq 'could not confirm mobile-v2.0.1-rc.1 on canonical block/buzz' "$tmp/insteadof-output"
[[ "$(git -C "$operator" cat-file -t refs/tags/mobile-v2.0.1-rc.1)" == tag ]] || \
fail "the local tag was not kept after an unconfirmed publication"
git -C "$operator" tag -d mobile-v2.0.1-rc.1 >/dev/null
assert_no_candidate_tag 2.0.1
# GitHub being unavailable after a successful push also stops without deleting
# the local tag, which may already be published.
if GH_TAG_REF_FAIL=1 run_release "$operator" candidate 2.0.2 > "$tmp/api-down-output" 2>&1; then
fail "candidate reported success without GitHub confirmation"
fi
grep -Fq 'could not confirm mobile-v2.0.2-rc.1 on canonical block/buzz' "$tmp/api-down-output"
[[ "$(git -C "$operator" cat-file -t refs/tags/mobile-v2.0.2-rc.1)" == tag ]] || \
fail "the local tag was deleted while GitHub was unavailable"
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v2.0.2-rc.1)" == tag ]]
# Rerunning after that numbers from the remote tags: rc.1 stays as published
# and the rerun publishes rc.2.
rc1_kept_oid="$(git --git-dir="$remote" rev-parse refs/tags/mobile-v2.0.2-rc.1)"
run_release "$operator" candidate 2.0.2 >/dev/null
[[ "$(git --git-dir="$remote" rev-parse refs/tags/mobile-v2.0.2-rc.1)" == "$rc1_kept_oid" ]]
[[ "$(git --git-dir="$remote" cat-file -t refs/tags/mobile-v2.0.2-rc.2)" == tag ]]
# push.followTags must not publish other local annotated tags: a stray local
# mobile tag on an ancestor stays unpublished.
git -C "$operator" fetch -q origin main
git -C "$operator" -c tag.gpgSign=false tag -a -m stray mobile-v9.9.9-rc.99 "$remote_main_sha"
git -C "$operator" config push.followTags true
refs_before="$(git --git-dir="$remote" for-each-ref --format='%(refname)')"
run_release "$operator" candidate 2.0.3 >/dev/null
git -C "$operator" config --unset push.followTags
git -C "$operator" tag -d mobile-v9.9.9-rc.99 >/dev/null
[[ "$(diff <(printf '%s\n' "$refs_before") \
<(git --git-dir="$remote" for-each-ref --format='%(refname)') | grep -c '^[<>]')" == 1 ]] || \
fail "candidate publication pushed more than one ref"
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v9.9.9-rc.99; then
fail "push.followTags published a stray local mobile tag"
fi
# A push that lands but reports failure keeps the local tag and goes on to the
# canonical GitHub check, which confirms the candidate.
GIT_PUSH_LANDS_BUT_FAILS=1 run_release "$operator" candidate 2.0.4 > "$tmp/landed-output" 2>&1
grep -Fq 'reported failure, but origin has mobile-v2.0.4-rc.1' "$tmp/landed-output"
grep -Fq 'Published mobile-v2.0.4-rc.1' "$tmp/landed-output"
[[ "$(git -C "$operator" cat-file -t refs/tags/mobile-v2.0.4-rc.1)" == tag ]] || \
fail "the local tag was deleted although the candidate landed"
# The same ambiguous push into a fork (origin rewritten by insteadOf) must not
# be declared usable: GitHub has no such tag.
git -C "$operator" config --unset "url.file://$remote.insteadOf"
git -C "$operator" config "url.file://$fork_remote.insteadOf" "$canonical_origin"
if (cd "$operator" && GIT_PUSH_LANDS_BUT_FAILS=1 "$script" candidate 2.0.5 > "$tmp/landed-fork-output" 2>&1); then
fail "an ambiguous push into a fork was reported as published"
fi
git -C "$operator" config --unset "url.file://$fork_remote.insteadOf"
grep -Fq 'could not confirm mobile-v2.0.5-rc.1 on canonical block/buzz' "$tmp/landed-fork-output"
git -C "$operator" tag -d mobile-v2.0.5-rc.1 >/dev/null
assert_no_candidate_tag 2.0.5
# A shallow operator clone can still pin a verified ancestor outside its
# local history.
shallow_operator="$tmp/shallow-operator"
git -c "url.file://$remote.insteadOf=$canonical_origin" \
-c protocol.file.allow=always clone -q --depth 1 "$canonical_origin" "$shallow_operator"
git -C "$shallow_operator" config tag.gpgSign false
git -C "$shallow_operator" config user.name test
git -C "$shallow_operator" config user.email test@example.com
if git -C "$shallow_operator" cat-file -e "$remote_main_sha^{commit}" 2>/dev/null; then
fail "shallow-clone fixture already contains the historical commit"
fi
run_release "$shallow_operator" candidate 7.7.2 --commit "$remote_main_sha" >/dev/null
[[ "$(git --git-dir="$remote" rev-parse 'refs/tags/mobile-v7.7.2-rc.1^{commit}')" == "$remote_main_sha" ]]
tags_before="$(git --git-dir="$remote" for-each-ref refs/tags)"
if GH_VERSION=2.86.0 run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
fail "gh older than 2.87.0 was accepted"
fi
if GH_VERSION=2.9.0 run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
fail "numeric gh version comparison accepted 2.9.0 as at least 2.87.0"
fi
[[ "$(wc -l < "$GH_WORKFLOW_CAPTURE")" == "$before_dispatches" ]]
[[ "$(git --git-dir="$remote" for-each-ref refs/tags)" == "$tags_before" ]]
# Dirty trees and invalid marketing versions fail before publication.
echo dirty > "$operator/untracked"
@@ -336,8 +462,8 @@ if run_release "$operator" candidate 01.2.3 >/dev/null 2>&1; then
fi
# Mobile no longer has release branches, finalization, a stable alias, or a
# GitHub Release call. Publication remains App-backed because the strict tag
# ruleset denies direct human creation.
# GitHub Release call, and no GitHub App or workflow publishes tags: the
# operator pushes the tag directly.
if run_release "$operator" start 2.0.0 >/dev/null 2>&1; then
fail "removed start command was accepted"
fi
@@ -351,9 +477,15 @@ if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v1.2.3; th
fail "stable mobile tag alias was created"
fi
assert_no_removed_mobile_release_behavior \
"$repo_root/scripts/mobile-release.sh" \
"$repo_root/scripts/publish-mobile-release-candidate.sh" \
"$repo_root/.github/workflows/mobile-release-candidate.yml"
"$repo_root/scripts/mobile-release.sh"
for removed in .github/workflows/mobile-release-candidate.yml \
scripts/publish-mobile-release-candidate.sh \
scripts/test-mobile-release-candidate-publisher.sh; do
[[ ! -e "$repo_root/$removed" ]] || fail "$removed must stay removed"
done
if grep -Eq 'buzz-release-bot|gh[[:space:]]+workflow' "$repo_root/scripts/mobile-release.sh"; then
fail "mobile-release.sh must not use the release App or dispatch a workflow"
fi
# Prove the negative contract itself is discriminating rather than merely
# accepting a missing or broken search tool as "not found."
printf '%s\n' 'gh release create forbidden' > "$tmp/forbidden-mobile-release-behavior"