name: CI on: push: branches: [main, release] 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: filters: | rust: - 'crates/**' - '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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: save-if: ${{ github.event_name != 'pull_request' }} - name: Install cargo-nextest uses: taiki-e/install-action@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5 with: tool: cargo-nextest@0.9.136 - name: Unit tests run: just test-unit desktop: name: Desktop 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop lint and format run: just desktop-check - name: Desktop build run: just desktop-build - name: Desktop smoke e2e run: cd desktop && pnpm exec playwright test --project=smoke - name: Desktop Tauri check run: just desktop-tauri-check 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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: 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: | . desktop/src-tauri save-if: ${{ github.event_name != 'pull_request' }} - name: Start integration services run: docker compose up -d postgres redis typesense minio minio-init - 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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" "sprout-postgres" wait_healthy "Redis" "sprout-redis" wait_healthy "Typesense" "sprout-typesense" wait_healthy "MinIO" "sprout-minio" - name: Apply database schema run: ./bin/pgschema apply --file schema/schema.sql --auto-approve env: PGHOST: localhost PGPORT: "5432" PGUSER: sprout PGPASSWORD: sprout_dev PGDATABASE: sprout - name: Build relay run: cargo build --profile ci -p sprout-relay - name: Start relay run: | nohup env \ DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout \ REDIS_URL=redis://localhost:6379 \ TYPESENSE_URL=http://localhost:8108 \ TYPESENSE_API_KEY=sprout_dev_key \ RELAY_URL=ws://localhost:3000 \ SPROUT_BIND_ADDR=0.0.0.0:3000 \ SPROUT_REQUIRE_AUTH_TOKEN=false \ SPROUT_RECONCILE_CHANNELS=true \ SPROUT_GIT_PROBE_WRITERS=8 \ ./target/ci/sprout-relay > /tmp/sprout-relay.log 2>&1 & echo $! > /tmp/sprout-relay.pid for attempt in $(seq 1 60); do if ! kill -0 "$(cat /tmp/sprout-relay.pid)" 2>/dev/null; then cat /tmp/sprout-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/sprout-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 - name: Upload desktop integration artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: desktop-e2e-integration-artifacts path: | desktop/playwright-report desktop/test-results /tmp/sprout-relay.log if-no-files-found: ignore - name: Save pnpm store cache if: github.event_name == 'push' uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # 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: 15 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.mobile == 'true' permissions: contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2.23.0 with: channel: stable - name: Install dependencies run: cd mobile && flutter pub get - 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # 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@6c1f7cf125e42770ff087ea443901b487cc5471a # v2.79.5 with: tool: cross@0.2.5 - name: Build server binaries env: TARGET: ${{ matrix.target }} run: | cross build --release --target "$TARGET" \ -p sprout-relay \ -p sprout-acp \ -p sprout-mcp \ -p sprout-agent \ -p sprout-dev-mcp \ -p git-credential-nostr \ -p git-sign-nostr 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # 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/sprout-acp-$TARGET" touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET" touch "desktop/src-tauri/binaries/sprout-agent-$TARGET" touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/sprout-$TARGET" - name: Build Tauri app run: cd desktop && pnpm tauri build env: CMAKE_POLICY_VERSION_MINIMUM: "3.5"