Files
buzz/.github/workflows/_ci-clients.yml
Tom Brow 12f3d51001 feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build,
set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build
trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that
Builderlab's relay assertions only to the relays in
`BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at
all; with none set, the app is a Nostr-only development build. The
decision is recorded in
`docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are
defined in `CONTEXT.md`.

Before this change, one build supported a public Builderlab and an
organization Builderlab at the same time, with two sessions. An
organization's members could reach public communities from the
organization's app.

### Behavior

- **Public build:** QR pairing, email sign-in and multiple communities
are unchanged. It has no knowledge of any organization. An
organization's relay already rejects clients that can't present its
Builderlab's assertion, and the public build relies on that.
- **Internal build:** the first screen has a single **Sign in** button
that goes straight to the organization's single sign-on and then joins
the organization's community. There is no QR pairing, community link or
community creation, and the community switcher is hidden. Only the exact
relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable.
- **Identity (internal):** a member whose account has no Nostr identity
yet gets a new key on first sign-in; the first relay assertion binds it,
and the app backs it up to the organization automatically. A member
whose account is already bound uses that key from this device, or
restores it from the organization's backup. If neither is possible, Sign
in says it couldn't restore the identity and saves nothing.
- **Sign-out (internal):** a destructive **Sign out** row at the bottom
of Settings, the build's only way out. It removes the community, closes
Settings and returns to Sign in. The Builderlab session is cleared from
storage immediately and can never be reused to sign back in. The relay
push-stop keeps the retired credential in memory only, for up to 10
minutes, and uses it only to get an assertion for a key that is already
bound. It is a best-effort request.
- **Upgrade to an internal build:** a saved public Builderlab session is
cleared. Communities on other relays are hidden, not deleted, and their
keys stay on the device.
- `BUZZ_HOSTED_RELAY_AUTH` is unchanged here.

### Screenshots (internal build)

