diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index a69eafb404..f44057268d 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -91,7 +91,7 @@ jobs: echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" if [[ "$TAG_PREFIX" == desktop-v ]]; then - echo "target_sha=${{ github.event.pull_request.head.sha }}" + echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}" echo "desktop=true" else echo "target_sha=$GITHUB_SHA" @@ -111,7 +111,7 @@ jobs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_PUSHER: ${{ github.event.pull_request.head.user.login }} + MERGED_BY: ${{ github.event.pull_request.merged_by.login }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | VERSION="${VERSION#desktop-v}" diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml new file mode 100644 index 0000000000..eddebea685 --- /dev/null +++ b/.github/workflows/desktop-release-candidate.yml @@ -0,0 +1,26 @@ +name: Desktop Release Candidate + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + name: Desktop Release Candidate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate immutable desktop candidate + if: startsWith(github.event.pull_request.head.ref, 'version-bump/') + env: + VERSION: ${{ github.event.pull_request.head.ref }} + run: | + VERSION="${VERSION#version-bump/}" + scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml deleted file mode 100644 index 7cc480b93b..0000000000 --- a/.github/workflows/prepare-desktop-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Prepare Desktop Release - -on: - workflow_dispatch: - inputs: - version: - description: Semver to prepare (for example 0.5.1) - required: true - -env: - RELEASE_AUTOMATION_NAME: Carl - RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz - -jobs: - prepare: - if: github.repository == 'block/buzz' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Create short-lived release preparer token - id: preparer - 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 - permission-pull-requests: write - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - token: ${{ steps.preparer.outputs.token }} - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Prepare immutable candidate and open or update PR - env: - GH_TOKEN: ${{ steps.preparer.outputs.token }} - VERSION: ${{ inputs.version }} - run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/RELEASING.md b/RELEASING.md index 11f669fc9b..e729f8b50c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | +| Desktop | `just release-desktop ` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -16,20 +16,15 @@ remains manual because OSS CI cannot trigger private CI. ## Quick Start -Desktop releases are prepared from the current remote `main` by GitHub Actions: +Prepare desktop releases locally from an up-to-date, clean `main` checkout: ```sh -gh workflow run prepare-desktop-release.yml \ - --repo block/buzz \ - --ref main \ - -f version=0.5.3 +just release-desktop 0.5.3 ``` -The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run -workflow**, select `main`, enter the version without a `v` prefix, and run it. -The local `just release-desktop ` recipe uses the same candidate script, -but the Actions workflow is the canonical operator path because it runs with the -release App identity and does not depend on an operator checkout. +The recipe generates the immutable candidate and opens or updates its pull +request. Candidate branch creation uses the operator's GitHub permissions; the +release App is intentionally limited to creating protected release tags. ```sh # Relay release @@ -52,21 +47,23 @@ or mobile GitHub Release. ### Desktop -1. Run **Prepare Desktop Release** with an explicit version. Automation fetches - the current `origin/main`, regenerates `version-bump/` as one +1. Run `just release-desktop ` from a clean, up-to-date `main` checkout. + The script fetches the current `origin/main`, regenerates + `version-bump/` as one deterministic candidate commit, records the frozen base and proposed `desktop-v` tag in `.release/desktop-candidate.json`, updates every desktop manifest and lockfile, writes a full-SHA changelog, and opens or updates the PR. 2. Review the recorded base and candidate SHA, the complete changelog, and CI. - The candidate must receive an approval on its exact current head. Any - regeneration changes that head and therefore requires a fresh approval. -3. Merge with **Create a merge commit**. Squash and rebase are invalid for - desktop release PRs. Repository settings and the `main` ruleset must allow - merge commits for this option to exist. -4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate - approval, and every required check, then tags the reviewed candidate—not the - merge commit—as `desktop-v`. + The required **Desktop Release Candidate** check validates the exact head. + Authorization is either an approval on that exact head or a permitted Default + ruleset bypass at merge time. Any regeneration changes the head and requires + the checks—and, for the review path, approval—to run again. +3. **Squash merge** the PR. The protected branch must still be exactly the + recorded base; otherwise regenerate the candidate from current `main`. +4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity, + required checks, and one of the two authorization paths, then tags the squash + commit as `desktop-v`. 5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel macOS, Windows, and Linux artifacts; publishes the versioned release only after the complete set succeeds; then updates the rolling updater manifest @@ -233,10 +230,10 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 - **Write access** to the `block/buzz` GitHub repository - An `origin` remote whose configured URL is the canonical `block/buzz` repository -- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch - the candidate workflow -- Repository settings and the `main` ruleset configured to allow **merge - commits**; desktop release PRs cannot be squash- or rebase-merged +- `gh` CLI authenticated with permission to push the candidate branch and open + its pull request +- 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 non-fast-forward protections and `buzz-release-bot` as its sole always-bypass @@ -247,7 +244,7 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 | Name | Kind | Purpose | |------|------|---------| - | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags | + | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags | | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key | | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` | | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket | @@ -268,21 +265,13 @@ actor list. ## Troubleshooting -### The release PR does not offer **Create a merge commit** +### The desktop candidate is stale or cannot be squash merged -The immutable desktop flow cannot release until both the repository merge -settings and the `main` ruleset allow merge commits. Do not squash the PR: the -auto-tagger deliberately rejects a one-parent squash commit. Enable merge -commits, then merge the already-approved exact candidate head with **Create a -merge commit**. - -### `Prepare Desktop Release` fails before opening a PR - -Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and -`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can -write contents and pull requests. Rerunning the preparer regenerates the -candidate from the then-current `origin/main`; if its head changes, obtain a new -approval before merging. +Do not update the branch manually and do not weaken the ruleset. Run +`just release-desktop ` again from current `main`; this regenerates the +candidate, reruns CI, and requires a fresh approval when using the review path. +The post-merge verifier refuses to tag a squash whose parent differs from the +recorded candidate base or whose tree differs from the validated PR head. ### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. diff --git a/scripts/desktop-release-bypass-authorized.jq b/scripts/desktop-release-bypass-authorized.jq new file mode 100644 index 0000000000..7dc40530b0 --- /dev/null +++ b/scripts/desktop-release-bypass-authorized.jq @@ -0,0 +1,10 @@ +def expected_default_pull_request_rule($ruleset_id): + [.rule_evaluations[] | select( + .rule_source.type == "ruleset" and + .rule_source.id == $ruleset_id and + .enforcement == "active" and + .rule_type == "pull_request" and + .result == "fail" + )] | length == 1; + +.result == "bypass" and expected_default_pull_request_rule($ruleset_id) diff --git a/scripts/fixtures/desktop-release-rule-suite-bypass.json b/scripts/fixtures/desktop-release-rule-suite-bypass.json new file mode 100644 index 0000000000..f2aa784357 --- /dev/null +++ b/scripts/fixtures/desktop-release-rule-suite-bypass.json @@ -0,0 +1 @@ +{"id":3520068134,"actor_id":15384764,"actor_name":"wpfleger96","before_sha":"6e02e0a9022a1a098c44ee611b4d9784addb10c7","after_sha":"209536ade6c5ebf7fa82671d7ca0b74f599a40cc","ref":"refs/heads/main","repository_id":1174789082,"repository_name":"buzz","pushed_at":"2026-07-31T11:06:21-06:00","result":"bypass","rule_evaluations":[{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"fail","rule_type":"pull_request","details":"1 review requesting changes by reviewers with write access."},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"required_status_checks"},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"non_fast_forward"},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"deletion"}]} \ No newline at end of file diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index c477a84cda..9a6f4d1cfa 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -71,7 +71,7 @@ cat >"$body" </dev/null || { + echo "real squash-bypass fixture was rejected" >&2 + exit 1 +} +for mutation in \ + '.result = "pass"' \ + '(.rule_evaluations[] | select(.rule_type == "pull_request")).result = "pass"' \ + '(.rule_evaluations[] | select(.rule_type == "pull_request")).rule_source.id = 0' \ + '(.rule_evaluations[] | select(.rule_type == "pull_request")).enforcement = "evaluate"' \ + 'del(.rule_evaluations[] | select(.rule_type == "pull_request"))'; do + if jq "$mutation" "$bypass_fixture" | jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" >/dev/null; then + echo "bypass filter accepted invalid fixture mutation: $mutation" >&2 + exit 1 + fi +done review_filter="$repo_root/scripts/review-decision-approved.jq" for fixture in \ '{"reviewDecision":"CHANGES_REQUESTED"}' \ diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh index 17bf2f4dcb..d8ca8015ba 100755 --- a/scripts/verify-desktop-release-merge.sh +++ b/scripts/verify-desktop-release-merge.sh @@ -3,10 +3,14 @@ set -euo pipefail : "${PR_HEAD_SHA:?}" : "${MERGE_SHA:?}" +: "${MERGED_BY:?}" : "${VERSION:?}" : "${PR_NUMBER:?}" : "${GH_TOKEN:?}" +# This ID is the release-authority policy anchor. A bypass of another ruleset +# must never authorize a desktop release. +readonly DEFAULT_RULESET_ID=13596885 required_checks=( "Desktop E2E Integration" "Desktop" @@ -21,6 +25,7 @@ required_checks=( "Relay E2E" "Desktop Build (macOS)" "DCO Check" + "Desktop Release Candidate" ) expected_branch="version-bump/$VERSION" @@ -30,21 +35,53 @@ expected_branch="version-bump/$VERSION" git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') -[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; } -[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; } -git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; } +[[ "${#parents[@]}" -eq 1 ]] || { echo "desktop release was not squash merged" >&2; exit 1; } +base_sha="$(git show "$PR_HEAD_SHA:.release/desktop-candidate.json" | jq -r .base_sha)" +[[ "${parents[0]}" == "$base_sha" ]] || { echo "squash parent is not the frozen candidate base" >&2; exit 1; } +[[ "$(git show -s --format=%T "$MERGE_SHA")" == "$(git show -s --format=%T "$PR_HEAD_SHA")" ]] || { + echo "squash tree differs from the validated candidate" >&2 + exit 1 +} +git merge-base --is-ancestor "$MERGE_SHA" origin/main || { echo "squash commit is not reachable from current main" >&2; exit 1; } git checkout --detach "$PR_HEAD_SHA" scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" -review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest') -jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || { - echo "pull request effective review decision is not APPROVED" >&2 +review="$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest')" +reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[][] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +review_authorized=false +if jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null && [[ "$valid_approvals" -gt 0 ]]; then + review_authorized=true +fi + +# Rule suites are GitHub's durable record that a permitted bypass actor landed +# this exact main update. The suite does not identify the matching bypass grant, +# so the Default ruleset's bypass list is itself the release-authority policy. +bypass_authorized=false +for attempt in {1..5}; do + suites="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/rulesets/rule-suites?ref=refs/heads/main&per_page=100")" + mapfile -t suite_ids < <(jq -r --arg before "$base_sha" --arg after "$MERGE_SHA" --arg actor "$MERGED_BY" ' + .[][] | select(.ref == "refs/heads/main" and .before_sha == $before and .after_sha == $after and .actor_name == $actor and .result == "bypass") | .id + ' <<<"$suites") + if [[ "${#suite_ids[@]}" -gt 1 ]]; then + echo "multiple rule suites matched the release landing" >&2 + exit 1 + fi + if [[ "${#suite_ids[@]}" -eq 1 ]]; then + suite="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/rule-suites/${suite_ids[0]}")" + if jq -e --argjson ruleset_id "$DEFAULT_RULESET_ID" -f scripts/desktop-release-bypass-authorized.jq <<<"$suite" >/dev/null; then + bypass_authorized=true + fi + break + fi + [[ "$attempt" -eq 5 ]] || sleep "$attempt" +done + +[[ "$review_authorized" == true || "$bypass_authorized" == true ]] || { + echo "release lacks an exact-head approval or authorized Default-ruleset bypass" >&2 exit 1 } -reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" -valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" -[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; } checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" for required in "${required_checks[@]}"; do @@ -59,4 +96,4 @@ jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || exit 1 } -echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA" +echo "verified desktop candidate $PR_HEAD_SHA at squash $MERGE_SHA"