name: CI on: push: branches: [main, release] pull_request: concurrency: group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: CARGO_TERM_COLOR: always PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright jobs: changes: name: Detect Changed Paths runs-on: ubuntu-latest timeout-minutes: 2 permissions: contents: read pull-requests: read outputs: rust: ${{ steps.filter.outputs.rust }} desktop: ${{ steps.filter.outputs.desktop }} desktop-rust: ${{ steps.filter.outputs.desktop-rust }} web: ${{ steps.filter.outputs.web }} mobile: ${{ steps.filter.outputs.mobile }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: filter with: filters: | rust: - 'crates/**' - 'migrations/**' - 'schema/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'deny.toml' - '.github/workflows/ci.yml' - 'scripts/run-tests.sh' - 'justfile' desktop: - 'desktop/**' - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' web: - 'web/**' - 'pnpm-lock.yaml' mobile: - 'mobile/**' rust-lint: name: Rust Lint runs-on: ubuntu-latest timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Format check run: just fmt-check - name: Desktop Tauri format check run: just desktop-tauri-fmt-check - name: Clippy run: just clippy unit-tests: name: Unit Tests runs-on: ubuntu-latest timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Install cargo-nextest uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 with: tool: cargo-nextest@0.9.136 - name: Unit tests run: just test-unit desktop-core: name: Desktop Core runs-on: ubuntu-latest timeout-minutes: 45 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} - name: Install Tauri dependencies (Linux) env: DEBIAN_FRONTEND: noninteractive run: | sudo apt-get update \ -o Acquire::Retries=3 \ -o Acquire::http::Timeout=30 \ -o Acquire::https::Timeout=30 sudo apt-get install -y --no-install-recommends \ -o Acquire::Retries=3 \ -o Acquire::http::Timeout=30 \ -o Acquire::https::Timeout=30 \ -o DPkg::Lock::Timeout=120 \ build-essential \ curl \ file \ libasound2-dev \ libayatana-appindicator3-dev \ libgtk-3-dev \ librsvg2-dev \ libssl-dev \ libwebkit2gtk-4.1-dev \ libxdo-dev \ patchelf \ wget - 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 desktop dependencies run: just desktop-install-ci - name: Desktop lint and format run: just desktop-check - name: Desktop unit tests run: just desktop-test - name: Desktop build run: just desktop-build - name: Desktop Tauri check run: just desktop-tauri-check env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Desktop Tauri tests run: just desktop-tauri-test env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Upload desktop e2e artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-artifacts path: | desktop/playwright-report desktop/test-results if-no-files-found: ignore - 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') }} desktop-smoke-e2e: name: Desktop Smoke E2E (${{ matrix.shard }}) runs-on: ubuntu-latest timeout-minutes: 20 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - 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 desktop dependencies run: just desktop-install-ci - name: Get Playwright version id: pw-version run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - name: Restore Playwright browser cache id: playwright-cache uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Install Playwright Chromium if: steps.playwright-cache.outputs.cache-hit != 'true' run: cd desktop && pnpm exec playwright install chromium - name: Install Playwright system dependencies run: cd desktop && pnpm exec playwright install-deps chromium - name: Save Playwright browser cache if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop build run: just desktop-build - name: Desktop smoke e2e run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 - name: Upload desktop smoke e2e artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} path: | desktop/playwright-report desktop/test-results if-no-files-found: ignore desktop: name: Desktop runs-on: ubuntu-latest timeout-minutes: 5 needs: [changes, desktop-core, desktop-smoke-e2e] if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') permissions: contents: read steps: - name: Check desktop jobs run: | if [ "${{ needs.desktop-core.result }}" != "success" ]; then echo "Desktop Core finished with: ${{ needs.desktop-core.result }}" exit 1 fi if [ "${{ needs.desktop-smoke-e2e.result }}" != "success" ]; then echo "Desktop Smoke E2E shards finished with: ${{ needs.desktop-smoke-e2e.result }}" exit 1 fi echo "Desktop jobs passed" desktop-e2e-relay: name: Desktop E2E Relay runs-on: ubuntu-latest timeout-minutes: 10 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: | . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} - name: Build relay run: cargo build --profile ci -p buzz-relay - name: Upload relay binary uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-relay path: target/ci/buzz-relay if-no-files-found: error retention-days: 1 desktop-e2e-integration-shard: name: Desktop E2E Integration (${{ matrix.shard }}/2) runs-on: ubuntu-latest timeout-minutes: 20 needs: [changes, desktop-e2e-relay] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' strategy: fail-fast: false matrix: shard: [1, 2] permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Start integration services run: | for attempt in 1 2 3; do if docker compose up -d postgres redis minio minio-init; then break fi if [ "$attempt" -eq 3 ]; then echo "docker compose up failed after 3 attempts" >&2 exit 1 fi echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 sleep $((attempt * 5)) done - 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 desktop dependencies run: just desktop-install-ci - name: Get Playwright version id: pw-version run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" - name: Restore Playwright browser cache id: playwright-cache uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Install Playwright Chromium if: steps.playwright-cache.outputs.cache-hit != 'true' run: cd desktop && pnpm exec playwright install chromium - name: Install Playwright system dependencies run: cd desktop && pnpm exec playwright install-deps chromium - name: Save Playwright browser cache if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' && matrix.shard == 1 uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop build run: just desktop-build - name: Wait for integration services run: | wait_healthy() { local service="$1" local container="$2" for attempt in $(seq 1 60); do status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") if [ "${status}" = "healthy" ]; then echo "${service} is healthy" return 0 fi sleep 2 done docker logs "${container}" || true return 1 } wait_healthy "Postgres" "buzz-postgres" wait_healthy "Redis" "buzz-redis" wait_healthy "MinIO" "buzz-minio" - name: Download relay binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: desktop-e2e-relay path: target/ci - name: Apply schema and seed deployment community # MT: the relay resolves each request's tenant from the communities host # map and fails closed on an unmapped host. The channel reconciler binds # the deployment community ONCE at boot (outside its retry loop) and # exits permanently on an unmapped host, so the 'localhost:3000' # community MUST exist before the relay starts — the retry loop only # handles late-seeded channels, not a late-seeded community. The relay # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE # below). lower(host) is the unique index → ON CONFLICT target. psql # isn't on PATH in hermit → exec into the buzz-postgres container. env: PGHOST: localhost PGPORT: "5432" PGUSER: buzz PGPASSWORD: buzz_dev PGDATABASE: buzz # Use the already-running docker postgres for desired-state planning instead of # downloading an embedded Postgres from Maven Central (transient-fetch flake source). PGSCHEMA_PLAN_HOST: localhost PGSCHEMA_PLAN_PORT: "5432" PGSCHEMA_PLAN_DB: buzz PGSCHEMA_PLAN_USER: buzz PGSCHEMA_PLAN_PASSWORD: buzz_dev run: | ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -qtA -c " INSERT INTO communities (id, host) VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & echo $! > /tmp/buzz-relay.pid for attempt in $(seq 1 60); do if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then cat /tmp/buzz-relay.log exit 1 fi status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) if [ "${status_code}" = "200" ]; then exit 0 fi sleep 1 done cat /tmp/buzz-relay.log exit 1 - name: Seed desktop e2e data run: bash scripts/setup-desktop-test-data.sh - name: Desktop relay-backed e2e run: cd desktop && pnpm exec playwright test --project=integration --shard=${{ matrix.shard }}/2 - name: Upload desktop integration artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-integration-artifacts-${{ matrix.shard }} path: | desktop/playwright-report desktop/test-results /tmp/buzz-relay.log if-no-files-found: ignore - 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') }} desktop-e2e-integration: name: Desktop E2E Integration runs-on: ubuntu-latest timeout-minutes: 5 needs: [changes, desktop-e2e-integration-shard] if: always() && (github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true') permissions: contents: read steps: - name: Check integration shards run: | if [ "${{ needs.desktop-e2e-integration-shard.result }}" != "success" ]; then echo "Desktop E2E Integration shards finished with: ${{ needs.desktop-e2e-integration-shard.result }}" exit 1 fi echo "Desktop E2E Integration shards passed" backend-integration: name: Backend Integration (relay e2e) runs-on: ubuntu-latest timeout-minutes: 20 needs: [changes, desktop-e2e-relay] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Start integration services run: | for attempt in 1 2 3; do if docker compose up -d postgres redis minio minio-init; then break fi if [ "$attempt" -eq 3 ]; then echo "docker compose up failed after 3 attempts" >&2 exit 1 fi echo "docker compose up failed (attempt $attempt), retrying in $((attempt * 5))s..." >&2 sleep $((attempt * 5)) done - name: Wait for integration services run: | wait_healthy() { local service="$1" local container="$2" for attempt in $(seq 1 60); do status=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "not_found") if [ "${status}" = "healthy" ]; then echo "${service} is healthy" return 0 fi sleep 2 done docker logs "${container}" || true return 1 } wait_healthy "Postgres" "buzz-postgres" wait_healthy "Redis" "buzz-redis" wait_healthy "MinIO" "buzz-minio" - name: Download relay binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: desktop-e2e-relay path: target/ci - name: Apply schema and seed deployment community # MT: the relay resolves each request's tenant from the communities host # map and fails closed on an unmapped host. The reminder scheduler binds # the deployment community ONCE at boot and exits permanently on an # unmapped host (no retry, unlike the channel reconciler), so the # 'localhost:3000' community MUST exist before the relay starts — seeding # after boot leaves the scheduler dead. The relay migrates at boot via # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec # into the buzz-postgres container. env: PGHOST: localhost PGPORT: "5432" PGUSER: buzz PGPASSWORD: buzz_dev PGDATABASE: buzz # Use the already-running docker postgres for desired-state planning instead of # downloading an embedded Postgres from Maven Central (transient-fetch flake source). PGSCHEMA_PLAN_HOST: localhost PGSCHEMA_PLAN_PORT: "5432" PGSCHEMA_PLAN_DB: buzz PGSCHEMA_PLAN_USER: buzz PGSCHEMA_PLAN_PASSWORD: buzz_dev run: | ./bin/pgschema apply --file schema/schema.sql --auto-approve docker exec -i -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -v ON_ERROR_STOP=1 < scripts/attach-schema-partitions.sql docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ psql -U buzz -d buzz -qtA -c " INSERT INTO communities (id, host) VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_GIT_PROBE_WRITERS=8 \ SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS=1 \ ./target/ci/buzz-relay > /tmp/buzz-relay.log 2>&1 & echo $! > /tmp/buzz-relay.pid for attempt in $(seq 1 60); do if ! kill -0 "$(cat /tmp/buzz-relay.pid)" 2>/dev/null; then cat /tmp/buzz-relay.log exit 1 fi status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) if [ "${status_code}" = "200" ]; then exit 0 fi sleep 1 done cat /tmp/buzz-relay.log exit 1 - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against # a live relay. The schema-drift / migration-version guarantee is owned # by the buzz-db migration.rs unit tests, not this suite. run: cargo test --profile ci -p buzz-test-client --test e2e_event_reminder -- --ignored env: RELAY_URL: ws://localhost:3000 - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: backend-integration-relay-log path: /tmp/buzz-relay.log if-no-files-found: ignore relay-e2e: name: Relay E2E runs-on: ubuntu-latest timeout-minutes: 20 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Start relay run: ./scripts/start-relay-for-tests.sh - name: Relay E2E tests run: cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - name: Upload relay logs if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: relay-e2e-artifacts path: /tmp/buzz-relay.log if-no-files-found: ignore web: name: Web runs-on: ubuntu-latest timeout-minutes: 15 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.web == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - 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: name: Mobile runs-on: ubuntu-latest timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - 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 run: cd mobile && dart format --output=none --set-exit-if-changed . - name: Analyze run: cd mobile && flutter analyze - name: Test run: cd mobile && flutter test security: name: Security runs-on: ubuntu-latest timeout-minutes: 20 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Dependency policy run: cargo-deny check dead-token-guard: name: Dead Token Reference Guard runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Check for dead API token references in client code run: | # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. # Relay crates are excluded — they still use token auth internally. PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_' PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example' EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool' if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then echo "::error::Dead API token references found in client code. See above." exit 1 fi echo "No dead token references found." server-cross-compile: name: Server Cross-Compile runs-on: ubuntu-latest timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: contents: read strategy: fail-fast: false matrix: target: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: key: cross-${{ matrix.target }} save-if: ${{ github.event_name != 'pull_request' }} - name: Install cross uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 with: tool: cross@0.2.5 - name: Build server binaries env: TARGET: ${{ matrix.target }} # PRs: compile + build-script gate only (no codegen/link). Main: full link gate. CARGO_CMD: ${{ github.event_name == 'pull_request' && 'check' || 'build' }} run: | cross "$CARGO_CMD" --release --target "$TARGET" \ -p buzz-relay \ -p buzz-acp \ -p buzz-agent \ -p buzz-dev-mcp \ -p git-credential-nostr \ -p git-sign-nostr windows-rust: name: Windows Rust (x86_64-pc-windows-msvc) runs-on: windows-latest # Windows runners are slow and this compiles the workspace + Tauri crate # cold across four steps; budget generously. timeout-minutes: 45 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true' permissions: contents: read env: TARGET: x86_64-pc-windows-msvc steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows # runner — hermit, used by the Linux jobs, does not provide MSVC. The # toolchain (1.95.0 + clippy via profile = default) comes from the # repo-root rust-toolchain.toml, which the runner's preinstalled rustup # honors on demand; the host triple already is x86_64-pc-windows-msvc. - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: | . desktop/src-tauri key: windows-msvc save-if: ${{ github.event_name != 'pull_request' }} # Tauri validates externalBin at compile time, so the Tauri-crate steps # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's # Windows naming (binaries/-.exe); empty files suffice for a # type-check since nothing executes them. - name: Create sidecar placeholders shell: bash run: | mkdir -p desktop/src-tauri/binaries for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" done - name: Clippy (workspace) run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings - name: Check (workspace) run: cargo check --workspace --all-targets --target $env:TARGET - name: Test (buzz-dev-mcp) # The Windows-only bash resolver lives in buzz-dev-mcp; its unit tests # only gate if this crate is tested ON Windows. run: cargo test -p buzz-dev-mcp --target $env:TARGET # The bundled-bash staging (PortableGit download + SFX extract of the WHOLE # tree, including mingw64/) runs only in release.yml on tag — so without this # step the agent's only Windows transport would ship UNEXERCISED until a # tagged release hits users. Stage the tree and spawn the LAUNCHER bash # (bin/bash.exe, the entry the resolver now uses) to prove all of: the SFX # `-o` POSIX-path extraction works; the launcher spawns and sets up # MSYSTEM/PATH; the lazily-loaded MSYS DLL closure survives; and — the whole # point of bundling the full toolchain — git/jq/curl resolve from the bundled # mingw64/bin and git is functional (a real commit round-trips). The launcher # prepending mingw64/bin to PATH under `-c` is the load-bearing behavior that # only a real Windows host can confirm; this gate is where it gets proven. # (End-to-end nostr-SIGNED commits compose build_git_env's GIT_CONFIG_* with # the git-sign-nostr helper — covered by buzz-dev-mcp unit tests + a bare-host # check, not here, since this job stages only empty sidecar placeholders.) - name: Smoke-test bundled bash + git toolchain staging shell: bash run: | set -euo pipefail stage_dir="$RUNNER_TEMP/git-bash" scripts/stage-windows-bash.sh "$stage_dir" launcher="$stage_dir/bin/bash.exe" [[ -f "$launcher" ]] || { echo "launcher bash missing: $launcher" >&2; exit 1; } # Faithfulness to the agent: shell.rs spawns the launcher with PATH set # wholesale to the shim PATH (shim tempdir + the MCP process's inherited # PATH). We strip PATH down to just the Windows system dir before # spawning — stricter than a bare host (ambient PATH stripped to force # resolution through the launcher). This stops the runner's ambient # git/jq from masking the launcher's own mingw64/bin prepend, so anything # that resolves had to come from the launcher itself. The fidelity gap is # benign: the real shim tempdir only ever holds rg/tree/buzz and the two # nostr helpers, never git/jq/curl, so a richer real PATH cannot shadow # mingw64/bin for these three tools. win_root="${SYSTEMROOT:-${SystemRoot:-C:\\Windows}}" bare_path="$win_root\\System32;$win_root" # Coreutils pipeline through the launcher (the resolver's entry point). out=$(PATH="$bare_path" "$launcher" -c 'echo hello | tr a-z A-Z') [[ "$out" == "HELLO" ]] || { echo "staged bash pipeline failed: got '$out'" >&2; exit 1; } # The full-toolchain payoff: git/jq/curl must resolve from the bundle's # mingw64/bin or usr/bin. Use NON-login `-c` to match the agent's actual # invocation (shell.rs spawns `bash -c`): if the launcher only fixes PATH # under a login shell, `-lc` would pass here while the real agent stays # broken — the exact green-but-broken trap this gate guards. # # Anchor to the ACTUAL staged tree, not a mount-shape: command -v reports # the MSYS mount path (/mingw64/bin/git), so we cygpath -m it back to the # real Windows location and assert it sits under $stage_dir. A bare # `/mingw64/*` shape check would pass for any /mingw64-rooted mount; the # cygpath round-trip proves the tool is literally inside the bundle we # just staged. cygpath ships in the bundle's usr/bin (MSYS2 core, same # tier as the `tr` proven above), so it resolves under the launcher. # # curl is the one tool of the three with a System32 twin # (C:\Windows\System32\curl.exe, on $bare_path) — if a future PortableGit # ever drops curl from mingw64/bin, the launcher would resolve the # System32 twin and this REDs on a non-bug; git/jq have no such twin. stage_m=$("$launcher" -c "cygpath -m '$stage_dir'") for tool in git jq curl; do located=$(PATH="$bare_path" "$launcher" -c "command -v $tool >/dev/null 2>&1 && cygpath -m \"\$(command -v $tool)\"" || true) [[ -n "$located" ]] || { echo "$tool did not resolve in bundled bash" >&2; exit 1; } case "$located" in "$stage_m"/*) ;; *) echo "$tool resolved outside the staged bundle: $located (stage: $stage_m)" >&2; exit 1 ;; esac echo "$tool -> $located" done # git is not just present but functional: a real commit round-trips, # again through non-login `-c` with the bare PATH. Single-quoted on # purpose — this script body runs in the launcher's shell, not ours. # shellcheck disable=SC2016 PATH="$bare_path" "$launcher" -c ' set -euo pipefail repo=$(mktemp -d) cd "$repo" git init -q git -c user.name=ci -c user.email=ci@example.com commit -q --allow-empty -m smoke git log -1 --format=%s | grep -qx smoke ' || { echo "bundled git failed a commit round-trip" >&2; exit 1; } echo "staged launcher bash spawned; git/jq/curl resolve from the bundle; git commit works" - name: Check (Tauri crate) run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Test (Tauri crate) run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" desktop-build-macos: name: Desktop Build (macOS) runs-on: macos-latest timeout-minutes: 45 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} - name: Install desktop dependencies run: just desktop-install-ci - name: Create sidecar placeholders run: | TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" # Mesh rev is derived from Cargo.lock so a dependency bump needs no # lockstep edit here; the cache key tracks it automatically. - name: Resolve mesh-llm rev id: mesh_rev run: | set -euo pipefail REV=$(grep -oE 'mesh-llm\.git\?rev=[0-9a-f]{40}' Cargo.lock | head -1 | grep -oE '[0-9a-f]{40}') [[ -n "$REV" ]] || { echo "::error::could not resolve mesh-llm rev from Cargo.lock"; exit 1; } echo "rev=$REV" >> "$GITHUB_OUTPUT" echo "short=${REV:0:7}" >> "$GITHUB_OUTPUT" - name: Restore mesh llama build cache id: llama_cache uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ github.workspace }}/.cache/mesh-llama key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - name: Build mesh llama native libraries if: steps.llama_cache.outputs.cache-hit != 'true' env: MESH_REV_SHORT: ${{ steps.mesh_rev.outputs.short }} run: | set -euo pipefail cargo fetch --manifest-path desktop/src-tauri/Cargo.toml SHORT="$MESH_REV_SHORT" MESH_ROOT=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" -path "*/$SHORT" -type d -name "$SHORT" | head -1) if [[ -z "$MESH_ROOT" ]]; then echo "::error::mesh-llm checkout for $SHORT not found after cargo fetch" exit 1 fi export LLAMA_STAGE_BACKEND=metal export LLAMA_STAGE_BUILD_DIR="$GITHUB_WORKSPACE/.cache/mesh-llama/build-stage-abi-metal" export CMAKE_OSX_DEPLOYMENT_TARGET=10.15 "$MESH_ROOT/scripts/prepare-llama.sh" pinned "$MESH_ROOT/scripts/build-llama.sh" -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 - name: Save mesh llama build cache if: steps.llama_cache.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: ${{ github.workspace }}/.cache/mesh-llama key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} - name: Build Tauri app run: cd desktop && pnpm tauri build env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" MACOSX_DEPLOYMENT_TARGET: "10.15" CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" LLAMA_STAGE_BACKEND: metal LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal SKIPPY_LLAMA_AUTO_BUILD: "0"