| Sign in | Community menu | Settings |
| --- | --- | --- |
| ![Internal build landing with a single Sign in
button](https://github.com/user-attachments/assets/05f076a2-1552-4095-8322-626754a8765b)
| ![Community menu with Agents, Appearance and Settings, without Switch
community](https://github.com/user-attachments/assets/0c0cf4e0-3fa9-458e-817f-b4665374505b)
| ![Settings ending with a red Sign out
row](https://github.com/user-attachments/assets/f3d60dc1-8170-45e3-a16d-0a1156fdae1a)
|

Gateway-side push revocation, which needs no Builderlab session, is
planned as a separate PR.

### Validation

Added tests:
- An internal-build journey: one Sign in creates and binds a key, and a
retry after a cancelled join reuses it. The app uploads that key's
backup itself. Sign out from Settings closes it and returns to the
landing, which ends the session, and Sign in then restores the same
identity from the backup. Communities on other relays stay stored but
hidden.
- Internal Sign in for a bound account: the key on this device, a key
restored from backup, and no backup (clear error, nothing saved). Each
guard was shown to fail when removed.
- Organization relay assertions: assertions go only to exact listed
hosts, not subdomains. After sign-out, only a push stop for a bound key
gets an assertion, and nothing can sign back in. Proven to fail without
the fix.
- Builderlab build setting parsing.

CI runs the internal and public journeys in their own lanes with those
settings. The full suite passes in the default configuration. The full
suite isn't meant to run under the internal configuration, because most
fixtures use relays that an internal build hides.

Automated full-app run on an iPhone 17 Pro simulator at the current
head:
- Public build, upgraded in place over a signed-in install: home loads,
a channel opens and a message sends.
- Internal build: a fresh install shows only Sign in. With a saved
organization community, the community menu has no Switch community, and
Settings → Sign out → confirm returns to Sign in.

Real single sign-on can't run in the simulator.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-06 21:27:14 -08:00

300 lines
12 KiB
YAML

name: CI / Clients
on:
workflow_call:
inputs:
web:
required: true
type: boolean
mobile:
required: true
type: boolean
lane:
required: true
type: string
outputs:
web_result:
value: ${{ jobs.results.outputs.web_result }}
mobile_result:
value: ${{ jobs.results.outputs.mobile_result }}
env:
CARGO_TERM_COLOR: always
BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright
jobs:
web:
name: Web
runs-on: ubuntu-latest
timeout-minutes: 15
if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.web)
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Get pnpm store directory
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm store cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Web lint and format
run: just web-check
- name: Web build
run: just web-build
- name: Save pnpm store cache
if: github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
# Mobile lanes run in parallel; the required check reads the combined
# matrix result. Test shards split files, so each compiles only its own.
mobile:
name: Mobile (${{ matrix.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
if: inputs.lane == 'required' && (github.event_name == 'push' || inputs.mobile)
strategy:
fail-fast: false
matrix:
include:
- name: Checks
lane: checks
- name: Tests 1/3
lane: test
shard: 0
- name: Tests 2/3
lane: test
shard: 1
- name: Tests 3/3
lane: test
shard: 2
- name: Android
lane: android
permissions:
contents: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 2
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Compute Hermit cache key
id: hermit-bin-hash
run: |
hash="$(find ./bin ! -type d | sort | xargs openssl sha256 | openssl sha256 -r | cut -d' ' -f1)"
echo "hash=$hash" >> "$GITHUB_OUTPUT"
- name: Restore Hermit package cache
id: hermit-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ~/.cache/hermit/pkg
key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }}
restore-keys: ${{ runner.os }}-hermit-cache-
- name: Prime Flutter SDK
run: flutter --version
- name: Save Hermit package cache
if: always() && steps.hermit-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
continue-on-error: true
with:
path: ~/.cache/hermit/pkg
key: ${{ runner.os }}-hermit-cache-${{ steps.hermit-bin-hash.outputs.hash }}
- name: Restore pub cache
id: pub-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
restore-keys: pub-${{ runner.os }}-
- name: Install dependencies
run: cd mobile && flutter pub get
- name: Save pub cache
if: always() && steps.pub-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
continue-on-error: true
with:
path: ~/.pub-cache
key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }}
- name: Format check
if: matrix.lane == 'checks'
run: cd mobile && dart format --output=none --set-exit-if-changed .
- name: Analyze
if: matrix.lane == 'checks'
run: cd mobile && flutter analyze
- name: Test
if: matrix.lane == 'test'
run: /bin/bash scripts/mobile-test-shard.sh ${{ matrix.shard }} 3 --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
- name: Test native secp256k1 package
if: matrix.lane == 'checks'
run: cd mobile/packages/buzz_secp256k1 && dart pub get && dart test
- name: Test internal build
if: matrix.lane == 'checks'
run: >-
cd mobile && flutter test
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
--dart-define=BUZZ_DISTRIBUTION=internal
--dart-define=BUZZ_BUILDERLAB_URL=https://id.example.org
--dart-define=BUZZ_BUILDERLAB_RELAYS=buzz.example.org
test/features/onboarding/internal_build_entry_test.dart
- name: Test public build
if: matrix.lane == 'checks'
run: >-
cd mobile && flutter test
--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
--dart-define=BUZZ_DISTRIBUTION=public
--dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlab.xyz
--dart-define=BUZZ_BUILDERLAB_RELAYS=communities.buzz.xyz
test/shared/auth/community_entry_requirements_test.dart
test/features/agents/agent_creation_link_journey_test.dart
test/features/invites/invite_join_provider_test.dart
test/features/onboarding/onboarding_entry_routes_test.dart
test/shared/hosted/self_hosted_entry_test.dart
test/features/agents/agents_signed_out_prompt_test.dart
test/features/agents/agents_restore_prompt_test.dart
test/shared/hosted/relay_auth_isolation_test.dart
test/features/onboarding/hosted_app_gate_environment_test.dart
test/features/onboarding/hosted_journeys_test.dart
test/features/onboarding/onboarding_sign_out_test.dart
test/features/onboarding/hosted_communities_test.dart
test/features/pairing/pairing_hosted_handoff_test.dart
test/shared/agents/remote_agents_test.dart
test/features/agents
- name: Test mobile without push configuration
if: matrix.lane == 'checks'
run: cd mobile && flutter test test/shared/push/push_unconfigured_build_test.dart
- name: Test mobile recipe argument forwarding
if: matrix.lane == 'checks'
run: /bin/bash scripts/test-mobile-gateway-recipes.sh
# ubuntu-latest (26.04) defaults to JDK 25, which Gradle 8.14 does not
# support. Pin the JDK 17 the 24.04 image used.
- name: Set up JDK 17
if: matrix.lane == 'android'
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: "17"
- name: Restore Gradle cache
if: matrix.lane == 'android'
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/pubspec.lock') }}
restore-keys: gradle-${{ runner.os }}-
# The Hermit package cache above holds only Flutter, so the build's first
# just and cmake calls download them.
- name: Fetch Hermit tools
if: matrix.lane == 'android'
run: |
scripts/ci-hermit-retry.sh ./bin/just --version
scripts/ci-hermit-retry.sh ./bin/cmake --version
# SDK component and plugin downloads intermittently fail on hosted
# runners; retry once only for those, so real build failures still fail.
- name: Build Android debug APK
if: matrix.lane == 'android'
run: |
set -o pipefail
log="$RUNNER_TEMP/android-build.log"
if ! just mobile-build-android 2>&1 | tee "$log"; then
grep -qE 'InstallFailedException|Could not (resolve|GET|download)' "$log"
just mobile-build-android
fi
- name: Test Android JVM units
if: matrix.lane == 'android'
run: cd mobile/android && ./gradlew :app:testDebugUnitTest --console=plain
- name: Save Gradle cache
if: matrix.lane == 'android' && github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
continue-on-error: true
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/pubspec.lock') }}
# The unsigned release build needs nothing from the native lane, so it runs
# beside it instead of after the simulator tests.
mobile-swift:
name: Mobile Swift (${{ matrix.name }})
runs-on: macos-latest
timeout-minutes: 45
if: inputs.lane == 'mobile-swift' && inputs.mobile
strategy:
fail-fast: false
matrix:
include:
- name: Native
lane: native
- name: Release
lane: release
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install Flutter dependencies
run: cd mobile && flutter pub get
# `swift test` builds BuzzPushKit; the Release lane covers its release build.
- name: Test
if: matrix.lane == 'native'
run: swift test --package-path mobile/ios/BuzzPushKit
- name: Test native pagination geometry
if: matrix.lane == 'native'
run: /bin/bash scripts/test-mobile-pagination.sh
- name: Generate iOS build configuration
if: matrix.lane == 'native'
run: cd mobile && flutter build ios --simulator --debug --no-pub --config-only
# Build the complete simulator app and RunnerTests once, in the
# background, while the simulator boots and runs the native-control
# scripts. The build also guards Apple Silicon simulator linking (#7646).
- name: Test native controls and Runner unit tests
if: matrix.lane == 'native'
run: |
device=$(xcrun simctl create "Native tests" "iPhone 16")
trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT
xcode=(xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner
-configuration Debug -destination "platform=iOS Simulator,id=$device"
CODE_SIGNING_ALLOWED=NO)
build_log="$RUNNER_TEMP/runner-build.log"
"${xcode[@]}" build-for-testing > "$build_log" 2>&1 &
build=$!
xcrun simctl boot "$device"
xcrun simctl bootstatus "$device" -b
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
/bin/bash scripts/test-mobile-carousel.sh "$device"
/bin/bash scripts/test-mobile-member-picker.sh "$device"
if ! wait "$build"; then
cat "$build_log"
exit 1
fi
tail -n 5 "$build_log"
"${xcode[@]}" -parallel-testing-enabled NO -only-testing:RunnerTests \
test-without-building
- name: Build complete unsigned iOS release
if: matrix.lane == 'release'
run: |
/bin/bash scripts/test-mobile-gateway-recipes.sh
cd mobile && flutter build ios --release --no-codesign --no-pub
results:
name: Results
if: ${{ always() }}
needs: [web, mobile, mobile-swift]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
outputs:
web_result: ${{ needs.web.result }}
mobile_result: ${{ needs.mobile.result }}
steps:
- run: echo "Captured client job results"