Skip to content

Commit db7e84d

Browse files
wesbillmanCarl
andauthored
fix(release): make desktop tagging squash-safe (#3965)
## Summary - validate desktop release candidates before merge and keep the repository squash-only - tag the squash commit only after proving frozen-base parent and complete-tree identity with the validated PR head - accept either an exact-head approval or the durable Default-ruleset bypass record as release authorization - remove the unusable App-backed preparation workflow; retain `just release-desktop` ## Ruleset follow-up After this PR merges, update Default ruleset `13596885` to: - enable strict required status checks - dismiss stale reviews on push and require approval after the last push - require the integration-bound `Desktop Release Candidate` check The next desktop release should be cut only after that settings update. ## Verification At commit `d8c254db427eedbcffac1a6e078e90d1d0f5e151` with a clean worktree: - `scripts/test-release-ref-contract.sh` - `scripts/test-desktop-release-candidate.sh` - `bash -n scripts/verify-desktop-release-merge.sh scripts/prepare-desktop-release.sh scripts/test-release-ref-contract.sh` - `git diff --check` The bypass test fixture is the captured rule-suite shape from real squash merge PR #2864 / suite `3520068134`. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
1 parent bb34bc4 commit db7e84d

9 files changed

Lines changed: 135 additions & 92 deletions

.github/workflows/auto-tag-on-release-pr-merge.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ jobs:
9191
echo "enabled=true"
9292
echo "tag=${TAG_PREFIX}${VERSION}"
9393
if [[ "$TAG_PREFIX" == desktop-v ]]; then
94-
echo "target_sha=${{ github.event.pull_request.head.sha }}"
94+
echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}"
9595
echo "desktop=true"
9696
else
9797
echo "target_sha=$GITHUB_SHA"
@@ -111,7 +111,7 @@ jobs:
111111
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
112112
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
113113
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
114-
PR_PUSHER: ${{ github.event.pull_request.head.user.login }}
114+
MERGED_BY: ${{ github.event.pull_request.merged_by.login }}
115115
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
116116
run: |
117117
VERSION="${VERSION#desktop-v}"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Desktop Release Candidate
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
validate:
12+
name: Desktop Release Candidate
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
16+
with:
17+
ref: ${{ github.event.pull_request.head.sha }}
18+
fetch-depth: 0
19+
persist-credentials: false
20+
- name: Validate immutable desktop candidate
21+
if: startsWith(github.event.pull_request.head.ref, 'version-bump/')
22+
env:
23+
VERSION: ${{ github.event.pull_request.head.ref }}
24+
run: |
25+
VERSION="${VERSION#version-bump/}"
26+
scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY"

.github/workflows/prepare-desktop-release.yml

Lines changed: 0 additions & 38 deletions
This file was deleted.

RELEASING.md

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`:
55

66
| Lane | Entry point | Artifact |
77
|------|-------------|----------|
8-
| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
8+
| Desktop | `just release-desktop <version>` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) |
99
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
1010
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
1111

@@ -16,20 +16,15 @@ remains manual because OSS CI cannot trigger private CI.
1616

1717
## Quick Start
1818

19-
Desktop releases are prepared from the current remote `main` by GitHub Actions:
19+
Prepare desktop releases locally from an up-to-date, clean `main` checkout:
2020

2121
```sh
22-
gh workflow run prepare-desktop-release.yml \
23-
--repo block/buzz \
24-
--ref main \
25-
-f version=0.5.3
22+
just release-desktop 0.5.3
2623
```
2724

28-
The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run
29-
workflow**, select `main`, enter the version without a `v` prefix, and run it.
30-
The local `just release-desktop <version>` recipe uses the same candidate script,
31-
but the Actions workflow is the canonical operator path because it runs with the
32-
release App identity and does not depend on an operator checkout.
25+
The recipe generates the immutable candidate and opens or updates its pull
26+
request. Candidate branch creation uses the operator's GitHub permissions; the
27+
release App is intentionally limited to creating protected release tags.
3328

3429
```sh
3530
# Relay release
@@ -52,21 +47,23 @@ or mobile GitHub Release.
5247

5348
### Desktop
5449

