Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters, and desktop token minting into compliance with [NIP-FI §kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md). ## What changed **`buzz-media/src/auth.rs`** - Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict` applies full NIP-FI rules (active modes); `Permissive` preserves pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15]. - Rewrite `verify_blossom_auth_event_for_verb` with count-based cardinality: exactly one `t`/`expiration`/`server` in `Strict`; duplicate `x` also rejected. `Permissive` keeps main's boolean semantics exactly: any valued `t` other than the verb (including `""`) rejects, valueless `t` is ignored. - `Strict`: mandatory `server` tag on all proofs (upload + read); absent or mismatched → `evidence_rejected` (`ServerMismatch`). - `Strict`: 60-second proof window (`now - created_at <= 60s`, `expiration <= created_at + 60s`). `Permissive`: 3600s / optional server. - **[F1]** Permissive server validation uses any-match semantics: a proof with multiple `server` tags is accepted if any tag matches the bound host. This restores origin/main base behavior. Strict duplicate-rejection unchanged. - **[F1 compat]** Permissive server gate uses `server_values.is_empty()` (valued tags only) rather than `server_count > 0` (includes valueless tags). A lone valueless `["server"]` tag is treated as absent in Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15]. - **[F4]** Valueless `x` tags in Strict get reads are tracked before `filter_map` so they cannot become absent scope (host-wide authorization). Any `x` tag present in Strict must contain the exact requested sha256. - **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they call `verify_blossom_upload_auth` and `verify_upload_hash_only` directly. They do not enter the upload pipeline (`process_upload`/`process_video_upload`). Retained as helper evidence. **`buzz-media/src/error.rs`** - Add `DuplicateTag(&'static str)` variant. - Add `blossom_denial_kind()` method: classifies each auth error as `MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`. Non-auth errors return `None`. - `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15]. **`buzz-relay/src/api/media.rs`** - Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy JSON in `Permissive`. - `blossom_strictness_from_state()` selects `Strict` when `state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on upload and read, and through the post-admission X-SHA-256, hash-binding, and membership denials. - `extract_blossom_auth` keeps first-value `Authorization` extraction; Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality gate, and Off keeps legacy behavior [FI-INV-15]. - Both membership-denial call sites use `media_denial(RelayMembershipRequired, strictness)` so Strict mode produces the correct fixed-text response. - `upload_blob` serving-write fence conversion (`:406`) uses `map_err(|e| media_denial(e, strictness))` — a community fence committed between auth admission and lease acquisition no longer resets Strict to Permissive. - Mode-driven regressions in `mod postgres_tests` (selected by the nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore = "requires Postgres"]`), run through the full relay router with a real Enforce verifier and assertion: Enforce rejects Permissive-only proofs (300s lifetime, missing `server` tag) on both upload routes and reads with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI responses; DenyProtected answers 503 before any proof check; membership denials are `authorization denied` text/plain in Enforce and exact legacy JSON 403 in Off, with a member positive control. GET and HEAD are each discriminated (HEAD with a suppressed body). Both upload routes pin the handler's `X-SHA-256` missing/malformed/unsigned and post-body hash-mismatch denials in both modes. Off rejects an empty-valued `t` beside a valid one with legacy 401 JSON in either order, and ignores a valueless one. Exact status, Content-Type, challenge, and body bytes are pinned. **`buzz-media/src/upload.rs`** (`mod minio_tests`) - **[F3 pipeline]** Four tests exercising the actual post-body call sites (`process_upload` `:85`, `process_video_upload` `:413`) with real MinIO I/O: buffered/streaming × expired-proof-accept + mismatched-hash-reject. The positive cases use `fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`, lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s sleep so the proof is expired when the upload completes. `nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio time does not advance it. The negative cases use a pre-expired proof with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`; no CI lane runs them automatically. **`buzz-media/src/validation.rs`** - `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a 618-byte pre-computed H.264/avc1 fast-start MP4 available to all `buzz-media` test modules; smoke-tested by `test_minimal_valid_mp4_passes_validate_video_file`. **`desktop/src-tauri/src/commands/media.rs`** - `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s (was 3600s/300s). - `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). **`desktop/src-tauri/src/media_proxy.rs`** - Single re-mint+retry on 401/403 for range requests; non-auth failures do not retry. **`crates/buzz-cli/src/client.rs`** - Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag mandatory on upload. **`crates/buzz-dev-mcp/src/view_image.rs`** - `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated. **`mobile/lib/shared/relay/media_auth.dart`** - `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated. **`mobile/lib/shared/relay/media_upload.dart`** - `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory. **`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`** - **[F2]** Remove Android-specific `VideoPlayerController.networkUrl` streaming path. `video_player_android` 2.9.5 freezes headers into static `DefaultHttpDataSource` request properties — a 60s proof minted at controller creation time becomes stale on seeks. All platforms use the authenticated local-file download path. - **[F2 transport]** `unawaited(request.sink.close())` before `client.send()`. `AbortableStreamedRequest` carries no request body; without closing the sink, `IOClient.send()` awaits `stream.pipe(ioRequest)` which blocks until the sink ends — every video download hung in loading indefinitely on every platform. - **[F2 pending-ctrl]** `pendingController` ref set before the first `await (initialize())`, cleared in all exit paths. Effect cleanup disposes `pendingController.value` so a close-during-init releases the native player even if the initialized event never arrives. - **[F2r(a)]** On native init failure (post-create, pre-initialized-event), the inner catch starts disposal detached via `unawaited(localController.dispose().catchError(...))` — the `.catchError` handler absorbs any disposal exception so it does not reach the test error zone — then rethrows the original load error. `video_player` 2.11.1 completes the init future with an error without disposing the native player. - **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()` (subscribe+cancel) instead of `drain()`. `drain()` waits for the upstream to close; a stalled server body blocked `initializeVideo` indefinitely. - **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in the catch — not `await`. Two failure mechanisms exist: (1) when `createWithOptions()` throws, `_creatingCompleter` is never completed and `await dispose()` deadlocks at `:682-683`, blocking the outer catch from setting error state. (2) when `createWithOptions()` succeeds but initialization fails, `dispose()` runs but its thrown exception becomes an unhandled async error reaching the test zone. Both are fixed: `unawaited` lets the outer catch run immediately and set `error.value`; `.catchError(...)` on the detached disposal future absorbs any disposal exception. **`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`** - `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test without the sink fix times out because the drain never completes, validating the transport contract. - F2r(a): `forceInitError=true` fake emits `PlatformException` on subscribe; `disposeCallCount >= 1` verifies disposal even on init failure. - F2r(b): `stalledBody` `StreamController` with `onCancel` Completer (bounded completion signal, not a sleep). Asserts: (1) `bodyStreamCancelled=true` while body stream is still open (impossible with `drain()`); (2) error UI visible while body remains open. Restoring `drain()` breaks both. - F2r(c): `neverInitialize=true` fake never sends initialized event; unmount asserts `disposeCallCount >= 1` via `pendingController`. Distinct from the event-error case. - F2r(d): `forceCreateError=true` fake throws `PlatformException` from `createWithOptions()` itself (before any player ID is returned). Asserts error UI visible within a bounded deadline. With the old `await dispose()` the outer catch is blocked — `error.value` is never set and the error UI assertion fails at the deadline; with `unawaited(dispose())` the error text appears immediately. **`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`** (separate file) - Real-IO loopback probes using `flutter_test` (not `package:test`) to avoid `depend_on_referenced_packages` lint. No `TestWidgetsFlutterBinding` — no HttpClient override interfering with `dart:io`. - Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request body before replying. Without `sink.close()`, `req.drain()` hangs and the test times out. - Probe 2: server signals request arrival via a Completer (not a sleep); abort fires after confirmed arrival; `client.send()` must throw `RequestAbortedException` (typed assertion) within a bounded 5s deadline. **`scripts/test-video-upload.sh`** - Proof minted with `+60` expiry and mandatory `server` tag. **`buzz-test-client/tests/e2e_media*.rs`** + **`buzz-relay/src/api/media.rs` test fixtures** - All fixtures updated to 55s expiry + `server` tag. **`RELEASING.md`** - New "NIP-FI upload proof freshness (relay floor)" section documenting that 60s proofs require the first relay release carrying this PR's post-body hash-only check (the first tag > `v0.2.1` — update once cut) for reliable large-file uploads. Operators on older self-hosted relays (≤ `v0.2.1`) must upgrade before receiving these clients. ## Finding 3: 60s upload proofs vs old-relay compatibility Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full expiry verification after the body is transferred. A valid upload whose body transfer takes >60s will fail on an old relay. **Resolution (relay floor prerequisite):** `RELEASING.md` requires that verified supported deployments (relay releases confirmed to carry the post-body hash-only check, commit `75e9bef748`) be in place before distributing 60s-proof client builds. Unknown or unverified self-hosted relay deployments are excluded from distribution until they upgrade. This makes the verified-deployment status a prerequisite for release distribution, not a post-distribution advisory. The concrete tag (`> v0.2.1`) is to be recorded once cut. ## Sequencing note Builds on #7264 (merged): Blossom strictness is derived from its `config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce` activates the strict verifier with no further wiring. ## GIF compatibility exception An unauthenticated Off-mode request on a configured tenant with no GIF provider changes from 404 to 401 (base `gifs.rs:269-275` checked an unconfigured provider first; the current code authenticates first). All other Off-mode Blossom responses are byte-identical to pre-NIP-FI behavior. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
18 KiB
Releasing Buzz
Buzz has three independent release lanes. Desktop and relay use release PRs.
Mobile uses immutable release-candidate tags cut directly from remote main:
| Lane | Entry point | Artifact |
|---|---|---|
| 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 |
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
Prepare desktop releases locally from an up-to-date, clean main checkout:
just release-desktop 0.5.3
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.
# Relay release
just release-relay
just release-relay 0.4.0
# Publish the next mobile candidate from the exact current remote main commit
scripts/mobile-release.sh candidate 0.5.0
Desktop uses an immutable generated candidate PR; relay continues using its
metadata PR. 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
Desktop
- Run
just release-desktop <version>from a clean, up-to-datemaincheckout. The script creates one deterministic candidate commit and records both its frozen base and the verified prior release ledger in candidate metadata. - Review the exact candidate SHA, complete changelog, and CI. Regenerating or pushing the branch creates a new candidate and requires checks to run again.
- Squash merge the PR after all protected-branch checks pass. The merge is
the human authorization event; an authorized owner/admin bypass is treated
the same way. Unrelated changes reaching
maindo not invalidate the reviewed candidate. auto-tag-on-release-pr-mergeverifies the closed event against GitHub's PR identity, validates candidate content, and proves every required check came from its trusted producer and was successful when the PR merged. It createsdesktop-v<version>at the exact reviewed PR head—not the squash commit. Retries accept that tag only at the same SHA and never move it. GitHub does not expose when an individual check rerun was created, so an ordinary rerun after merge deliberately makes tag verification fail closed; inspect that run and create a new candidate version rather than retrying the blocked tag.- The tag triggers
release.yml. It builds and stages all platform artifacts, publishes the versioned release only after the complete set succeeds, then updates the rolling updater manifest last for stable versions.
Because squash merging leaves immutable candidate tags on side history, the next release uses validated prior candidate metadata as its ledger boundary. It includes unrelated commits after the prior frozen base and excludes exactly the prior release's recorded squash commit; tag ancestry is deliberately irrelevant.
Relay
just release-relayruns locally onmain, creates or updates arelay-release/<version>PR, bumpscrates/buzz-relay/Cargo.toml, regeneratesCargo.lock, and updates the relay changelog.- Merge the PR.
auto-tag-on-release-pr-mergepushesrelay-v<version>. - The tag triggers
docker.yml. Stable releases update the version aliases andlatest; prereleases do not. Each release also publishes an optimized, symbol-bearing image under matchingdebug-tags (for example,debug-0.3.0anddebug-latest) for native profiling. The ordinary tags remain stripped and are the default for deployments that do not need it.
Every push to main continues to publish the rolling relay :main and
:sha-<7> tags, plus matching :debug-main and :debug-sha-<7> variants.
NIP-FI upload proof freshness (relay floor)
Desktop, CLI, and mobile clients mint Blossom upload authorization proofs with
a 60-second expiry (expiration = now + 60). Older relays (≤ v0.2.1)
re-run the full auth verifier — including the expiry check — after receiving the
full upload body, so a 60-second proof that was valid when admitted can expire
during a large or slow transfer, causing a spurious rejection.
The repair (commit 75e9bef748d2149ce459b14da842e706a51a5f78, landed in this
PR) replaces the post-body full-verifier call with a hash-only check
(verify_upload_hash_only): the relay verifies the freshness and all other
proof fields before reading the body; the only check after transfer is that the
body's SHA-256 matches what was declared in the signed proof.
Clients cannot lengthen proofs beyond 60 seconds (NIP-FI §Strict limits the max proof window to 60 s). This is a relay-side concern:
- The relay release that includes this PR's changes (the first relay
tag >
v0.2.1, containing commit75e9bef748d2149ce459b14da842e706a51a5f78) performs a hash-only post-body check, eliminating the expiry window during transfer. Update this line with the concrete relay tag once it is cut. - Older self-hosted relays (≤
v0.2.1) re-verify the full proof expiry after transfer and may reject large uploads over slow connections. Upgrading the relay is the fix; no client workaround exists within Strict constraints.
Prerequisite before distributing desktop, CLI, or mobile builds that mint
60-second upload proofs: verify that all supported relay environments have
been upgraded to a relay release containing commit
75e9bef748d2149ce459b14da842e706a51a5f78. An unknown or unverified deployment
does not satisfy this prerequisite. Deployments running ≤ v0.2.1 must be
upgraded before receiving these clients.
When releasing a new relay version that carries the post-body hash-only check, record the concrete tag here so release operators can verify the prerequisite. When releasing desktop, CLI, or mobile for environments where self-hosted relays may be running pre-floor versions, document the floor in release notes and require operators to upgrade before distributing these clients.
Mobile
- Publish a candidate. From a clean checkout whose
originis the canonicalblock/buzzrepository, runscripts/mobile-release.sh candidate X.Y.Z. The script resolves and fetches the exact currentorigin/maincommit, derives the next number from exact remote tags for that marketing version, and publishes an annotatedmobile-vX.Y.Z-rc.Ntag there through the dedicatedbuzz-release-botGitHub App. It never uses the operator's checked-out commit and never moves an existing candidate. - Build the exact tag. Enter the candidate tag as
mobile_refin 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 versionX.Y.Z; Buildkite's monotonically increasing build number supplies the platform build number. - 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.
Version Sources
| 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 |
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.
Signed macOS Canary
Use the manual Signed macOS Canary workflow when you need an Apple Silicon
build of current main for explicit testing without publishing a release:
gh workflow run signed-macos-canary.yml --repo block/buzz --ref main
The workflow derives a -test.<run-number> version, signs and notarizes the
DMG, verifies it with Gatekeeper, and uploads it as a short-lived Actions
artifact with seven-day retention. Because this is a public repository, any
signed-in GitHub user can download that artifact while it exists; it is
unpublished, not private. The workflow has no release permissions, does not
create or move tags, and cannot update buzz-desktop-latest or latest.json.
Download the artifact from the completed run:
gh run download <run-id> --repo block/buzz --name <artifact-name>
The workflow intentionally accepts only main. Use the normal release process
for distributable builds or builds from an immutable release tag.
Release Retry
release.yml has no manual dispatch and cannot build from main or another
caller-selected ref. If a run for an existing immutable
desktop-v<version> tag fails, rerun that failed workflow from GitHub Actions
(or use gh run rerun <run-id> --failed --repo block/buzz). A rerun
repairs the versioned draft if publication did not complete. It does not
promote that version to the auto-updater; promotion is a separate manual
action. Do not recreate, move, or push the immutable tag again.
Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag.
Internal Releases
For mobile, trigger the private
Release Mobile pipeline with
an exact RC tag for the platform build being cut. For desktop, start
Release Desktop and enter the
exact public source tag as desktop_ref=desktop-v<version>; a generic
v<version> tag is intentionally rejected. See the
buzz-releases README
for the rest of the private pipeline contract.
What Gets Published
Desktop publishes two GitHub releases:
desktop-v<version>: the user-facing release with installers and the exactupdater-manifest.jsonpromotion candidate. Publishing this release does not expose it through in-app auto-update.buzz-desktop-latest: the rolling auto-updater release. Itslatest.jsonchanges only through the manual promotion workflow.
Promote an OSS desktop release to auto-update
After installing and testing the published desktop-v<version> artifacts, run
Promote OSS Desktop Auto-Update from the main branch and enter the exact
stable X.Y.Z version. The workflow validates the immutable tag and release,
the retained manifest and every referenced updater asset, and requires the
version to be newer than the currently promoted version before replacing
buzz-desktop-latest/latest.json. Same-version retries succeed only when the
manifest is identical; downgrades are rejected.
Withholding promotion leaves existing clients on the previous version. If a promoted release is bad, ship and promote a higher patch version; changing the manifest to an older version does not downgrade clients that already updated.
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
Silicon (darwin-aarch64, the release job) and Intel
(darwin-x86_64, the release-macos-x64 job), an unsigned Windows x64
NSIS installer (its filename includes _alpha-unsigned), and Linux .deb and
.AppImage packages. Both macOS DMGs are codesigned, notarized, and attached
to the same desktop-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
tauri-apps/tauri#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
(Ubuntu 22.04 or newer). The release-linux job builds inside a
ubuntu:22.04 container for broad GLIBC compatibility.
Prerequisites
- Write access to the
block/buzzGitHub repository - An
originremote whose configured URL is the canonicalblock/buzzrepository ghCLI authenticated with permission to push the candidate branch and open its pull request- The Default
mainruleset configured for squash-only merging, strict required checks, stale-review dismissal, and the Desktop Release Candidate check - Release tag ruleset
14378754active fordesktop-v*andmobile-v*, with creation, update, deletion, and non-fast-forward protections andbuzz-release-botas its sole always-bypass actor - The
buzz-release-botApp credentials configured for GitHub Actions - The following GitHub Actions variables and secrets configured for the
desktop release lane:
Name Kind Purpose BUZZ_RELEASE_TAGGER_CLIENT_IDVariable GitHub App client ID used to create protected release tags BUZZ_RELEASE_TAGGER_PRIVATE_KEYSecret GitHub App private key OSX_CODESIGN_ROLESecret macOS signing role used by block/apple-codesign-actionCODESIGN_S3_BUCKETSecret macOS signing exchange bucket BUZZ_UPDATER_PUBLIC_KEYorSPROUT_UPDATER_PUBLIC_KEYSecret Tauri updater public key TAURI_SIGNING_PRIVATE_KEYSecret Tauri updater private key TAURI_SIGNING_PRIVATE_KEY_PASSWORDSecret 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 the App token's effective
current_user_can_bypass value rather than reading the ruleset's hidden bypass
actor list.
Troubleshooting
The desktop candidate is stale or cannot be squash merged
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 trusted approval on the new exact
head. 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.
Local just release-desktop fails with "working tree is dirty"
Commit or stash your changes before running the release recipe.
New commits land after publishing a mobile candidate
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.