diff --git a/.env.example b/.env.example index bb19eaa8fb..34dc18d061 100644 --- a/.env.example +++ b/.env.example @@ -212,6 +212,13 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # app launch while keeping the current identity and relay data. # VITE_BUZZ_FORCE_FRESH_ONBOARDING=true +# ── Browser desktop-download channel ──────────────────────────────────────── +# Vite bakes this public GitHub owner/repository into the web bundle. Leave it +# unset to use the upstream release channel. Fork deployments should pass it at +# Docker build time instead, e.g. --build-arg +# VITE_BUZZ_RELEASES_REPOSITORY=Cvv9/buzz. +# VITE_BUZZ_RELEASES_REPOSITORY=block/buzz + # ── Subscription & filtering ───────────────────────────────────────────────── # Subscribe mode: "mentions" (default), "all", or "config" (rule-based). # BUZZ_ACP_SUBSCRIBE=mentions diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 9de484dd07..c3bc658015 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -77,6 +77,10 @@ env: # letter, so use its canonical registry spelling unless a variable overrides it. IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/cvv9/buzz' }} PUSH_GATEWAY_IMAGE: ${{ vars.GHCR_PUSH_GATEWAY_IMAGE != '' && vars.GHCR_PUSH_GATEWAY_IMAGE || 'ghcr.io/cvv9/buzz-push-gateway' }} + # Public GitHub release channel embedded in the bundled web app. Forks + # default to their own repository; set the variable only to deliberately + # publish downloads from another trusted release repository. + DESKTOP_RELEASES_REPOSITORY: ${{ vars.BUZZ_DESKTOP_RELEASES_REPOSITORY != '' && vars.BUZZ_DESKTOP_RELEASES_REPOSITORY || github.repository }} jobs: build: @@ -172,6 +176,8 @@ jobs: target: runtime platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VITE_BUZZ_RELEASES_REPOSITORY=${{ env.DESKTOP_RELEASES_REPOSITORY }} # Push by digest, not by tag — the merge job assembles the tags # into one multi-arch manifest. This is what makes the native-arm # matrix possible. @@ -190,6 +196,8 @@ jobs: target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VITE_BUZZ_RELEASES_REPOSITORY=${{ env.DESKTOP_RELEASES_REPOSITORY }} outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: | type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7d5f3fbf40..367cca0838 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,6 @@ jobs: # create the release objects all four platform jobs upload into. setup: name: Setup - if: github.repository == 'block/buzz' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -22,6 +21,9 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} + updater_endpoint: ${{ steps.release_channel.outputs.updater_endpoint }} + release_download_base: ${{ steps.release_channel.outputs.release_download_base }} + release_page_url: ${{ steps.release_channel.outputs.release_page_url }} steps: - name: Determine version id: version @@ -36,6 +38,76 @@ jobs: exit 1 fi + # The updater must stay within the repository that publishes these + # artifacts. Deriving it from the workflow context keeps forks from + # accidentally updating into block/buzz, while resolving to the exact + # same URLs for the upstream repository. + - name: Configure repository-owned release channel + id: release_channel + env: + RELEASE_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + if ! [[ "$RELEASE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::Invalid GitHub release repository: $RELEASE_REPOSITORY" + exit 1 + fi + RELEASE_DOWNLOAD_BASE="https://github.com/${RELEASE_REPOSITORY}/releases/download" + echo "release_download_base=${RELEASE_DOWNLOAD_BASE}" >> "$GITHUB_OUTPUT" + echo "updater_endpoint=${RELEASE_DOWNLOAD_BASE}/buzz-desktop-latest/latest.json" >> "$GITHUB_OUTPUT" + echo "release_page_url=https://github.com/${RELEASE_REPOSITORY}/releases/latest" >> "$GITHUB_OUTPUT" + + # Validate every credential before fan-out. Forks do not inherit secrets, + # and use their own Apple Developer ID/notary path rather than Block's + # signing service, so this avoids consuming four runners before failure. + - name: Verify desktop release credentials + env: + RELEASE_REPOSITORY: ${{ github.repository }} + UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + OSX_CODESIGN_ROLE: ${{ secrets.OSX_CODESIGN_ROLE }} + CODESIGN_S3_BUCKET: ${{ secrets.CODESIGN_S3_BUCKET }} + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ vars.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ vars.APPLE_API_ISSUER_ID }} + run: | + set -euo pipefail + required=( + UPDATER_PUBLIC_KEY + TAURI_SIGNING_PRIVATE_KEY + ) + if [[ "$RELEASE_REPOSITORY" == "block/buzz" ]]; then + required+=( + OSX_CODESIGN_ROLE + CODESIGN_S3_BUCKET + ) + else + required+=( + APPLE_CERTIFICATE_BASE64 + APPLE_CERTIFICATE_PASSWORD + APPLE_SIGNING_IDENTITY + APPLE_TEAM_ID + APPLE_API_KEY_BASE64 + APPLE_API_KEY_ID + APPLE_API_ISSUER_ID + ) + fi + missing=() + for credential in "${required[@]}"; do + if [[ -z "${!credential}" ]]; then + missing+=("$credential") + fi + done + if [[ ${#missing[@]} -gt 0 ]]; then + echo "::error::Desktop release credentials are not configured: ${missing[*]}" + echo "::error::Configure the updater key pair and this repository's macOS signing credentials before releasing from ${GITHUB_REPOSITORY}." + exit 1 + fi + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: fetch-depth: 0 @@ -51,7 +123,6 @@ jobs: release: name: Release - if: github.repository == 'block/buzz' runs-on: macos-latest needs: setup timeout-minutes: 60 @@ -63,6 +134,7 @@ jobs: sig: ${{ steps.read-sig.outputs.sig }} env: VERSION: ${{ needs.setup.outputs.version }} + VITE_BUZZ_RELEASES_URL: ${{ needs.setup.outputs.release_page_url }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -87,7 +159,7 @@ jobs: run: cd desktop && node scripts/build-release-config.mjs env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} - name: Build sidecars run: | @@ -138,7 +210,7 @@ jobs: run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" @@ -171,6 +243,7 @@ jobs: - name: Codesign and Notarize id: codesign + if: github.repository == 'block/buzz' uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 with: osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} @@ -179,13 +252,12 @@ jobs: entitlements-plist-path: ${{ runner.temp }}/entitlements.plist artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-arm64 - - name: Replace DMG and rebuild updater archive + - name: Replace DMG and signed app + if: github.repository == 'block/buzz' env: SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }} UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail BUNDLE_DIR="desktop/src-tauri/target/release/bundle" @@ -201,13 +273,36 @@ jobs: rm -rf "${APP_DIR}/Buzz.app" cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app" - # Rebuild the updater archive from the signed .app and re-sign it with the Tauri updater key. + - name: Sign and notarize with Apple Developer ID + if: github.repository != 'block/buzz' + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ vars.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ vars.APPLE_API_ISSUER_ID }} + run: | + bash desktop/scripts/sign-and-notarize-macos.sh \ + desktop/src-tauri/target/release/bundle/macos/Buzz.app \ + "${{ steps.unsigned.outputs.dmg }}" \ + desktop/src-tauri/Entitlements.plist + + - name: Rebuild updater archive from signed app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + APP_DIR="desktop/src-tauri/target/release/bundle/macos" rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig" (cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app) TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz" (cd desktop && pnpm tauri signer sign "$TARBALL_ABS") - name: Verify code signature + if: github.repository == 'block/buzz' run: | codesign --verify --deep --strict --verbose=2 \ desktop/src-tauri/target/release/bundle/macos/Buzz.app @@ -216,6 +311,17 @@ jobs: desktop/scripts/verify-macos-entitlements.sh \ desktop/src-tauri/target/release/bundle/macos/Buzz.app + - name: Verify Apple Developer ID release + if: github.repository != 'block/buzz' + env: + DMG_PATH: ${{ steps.unsigned.outputs.dmg }} + run: | + set -euo pipefail + APP_DIR="desktop/src-tauri/target/release/bundle/macos/Buzz.app" + codesign --verify --deep --strict --verbose=2 "$APP_DIR" + desktop/scripts/verify-macos-entitlements.sh "$APP_DIR" + xcrun stapler validate "$DMG_PATH" + - name: Locate build artifacts id: artifacts run: | @@ -264,7 +370,6 @@ jobs: release-macos-x64: name: Release macOS (Intel) - if: github.repository == 'block/buzz' runs-on: macos-latest needs: setup timeout-minutes: 60 @@ -277,6 +382,7 @@ jobs: env: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-apple-darwin + VITE_BUZZ_RELEASES_URL: ${{ needs.setup.outputs.release_page_url }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -304,7 +410,7 @@ jobs: run: cd desktop && node scripts/build-release-config.mjs env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} - name: Build sidecars run: | @@ -315,7 +421,7 @@ jobs: run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" @@ -345,6 +451,7 @@ jobs: - name: Codesign and Notarize id: codesign + if: github.repository == 'block/buzz' uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 with: osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} @@ -353,13 +460,12 @@ jobs: entitlements-plist-path: ${{ runner.temp }}/entitlements.plist artifact-name: buzz-${{ github.sha }}-${{ github.run_id }}-x64 - - name: Replace DMG and rebuild updater archive + - name: Replace DMG and signed app + if: github.repository == 'block/buzz' env: SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }} UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos" @@ -374,19 +480,53 @@ jobs: rm -rf "${APP_DIR}/Buzz.app" cp -R "${EXTRACT_DIR}/Buzz.app" "${APP_DIR}/Buzz.app" - # Rebuild the updater archive from the signed .app and re-sign with the Tauri updater key. + - name: Sign and notarize with Apple Developer ID + if: github.repository != 'block/buzz' + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ vars.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ vars.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ vars.APPLE_API_ISSUER_ID }} + run: | + bash desktop/scripts/sign-and-notarize-macos.sh \ + desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app \ + "${{ steps.unsigned.outputs.dmg }}" \ + desktop/src-tauri/Entitlements.plist + + - name: Rebuild updater archive from signed app + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos" rm -f "${APP_DIR}/Buzz.app.tar.gz" "${APP_DIR}/Buzz.app.tar.gz.sig" (cd "$APP_DIR" && tar -czf Buzz.app.tar.gz Buzz.app) TARBALL_ABS="$(pwd)/${APP_DIR}/Buzz.app.tar.gz" (cd desktop && pnpm tauri signer sign "$TARBALL_ABS") - name: Verify code signature + if: github.repository == 'block/buzz' run: | APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app" codesign --verify --deep --strict --verbose=2 "$APP_DIR" spctl --assess --type execute --verbose=4 "$APP_DIR" desktop/scripts/verify-macos-entitlements.sh "$APP_DIR" + - name: Verify Apple Developer ID release + if: github.repository != 'block/buzz' + env: + DMG_PATH: ${{ steps.unsigned.outputs.dmg }} + run: | + set -euo pipefail + APP_DIR="desktop/src-tauri/target/${TARGET}/release/bundle/macos/Buzz.app" + codesign --verify --deep --strict --verbose=2 "$APP_DIR" + desktop/scripts/verify-macos-entitlements.sh "$APP_DIR" + xcrun stapler validate "$DMG_PATH" + - name: Locate updater archive id: artifacts run: | @@ -425,7 +565,6 @@ jobs: release-linux: name: Release Linux - if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 @@ -437,6 +576,7 @@ jobs: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. APPIMAGE_EXTRACT_AND_RUN: "1" + VITE_BUZZ_RELEASES_URL: ${{ needs.setup.outputs.release_page_url }} # This job runs in a container where the default run shell is dash; # the AppImage steps below use bash-only syntax ([[ ]], mapfile, arrays). defaults: @@ -570,13 +710,13 @@ jobs: run: cd desktop && node scripts/build-release-config.mjs env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} - name: Build Linux Tauri app run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -665,6 +805,7 @@ jobs: env: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-pc-windows-msvc + VITE_BUZZ_RELEASES_URL: ${{ needs.setup.outputs.release_page_url }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: @@ -707,7 +848,7 @@ jobs: run: cd desktop && node scripts/build-release-config.mjs env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} - name: Build sidecars shell: bash @@ -720,7 +861,7 @@ jobs: run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} - BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_UPDATER_ENDPOINT: ${{ needs.setup.outputs.updater_endpoint }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" @@ -886,9 +1027,10 @@ jobs: ARCHIVE_X64: ${{ needs.release-macos-x64.outputs.archive_name }} ARCHIVE_LINUX: ${{ needs.release-linux.outputs.archive_name }} ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} + RELEASE_DOWNLOAD_BASE: ${{ needs.setup.outputs.release_download_base }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" + BASE="${RELEASE_DOWNLOAD_BASE}/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -947,6 +1089,18 @@ jobs: if: env.already_published != 'true' run: gh release edit "desktop-v${VERSION}" --draft=false + - name: Create rolling updater release + if: ${{ !contains(needs.setup.outputs.version, '-') }} + run: | + set -euo pipefail + if ! gh release view buzz-desktop-latest >/dev/null 2>&1; then + gh release create buzz-desktop-latest \ + --target "${{ needs.setup.outputs.source_sha }}" \ + --title "Buzz Desktop updater manifest" \ + --notes "Rolling updater manifest for Buzz Desktop releases." \ + --latest=false + fi + - name: Upload latest.json to rolling release last if: ${{ !contains(needs.setup.outputs.version, '-') }} run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/Dockerfile b/Dockerfile index e6fc225f8f..638308d992 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,14 @@ ARG EXTRA_CA_CERTS= # npmjs, so public CI builds are unaffected. Consumed by the web-builder stage. ARG NPM_REGISTRY= +# Public GitHub owner/repository from which the browser invite page resolves +# desktop packages. This is intentionally a build argument: Vite embeds public +# VITE_* settings in the static bundle and runtime container environment cannot +# change them. Empty keeps source builds on the upstream block/buzz channel. +# Example fork deployment: +# docker build --build-arg VITE_BUZZ_RELEASES_REPOSITORY=Cvv9/buzz ... +ARG VITE_BUZZ_RELEASES_REPOSITORY= + # ─── Stage 1: cargo-chef base ─────────────────────────────────────────────── FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef # Trust an optional corporate-proxy CA before any network fetch (no-op if unset). @@ -106,7 +114,10 @@ ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt # corepack reads COREPACK_NPM_REGISTRY to fetch the pinned pnpm; pnpm/npm read # the .npmrc registry for dependency installs. ARG NPM_REGISTRY +ARG VITE_BUZZ_RELEASES_REPOSITORY ENV COREPACK_NPM_REGISTRY=${NPM_REGISTRY} +# The browser bundle only reads VITE_* settings at build time. +ENV VITE_BUZZ_RELEASES_REPOSITORY=${VITE_BUZZ_RELEASES_REPOSITORY} # When using a mirror, disable corepack's npmjs signature check: the mirror # republishes tarballs without the public registry's provenance signatures, so # strict verification fails ("No compatible signature found"). Only relaxed on diff --git a/RELEASING.md b/RELEASING.md index e729f8b50c..ec2a35f9a4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -252,6 +252,33 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key | | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key | +### Fork-owned desktop releases + +`release.yml` derives both the updater manifest endpoint and all versioned +artifact URLs from `github.repository`. For example, a release running in +`Cvv9/buzz` embeds and publishes through `https://github.com/Cvv9/buzz`, while +the `block/buzz` release URLs remain unchanged. The workflow creates the rolling +`buzz-desktop-latest` release on its first stable desktop release. + +Non-`block/buzz` repositories use an Apple Developer ID and App Store Connect +notarization path rather than Block's signing service. Configure these repository +settings before pushing a `desktop-v*` tag; the setup job validates them before +starting any platform build: + +| Name | Purpose | +|------|---------| +| `APPLE_CERTIFICATE_BASE64` | Base64-encoded Developer ID Application `.p12` certificate | +| `APPLE_CERTIFICATE_PASSWORD` | Password for that `.p12` certificate | +| `APPLE_SIGNING_IDENTITY` | Variable: Developer ID Application signing identity name | +| `APPLE_TEAM_ID` | Variable: Apple Developer Team ID expected in the signed bundle | +| `APPLE_API_KEY_BASE64` | Base64-encoded App Store Connect API-key `.p8` file | +| `APPLE_API_KEY_ID` | Variable: App Store Connect API key ID | +| `APPLE_API_ISSUER_ID` | Variable: App Store Connect API key issuer ID | + +The fork still needs `BUZZ_UPDATER_PUBLIC_KEY` (or +`SPROUT_UPDATER_PUBLIC_KEY`) and `TAURI_SIGNING_PRIVATE_KEY`; they must be the +same Tauri updater key pair used to sign every platform's update artifact. + 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 diff --git a/desktop/scripts/sign-and-notarize-macos.sh b/desktop/scripts/sign-and-notarize-macos.sh new file mode 100644 index 0000000000..b86c9fc578 --- /dev/null +++ b/desktop/scripts/sign-and-notarize-macos.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$#" -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +app_bundle="$1" +dmg_path="$2" +entitlements_path="$3" + +for path in "$app_bundle" "$dmg_path" "$entitlements_path"; do + if [[ ! -e "$path" ]]; then + echo "::error::Required signing input does not exist: $path" >&2 + exit 1 + fi +done + +required=( + APPLE_CERTIFICATE_BASE64 + APPLE_CERTIFICATE_PASSWORD + APPLE_SIGNING_IDENTITY + APPLE_TEAM_ID + APPLE_API_KEY_BASE64 + APPLE_API_KEY_ID + APPLE_API_ISSUER_ID +) +missing=() +for name in "${required[@]}"; do + if [[ -z "${!name:-}" ]]; then + missing+=("$name") + fi +done +if [[ ${#missing[@]} -gt 0 ]]; then + echo "::error::Missing Apple signing credentials: ${missing[*]}" >&2 + exit 1 +fi + +temp_root="${RUNNER_TEMP:-/tmp}/buzz-apple-signing-${RANDOM}-${RANDOM}" +keychain_path="${temp_root}.keychain-db" +keychain_password="$(uuidgen)" +certificate_path="${temp_root}.p12" +api_key_path="${temp_root}.p8" +rw_dmg_base="${temp_root}-rw" +rw_dmg_path="${rw_dmg_base}.dmg" +signed_dmg_base="${temp_root}-signed" +signed_dmg_path="${signed_dmg_base}.dmg" +app_notary_zip="${temp_root}-app.zip" +mount_path="$(mktemp -d "${RUNNER_TEMP:-/tmp}/buzz-dmg-mount.XXXXXX")" +mounted=false + +cleanup() { + set +e + if [[ "$mounted" == true ]]; then + hdiutil detach "$mount_path" -quiet + fi + rm -rf "$mount_path" "$keychain_path" "$certificate_path" "$api_key_path" \ + "$rw_dmg_path" "$signed_dmg_path" "$app_notary_zip" +} +trap cleanup EXIT + +submit_for_notarization() { + xcrun notarytool submit "$1" --key "$api_key_path" \ + --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID" --wait +} + +# macOS uses BSD base64, whose decode flag is -D. Keep decoded credential files +# in the runner's temporary directory and remove them via the trap above. +printf '%s' "$APPLE_CERTIFICATE_BASE64" | base64 -D > "$certificate_path" +printf '%s' "$APPLE_API_KEY_BASE64" | base64 -D > "$api_key_path" +chmod 600 "$certificate_path" "$api_key_path" + +security create-keychain -p "$keychain_password" "$keychain_path" +security set-keychain-settings -lut 21600 "$keychain_path" +security unlock-keychain -p "$keychain_password" "$keychain_path" +security import "$certificate_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign -T /usr/bin/security +security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ + -k "$keychain_password" "$keychain_path" + +# Sign the final app bundle with hardened runtime. The app-level entitlements +# must be applied only to the outer bundle; --deep signs nested frameworks and +# sidecars without copying those entitlements into their individual signatures. +codesign --force --deep --options runtime --timestamp --keychain "$keychain_path" \ + --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$entitlements_path" "$app_bundle" +codesign --verify --deep --strict --verbose=2 "$app_bundle" + +actual_team_id="$(codesign -dvvv "$app_bundle" 2>&1 | sed -n 's/^TeamIdentifier=//p' | head -1)" +if [[ "$actual_team_id" != "$APPLE_TEAM_ID" ]]; then + echo "::error::Signing certificate team '$actual_team_id' does not match APPLE_TEAM_ID" >&2 + exit 1 +fi + +# The updater delivers the tarball, not the DMG. Notarize and staple the app +# itself before it is packaged so an offline Gatekeeper assessment succeeds +# after an in-app update as well as after an initial DMG installation. +ditto -c -k --keepParent "$app_bundle" "$app_notary_zip" +submit_for_notarization "$app_notary_zip" +xcrun stapler staple "$app_bundle" +xcrun stapler validate "$app_bundle" + +# Preserve the Finder layout/background generated by Tauri. A compressed DMG is +# read-only, so replace the app in a temporary writable image, then recompress. +hdiutil convert "$dmg_path" -format UDRW -o "$rw_dmg_base" +hdiutil attach "$rw_dmg_path" -readwrite -noverify -noautoopen \ + -mountpoint "$mount_path" >/dev/null +mounted=true +rm -rf "$mount_path/Buzz.app" +ditto "$app_bundle" "$mount_path/Buzz.app" +hdiutil detach "$mount_path" -quiet +mounted=false +hdiutil convert "$rw_dmg_path" -format UDZO -o "$signed_dmg_base" +mv "$signed_dmg_path" "$dmg_path" + +submit_for_notarization "$dmg_path" +xcrun stapler staple "$dmg_path" +xcrun stapler validate "$dmg_path" diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts index 70f2544a8c..a445844dd1 100644 --- a/desktop/src/features/settings/hooks/use-updater.ts +++ b/desktop/src/features/settings/hooks/use-updater.ts @@ -30,7 +30,11 @@ const BACKGROUND_BLOCKED_STATES = new Set([ "manual-required", ]); -const GITHUB_RELEASES_URL = "https://github.com/block/buzz/releases/latest"; +// Release builds inject the repository that owns their signed updater +// artifacts. Local OSS builds retain the canonical manual-download page. +const GITHUB_RELEASES_URL = + import.meta.env.VITE_BUZZ_RELEASES_URL ?? + "https://github.com/block/buzz/releases/latest"; function toErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); diff --git a/docs/varvik-desktop-release.md b/docs/varvik-desktop-release.md new file mode 100644 index 0000000000..4ef7b20547 --- /dev/null +++ b/docs/varvik-desktop-release.md @@ -0,0 +1,148 @@ +# VarVik Desktop Release Channel + +This document describes the independent native-app channel for `Cvv9/buzz`. +It must be configured before distributing the first VarVik Windows or macOS +installer. + +The web application and the desktop application are separate artifacts: + +- A web/relay deployment changes server-backed data immediately: messages, + channels, agent records, and avatars. +- A native UI or Tauri change needs a desktop release. An updater-enabled + desktop install checks at launch and every six hours, downloads a verified + update, then asks the user to install and relaunch it. + +Treat every user-facing Buzz product release as coordinated: deploy the web +build and publish a higher `desktop-v` containing the corresponding +desktop implementation. Server-only data/configuration deployments do not need +an empty native rebuild. + +Do not point VarVik builds at the upstream `block/buzz` updater. It can replace +a customized desktop client with an upstream build. + +## Release channel + +The release workflow derives the endpoint and asset URLs from the repository +that publishes the release. For this fork those URLs are: + +```text +https://github.com/Cvv9/buzz/releases/download/buzz-desktop-latest/latest.json +https://github.com/Cvv9/buzz/releases/download/desktop-v/ +``` + +The website download links, desktop manual-update link, updater endpoint, and +generated `latest.json` asset URLs must always use that same release channel. +The workflow creates the rolling `buzz-desktop-latest` release automatically +when publishing the first stable desktop release. +The local release-candidate script derives its changelog links from `origin`, +so the configured `origin` remote must remain `https://github.com/Cvv9/buzz.git` +when publishing from this fork. + +## Identity and signing + +Complete this setup before the first installer is distributed: + +1. Use a new, permanent Tauri updater key pair for VarVik. Every future update + must be signed by its private key and verified against its embedded public + key. Back up the private key in the organization password vault; rotating it + later does not update already-installed clients. +2. Keep the current bundle identifier for the first VarVik release so the + existing pilot installation and its local data can migrate cleanly. Changing + the identifier later requires an explicit data/keychain migration and a + one-time reinstall. +3. Non-upstream releases use VarVik's Apple Developer ID certificate and App + Store Connect API key. The workflow signs, notarizes, and staples the `.app` + before creating the updater archive, then notarizes and staples the DMG. +4. Authenticode-sign the Windows NSIS installer when distributing beyond the + pilot. The existing workflow labels + it `_alpha-unsigned`; Tauri updater signatures protect update integrity, but + do not prevent Windows SmartScreen / "Unknown publisher" warnings. + +## GitHub configuration + +Configure these names in the `Cvv9/buzz` repository before using the current +workflow. Never put the actual values in this repository. + +| GitHub setting | Name | Purpose | +| --- | --- | --- | +| Secret | `BUZZ_UPDATER_PUBLIC_KEY` | Public half of the permanent Tauri updater key, embedded into release builds. | +| Secret | `TAURI_SIGNING_PRIVATE_KEY` | Private half used to sign updater archives. | +| Secret | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the updater private key. | +| Secret | `APPLE_CERTIFICATE_BASE64` | Base64 Developer ID Application `.p12` certificate. | +| Secret | `APPLE_CERTIFICATE_PASSWORD` | Password for the `.p12` certificate. | +| Variable | `APPLE_SIGNING_IDENTITY` | Full Developer ID Application signing identity. | +| Variable | `APPLE_TEAM_ID` | Apple Developer team ID expected in the signed app. | +| Secret | `APPLE_API_KEY_BASE64` | Base64 App Store Connect API-key `.p8` file. | +| Variable | `APPLE_API_KEY_ID` | App Store Connect API key ID. | +| Variable | `APPLE_API_ISSUER_ID` | App Store Connect issuer ID. | + +The optional release-candidate automation additionally uses +`BUZZ_RELEASE_TAGGER_CLIENT_ID` (variable) and +`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` (secret). They are not needed when an +authorized maintainer creates the immutable `desktop-v*` tag manually. + +Generate the updater key outside the repository, then store the two halves in +the listed GitHub secrets: + +```sh +cd desktop +pnpm tauri signer generate --write-keys /secure/location/varvik-buzz-updater.key +``` + +The command prints the public key and writes the private key. Do not commit the +written key or share it through chat. + +## First release checklist + +1. Add the required signing secrets and variables above. Configure the optional + GitHub App if automatic tagging is retained, and protect `desktop-v*` tags + against modification/deletion. +2. Validate the repository configuration without exposing secret values: + + ```sh + bash scripts/verify-varvik-desktop-release-readiness.sh + ``` + + From PowerShell on Windows, invoke the Git-for-Windows shell explicitly + rather than the WSL `bash` shim: + + ```powershell + & 'C:\Program Files\Git\bin\bash.exe' scripts/verify-varvik-desktop-release-readiness.sh + ``` + +3. Cut `desktop-v0.5.4` or a higher stable version. Publish the versioned + release first, then upload `latest.json` last. Confirm it contains + `darwin-aarch64`, `darwin-x86_64`, and `windows-x86_64` entries that point + to assets in the same VarVik release channel. +4. Install this first updater-enabled installer manually on Windows and each + Mac architecture. Later coordinated desktop releases update in place; + server-only deployments continue to synchronize through the relay. + +## Verification after publishing + +On a clean device, install the matching package: + +- Apple Silicon Mac: `Buzz__aarch64.dmg` +- Intel Mac: `Buzz__x64.dmg` +- Windows: the Authenticode-signed x64 NSIS installer + +Sign in to the same Buzz identity and production community. The client should +show the same server-backed agents, channels, messages, and avatars as the web +app. Native notification permission, Inbox/read-state UI, and local settings +remain per-device. + +For the next release, use the in-app update check. It should discover the +update from the VarVik `latest.json`, download it, and offer **Install and +relaunch** without requiring a new installer. + +## Signing hardening still recommended + +The current Windows job deliberately publishes an `_alpha-unsigned` installer. +That does not break Tauri's updater verification, but it does cause an unknown +publisher / SmartScreen warning. Before distributing beyond the pilot, add an +Authenticode signing step backed by a VarVik certificate (or a hardware-backed +signing provider) and treat its credentials as release secrets. + +The macOS signing path is already wired for a Developer ID Application +certificate and App Store Connect API key. The release workflow stops during +setup with the exact missing names rather than producing an unsigned Mac build. diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index fb586005fb..f5f23e019d 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -9,6 +9,11 @@ mode="${2:-publish}" } remote="${RELEASE_REMOTE:-origin}" +release_repo="${RELEASE_REPOSITORY:-$(git remote get-url "$remote" | sed -E 's|.*github\.com[:/]||; s|\.git$||')}" +[[ "$release_repo" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "release repository must be an owner/repository pair; got '$release_repo'" >&2 + exit 1 +} git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*' base_sha="$(git rev-parse refs/remotes/origin/main)" @@ -22,7 +27,7 @@ fi git checkout -B "$branch" "$base_sha" just bump-desktop-version "$version" -scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz +scripts/desktop_release.py generate "$version" --base "$base_sha" --repo "$release_repo" git add \ .release/desktop-candidate.json \ @@ -44,7 +49,7 @@ Co-authored-by: $agent_name <$agent_email> EOF git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ commit -s -F "$msg" -scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz +scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo "$release_repo" candidate_sha="$(git rev-parse HEAD)" previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')" diff --git a/scripts/verify-varvik-desktop-release-readiness.sh b/scripts/verify-varvik-desktop-release-readiness.sh new file mode 100644 index 0000000000..51970269ba --- /dev/null +++ b/scripts/verify-varvik-desktop-release-readiness.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# Verify GitHub-side prerequisites for the VarVik desktop release channel. +# +# This script only reads repository metadata and secret/variable names. It never +# prints secret values, creates tags/releases, or mutates GitHub configuration. +set -euo pipefail + +repository="${VARVIK_RELEASE_REPOSITORY:-Cvv9/buzz}" +failures=0 + +need_command() { + command -v "$1" >/dev/null 2>&1 || { + echo "missing required command: $1" >&2 + exit 2 + } +} + +require_name() { + local category="$1" name="$2" available="$3" + if ! grep -Fxq "$name" <<<"$available"; then + echo "missing $category: $name" >&2 + failures=1 + fi +} + +need_command gh + +if ! gh auth status >/dev/null 2>&1; then + echo "GitHub CLI is not authenticated" >&2 + exit 2 +fi + +repo_name="$(gh repo view "$repository" --json nameWithOwner --jq .nameWithOwner)" +permission="$(gh repo view "$repository" --json viewerPermission --jq .viewerPermission)" +if [[ "$repo_name" != "$repository" ]]; then + echo "resolved repository '$repo_name' does not match '$repository'" >&2 + exit 2 +fi +if [[ "$permission" != "ADMIN" && "$permission" != "MAINTAIN" ]]; then + echo "release setup needs ADMIN or MAINTAIN permission; current permission: $permission" >&2 + failures=1 +fi + +actions_enabled="$(gh api "repos/$repository/actions/permissions" --jq .enabled)" +if [[ "$actions_enabled" != "true" ]]; then + echo "GitHub Actions is disabled for $repository" >&2 + failures=1 +fi + +if grep -Fq "github.com/block/buzz/releases/download" .github/workflows/release.yml; then + echo "release workflow still publishes updater URLs under block/buzz" >&2 + failures=1 +fi + +secret_names="$(gh secret list --repo "$repository" --json name --jq '.[].name')" +variable_names="$(gh variable list --repo "$repository" --json name --jq '.[].name')" + +# Tauri artifact signing. The public key is embedded in every release build; +# the matching private key must remain stable for the entire update channel. +for name in \ + BUZZ_UPDATER_PUBLIC_KEY \ + TAURI_SIGNING_PRIVATE_KEY \ + TAURI_SIGNING_PRIVATE_KEY_PASSWORD \ + APPLE_CERTIFICATE_BASE64 \ + APPLE_CERTIFICATE_PASSWORD \ + APPLE_API_KEY_BASE64; do + require_name secret "$name" "$secret_names" +done + +# These values identify release infrastructure but are not private key +# material. Keeping them as repository variables makes the release workflow +# portable without embedding VarVik account identifiers in source. +for name in \ + APPLE_SIGNING_IDENTITY \ + APPLE_TEAM_ID \ + APPLE_API_KEY_ID \ + APPLE_API_ISSUER_ID; do + require_name variable "$name" "$variable_names" +done + +if ! gh release view buzz-desktop-latest --repo "$repository" >/dev/null 2>&1; then + echo "missing rolling updater release: buzz-desktop-latest" >&2 + failures=1 +fi + +if (( failures != 0 )); then + echo >&2 + echo "VarVik desktop release channel is not ready. See docs/varvik-desktop-release.md." >&2 + exit 1 +fi + +echo "VarVik desktop release prerequisites are present for $repository." diff --git a/web/package.json b/web/package.json index a074a581dc..129cd68763 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "check:file-sizes": "node ./scripts/check-file-sizes.mjs", "check:pubkey-truncation": "node ./scripts/check-pubkey-truncation.mjs", "lint": "biome lint .", - "check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation", + "test:unit": "node --experimental-strip-types --test tests/buzz-download.test.ts", + "check": "biome check . && pnpm check:file-sizes && pnpm check:pubkey-truncation && pnpm test:unit", "format": "biome format --write .", "preview": "vite preview", "test:e2e": "pnpm build && playwright test", diff --git a/web/src/shared/lib/buzz-download.ts b/web/src/shared/lib/buzz-download.ts index 3c198382b4..c32d25b8ae 100644 --- a/web/src/shared/lib/buzz-download.ts +++ b/web/src/shared/lib/buzz-download.ts @@ -1,7 +1,47 @@ -export const BUZZ_RELEASES_URL = "https://github.com/block/buzz/releases"; -const BUZZ_RELEASES_API_URL = - "https://api.github.com/repos/block/buzz/releases?per_page=10"; -const CACHE_KEY = "buzz.latestDownload.v1"; +/** Default public release channel for source builds that do not configure one. */ +export const DEFAULT_BUZZ_RELEASES_REPOSITORY = "block/buzz"; + +type BuzzReleaseSource = { + repository: string; + releasesUrl: string; + releasesApiUrl: string; +}; + +/** + * Resolve the GitHub release channel baked into the web bundle. + * + * `VITE_BUZZ_RELEASES_REPOSITORY` is a build-time setting, intentionally + * limited to a GitHub owner/repository pair so a malformed deployment setting + * cannot turn the invite page into an arbitrary external redirect. + */ +export function resolveBuzzReleaseSource( + configuredRepository: string | undefined, +): BuzzReleaseSource { + const repository = configuredRepository?.trim(); + const isValidGitHubRepository = + repository !== undefined && + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9](?:[A-Za-z0-9._-]{0,99})$/.test( + repository, + ); + const resolvedRepository = isValidGitHubRepository + ? repository + : DEFAULT_BUZZ_RELEASES_REPOSITORY; + + return { + repository: resolvedRepository, + releasesUrl: `https://github.com/${resolvedRepository}/releases`, + releasesApiUrl: `https://api.github.com/repos/${resolvedRepository}/releases?per_page=10`, + }; +} + +const releaseSource = resolveBuzzReleaseSource( + import.meta.env?.VITE_BUZZ_RELEASES_REPOSITORY, +); + +export const BUZZ_RELEASES_REPOSITORY = releaseSource.repository; +export const BUZZ_RELEASES_URL = releaseSource.releasesUrl; +const BUZZ_RELEASES_API_URL = releaseSource.releasesApiUrl; +const CACHE_KEY = "buzz.latestDownload.v2"; const CACHE_TTL_MS = 60 * 60 * 1000; export type BuzzDownloadPlatform = { @@ -145,12 +185,14 @@ export async function resolveBuzzDownloadUrlForPlatform( try { const cached = JSON.parse(sessionStorage.getItem(CACHE_KEY) ?? "null") as { expiresAt: number; + repository: string; platform: BuzzDownloadPlatform; url: string; } | null; if ( cached && cached.expiresAt > Date.now() && + cached.repository === BUZZ_RELEASES_REPOSITORY && cached.platform.operatingSystem === platform.operatingSystem && cached.platform.architecture === platform.architecture ) { @@ -175,6 +217,7 @@ export async function resolveBuzzDownloadUrlForPlatform( CACHE_KEY, JSON.stringify({ expiresAt: Date.now() + CACHE_TTL_MS, + repository: BUZZ_RELEASES_REPOSITORY, platform, url, }), diff --git a/web/tests/buzz-download.test.ts b/web/tests/buzz-download.test.ts new file mode 100644 index 0000000000..720d44a05d --- /dev/null +++ b/web/tests/buzz-download.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_BUZZ_RELEASES_REPOSITORY, + resolveBuzzReleaseSource, +} from "../src/shared/lib/buzz-download.ts"; + +test("uses the upstream release channel when no repository is configured", () => { + assert.deepEqual(resolveBuzzReleaseSource(undefined), { + repository: DEFAULT_BUZZ_RELEASES_REPOSITORY, + releasesUrl: "https://github.com/block/buzz/releases", + releasesApiUrl: + "https://api.github.com/repos/block/buzz/releases?per_page=10", + }); +}); + +test("uses a configured fork release channel", () => { + assert.deepEqual(resolveBuzzReleaseSource("Cvv9/buzz"), { + repository: "Cvv9/buzz", + releasesUrl: "https://github.com/Cvv9/buzz/releases", + releasesApiUrl: + "https://api.github.com/repos/Cvv9/buzz/releases?per_page=10", + }); +}); + +test("rejects malformed release repositories", () => { + for (const invalidRepository of [ + "", + "block", + "https://example.invalid/releases", + "block/buzz/releases", + "block/buzz?redirect=https://example.invalid", + ]) { + assert.equal( + resolveBuzzReleaseSource(invalidRepository).repository, + DEFAULT_BUZZ_RELEASES_REPOSITORY, + ); + } +});