diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml new file mode 100644 index 000000000..36435668a --- /dev/null +++ b/.github/ci-paths.yml @@ -0,0 +1,89 @@ +# Pull-request checks are routed by the behavior they prove. Package consumers +# do not inherit their dependencies' test suites; main, merge queue, and manual +# runs execute every contract regardless of these paths. +routing: + - ".github/ci-paths.yml" + +workspace: + - ".mise.toml" + - ".tool-versions" + - "package.json" + - "package-lock.json" + - "patches/**" + - "scripts/npm-retry.mjs" + - "tsconfig.json" + - "tsconfig.base.json" + +ci: + - ".github/actions/**" + - ".github/workflows/ci.yml" + +format: + - ".github/**/*.yml" + - "docs/**/*.md" + - "packages/**/*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + - "*.{cjs,css,html,js,json,jsonc,jsx,md,mjs,ts,tsx,yaml,yml}" + +quality: + - "packages/**/*.{cjs,js,json,jsx,mjs,ts,tsx}" + - "*.{cjs,js,json,jsx,mjs,ts,tsx}" + +server: + - "packages/server/**" + - "packages/app/e2e/fixtures/recording.*" + - "vitest.config.ts" + +desktop: + - "packages/desktop/**" + - "vitest.config.ts" + +app: + - "packages/app/**" + - "vitest.config.ts" + +sdk: + - "packages/client/**" + - "packages/protocol/**" + - "vitest.config.ts" + +playwright: + - "packages/app/src/**" + - "packages/app/e2e/**" + - "packages/app/assets/**" + - "packages/app/public/**" + - "packages/app/index.ts" + - "packages/app/*config.{cjs,js,ts}" + - "packages/app/package.json" + - "packages/server/**" + +playwright_desktop: + - "packages/desktop/**" + - "packages/app/src/**/*.electron.{ts,tsx}" + - "packages/app/e2e/project-picker-desktop.spec.ts" + - "packages/app/e2e/helpers/desktop-updates.ts" + - "packages/app/e2e/global-setup.ts" + - "packages/app/playwright.config.ts" + +relay: + - "packages/relay/**" + - "vitest.config.ts" + +cli: + - "packages/cli/**" + - "vitest.config.ts" + +docker: + - ".dockerignore" + - ".github/workflows/docker.yml" + - "docker/**" + - "scripts/build-daemon-web-ui.mjs" + +nix: + - ".github/workflows/nix.yml" + - "flake.lock" + - "flake.nix" + - "nix/**" + - "scripts/build-daemon-web-ui.mjs" + - "scripts/fix-lockfile.mjs" + - "scripts/trace-daemon.mjs" + - "scripts/update-nix.sh" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f0214819..7a7cb4133 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,110 +19,43 @@ env: jobs: changes: + name: changes runs-on: ubuntu-latest permissions: contents: read pull-requests: read outputs: - quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }} - server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }} - desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }} - desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }} - app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }} - sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }} - playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }} - relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }} - cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }} + full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' || steps.filter.outputs.ci != 'false' }} + format: ${{ steps.filter.outputs.format }} + quality: ${{ steps.filter.outputs.quality }} + server: ${{ steps.filter.outputs.server }} + desktop: ${{ steps.filter.outputs.desktop }} + app: ${{ steps.filter.outputs.app }} + sdk: ${{ steps.filter.outputs.sdk }} + playwright: ${{ steps.filter.outputs.playwright }} + playwright_desktop: ${{ steps.filter.outputs.playwright_desktop }} + relay: ${{ steps.filter.outputs.relay }} + cli: ${{ steps.filter.outputs.cli }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - - name: Detect affected CI jobs + - name: Detect affected CI contracts id: filter uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 with: - filters: | - shared: - - '.github/workflows/ci.yml' - - '.github/actions/**' - - '.mise.toml' - - '.tool-versions' - - 'package.json' - - 'package-lock.json' - - 'patches/**' - - 'scripts/**' - - 'tsconfig.json' - - 'tsconfig.base.json' - - 'vitest.config.ts' - quality: - - 'packages/**' - - '*.cjs' - - '*.js' - - '*.json' - - '*.mjs' - - '*.ts' - server: - - 'packages/app/e2e/fixtures/recording.*' - - 'packages/client/**' - - 'packages/cli/src/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' - desktop: - - 'packages/app/**' - - 'packages/cli/**' - - 'packages/client/**' - - 'packages/desktop/**' - - 'packages/expo-two-way-audio/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' - desktop_package: - - '.github/workflows/ci.yml' - - 'packages/desktop/**' - app: - - 'packages/app/**' - - 'packages/client/**' - - 'packages/expo-two-way-audio/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - sdk: - - 'packages/client/**' - - 'packages/protocol/**' - - 'packages/relay/**' - playwright: - - 'packages/app/**' - - 'packages/client/**' - - 'packages/expo-two-way-audio/**' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' - relay: - - 'packages/relay/**' - cli: - - 'nix/**' - - 'packages/app/e2e/global-setup.ts' - - 'packages/cli/**' - - 'packages/client/**' - - 'packages/desktop/src/daemon/runtime-paths.ts' - - 'packages/highlight/**' - - 'packages/protocol/**' - - 'packages/relay/**' - - 'packages/server/**' + filters: .github/ci-paths.yml - name: Validate CI workflow run: node --test scripts/ci-workflow.test.mjs format: + name: format + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }} runs-on: ubuntu-latest env: - # This job never executes Electron. Skipping the hosted binary avoids - # unrelated npm ci failures when Electron's CDN returns 504. ELECTRON_SKIP_BINARY_DOWNLOAD: "1" steps: - uses: actions/checkout@v4 @@ -136,15 +69,12 @@ jobs: run: node scripts/npm-retry.mjs ci - name: Check formatting - run: npx oxfmt --check . + run: npm run format:check lint: + name: lint needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.quality != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -169,12 +99,9 @@ jobs: run: npm run lint typecheck: + name: typecheck needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.quality != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -188,6 +115,7 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build server stack run: npm run build:server @@ -200,118 +128,93 @@ jobs: npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server - server-tests: + server-tests-ubuntu: + name: server-tests (ubuntu-latest) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} - name: server-tests (${{ matrix.os }}) + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.server != 'false' }} - steps: - - name: Skip unaffected server tests - if: env.RUN_TESTS != 'true' - run: echo "No server changes detected." - + steps: &server_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' with: fetch-depth: 0 - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Fetch origin/main (worktree tests) - if: env.RUN_TESTS == 'true' run: git fetch --no-tags origin main:refs/remotes/origin/main - name: Install dependencies - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci + - name: Install agent CLIs for provider tests - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai - name: Build server dependencies - if: env.RUN_TESTS == 'true' run: npm run build:server-deps - name: Run server tests - if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/server env: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} - desktop-tests: + server-tests-windows: + name: server-tests (windows-latest) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] - runs-on: ${{ matrix.os }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }} + runs-on: windows-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: *server_test_steps + + desktop-tests-ubuntu: + name: desktop-tests (ubuntu-latest) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} + runs-on: ubuntu-latest timeout-minutes: 30 permissions: contents: read - env: - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop != 'false' }} - steps: - - name: Skip unaffected desktop tests - if: env.RUN_TESTS != 'true' - run: echo "No desktop changes detected." - + steps: &desktop_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies with retry - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci + - name: Build server stack - if: env.RUN_TESTS == 'true' run: npm run build:server - name: Run desktop tests - if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/desktop - name: Build app dependencies for desktop E2E - if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' + if: runner.os == 'Linux' run: npm run build:app-deps - name: Install virtual display - if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' + if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y xvfb xauth - name: Run real Electron browser tab bridge E2E - if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' + if: runner.os == 'Linux' run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop env: PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e - name: Upload browser tab bridge diagnostics uses: actions/upload-artifact@v4 - if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' + if: failure() && runner.os == 'Linux' with: name: browser-tab-bridge-e2e path: ${{ runner.temp }}/browser-tab-bridge-e2e @@ -319,11 +222,7 @@ jobs: retention-days: 7 - name: Build and smoke unpacked desktop app - if: >- - env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop_package != 'false') + if: runner.os == 'Linux' run: npm run build:desktop -- --publish never --linux --x64 --dir env: EP_GH_IGNORE_TIME: true @@ -331,25 +230,28 @@ jobs: PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke - name: Upload packaged smoke diagnostics - if: >- - env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.desktop_package != 'false') uses: actions/upload-artifact@v4 + if: failure() && runner.os == 'Linux' with: name: desktop-packaged-smoke-linux-x64 path: ${{ runner.temp }}/desktop-smoke if-no-files-found: ignore retention-days: 7 - app-tests: + desktop-tests-windows: + name: desktop-tests (windows-latest) needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.app != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.desktop != 'false') }} + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: read + steps: *desktop_test_steps + + app-tests: + name: app-tests + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -363,6 +265,7 @@ jobs: - name: Install dependencies with retry run: node scripts/npm-retry.mjs ci + - name: Install Playwright browsers timeout-minutes: 10 run: npx playwright install chromium @@ -374,12 +277,9 @@ jobs: run: npm run test --workspace=@getpaseo/app sdk-tests: + name: sdk-tests needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.sdk != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -393,6 +293,7 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci + - name: Build client dependencies run: npm run build:client @@ -405,87 +306,132 @@ jobs: - name: Typecheck client examples run: npm run typecheck:examples --workspace=@getpaseo/client - playwright: + playwright-1: + name: playwright (shard 1/4) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - include: - - { label: "shard 1/4", shard: 1, desktop: false } - - { label: "shard 2/4", shard: 2, desktop: false } - - { label: "shard 3/4", shard: 3, desktop: false } - - { label: "shard 4/4", shard: 4, desktop: false } - - { label: "desktop overlay", shard: "desktop", desktop: true } - name: playwright (${{ matrix.label }}) + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.playwright != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.playwright != 'false' }} - steps: - - name: Skip unaffected Playwright tests - if: env.RUN_TESTS != 'true' - run: echo "No Playwright changes detected." - + PLAYWRIGHT_SHARD: "1/4" + PLAYWRIGHT_ARTIFACT: "1" + steps: &playwright_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies with retry - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci + - name: Install Playwright browsers - if: env.RUN_TESTS == 'true' timeout-minutes: 10 run: npx playwright install chromium - name: Build app dependencies - if: env.RUN_TESTS == 'true' run: npm run build:app-deps - name: Build server stack - if: env.RUN_TESTS == 'true' run: npm run build:server - name: Install agent CLIs for provider tests - if: env.RUN_TESTS == 'true' && !matrix.desktop run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run Playwright E2E tests - if: env.RUN_TESTS == 'true' && !matrix.desktop - run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4 + run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Upload test artifacts + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-results-${{ env.PLAYWRIGHT_ARTIFACT }} + path: | + packages/app/test-results/ + packages/app/playwright-report/ + retention-days: 7 + + playwright-2: + name: playwright (shard 2/4) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.playwright != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PLAYWRIGHT_SHARD: "2/4" + PLAYWRIGHT_ARTIFACT: "2" + steps: *playwright_test_steps + + playwright-3: + name: playwright (shard 3/4) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.playwright != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PLAYWRIGHT_SHARD: "3/4" + PLAYWRIGHT_ARTIFACT: "3" + steps: *playwright_test_steps + + playwright-4: + name: playwright (shard 4/4) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.playwright != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PLAYWRIGHT_SHARD: "4/4" + PLAYWRIGHT_ARTIFACT: "4" + steps: *playwright_test_steps + + playwright-desktop: + name: playwright (desktop overlay) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.playwright_desktop != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies with retry + run: node scripts/npm-retry.mjs ci + + - name: Install Playwright browsers + timeout-minutes: 10 + run: npx playwright install chromium + + - name: Build app dependencies + run: npm run build:app-deps + + - name: Build server stack + run: npm run build:server + - name: Run desktop-overlay Playwright tests - if: env.RUN_TESTS == 'true' && matrix.desktop run: npm run test:e2e:desktop --workspace=@getpaseo/app - name: Upload test artifacts uses: actions/upload-artifact@v4 - if: env.RUN_TESTS == 'true' && failure() + if: failure() with: - name: playwright-results-${{ matrix.shard }} + name: playwright-results-desktop path: | packages/app/test-results/ packages/app/playwright-report/ retention-days: 7 relay-tests: + name: relay-tests needs: changes - if: >- - ${{ !cancelled() && - (github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.relay != 'false') }} + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }} runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" @@ -506,53 +452,62 @@ jobs: - name: Run relay tests run: npm run test --workspace=@getpaseo/relay - cli-tests: + cli-tests-1: + name: cli-tests (shard 1/3) needs: changes - if: ${{ !cancelled() }} - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} runs-on: ubuntu-latest - name: cli-tests (shard ${{ matrix.shard }}/3) env: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - RUN_TESTS: >- - ${{ github.event_name == 'workflow_dispatch' || - needs.changes.result != 'success' || - needs.changes.outputs.cli != 'false' }} - steps: - - name: Skip unaffected CLI tests - if: env.RUN_TESTS != 'true' - run: echo "No CLI changes detected." - + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "1" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: &cli_test_steps - uses: actions/checkout@v4 - if: env.RUN_TESTS == 'true' - uses: actions/setup-node@v4 - if: env.RUN_TESTS == 'true' with: node-version: "22" cache: "npm" - name: Install dependencies - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs ci - name: Build server stack - if: env.RUN_TESTS == 'true' run: npm run build:server - name: Install agent CLIs for provider tests - if: env.RUN_TESTS == 'true' run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai - name: Run CLI tests - if: env.RUN_TESTS == 'true' run: npm run test --workspace=@getpaseo/cli - env: - PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" - PASEO_DICTATION_ENABLED: "0" - PASEO_VOICE_MODE_ENABLED: "0" - PASEO_CLI_TEST_SHARD: ${{ matrix.shard }} - PASEO_CLI_TEST_SHARD_TOTAL: "3" + + cli-tests-2: + name: cli-tests (shard 2/3) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "2" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: *cli_test_steps + + cli-tests-3: + name: cli-tests (shard 3/3) + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }} + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: "1" + PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0" + PASEO_DICTATION_ENABLED: "0" + PASEO_VOICE_MODE_ENABLED: "0" + PASEO_CLI_TEST_SHARD: "3" + PASEO_CLI_TEST_SHARD_TOTAL: "3" + steps: *cli_test_steps diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d7a5bc09c..f901ae62c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,24 +3,6 @@ name: Docker on: pull_request: branches: [main] - paths: - - "docker/**" - - ".dockerignore" - - ".github/workflows/docker.yml" - - "package.json" - - "package-lock.json" - - "patches/**" - - "scripts/**" - - "tsconfig.json" - - "tsconfig.base.json" - - "packages/app/**" - - "packages/cli/**" - - "packages/client/**" - - "packages/expo-two-way-audio/**" - - "packages/highlight/**" - - "packages/protocol/**" - - "packages/relay/**" - - "packages/server/**" push: branches: [main] tags: @@ -58,8 +40,14 @@ env: jobs: setup: + name: setup runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read outputs: + full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' }} + docker: ${{ steps.filter.outputs.docker }} image: ${{ steps.values.outputs.image }} install_version: ${{ steps.values.outputs.install_version }} publish: ${{ steps.values.outputs.publish }} @@ -68,6 +56,12 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Detect Docker contract changes + id: filter + uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + with: + filters: .github/ci-paths.yml + - id: values env: INPUT_PASEO_VERSION: ${{ inputs.paseo_version }} @@ -131,8 +125,9 @@ jobs: echo "Resolved image=${image} install_version=${install_version} publish=${publish}" build: + name: build needs: setup - if: needs.setup.outputs.publish != 'true' + if: ${{ !cancelled() && needs.setup.outputs.publish != 'true' && (needs.setup.outputs.full != 'false' || needs.setup.outputs.docker != 'false') }} runs-on: ubuntu-latest permissions: contents: read @@ -156,8 +151,9 @@ jobs: cache-to: type=gha,scope=paseo,mode=max publish: + name: publish needs: setup - if: needs.setup.outputs.publish == 'true' + if: ${{ !cancelled() && needs.setup.outputs.publish == 'true' && (needs.setup.outputs.full != 'false' || needs.setup.outputs.docker != 'false') }} runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 4bf1ae807..dadd3ac6c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -3,35 +3,43 @@ name: Nix on: pull_request: branches: [main] - paths: - - "nix/**" - - "flake.nix" - - "flake.lock" - - "package.json" - - "package-lock.json" - - "packages/app/**" - - "packages/expo-two-way-audio/**" - - "packages/highlight/**" - - "packages/protocol/**" - - "packages/client/**" - - "packages/server/**" - - "packages/relay/**" - - "packages/cli/**" - - "scripts/build-daemon-web-ui.mjs" - - "scripts/update-nix.sh" - - "scripts/fix-lockfile.mjs" - - ".github/workflows/nix.yml" + push: + branches: [main] + workflow_dispatch: permissions: contents: read jobs: + changes: + name: changes + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + full: ${{ github.event_name != 'pull_request' || steps.filter.outputs.routing != 'false' || steps.filter.outputs.workspace != 'false' }} + nix: ${{ steps.filter.outputs.nix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect Nix contract changes + id: filter + uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3 + with: + filters: .github/ci-paths.yml + build: + name: build + needs: changes + if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.nix != 'false') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@v4 with: @@ -58,6 +66,7 @@ jobs: WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log" + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap. cleanup() { if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then kill "$DAEMON_PID" diff --git a/docs/testing.md b/docs/testing.md index 876971979..ffa0b8d8a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -179,6 +179,12 @@ Test suites in this repo are heavy. Running them in bulk freezes the machine, es - CI can shard app Playwright across multiple jobs; each shard still owns a full isolated daemon/relay/Metro stack from global setup. Helpers that restart the daemon must preserve the global setup environment, including disabled speech/local-model settings, so a restart does not change the tested surface or start background downloads. - Global setup starts Metro before Wrangler, assigns Wrangler explicit distinct relay and inspector ports, and accepts Metro as ready only when `/status` returns `packager-status:running`. A generic TCP listener is not sufficient readiness evidence. +## Pull-request test routing + +PR checks are routed by the behavior each suite proves, using `.github/ci-paths.yml`. A package does not inherit every test suite of its runtime consumers: app changes do not run CLI or Electron-wrapper tests, and protocol changes do not run every package that imports the protocol. Cross-package static compatibility belongs to `typecheck`; full integration coverage runs on main, merge queue, and manual CI runs. + +Required matrix legs are declared as statically named jobs. Their shared steps use YAML anchors, while job-level `if` conditions let GitHub report an unaffected leg as genuinely skipped without allocating a runner or losing the exact required-check name. + ## Agent authentication in tests Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it. diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 7954357dd..6d6a0ecc4 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -1,8 +1,34 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { matchesGlob } from "node:path"; import test from "node:test"; -const workflowPath = new URL("../.github/workflows/ci.yml", import.meta.url); +const repoRoot = new URL("../", import.meta.url); +const ciWorkflowPath = new URL(".github/workflows/ci.yml", repoRoot); +const dockerWorkflowPath = new URL(".github/workflows/docker.yml", repoRoot); +const nixWorkflowPath = new URL(".github/workflows/nix.yml", repoRoot); +const filtersPath = new URL(".github/ci-paths.yml", repoRoot); + +const requiredCiJobs = new Map([ + ["format", { name: "format", contract: "format" }], + ["lint", { name: "lint", contract: "quality" }], + ["typecheck", { name: "typecheck", contract: "quality" }], + ["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contract: "server" }], + ["server-tests-windows", { name: "server-tests (windows-latest)", contract: "server" }], + ["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }], + ["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }], + ["app-tests", { name: "app-tests", contract: "app" }], + ["sdk-tests", { name: "sdk-tests", contract: "sdk" }], + ["playwright-1", { name: "playwright (shard 1/4)", contract: "playwright" }], + ["playwright-2", { name: "playwright (shard 2/4)", contract: "playwright" }], + ["playwright-3", { name: "playwright (shard 3/4)", contract: "playwright" }], + ["playwright-4", { name: "playwright (shard 4/4)", contract: "playwright" }], + ["playwright-desktop", { name: "playwright (desktop overlay)", contract: "playwright_desktop" }], + ["relay-tests", { name: "relay-tests", contract: "relay" }], + ["cli-tests-1", { name: "cli-tests (shard 1/3)", contract: "cli" }], + ["cli-tests-2", { name: "cli-tests (shard 2/3)", contract: "cli" }], + ["cli-tests-3", { name: "cli-tests (shard 3/3)", contract: "cli" }], +]); function jobBlocks(source) { const jobs = new Map(); @@ -15,43 +41,116 @@ function jobBlocks(source) { jobs.set(currentJob, []); continue; } - - if (currentJob && (/^ \S/.test(line) || /^ \S/.test(line))) { - jobs.get(currentJob).push(line); - } + if (currentJob) jobs.get(currentJob).push(line); } - return jobs; } -test("matrix jobs expand before change gating", () => { - const workflow = readFileSync(workflowPath, "utf8"); - const gatedMatrixJobs = [...jobBlocks(workflow)] - .filter(([, lines]) => { - const hasMatrix = lines.some((line) => line.startsWith(" matrix:")); - const unsafeJobCondition = lines.some( - (line) => line.startsWith(" if:") && line.trim() !== "if: ${{ !cancelled() }}", - ); - return hasMatrix && unsafeJobCondition; - }) - .map(([jobId]) => jobId); +function loadFilters(path) { + const filters = {}; + let currentFilter; - assert.deepEqual( - gatedMatrixJobs, - [], - "change-based job conditions skip a matrix before GitHub can emit its interpolated check names", + for (const line of readFileSync(path, "utf8").split("\n")) { + const filterMatch = /^([a-z_]+):\s*$/.exec(line); + if (filterMatch) { + currentFilter = filterMatch[1]; + filters[currentFilter] = []; + continue; + } + const patternMatch = /^ - "([^"]+)"\s*$/.exec(line); + if (currentFilter && patternMatch) filters[currentFilter].push(patternMatch[1]); + } + return filters; +} + +function matchesFilter(filters, filterName, changedPath) { + return filters[filterName].some((pattern) => matchesGlob(changedPath, pattern)); +} + +function affectedContracts(filters, changedPath) { + const direct = Object.keys(filters).filter((filterName) => + matchesFilter(filters, filterName, changedPath), ); + const contracts = Object.keys(filters).filter( + (filterName) => !["routing", "workspace", "ci"].includes(filterName), + ); + if (direct.some((filterName) => ["routing", "workspace"].includes(filterName))) { + return contracts.sort(); + } + return direct.filter((filterName) => !["routing", "workspace", "ci"].includes(filterName)).sort(); +} + +test("required checks are statically named jobs with real job-level gating", () => { + const workflowSource = readFileSync(ciWorkflowPath, "utf8"); + const jobs = jobBlocks(workflowSource); + + assert.doesNotMatch(workflowSource, /strategy:\s*\n\s+matrix:/); + assert.doesNotMatch(workflowSource, /RUN_TESTS|Skip unaffected|No .* changes detected/); + + for (const [jobId, expected] of requiredCiJobs) { + const job = jobs.get(jobId)?.join("\n"); + assert.ok(job, `missing static job ${jobId}`); + assert.match(job, new RegExp(`^ name: ${expected.name.replace(/[()]/g, "\\$&")}$`, "m")); + assert.match(job, /needs\.changes\.outputs\.full != 'false'/); + assert.match(job, new RegExp(`needs\\.changes\\.outputs\\.${expected.contract} != 'false'`)); + } }); test("change gating allows superseded workflow runs to cancel", () => { - const workflow = readFileSync(workflowPath, "utf8"); - const cancellationBlockingJobs = [...jobBlocks(workflow)] - .filter(([, lines]) => lines.some((line) => line.trim().startsWith("${{ always()"))) - .map(([jobId]) => jobId); - - assert.deepEqual( - cancellationBlockingJobs, - [], - "always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating", - ); + for (const workflowPath of [ciWorkflowPath, dockerWorkflowPath, nixWorkflowPath]) { + const source = readFileSync(workflowPath, "utf8"); + assert.doesNotMatch( + source, + /\$\{\{\s*always\(\)/, + "always() keeps jobs alive after concurrency cancellation; use !cancelled() for fail-open gating", + ); + } +}); + +test("PR routing follows test contracts instead of package consumers", () => { + const filters = loadFilters(filtersPath); + const cases = new Map([ + ["packages/app/src/components/message.tsx", ["app", "format", "playwright", "quality"]], + ["packages/server/src/server/bootstrap.ts", ["format", "playwright", "quality", "server"]], + ["packages/cli/src/commands/agent/ls.ts", ["cli", "format", "quality"]], + ["packages/desktop/src/main.ts", ["desktop", "format", "playwright_desktop", "quality"]], + ["packages/protocol/src/messages.ts", ["format", "quality", "sdk"]], + ["packages/client/src/client.ts", ["format", "quality", "sdk"]], + ["packages/relay/src/index.ts", ["format", "quality", "relay"]], + ["docker/base/Dockerfile", ["docker"]], + ["nix/package.nix", ["nix"]], + ]); + + for (const [changedPath, expected] of cases) { + assert.deepEqual(affectedContracts(filters, changedPath), expected, changedPath); + } +}); + +test("root dependency and CI infrastructure changes run every contract", () => { + const filters = loadFilters(filtersPath); + const allContracts = Object.keys(filters) + .filter((filterName) => !["routing", "workspace", "ci"].includes(filterName)) + .sort(); + + for (const changedPath of [ + "package.json", + "package-lock.json", + ".github/ci-paths.yml", + "scripts/npm-retry.mjs", + ]) { + assert.deepEqual(affectedContracts(filters, changedPath), allContracts, changedPath); + } +}); + +test("Docker and Nix required jobs use job-level gates instead of workflow path filters", () => { + for (const [workflowPath, jobId, output] of [ + [dockerWorkflowPath, "build", "docker"], + [nixWorkflowPath, "build", "nix"], + ]) { + const source = readFileSync(workflowPath, "utf8"); + const trigger = source.split("jobs:", 1)[0]; + const job = jobBlocks(source).get(jobId)?.join("\n"); + assert.doesNotMatch(trigger, /^\s+paths:\s*$/m); + assert.match(job, new RegExp(`outputs\\.${output} != 'false'`)); + } });