55-
1. Run **Prepare Desktop Release** with an explicit version. Automation fetches
56-
the current `origin/main`, regenerates `version-bump/<version>` as one
50+
1. Run `just release-desktop <version>` from a clean, up-to-date `main` checkout.
51+
The script fetches the current `origin/main`, regenerates
52+
`version-bump/<version>` as one
5753
deterministic candidate commit, records the frozen base and proposed
5854
`desktop-v<version>` tag in `.release/desktop-candidate.json`, updates every
5955
desktop manifest and lockfile, writes a full-SHA changelog, and opens or
6056
updates the PR.
6157
2. Review the recorded base and candidate SHA, the complete changelog, and CI.
62-
The candidate must receive an approval on its exact current head. Any
63-
regeneration changes that head and therefore requires a fresh approval.
64-
3. Merge with **Create a merge commit**. Squash and rebase are invalid for
65-
desktop release PRs. Repository settings and the `main` ruleset must allow
66-
merge commits for this option to exist.
67-
4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate
68-
approval, and every required check, then tags the reviewed candidate—not the
69-
merge commit—as `desktop-v<version>`.
58+
The required **Desktop Release Candidate** check validates the exact head.
59+
Authorization is either an approval on that exact head or a permitted Default
60+
ruleset bypass at merge time. Any regeneration changes the head and requires
61+
the checks—and, for the review path, approval—to run again.
62+
3. **Squash merge** the PR. The protected branch must still be exactly the
63+
recorded base; otherwise regenerate the candidate from current `main`.
64+
4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity,
65+
required checks, and one of the two authorization paths, then tags the squash
66+
commit as `desktop-v<version>`.
7067
5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel
7168
macOS, Windows, and Linux artifacts; publishes the versioned release only
7269
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
233230
- **Write access** to the `block/buzz` GitHub repository
234231
- An `origin` remote whose configured URL is the canonical `block/buzz`
235232
repository
236-
- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch
237-
the candidate workflow
238-
- Repository settings and the `main` ruleset configured to allow **merge
239-
commits**; desktop release PRs cannot be squash- or rebase-merged
233+
- `gh` CLI authenticated with permission to push the candidate branch and open
234+
its pull request
235+
- The Default `main` ruleset configured for squash-only merging, strict required
236+
checks, stale-review dismissal, and the **Desktop Release Candidate** check
240237
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
241238
active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and
242239
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
247244

248245
| Name | Kind | Purpose |
249246
|------|------|---------|
250-
| `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags |
247+
| `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags |
251248
| `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key |
252249
| `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` |
253250
| `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket |
@@ -268,21 +265,13 @@ actor list.
268265

269266
## Troubleshooting
270267

271-
### The release PR does not offer **Create a merge commit**
268+
### The desktop candidate is stale or cannot be squash merged
272269

