From 5d094cd76992b840365d33c34006393d6eccd8b0 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 5 Nov 2026 16:56:27 -0800 Subject: [PATCH] release(mobile): push candidate tags as the operator, not the App (#9084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Tom Brow Co-authored-by: Claude Opus 5.5 Co-authored-by: studio --- .../auto-tag-on-release-pr-merge.yml | 7 +- .github/workflows/ci.yml | 7 +- .../workflows/mobile-release-candidate.yml | 75 ---- RELEASING.md | 93 +++-- scripts/mobile-release-source.sh | 11 +- scripts/mobile-release.sh | 83 ++-- scripts/publish-mobile-release-candidate.sh | 85 ---- scripts/release-rulesets.sh | 58 ++- ...test-mobile-release-candidate-publisher.sh | 266 ------------ scripts/test-mobile-release-contract.sh | 388 ++++++++++++------ 10 files changed, 424 insertions(+), 649 deletions(-) delete mode 100644 .github/workflows/mobile-release-candidate.yml delete mode 100755 scripts/publish-mobile-release-candidate.sh delete mode 100755 scripts/test-mobile-release-candidate-publisher.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 3db5c6baaa..d42ab4c90e 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -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 → helm-chart.yml (helm chart) # -# Mobile candidate tags do not come from merged PRs. Operators create immutable -# mobile-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-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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91943dc02c..4191f3891f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/mobile-release-candidate.yml b/.github/workflows/mobile-release-candidate.yml deleted file mode 100644 index 131e8aab0b..0000000000 --- a/.github/workflows/mobile-release-candidate.yml +++ /dev/null @@ -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" diff --git a/RELEASING.md b/RELEASING.md index 6c7e2779ff..4279b80ec3 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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" diff --git a/scripts/mobile-release-source.sh b/scripts/mobile-release-source.sh index bc3321132a..dc2df2ce24 100644 --- a/scripts/mobile-release-source.sh +++ b/scripts/mobile-release-source.sh @@ -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 } } diff --git a/scripts/mobile-release.sh b/scripts/mobile-release.sh index 724c8337a5..aedc9af762 100755 --- a/scripts/mobile-release.sh +++ b/scripts/mobile-release.sh @@ -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" ;; diff --git a/scripts/publish-mobile-release-candidate.sh b/scripts/publish-mobile-release-candidate.sh deleted file mode 100755 index 5119611c67..0000000000 --- a/scripts/publish-mobile-release-candidate.sh +++ /dev/null @@ -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" diff --git a/scripts/release-rulesets.sh b/scripts/release-rulesets.sh index bd0ac1fdf8..e550375600 100755 --- a/scripts/release-rulesets.sh +++ b/scripts/release-rulesets.sh @@ -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 } diff --git a/scripts/test-mobile-release-candidate-publisher.sh b/scripts/test-mobile-release-candidate-publisher.sh deleted file mode 100755 index dbfda358b5..0000000000 --- a/scripts/test-mobile-release-candidate-publisher.sh +++ /dev/null @@ -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" diff --git a/scripts/test-mobile-release-contract.sh b/scripts/test-mobile-release-contract.sh index 395060e4bd..d66125367a 100755 --- a/scripts/test-mobile-release-contract.sh +++ b/scripts/test-mobile-release-contract.sh @@ -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" <> "$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"