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"