273-
The immutable desktop flow cannot release until both the repository merge
274-
settings and the `main` ruleset allow merge commits. Do not squash the PR: the
275-
auto-tagger deliberately rejects a one-parent squash commit. Enable merge
276-
commits, then merge the already-approved exact candidate head with **Create a
277-
merge commit**.
278-
279-
### `Prepare Desktop Release` fails before opening a PR
280-
281-
Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and
282-
`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can
283-
write contents and pull requests. Rerunning the preparer regenerates the
284-
candidate from the then-current `origin/main`; if its head changes, obtain a new
285-
approval before merging.
270+
Do not update the branch manually and do not weaken the ruleset. Run
271+
`just release-desktop <version>` again from current `main`; this regenerates the
272+
candidate, reruns CI, and requires a fresh approval when using the review path.
273+
The post-merge verifier refuses to tag a squash whose parent differs from the
274+
recorded candidate base or whose tree differs from the validated PR head.
286275

287276
### Local `just release-desktop` fails with "must be on main branch"
288277
Switch to `main` and pull latest before running the release recipe.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
def expected_default_pull_request_rule($ruleset_id):
2+
[.rule_evaluations[] | select(
3+
.rule_source.type == "ruleset" and
4+
.rule_source.id == $ruleset_id and
5+
.enforcement == "active" and
6+
.rule_type == "pull_request" and
7+
.result == "fail"
8+
)] | length == 1;
9+
10+
.result == "bypass" and expected_default_pull_request_rule($ruleset_id)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}]}

scripts/prepare-desktop-release.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ cat >"$body" <<EOF
7171
- **Previous desktop release:** \`$previous_tag\`
7272
- **Proposed immutable tag:** \`desktop-v$version\`
7373
74-
This PR must be merged with **Create a merge commit**. Squash/rebase, stale-head approval, incomplete notes, or a candidate mismatch produce no tag.
74+
This PR must be **squash merged** only after the Desktop Release Candidate check passes. The branch must remain based directly on current `main`; stale base, payload drift, incomplete notes, or an unauthorized merge produce no tag.
7575
7676
The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag.
7777
EOF

scripts/test-release-ref-contract.sh

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,26 @@ grep -q 'permission-contents: write' "$auto_tag"
6262
grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag"
6363
grep -Fq 'git/refs' "$auto_tag"
6464
grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag"
65-
grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag"
65+
grep -Fq 'target_sha=${{ github.event.pull_request.merge_commit_sha }}' "$auto_tag"
6666
grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag"
67+
68+
bypass_filter="$repo_root/scripts/desktop-release-bypass-authorized.jq"
69+
bypass_fixture="$repo_root/scripts/fixtures/desktop-release-rule-suite-bypass.json"
70+
jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" "$bypass_fixture" >/dev/null || {
71+
echo "real squash-bypass fixture was rejected" >&2
72+
exit 1
73+
}
74+
for mutation in \
75+
'.result = "pass"' \
76+
'(.rule_evaluations[] | select(.rule_type == "pull_request")).result = "pass"' \
77+
'(.rule_evaluations[] | select(.rule_type == "pull_request")).rule_source.id = 0' \
78+
'(.rule_evaluations[] | select(.rule_type == "pull_request")).enforcement = "evaluate"' \
79+
'del(.rule_evaluations[] | select(.rule_type == "pull_request"))'; do
80+
if jq "$mutation" "$bypass_fixture" | jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" >/dev/null; then
81+
echo "bypass filter accepted invalid fixture mutation: $mutation" >&2
82+
exit 1
83+
fi
84+
done
6785
review_filter="$repo_root/scripts/review-decision-approved.jq"
6886
for fixture in \
6987
'{"reviewDecision":"CHANGES_REQUESTED"}' \

scripts/verify-desktop-release-merge.sh

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ set -euo pipefail
33

44
: "${PR_HEAD_SHA:?}"
55
: "${MERGE_SHA:?}"
6+
: "${MERGED_BY:?}"
67
: "${VERSION:?}"
78
: "${PR_NUMBER:?}"
89
: "${GH_TOKEN:?}"
910

11+
# This ID is the release-authority policy anchor. A bypass of another ruleset
12+
# must never authorize a desktop release.
13+
readonly DEFAULT_RULESET_ID=13596885
1014
required_checks=(
1115
"Desktop E2E Integration"
1216
"Desktop"
@@ -21,6 +25,7 @@ required_checks=(
2125
"Relay E2E"
2226
"Desktop Build (macOS)"
2327
"DCO Check"
28+
"Desktop Release Candidate"
2429
)
2530

2631
expected_branch="version-bump/$VERSION"
@@ -30,21 +35,53 @@ expected_branch="version-bump/$VERSION"
3035

3136
git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags
3237
mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n')
33-
[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; }
34-
[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; }
35-
git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; }
38+
[[ "${#parents[@]}" -eq 1 ]] || { echo "desktop release was not squash merged" >&2; exit 1; }
39+
base_sha="$(git show "$PR_HEAD_SHA:.release/desktop-candidate.json" | jq -r .base_sha)"
40+
[[ "${parents[0]}" == "$base_sha" ]] || { echo "squash parent is not the frozen candidate base" >&2; exit 1; }
41+
[[ "$(git show -s --format=%T "$MERGE_SHA")" == "$(git show -s --format=%T "$PR_HEAD_SHA")" ]] || {
42+
echo "squash tree differs from the validated candidate" >&2
43+
exit 1
44+
}
45+
git merge-base --is-ancestor "$MERGE_SHA" origin/main || { echo "squash commit is not reachable from current main" >&2; exit 1; }
3646

3747
git checkout --detach "$PR_HEAD_SHA"
3848
scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY"
3949

40-
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')
41-
jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || {
42-
echo "pull request effective review decision is not APPROVED" >&2
50+
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')"
51+
reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")"
52+
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")"
53+
review_authorized=false
54+
if jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null && [[ "$valid_approvals" -gt 0 ]]; then
55+
review_authorized=true
56+
fi
57+
58+
# Rule suites are GitHub's durable record that a permitted bypass actor landed
59+
# this exact main update. The suite does not identify the matching bypass grant,
60+
# so the Default ruleset's bypass list is itself the release-authority policy.
61+
bypass_authorized=false
62+
for attempt in {1..5}; do
63+
suites="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/rulesets/rule-suites?ref=refs/heads/main&per_page=100")"
64+
mapfile -t suite_ids < <(jq -r --arg before "$base_sha" --arg after "$MERGE_SHA" --arg actor "$MERGED_BY" '
65+
.[][] | select(.ref == "refs/heads/main" and .before_sha == $before and .after_sha == $after and .actor_name == $actor and .result == "bypass") | .id
66+
' <<<"$suites")
67+
if [[ "${#suite_ids[@]}" -gt 1 ]]; then
68+
echo "multiple rule suites matched the release landing" >&2
69+
exit 1
70+
fi
71+
if [[ "${#suite_ids[@]}" -eq 1 ]]; then
72+
suite="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/rule-suites/${suite_ids[0]}")"
73+
if jq -e --argjson ruleset_id "$DEFAULT_RULESET_ID" -f scripts/desktop-release-bypass-authorized.jq <<<"$suite" >/dev/null; then
74+
bypass_authorized=true
75+
fi
76+
break
77+
fi
78+
[[ "$attempt" -eq 5 ]] || sleep "$attempt"
79+
done
80+
81+
[[ "$review_authorized" == true || "$bypass_authorized" == true ]] || {
82+
echo "release lacks an exact-head approval or authorized Default-ruleset bypass" >&2
4383
exit 1
4484
}
45-
reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")"
46-
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")"
47-
[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; }
4885

4986
checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")"
5087
for required in "${required_checks[@]}"; do
@@ -59,4 +96,4 @@ jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null ||
5996
exit 1
6097
}
6198

62-
echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA"
99+
echo "verified desktop candidate $PR_HEAD_SHA at squash $MERGE_SHA"

0 commit comments

Comments
 (0)