chore(mobile): lighter-weight release process (#2144)

Signed-off-by: npub1sv749mw8zcmld4ygjx2mx2aqcn3zvtuj2nlmgatxgad3t9uweu9q5marze <833d52edc71637f6d4889195b32ba0c4e2262f9254ffb47566475b15978ecf0a@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1re830n24qhgstulznk5dxdluccspxkxxdq643lm4q3zwwwjgsuqqmcmdrm <1e4f17cd5505d105f3e29da8d337fcc6201358c6683558ff750444e73a488700@buzz.block.builderlab.xyz>
Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
Co-authored-by: npub1sv749mw8zcmld4ygjx2mx2aqcn3zvtuj2nlmgatxgad3t9uweu9q5marze <833d52edc71637f6d4889195b32ba0c4e2262f9254ffb47566475b15978ecf0a@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1re830n24qhgstulznk5dxdluccspxkxxdq643lm4q3zwwwjgsuqqmcmdrm <1e4f17cd5505d105f3e29da8d337fcc6201358c6683558ff750444e73a488700@buzz.block.builderlab.xyz>
Co-authored-by: 7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
This commit is contained in:
Tom Brow
2026-07-23 15:05:59 -07:00
committed by GitHub
parent e67303f603
commit 21573b6cb9
13 changed files with 1014 additions and 190 deletions

View File

@@ -1,6 +1,6 @@
name: Auto-tag on Release PR Merge
# Five release lanes share this one workflow. Four use an explicit branch
# Four PR-driven release lanes share this workflow. Each uses an explicit branch
# prefix; the main chart lane also auto-detects a Chart.yaml version bump so
# a chart feature PR can publish its own new version when merged:
#
@@ -10,18 +10,20 @@ name: Auto-tag on Release PR Merge
# push-chart-release/<v> → tag push-chart-v<v> → push-gateway-helm-chart.yml
# any internal PR that bumps deploy/charts/buzz/Chart.yaml `version`
# → tag chart-v<v> → helm-chart.yml (helm chart)
# mobile-release/<v> → tag mobile-v<v> → (manual sprout_ref for buzz-releases build — see below)
#
# Mobile candidate tags do not come from merged PRs. Operators create immutable
# mobile-v<v>-rc.N tags directly from remote main with scripts/mobile-release.sh,
# 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
# App, so the consumer's `on.push.tags` trigger runs normally. The workflow's
# default GITHUB_TOKEN remains read-only and is never used to create a tag.
#
# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must
# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across
# that boundary is deliberately disallowed. The mobile-v* tag is consumed
# manually instead — a human feeds it as the `sprout_ref` input to the
# `buzz-releases` Buildkite pipeline, which builds and ships mobile.
# Mobile is manual-only by infosec necessity: OSS `block/buzz` CI must
# not trigger CI in the private `buzz-releases` repo. A human feeds the exact
# mobile candidate tag to the private Buildkite pipeline, which builds and
# ships mobile.
on:
pull_request:
@@ -65,9 +67,6 @@ jobs:
push-chart-release/*)
VERSION="${BRANCH#push-chart-release/}"
TAG_PREFIX="push-chart-v" ;;
mobile-release/*)
VERSION="${BRANCH#mobile-release/}"
TAG_PREFIX="mobile-v" ;;
*)
parent_sha="$(git rev-parse HEAD^)"
old_version="$(git show "${parent_sha}:deploy/charts/buzz/Chart.yaml" 2>/dev/null | awk '/^version:/ {print $2}')"

View File

@@ -58,9 +58,19 @@ jobs:
- 'pnpm-lock.yaml'
mobile:
- 'mobile/**'
- 'scripts/mobile-release.sh'
- 'scripts/publish-mobile-release-candidate.sh'
- 'scripts/release-rulesets.sh'
- 'scripts/test-mobile-release-contract.sh'
- 'scripts/test-mobile-release-candidate-publisher.sh'
- '.github/workflows/mobile-release-candidate.yml'
- '.github/workflows/ci.yml'
- name: Release workflow source contract
run: scripts/test-release-ref-contract.sh
- name: Mobile release contract
run: |
scripts/test-mobile-release-contract.sh
scripts/test-mobile-release-candidate-publisher.sh
rust-lint:
name: Rust Lint

View File

@@ -0,0 +1,69 @@
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
target_sha:
description: Exact current block/buzz main commit
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 }}
TARGET_SHA: ${{ inputs.target_sha }}
run: scripts/publish-mobile-release-candidate.sh "$MOBILE_VERSION" "$CANDIDATE_NUMBER" "$TARGET_SHA"

View File

@@ -569,5 +569,5 @@ just mobile-dev
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
- [TESTING.md](TESTING.md) — multi-agent E2E test guide
- [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds
- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `scripts/mobile-release.sh`, candidate tags, internal builds
- [README.md](README.md) — project overview and quick start

View File

@@ -674,14 +674,6 @@ get-next-patch-version:
get-next-relay-patch-version:
@python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Read the current mobile version from pubspec.yaml (strips the +build suffix)
get-current-mobile-version:
@grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/'
# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1)
get-next-mobile-patch-version:
@python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')"
# Update version in desktop package manifests and regenerate lockfiles
bump-desktop-version version:
#!/usr/bin/env bash
@@ -721,16 +713,6 @@ bump-relay-version version:
cargo update -p buzz-relay
echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock"
# Bump the mobile pubspec version and regenerate the lockfile
bump-mobile-version version:
#!/usr/bin/env bash
set -euo pipefail
# pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention
# (a literal `+1`, matching the desktop lane's prior behavior).
perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml
(unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get)
echo "Bumped mobile to {{ version }} and regenerated pubspec.lock"
# Open or update the desktop release PR (signed desktop app)
release-desktop *ARGS:
#!/usr/bin/env bash
@@ -755,22 +737,8 @@ release-relay *ARGS:
fi
just _release-pr relay "$VERSION"
# Open or update the mobile release PR (Buzz mobile app)
release-mobile *ARGS:
#!/usr/bin/env bash
set -euo pipefail
ARG="{{ ARGS }}"
if [[ -z "$ARG" || "$ARG" == "patch" ]]; then
VERSION=$(just get-next-mobile-patch-version)
else
VERSION="$ARG"
fi
just _release-pr mobile "$VERSION"
# Shared release-PR engine. One body, three lanes — the only lane-specific steps
# are the version-bump command and the file/tag/changelog identifiers selected
# in the `case` below. Everything else (git preflight, branch reset, changelog
# generation, commit, push, PR open/edit) is identical across lanes.
# Shared release-PR engine for desktop and relay. Mobile publishes immutable
# candidate tags directly from remote main instead of using metadata-only PRs.
_release-pr lane version:
#!/usr/bin/env bash
set -euo pipefail
@@ -801,16 +769,6 @@ _release-pr lane version:
ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md)
LOG_PATHS=(crates/buzz-relay/ crates/buzz-core/ crates/buzz-db/ crates/buzz-auth/ crates/buzz-pubsub/ crates/buzz-search/ crates/buzz-audit/ crates/buzz-media/ crates/buzz-sdk/ crates/buzz-workflow/ crates/buzz-conformance/ migrations/)
ARTIFACT="Buzz Relay" ;;
mobile)
BRANCH_PREFIX="mobile-release"
TAG_FETCH='mobile-v*'
TAG_MATCH='mobile-v[0-9]*'
TAG_EXCLUDE='mobile-v*-*'
TAG_PREFIX="mobile-v"
CHANGELOG="mobile/CHANGELOG.md"
ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md)
LOG_PATHS=(mobile/)
ARTIFACT="Buzz Mobile" ;;
*)
echo "Error: unknown release lane '{{ lane }}'"
exit 1 ;;
@@ -851,7 +809,6 @@ _release-pr lane version:
case "{{ lane }}" in
desktop) just bump-desktop-version "$VERSION" ;;
relay) just bump-relay-version "$VERSION" ;;
mobile) just bump-mobile-version "$VERSION" ;;
esac
# Generate the changelog from commits since this lane's last release tag.
LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "")

View File

@@ -1,25 +1,18 @@
# Releasing Buzz
Buzz has three independent release lanes, each driven by a release PR — no human
ever pushes a git tag:
Buzz has three independent release lanes. Desktop and relay use release PRs.
Mobile uses immutable release-candidate tags cut directly from remote `main`:
| Lane | Recipe | Artifact |
|------|--------|----------|
| Lane | Entry point | Artifact |
|------|-------------|----------|
| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) |
| Relay | `just release-relay` | `ghcr.io/block/buzz` container image |
| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) |
| Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity |
The three lanes version independently: the desktop version lives in
`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and
the mobile version in `mobile/pubspec.yaml`.
The mobile lane publishes a `mobile-v<version>` tag that is consumed
**manually**, cross-repo, as the `sprout_ref` input to the internal
`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store →
TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is
tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private
`buzz-releases` repo (infosec), so a human cuts the internal build from the tag
rather than auto-dispatching across that boundary.
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.
## Quick Start
@@ -27,126 +20,97 @@ rather than auto-dispatching across that boundary.
# Desktop release (next patch version)
just release-desktop
# Desktop patch / minor / explicit
just release-desktop patch
# Desktop explicit version
just release-desktop 0.4.0
just release-desktop 1.0.0
# Relay release (same argument forms)
# Relay release
just release-relay
just release-relay 0.4.0
# Mobile release (same argument forms)
just release-mobile
just release-mobile 0.4.0
# Publish the next mobile candidate from the exact current remote main commit
scripts/mobile-release.sh candidate 0.5.0
```
`just release-desktop` creates a `version-bump/<version>` PR; `just
release-relay` creates a `relay-release/<version>` PR; `just release-mobile`
creates a `mobile-release/<version>` PR. Each bumps its own version manifest,
regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger
the build automatically (the mobile tag is instead the `sprout_ref` a human
feeds the internal build — see above).
Re-running any of these recipes with the same version is safe — it detects the
existing branch and PR, resets to current `main`, regenerates the changelog
with any new commits, and updates the PR in place.
Desktop and relay releases use metadata PRs. Mobile does not. Each
`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record.
There is no mobile release branch, stable mobile tag alias, finalization step,
or mobile GitHub Release.
---
## How It Works
All three lanes share one engine; they differ only in which version manifest
they bump, which branch prefix they use, and what the merge triggers.
The merge workflow creates tags with a short-lived installation token from the
dedicated `buzz-release-bot` GitHub App. Release-tag rules allow that App to
create matching tags and prevent other actors from creating, moving, or
deleting them. The workflow's default `GITHUB_TOKEN` is read-only.
### Desktop
1. **`just release-desktop`** runs locally on `main` — computes the next
version, creates (or reuses) a `version-bump/<version>` branch, bumps the
desktop manifests, regenerates lockfiles, generates a changelog
entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`version-bump/*` branch merge and pushes a `v<version>` tag.
3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the
desktop app for macOS and Linux.
1. **`just release-desktop`** runs locally on `main`, creates or updates a
`version-bump/<version>` PR, bumps the desktop manifests, regenerates
lockfiles, and updates `CHANGELOG.md`.
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v<version>`.
3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and
publishes the desktop app for macOS and Linux.
### Relay
1. **`just release-relay`** runs locally on `main` — computes the next relay
version, creates (or reuses) a `relay-release/<version>` branch, bumps
`crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a
changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and
opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`relay-release/*` branch merge and pushes a `relay-v<version>` tag.
3. **Tag triggers `docker.yml`** — the `relay-v<version>` push triggers
`docker.yml`, which builds the multi-arch relay
image and publishes `ghcr.io/block/buzz:<version>` (plus `:<major>.<minor>`,
`:<major>`, and `:latest` for stable releases). Prereleases
(`relay-v<version>-rc.1`) publish only the prerelease tag and do **not**
move `:latest`. GitHub runs the tag trigger because the tag is created by
the dedicated GitHub App rather than the workflow's `GITHUB_TOKEN`.
1. **`just release-relay`** runs locally on `main`, creates or updates a
`relay-release/<version>` PR, bumps `crates/buzz-relay/Cargo.toml`,
regenerates `Cargo.lock`, and updates the relay changelog.
2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes
`relay-v<version>`.
3. **The tag triggers `docker.yml`.** Stable releases update the version
aliases and `latest`; prereleases do not.
Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags
(the rolling development image). The `:latest` tag tracks the latest **stable**
relay release only — it does not move on main pushes or prereleases.
Every push to `main` continues to publish the rolling relay `:main` and
`:sha-<7>` tags.
### Mobile
1. **`just release-mobile`** runs locally on `main` — computes the next mobile
version, creates (or reuses) a `mobile-release/<version>` branch, bumps
`mobile/pubspec.yaml` (preserving the `+build` number), regenerates
`mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`,
commits, pushes, and opens (or updates) a PR.
2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the
`mobile-release/*` branch merge and pushes a `mobile-v<version>` tag.
3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz`
builds on the tag (OSS CI must not trigger CI in the private `buzz-releases`
repo — infosec). A human feeds the `mobile-v<version>` tag as the
`sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which
builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App
Store, opt-in). See [Internal Releases](#internal-releases).
1. **Publish a candidate.** From a clean checkout whose `origin` is the
canonical `block/buzz` repository, run
`scripts/mobile-release.sh candidate X.Y.Z`. The script resolves and fetches
the exact current `origin/main` commit, 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.
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
version. Flutter receives clean marketing version `X.Y.Z`; Buildkite's
monotonically increasing build number supplies the platform build number.
3. **Promote tested artifacts.** Promote the already-built signed artifact for
each platform through its store workflow. Record the exact tag with the
build or rollout record. No source ref is changed and no final build is cut.
The iOS and Android artifacts for one marketing version may come from different
RC tags. For example, iOS can ship `mobile-v0.5.0-rc.2` while Android ships
`mobile-v0.5.0-rc.3`. Each platform's exact candidate tag is its source record.
There is intentionally no single selected or final candidate for the marketing
version.
The simplification trades away a separate stabilization line. Unrelated commits
that reach `main` become part of every later candidate, and there is no retained
hotfix branch or branch-ancestry history. Add a dedicated hotfix flow later if a
release actually needs isolation from `main`.
`mobile/pubspec.yaml` keeps `0.0.0+1` only as a valid, visibly non-release
fallback for local development and validation builds. Release jobs always
inject both version fields. `mobile/CHANGELOG.md` is retained as historical
release data. It is not a release ledger for this flow.
---
## Release Types
## Version Sources
The argument forms below apply to `release-desktop`, `release-relay`, and
`release-mobile`:
| Lane | Release version authority |
|------|---------------------------|
| Desktop | `desktop/package.json` and synchronized desktop manifests |
| Relay | `crates/buzz-relay/Cargo.toml` |
| Mobile | Exact `mobile-vX.Y.Z-rc.N` remote tag |
| Command | Version | Example |
|---------|---------|---------|
| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` |
| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` |
| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` |
| `just release-desktop 1.0.0` | Explicit | `1.0.0` |
---
## Version Files
`just bump-desktop-version <version>` (desktop lane) updates these files:
| File | Field |
|------|-------|
| `desktop/package.json` | `"version"` |
| `desktop/src-tauri/tauri.conf.json` | `"version"` |
| `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) |
It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`.
`just bump-relay-version <version>` (relay lane) updates
`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the
workspace `Cargo.lock`.
`just bump-mobile-version <version>` (mobile lane) updates
`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and
regenerates `mobile/pubspec.lock`.
`just bump-desktop-version <version>` updates the desktop manifests and
regenerates their lockfiles. `just bump-relay-version <version>` updates the
relay crate and regenerates `Cargo.lock`. Mobile has no bump recipe or
release-metadata PR.
---
@@ -184,42 +148,46 @@ existing immutable `v<version>` tag. Select that tag in the ref picker and
provide the matching semver version without the `v` prefix. It cannot build
from `main` or another caller-selected source ref.
Mobile intentionally has no branch or arbitrary-ref fallback. The private
Buildkite pipeline accepts only an exact candidate tag.
---
## Internal Releases
After the OSS release ships, trigger an internal build via the
[sprout-releases Buildkite pipeline](https://buildkite.com/runway/sprout-releases).
See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the full step-by-step instructions and input field reference.
For mobile, trigger the private
[Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with
an exact RC tag for the platform build being cut. For desktop, use
[Release Desktop](https://buildkite.com/runway/sprout-releases). See the
[buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release)
for the private pipeline contract.
---
## What Gets Published
Each release produces two GitHub releases:
Desktop publishes two GitHub releases:
1. **`v<version>`** — the user-facing release with the `.dmg` installer
(macOS).
1. **`v<version>`**: the user-facing release with installers.
2. **`buzz-desktop-latest`**: the rolling auto-updater release.
2. **`buzz-desktop-latest`** — a rolling pre-release for the Tauri
auto-updater containing `latest.json` and each platform's signed
updater artifact plus its `.sig` signature (`.tar.gz` on macOS,
`.AppImage` on Linux, and `_alpha-unsigned.exe` on Windows).
Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts
and rollout records retain the exact tag they used. Mobile does not publish a
GitHub Release or a stable `mobile-vX.Y.Z` alias.
---
## Platform Support
The release workflow builds **two separate macOS DMGs** — Apple
The release workflow builds **two separate macOS DMGs**: Apple
Silicon (`darwin-aarch64`, the `release` job) and Intel
(`darwin-x86_64`, the `release-macos-x64` job) — plus Linux `.deb` and
(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and
`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to
the same `v<version>` release. Intel users download the `_x64.dmg`.
The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`,
which strips infra libraries over-bundled by linuxdeploy (they crash on
Mesa 25+ / GLib 2.88 distros — see
Mesa 25+ / GLib 2.88 distros; see
[tauri-apps/tauri#15665](https://github.com/tauri-apps/tauri/issues/15665))
and re-signs the artifact. As a result the AppImage relies on the
host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
@@ -231,8 +199,16 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
## Prerequisites
- **Write access** to the `block/buzz` GitHub repository
- **`gh` CLI** authenticated (`gh auth status`)
- The following **GitHub Actions secrets** must be configured:
- 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
- Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754)
active for `mobile-v*`, 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
- The following **GitHub Actions secrets** must also be configured for the
desktop release lane:
| Secret | Purpose |
|--------|---------|
@@ -240,6 +216,14 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72
| `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key |
| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | 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, repository Administration permission, or a
mobile release-branch ruleset. The publisher validates both the App token's
effective `current_user_can_bypass` value and the exact ruleset bypass actor set.
---
## Troubleshooting
@@ -250,15 +234,38 @@ Switch to `main` and pull latest before running the release recipe.
### `just release-desktop` fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
### New commits merged after creating the release PR
Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch.
### New commits land after publishing a mobile candidate
### Build fails at "Validate version"
The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix.
Run `scripts/mobile-release.sh candidate <version>` again after the intended
fix reaches remote `main`. It publishes a new immutable RC tag at the new exact
remote commit. Continue referring to each tested or shipped platform artifact by
its own exact tag.
### `scripts/mobile-release.sh candidate` fails because `main` moved during publication
The App-backed workflow may already have published the requested immutable RC
at the prior `main` tip before the operator command detects the race. Do not
move or delete that tag, and do not treat it as the candidate for current
`main`. Inspect the run URL from the command output, then rerun
`scripts/mobile-release.sh candidate <version>` to publish the next RC from the
new current `main` tip.
### A mobile candidate command selects the wrong RC number
Do not retry by moving or deleting a tag. Inspect the exact remote `mobile-v*`
tags and resolve the unexpected state. Candidate numbers are monotonically
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
candidate tags must remain immutable.
### Auto-updater reports "no update available"
Verify that the `buzz-desktop-latest` release exists and contains a
valid `latest.json`. The manifest covers all four platform keys
(`darwin-aarch64`, `darwin-x86_64`, `linux-x86_64`,
`windows-x86_64`); a missing entry usually means that platform's
release job failed — check the workflow run.
release job failed. Check the workflow run.

View File

@@ -1,7 +1,7 @@
name: buzz
description: Buzz mobile client
publish_to: 'none'
version: 0.4.11+1
version: 0.0.0+1
environment:
sdk: ^3.11.4

169
scripts/mobile-release.sh Executable file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat >&2 <<'USAGE'
usage:
scripts/mobile-release.sh candidate X.Y.Z
candidate Publish the next immutable mobile-vX.Y.Z-rc.N candidate tag at the
exact current commit of block/buzz's remote main branch.
USAGE
exit 2
}
fail() {
echo "Error: $*" >&2
exit 1
}
# shellcheck source=scripts/release-rulesets.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-rulesets.sh"
require_gh_minimum_version() {
local minimum="2.87.0" version
command -v gh >/dev/null 2>&1 || fail "gh >= $minimum is required"
version="$(gh --version 2>/dev/null | awk 'NR == 1 { print $3 }')" || \
fail "could not determine gh version (gh >= $minimum is required)"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || \
fail "gh returned invalid version '$version'"
if ! awk -v current="$version" -v minimum="$minimum" '
BEGIN {
split(current, c, ".")
split(minimum, m, ".")
for (i = 1; i <= 3; i++) {
if ((c[i] + 0) > (m[i] + 0)) exit 0
if ((c[i] + 0) < (m[i] + 0)) exit 1
}
exit 0
}
'; then
fail "gh $version is too old; gh >= $minimum is required"
fi
}
require_clean_semver() {
[[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || \
fail "'$1' is not a mobile release version (expected X.Y.Z)"
}
require_clean_tree() {
git diff --quiet && git diff --cached --quiet && \
[[ -z "$(git status --short --untracked-files=normal)" ]] || \
fail "working tree is dirty; commit or stash changes first"
}
require_annotated_tag() {
local git_dir="$1" object="$2" label="$3"
if [[ "$(git -C "$git_dir" cat-file -t "$object" 2>/dev/null || true)" != "tag" ]]; then
echo "$label must be an annotated tag" >&2
return 1
fi
}
remote_tag_commit_sha() {
local ref="$1" line advertised_oid tmp fetched_oid commit
line="$(git ls-remote --refs origin "$ref")" || return 1
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
advertised_oid="${line%%$'\t'*}"
tmp="$(mktemp -d)"
git -C "$tmp" init -q
git -C "$tmp" remote add origin "$(git remote get-url origin)"
if ! git -C "$tmp" fetch -q --depth 1 origin "$ref"; then
rm -rf "$tmp"
return 1
fi
fetched_oid="$(git -C "$tmp" rev-parse --verify FETCH_HEAD)"
if [[ "$fetched_oid" != "$advertised_oid" ]]; then
rm -rf "$tmp"
fail "$ref moved while it was being resolved"
fi
if ! require_annotated_tag "$tmp" FETCH_HEAD "$ref"; then
rm -rf "$tmp"
return 1
fi
if ! commit="$(git -C "$tmp" rev-parse --verify 'FETCH_HEAD^{commit}')"; then
rm -rf "$tmp"
return 1
fi
rm -rf "$tmp"
printf '%s' "$commit"
}
remote_main_commit_sha() {
local ref="refs/heads/main" line advertised_oid fetched_oid commit
line="$(git ls-remote --refs origin "$ref")" || return 1
[[ -n "$line" && "$line" != *$'\n'* ]] || return 1
advertised_oid="${line%%$'\t'*}"
git fetch -q --no-tags origin "$ref"
fetched_oid="$(git rev-parse --verify FETCH_HEAD)"
[[ "$fetched_oid" == "$advertised_oid" ]] || \
fail "origin/main moved while it was being resolved"
commit="$(git rev-parse --verify 'FETCH_HEAD^{commit}')" || return 1
[[ "$commit" == "$advertised_oid" ]] || \
fail "origin/main did not resolve directly to a commit"
printf '%s' "$commit"
}
command="${1:-}"
case "$command" in
candidate)
[[ "$#" -eq 2 ]] || usage
version="$2"
require_clean_semver "$version"
require_clean_tree
require_canonical_repository || exit 1
require_gh_minimum_version
local_head_sha="$(git rev-parse --verify 'HEAD^{commit}')" || fail "HEAD is not a commit"
main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist"
next=1
if ! remote_tags="$(git ls-remote --refs --tags origin "refs/tags/mobile-v${version}-rc.*")"; then
fail "could not list existing candidates for $version"
fi
while IFS=$'\t' read -r _ ref; do
[[ "$ref" =~ ^refs/tags/mobile-v${version//./\.}-rc\.([1-9][0-9]*)$ ]] || continue
number="${BASH_REMATCH[1]}"
(( number >= next )) && next=$((number + 1))
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 "target_sha=$main_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"
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"
current_main_sha="$(remote_main_commit_sha)" || fail "origin/main does not exist after publication"
[[ "$current_main_sha" == "$main_sha" ]] || \
fail "origin/main moved from requested commit $main_sha to $current_main_sha during publication"
published_sha="$(remote_tag_commit_sha "refs/tags/$tag")" || \
fail "publication completed without exact annotated candidate tag $tag"
[[ "$published_sha" == "$main_sha" ]] || \
fail "$tag resolved to $published_sha instead of requested commit $main_sha"
if [[ "$local_head_sha" != "$main_sha" ]]; then
echo "Note: local HEAD is $local_head_sha; candidate source is current origin/main $main_sha." >&2
fi
printf 'Published %s at origin/main commit %s through buzz-release-bot. Use this exact tag in Release Mobile.\n' \
"$tag" "$main_sha"
;;
*) usage ;;
esac

View File

@@ -0,0 +1,80 @@
#!/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"
[[ "$#" -eq 3 ]] || fail "usage: $0 X.Y.Z N COMMIT_SHA"
version="$1"
candidate_number="$2"
target_sha="$3"
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"
command -v gh >/dev/null 2>&1 || fail "gh is required"
require_release_tag_ruleset || exit 1
main_sha="$(gh api "repos/$repo/git/ref/heads/main" --jq .object.sha)" || \
fail "could not resolve $repo main"
[[ "$main_sha" == "$target_sha" ]] || \
fail "$repo main moved from requested commit $target_sha to $main_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"
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"

59
scripts/release-rulesets.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
readonly RELEASE_TAG_RULESET_ID=14378754
fail_release_ruleset() {
echo "Error: $*" >&2
return 1
}
require_canonical_repository() {
local origin_url
origin_url="$(git config --get remote.origin.url 2>/dev/null)" || \
fail_release_ruleset "origin is required and must point to block/buzz" || return 1
case "$origin_url" in
git@github.com:block/buzz.git|ssh://git@github.com/block/buzz.git|https://github.com/block/buzz.git|https://github.com/block/buzz)
;;
*)
fail_release_ruleset "origin must point to canonical block/buzz, not '$origin_url'" || return 1
;;
esac
}
require_release_tag_ruleset() {
local ruleset_endpoint state can_bypass bypass_actors rule_types includes excludes
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"
state="$(gh api "$ruleset_endpoint" --jq .enforcement)" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID" || return 1
[[ "$state" == "active" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID is '$state'" || 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
[[ "$can_bypass" == "always" ]] || \
fail_release_ruleset "release App cannot always bypass Release tag ruleset $RELEASE_TAG_RULESET_ID (reported '$can_bypass')" || return 1
bypass_actors="$(gh api "$ruleset_endpoint" --jq '[.bypass_actors[] | [.actor_type, (.actor_id | tostring), .bypass_mode] | join(":")] | sort | join(",")')" || \
fail_release_ruleset "could not verify Release tag ruleset $RELEASE_TAG_RULESET_ID bypass actors" || return 1
[[ "$bypass_actors" == "Integration:4349119:always" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected bypass actors: '$bypass_actors'" || 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
[[ "$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
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
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
[[ -z "$excludes" ]] || \
fail_release_ruleset "Release tag ruleset $RELEASE_TAG_RULESET_ID has unexpected exclusions: '$excludes'" || return 1
}

View File

@@ -0,0 +1,173 @@
#!/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/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}" ;;
*'.bypass_actors[]'*) printf '%s\n' "${GH_BYPASS_ACTORS:-Integration:4349119: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/commits/*) printf '%s\n' "$GH_TARGET_SHA" ;;
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_TAG_OBJECT_SHA=2222222222222222222222222222222222222222
"$publisher" 1.2.3 1 "$GH_TARGET_SHA"
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" >/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" >/dev/null 2>&1; then
echo "publisher accepted a ruleset response without an effective bypass" >&2
exit 1
fi
if GH_BYPASS_ACTORS='Integration:4349119:always,Integration:9876543:always' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted an extra ruleset bypass actor" >&2
exit 1
fi
if GH_BYPASS_ACTORS='Integration:9876543:always' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a substituted ruleset bypass actor" >&2
exit 1
fi
if GH_BYPASS_ACTORS='Integration:4349119:pull_request' \
"$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/dev/null 2>&1; then
echo "publisher accepted a ruleset bypass actor with the wrong mode" >&2
exit 1
fi
if GH_TAG_RULESET_STATE=disabled "$publisher" 1.2.3 1 "$GH_TARGET_SHA" >/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" >/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" >/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" >/dev/null 2>&1; then
echo "publisher accepted tag ruleset exclusions" >&2
exit 1
fi
if GH_TARGET_SHA=3333333333333333333333333333333333333333 \
"$publisher" 1.2.3 1 1111111111111111111111111111111111111111 >/dev/null 2>&1; then
echo "publisher accepted a moved main branch" >&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" >/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" >/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" >/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" >/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" >/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" >/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" >/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" >/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" >/dev/null 2>&1; then
echo "publisher accepted the wrong repository" >&2
exit 1
fi
echo "mobile release candidate publisher contract passed"

View File

@@ -0,0 +1,301 @@
#!/usr/bin/env bash
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
remote="$tmp/remote.git"
work="$tmp/work"
operator="$tmp/operator"
bin="$tmp/bin"
canonical_origin="git@github.com:block/buzz.git"
mkdir -p "$bin"
cat > "$bin/gh" <<'GH'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}:${2:-}" in
--version:*) printf 'gh version %s (test)\n' "${GH_VERSION:-2.94.0}" ;;
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
;;
workflow:run)
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=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
version=*) version="${1#version=}" ;;
candidate_number=*) number="${1#candidate_number=}" ;;
target_sha=*) sha="${1#target_sha=}" ;;
esac
shift
done
[[ -n "$version" && -n "$number" && -n "$sha" ]]
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"
export PATH="$bin:$PATH"
export GH_WORKFLOW_CAPTURE="$tmp/workflow-dispatches"
export GH_WORKTREE="$work"
run_release() {
local repo="$1"
shift
(
cd "$repo"
git config "url.file://$remote.insteadOf" "$canonical_origin"
git config protocol.file.allow always
"$script" "$@"
)
}
fail() {
echo "$*" >&2
exit 1
}
assert_no_removed_mobile_release_behavior() {
local status
grep -Eq 'gh[[:space:]]+release|mobile-release/|finalize' "$@" && \
fail "removed branch/finalization/GitHub Release behavior remains"
status="$?"
[[ "$status" -eq 1 ]] || \
fail "could not scan for removed branch/finalization/GitHub Release behavior"
}
git init -q --bare "$remote"
git init -q "$work"
git -C "$work" config user.name test
git -C "$work" config user.email test@example.com
git -C "$work" remote add origin "$canonical_origin"
git -C "$work" config "url.file://$remote.insteadOf" "$canonical_origin"
git -C "$work" config protocol.file.allow always
echo first > "$work/file"
git -C "$work" add file
git -C "$work" commit -qm first
git -C "$work" branch -M main
git --git-dir="$remote" symbolic-ref HEAD refs/heads/main
git -C "$work" push -q -u origin main
# Candidate publication must work from a stale operator clone, warn about the
# stale checkout, and target the exact current remote main commit.
git -c "url.file://$remote.insteadOf=$canonical_origin" \
-c protocol.file.allow=always clone -q "$canonical_origin" "$operator"
git -C "$operator" config user.name test
git -C "$operator" config user.email test@example.com
echo remote-only >> "$work/file"
git -C "$work" commit -qam remote-only
git -C "$work" push -q origin main
remote_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
if git -C "$operator" cat-file -e "$remote_main_sha^{commit}" 2>/dev/null; then
fail "stale-clone fixture already contains the remote-only commit"
fi
run_release "$operator" candidate 1.2.3 > "$tmp/stale-output" 2> "$tmp/stale-error"
grep -Fq "Note: local HEAD is $(git -C "$operator" rev-parse HEAD); candidate source is current origin/main $remote_main_sha." \
"$tmp/stale-error"
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"
# Existing remote identities remain unchanged. Later candidates sequence
# monotonically and target the then-current remote main commit.
rc1_tag_oid="$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)"
echo newer >> "$work/file"
git -C "$work" commit -qam newer
git -C "$work" push -q origin main
new_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
run_release "$operator" candidate 1.2.3
[[ "$(git --git-dir="$remote" rev-parse refs/tags/mobile-v1.2.3-rc.1)" == "$rc1_tag_oid" ]]
[[ "$(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.
git -C "$work" -c tag.gpgSign=false tag -a -m gap mobile-v1.2.3-rc.7 "$new_main_sha"
git -C "$work" -c tag.gpgSign=false tag -a -m malformed mobile-v1.2.3-rc.08 "$new_main_sha"
git -C "$work" -c tag.gpgSign=false tag -a -m other mobile-v1.2.4-rc.99 "$new_main_sha"
git -C "$work" push -q origin \
refs/tags/mobile-v1.2.3-rc.7 refs/tags/mobile-v1.2.3-rc.08 \
refs/tags/mobile-v1.2.4-rc.99
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"
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 ]]
# The publisher and operator both reject a main-tip race. The immutable tag may
# already exist at the prior tip, but the operator must not report it as a
# current-main candidate.
pre_race_main_sha="$(git --git-dir="$remote" rev-parse refs/heads/main)"
if GH_MAIN_MOVE_DURING_WORKFLOW=1 run_release "$operator" candidate 9.9.3 > "$tmp/main-race-output" 2>&1; then
fail "candidate succeeded after main moved during publication"
fi
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 "origin/main moved from requested commit $pre_race_main_sha to $post_race_main_sha during publication" \
"$tmp/main-race-output"
# Publishing through a fork is rejected and unsupported gh versions fail before
# dispatching any candidate publication.
fork_operator="$tmp/fork-operator"
git clone -q "$remote" "$fork_operator"
git -C "$fork_operator" config user.name test
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")"
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" ]]
# Dirty trees and invalid marketing versions fail before publication.
echo dirty > "$operator/untracked"
if run_release "$operator" candidate 2.0.0 >/dev/null 2>&1; then
fail "dirty operator tree was accepted"
fi
rm "$operator/untracked"
if run_release "$operator" candidate 1.2 >/dev/null 2>&1; then
fail "invalid marketing version was accepted"
fi
if run_release "$operator" candidate 01.2.3 >/dev/null 2>&1; then
fail "marketing version with a leading zero was accepted"
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.
if run_release "$operator" start 2.0.0 >/dev/null 2>&1; then
fail "removed start command was accepted"
fi
if run_release "$operator" finalize 1.2.3-rc.2 >/dev/null 2>&1; then
fail "removed finalize command was accepted"
fi
if git --git-dir="$remote" for-each-ref --format='%(refname)' refs/heads/mobile-release/ | grep -q .; then
fail "mobile release branch was created"
fi
if git --git-dir="$remote" show-ref --verify --quiet refs/tags/mobile-v1.2.3; then
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"
# 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"
if (assert_no_removed_mobile_release_behavior "$tmp/forbidden-mobile-release-behavior") \
>/dev/null 2>&1; then
fail "removed-behavior assertion did not reject a forbidden GitHub Release call"
fi
grep -Fq 'version: 0.0.0+1' "$repo_root/mobile/pubspec.yaml"
if grep -qE 'release-mobile|bump-mobile-version|get-current-mobile-version' "$repo_root/Justfile"; then
fail "metadata-only mobile release recipe remains in Justfile"
fi
echo "mobile release contract passed"

View File

@@ -12,7 +12,7 @@ git -C "$tmp" config user.email test@example.com
echo first >"$tmp/file"
git -C "$tmp" add file
git -C "$tmp" commit -qm first
git -C "$tmp" tag v1.2.3
git -C "$tmp" tag -m "desktop release" v1.2.3
(
cd "$tmp"
@@ -37,7 +37,7 @@ if (
exit 1
fi
git -C "$tmp" tag relay-v2.0.0
git -C "$tmp" tag -m "relay release" relay-v2.0.0
(
cd "$tmp"
GITHUB_REF=refs/tags/relay-v2.0.0 "$verify" relay-v 2.0.0