Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/auto-tag-on-release-pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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}"
Expand Down
26 changes: 26 additions & 0 deletions .github/workflows/desktop-release-candidate.yml
Original file line number Diff line number Diff line change
@@ -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"
38 changes: 0 additions & 38 deletions .github/workflows/prepare-desktop-release.yml

This file was deleted.

69 changes: 29 additions & 40 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <version>` | 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 |

Expand All @@ -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 <version>` 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
Expand All @@ -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/<version>` as one
1. Run `just release-desktop <version>` from a clean, up-to-date `main` checkout.
The script fetches the current `origin/main`, regenerates
`version-bump/<version>` as one
deterministic candidate commit, records the frozen base and proposed
`desktop-v<version>` 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<version>`.
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<version>`.
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
Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand All @@ -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 <version>` 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.
Expand Down
10 changes: 10 additions & 0 deletions scripts/desktop-release-bypass-authorized.jq
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions scripts/fixtures/desktop-release-rule-suite-bypass.json
Original file line number Diff line number Diff line change
@@ -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"}]}
2 changes: 1 addition & 1 deletion scripts/prepare-desktop-release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ cat >"$body" <<EOF
- **Previous desktop release:** \`$previous_tag\`
- **Proposed immutable tag:** \`desktop-v$version\`

This PR must be merged with **Create a merge commit**. Squash/rebase, stale-head approval, incomplete notes, or a candidate mismatch produce no tag.
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.

The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag.
EOF
Expand Down
20 changes: 19 additions & 1 deletion scripts/test-release-ref-contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,26 @@ grep -q 'permission-contents: write' "$auto_tag"
grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag"
grep -Fq 'git/refs' "$auto_tag"
grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag"
grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag"
grep -Fq 'target_sha=${{ github.event.pull_request.merge_commit_sha }}' "$auto_tag"
grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag"

bypass_filter="$repo_root/scripts/desktop-release-bypass-authorized.jq"
bypass_fixture="$repo_root/scripts/fixtures/desktop-release-rule-suite-bypass.json"
jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" "$bypass_fixture" >/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"}' \
Expand Down
57 changes: 47 additions & 10 deletions scripts/verify-desktop-release-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -21,6 +25,7 @@ required_checks=(
"Relay E2E"
"Desktop Build (macOS)"
"DCO Check"
"Desktop Release Candidate"
)

expected_branch="version-bump/$VERSION"
Expand All @@ -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
Expand All @@ -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"
Loading