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 BUZZ_TEST_POSTGRES_PASSWORD: buzz_dev 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.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: token: '' filters: | rust: - 'crates/**' - 'migrations/**' - 'schema/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - 'deny.toml' - '.github/workflows/ci.yml' - 'scripts/run-tests.sh' - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - 'justfile' desktop: - 'scripts/model-capabilities.json' - 'scripts/normative-corpus.json' - 'desktop/**' - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' web: - 'web/**' - 'pnpm-lock.yaml' mobile: - 'mobile/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' - 'scripts/mobile-worktree-clean.sh' - 'scripts/publish-mobile-release-candidate.sh' - 'scripts/release-rulesets.sh' - 'scripts/test-mobile-release-contract.sh' - 'scripts/test-mobile-release-candidate-publisher.sh' - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh - name: Relay image eligibility contract run: scripts/test-relay-image-eligibility-workflow.sh - name: Desktop release candidate contract run: scripts/test-desktop-release-candidate.sh - name: OSS desktop promotion contract run: | scripts/test-oss-desktop-promotion.sh scripts/test-oss-desktop-promotion-behavior.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh scripts/test-mobile-release-candidate-publisher.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh - name: Codex security review contract run: just security-review-check - name: Rust cache contract run: | scripts/test-rust-cache-contract.sh scripts/test-rust-cache-contract-regressions.sh - name: File size policy run: just file-size-check 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.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 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.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 with: key: sherpa-cache-v1 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.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 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 clippy run: just desktop-tauri-clippy env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - 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: Desktop Tauri compiled-flag verification run: just desktop-tauri-test-compiled-flags 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: 30 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.0.3 - 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 E2E build run: pnpm -C desktop build:e2e - name: Desktop smoke e2e run: cd desktop && pnpm exec playwright test --project=smoke --shard=${{ matrix.shard }}/4 - name: Summarize flaky tests if: ${{ !cancelled() }} run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop Smoke E2E (${{ matrix.shard }})" working-directory: desktop - name: Upload desktop smoke e2e artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-smoke-e2e-artifacts-${{ matrix.shard }} path: | desktop/playwright-report desktop/playwright-report.json desktop/test-results if-no-files-found: ignore retention-days: 7 desktop: name: Desktop runs-on: ubuntu-latest timeout-minutes: 5 needs: [changes, desktop-core, desktop-smoke-e2e, desktop-windows-build] 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 if [ "${{ needs.desktop-windows-build.result }}" != "success" ]; then echo "Desktop Windows Build finished with: ${{ needs.desktop-windows-build.result }}" exit 1 fi echo "Desktop jobs passed" desktop-e2e-relay: name: Desktop E2E Relay runs-on: ubuntu-latest timeout-minutes: 30 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 env: SCCACHE_GHA_ENABLED: "true" SCCACHE_GHA_RW_MODE: ${{ (github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.number == 5224)) && 'READ_WRITE' || 'READ_ONLY' }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 # Reuse the relay binaries and backend test archive when none of their # inputs changed (desktop-only PRs hit this every time). The key covers # everything they embed, including migrations via sqlx migrate!. - name: Restore relay artifacts cache id: relay-artifacts-cache uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' with: workspaces: | . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} # Cache rustc outputs for unchanged workspace crates. Trusted pushes write; # the bounded PR 5224 trial writes only to its isolated merge-ref scope. - name: Set up sccache if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 # zizmor: ignore[cache-poisoning] Bounded trial: only PR 5224 writes to its isolated merge-ref scope; trusted pushes retain production writes. with: version: v0.16.0 - name: Install cargo-nextest if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 with: tool: cargo-nextest@0.9.136 - name: Build relay artifacts if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' env: RUSTC_WRAPPER: sccache run: | cargo build --profile ci -p buzz-relay -p git-credential-nostr cargo nextest archive \ --cargo-profile ci \ -p buzz-db \ -p buzz-relay \ -p buzz-test-client \ --lib \ --bin buzz-relay \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst - name: Save relay artifacts cache # PR-scoped exact-source entries cannot warm main or other PRs and churn # the shared cache pool. sccache provides read-only PR reuse instead. if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' && github.event_name == 'push' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: path: | target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} - name: Upload relay artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-relay path: | target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst 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.0.3 - 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 E2E build run: pnpm -C desktop build:e2e - 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/reconcile-schema-after-pgschema.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_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ BUZZ_REQUIRE_AUTH_TOKEN=false \ BUZZ_RECONCILE_CHANNELS=true \ BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN=100000 \ BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN=100000 \ BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC=10000 \ 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: Summarize flaky tests if: ${{ !cancelled() }} run: node scripts/summarize-flaky-tests.mjs playwright-report.json "Desktop E2E Integration (${{ matrix.shard }}/2)" working-directory: desktop - name: Upload desktop integration artifacts if: ${{ !cancelled() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-integration-artifacts-${{ matrix.shard }} path: | desktop/playwright-report desktop/playwright-report.json desktop/test-results /tmp/buzz-relay.log if-no-files-found: ignore retention-days: 7 - 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.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Install cargo-nextest uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 with: tool: cargo-nextest@0.9.136 - 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 artifacts 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/reconcile-schema-after-pgschema.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: Workflow message provenance tests # The relay's workflow_sink suite is not selected by the infra-free # unit job. Run both its pure tests and ignored PostgreSQL tests here so # authored-template provenance cannot regress behind a green CI build. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/workflow_sink/)' \ --run-ignored all env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Replaceable persistence PostgreSQL tests # Transaction, concurrency, and mention-index coverage for the # replaceable-event store seam. These tests require real Postgres and # are ignored by the infrastructure-free unit-test job. run: | filter='package(buzz-db) and test(/tests::(parameterized_|concurrent_parameterized_)/)' cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E "${filter}" \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Database pressure observability PostgreSQL tests # Explicit pool acquisition and advisory-lock metrics require real # Postgres and are ignored by the infrastructure-free unit-test job. run: | filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E "${filter}" \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Writer session timeout guardrails run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-db) and test(session_timeouts_install_through_db_new_and_bound_lock_waits)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Audit writer session timeout guardrails run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(audit_writer_pool_installs_timeouts_and_bounds_advisory_lock_waits)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Audit worker lock-timeout recovery run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(audit_worker_retries_lock_timeout_until_original_entry_is_appended_once)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL="postgres://buzz:${BUZZ_TEST_POSTGRES_PASSWORD}@localhost:5432/buzz" \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ BUZZ_RELAY_PRIVATE_KEY="$(openssl rand -hex 32)" \ 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: Invite security tests run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Workspace profile (kind:9033) gate tests # Call-site integration for the 9033 authorization gate: open relay # rosterless/steward transitions and the closed-relay admin/owner rule, # against real Postgres. #[ignore]d in the default suite, selected # explicitly here — see handlers::relay_admin::tests. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - 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 nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'binary(e2e_event_reminder)' \ --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 - name: NIP-MP coordinate deletion guard # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: # a stale tombstone (created_at earlier than the live head) spares that # head, and an equal-timestamp tombstone deletes it. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Admin API nip98 read-write attribution test # The only real HTTP → nip98 operator principal → mutation → cross-table # attribution coverage: an authenticated operator's dismiss attributes # to the operator's own key with relay_operator authority. Staffing # PUT/DELETE attribution is covered by # nip98_staffing_put_and_delete_write_attributed_audit_rows in the # roster-audit lane below. #[ignore]d in the default suite — see # api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(=api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator)' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Admin API unrostered-signer replay invariant # The only causal proof that a validly-signing but unrostered key cannot # consume NIP-98 replay slots: it asserts principal resolution fails # BEFORE the replay ID is claimed (tracking.claim_count() == 0). This # test is non-ignored, so it runs neither in Backend Integration's # ignored-only selectors nor in the infra-free unit job — the unit job's # api::admin selector excludes it because DB-free it only passes by # waiting out the ~30s sqlx acquire timeout on a read-route fallthrough. # It lives here so a reachable Postgres resolves (and fails) the lookup # fast instead of timing out. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Admin API roster-audit / timeout / canonicalization security tests # Security-review fixes for the roster admin API, all #[ignore]d in the # default suite (they need Postgres) and selected by no other job: # - buzz-db relay_operators::tests: audit pre-image trail, per-target # lock serialization, insertion-time audit ordering, and # audit-failure rollback coupling. # - buzz-db relay_operators::tests last-operator invariant: sole DB # operator cannot self-demote or self-delete to zero, config presence # lifts the guard, and concurrent cross-target deletes racing to zero # leave exactly one operator (roster-wide advisory lock). # - buzz-relay api::admin: NIP-98 staffing writes attributed audit rows, # adversarial expirationSecs rejected at the resolve route, mixed-case # staffing normalizes to one canonical row. # # --test-threads=1: the last-operator invariant counts the roster # globally, and the sole-operator tests clear the roster then assert # their operator is the only one. They must not race each other on the # shared test roster, so this lane runs serially. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ --test-threads=1 \ -E '(package(buzz-db) and test(=relay_operators::tests::roster_mutations_write_pre_image_audit_rows)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_upserts_serialize_and_record_true_pre_image)) or (package(buzz-db) and test(=relay_operators::tests::audit_order_follows_seq_under_backward_clock)) or (package(buzz-db) and test(=relay_operators::tests::audit_insert_failure_rolls_back_roster_mutation)) or (package(buzz-db) and test(=relay_operators::tests::demoting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::deleting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::config_present_allows_deleting_last_db_operator)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_deletes_racing_to_zero_leave_one_operator)) or (package(buzz-relay) and test(=api::admin::tests::nip98_staffing_put_and_delete_write_attributed_audit_rows)) or (package(buzz-relay) and test(=api::admin::tests::resolve_route_rejects_adversarial_expiration_and_leaves_report_open)) or (package(buzz-relay) and test(=api::admin::tests::mixed_case_non_config_staffing_normalizes_to_one_row))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Admin API escalation-scoping tests # Escalation scoping for the moderation queue, all #[ignore]d (they need # Postgres) and selected by no other job: # - GET /reports defaults to the escalated-only backstop, scope=all # restores full visibility, explicit status= overrides the default. # - member reports with category 'illegal' auto-escalate at ingestion # while every other category still lands 'open'. run: | cargo nextest run \ --archive-file target/ci/backend-integration-tests.tar.zst \ -E '(package(buzz-relay) and test(=api::admin::tests::reports_default_lists_escalated_only)) or (package(buzz-relay) and test(=api::admin::tests::reports_scope_all_lists_every_status)) or (package(buzz-relay) and test(=api::admin::tests::reports_explicit_status_filter_overrides_default)) or (package(buzz-db) and test(=moderation::tests::illegal_report_auto_escalates_at_ingest)) or (package(buzz-db) and test(=moderation::tests::non_illegal_report_lands_open_at_ingest)) or (package(buzz-db) and test(=relay_admin_actions::tests::auto_escalated_report_reopens_like_an_admin_escalated_one))' \ --run-ignored ignored-only env: DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - 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, desktop-e2e-relay] if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 with: save-if: ${{ github.event_name != 'pull_request' }} # Reuse the relay + git-credential-nostr built by Desktop E2E Relay # instead of compiling them a second time. - name: Download relay binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: desktop-e2e-relay path: target/ci - name: Start relay run: | chmod +x ./target/ci/buzz-relay ./target/ci/git-credential-nostr ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 GIT_CREDENTIAL_NOSTR_BIN: ${{ github.workspace }}/target/ci/git-credential-nostr - name: Media read-auth e2e # Reads require kind:24242 `t=get` auth, so these binaries are the only # coverage that a real relay rejects bare reads and honours host- and # hash-scoped tokens. They were #[ignore]d and selected by no CI job, so # the lane never ran; select it here, where MinIO and the seeded # 'localhost:3000' community already exist. # --no-fail-fast: without it cargo stops after the first failing binary, # so one broken case hides every later binary's result. run: | cargo test -p buzz-test-client --no-fail-fast --test e2e_media --test e2e_media_extended --test e2e_media_video -- --ignored --nocapture env: RELAY_URL: ws://localhost:3000 RELAY_HTTP_URL: http://localhost:3000 - 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.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: 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.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 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 - name: Build Android debug APK run: just mobile-build-android mobile-swift: name: Mobile Swift runs-on: macos-latest timeout-minutes: 10 needs: [changes] if: needs.changes.outputs.mobile == 'true' steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Build run: swift build --package-path mobile/ios/BuzzPushKit - name: Build release run: swift build -c release --package-path mobile/ios/BuzzPushKit - name: Test run: swift test --package-path mobile/ios/BuzzPushKit 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.0.3 - 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.0.3 - 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.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 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 desktop-windows-build: name: Desktop Windows Build runs-on: windows-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' permissions: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: version: 11.4.0 - name: Install desktop dependencies shell: bash run: pnpm install --frozen-lockfile - name: Build both protected-feature selections shell: pwsh run: | Remove-Item Env:VITE_BUZZ_BESTIE -ErrorAction SilentlyContinue pnpm -C desktop build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } $env:VITE_BUZZ_BESTIE = "1" pnpm -C desktop build if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } 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.0.3 # 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.9.1 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. # Serial: windows_resolver_tests mutate process-global env # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 # Smoke-test the new host-prereq contract: Git for Windows (which provides # bash) is available on the runner, a shell command round-trips, and bash # does NOT resolve from System32 (so WSL's launcher is never picked up). # windows-latest runners have Git for Windows pre-installed; the unit tests # above exercise the MCP resolver itself. This step verifies the host env. - name: Smoke-test host Git Bash prereq (host env check) shell: bash run: | set -euo pipefail # Git for Windows ships bash.exe under its bin/ directory; confirm it # resolves from the standard location the runtime resolver probes first. bash_path=$(command -v bash 2>/dev/null || true) [[ -n "$bash_path" ]] || { echo "ERROR: bash not found on PATH — host Git for Windows missing" >&2; exit 1; } echo "Resolved bash: $bash_path" [[ "$bash_path" != *System32* ]] || { echo "ERROR: resolved bash is WSL's System32 launcher" >&2; exit 1; } # Run a basic pipeline through the resolved bash (same invocation the # agent uses: bash -c '...'). out=$(bash -c 'echo hello | tr a-z A-Z') [[ "$out" == "HELLO" ]] || { echo "bash pipeline failed: got '$out'" >&2; exit 1; } # Confirm git itself works — agents run git commands frequently. git --version 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 "Host bash resolved and functional; git commit round-trip passed" - name: Check (Tauri crate) run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --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.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 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-backend-kubernetes-$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=$(python3 -c 'import tomllib; d=tomllib.load(open("Cargo.lock", "rb")); p=next(p for p in d["package"] if p["name"] == "mesh-llm-sdk"); print(p["source"].rsplit("#", 1)[1])') [[ -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"