diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..6f92573 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,40 @@ +# yaml-language-server: $schema=https://storage.googleapis.com/coderabbit_public_assets/schema.v2.json +language: en-US +reviews: + profile: chill + request_changes_workflow: false + high_level_summary: false + poem: false + review_status: true + review_details: false + auto_review: + enabled: true + drafts: false + path_filters: + - '!bun.lock' + - '!**/Cargo.lock' + - '!**/dist/**' + - '!**/target/**' + - '!apps/shell/src-tauri/icons/**' + path_instructions: + - path: '**/*' + instructions: >- + Follow AGENTS.md. Prioritize reproducible bugs, regressions, security + boundaries and missing failure handling. Avoid cosmetic suggestions. + Tests must isolate application data and never use real provider logins + unless explicitly opted in. Do not suggest enabling live tests in CI. + - path: '.github/**' + instructions: >- + Check fork pull requests, skipped and cancelled dependencies, least + privilege, immutable action pins, cache correctness and publication + of the exact tested artifacts. Keep nightly publication opt-in. + - path: 'packages/core/**' + instructions: >- + Check owner versus paired-device authorization and provider-specific + behavior. Agent processes must use the traced process launcher. + - path: 'packages/ui/**' + instructions: >- + Check desktop and phone behavior, real and in-memory clients, and + routing through the Store belonging to the connected machine. +chat: + auto_reply: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6dfa3af..c7396b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,9 @@ on: force: type: boolean default: true + skip-server: + type: boolean + default: false channel: type: string default: stable @@ -22,7 +25,7 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: ${{ github.workflow == 'ci' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) }} env: CARGO_BUILD_JOBS: 8 @@ -31,7 +34,10 @@ jobs: changes: runs-on: ubuntu-24.04 outputs: - code: ${{ steps.filter.outputs.code }} + core: ${{ steps.filter.outputs.core }} + web: ${{ steps.filter.outputs.web }} + desktop: ${{ steps.filter.outputs.desktop }} + server: ${{ steps.filter.outputs.server == 'true' && !inputs.skip-server }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -55,35 +61,43 @@ jobs: tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint "$RUNNER_TEMP/actionlint" - checks: + core: needs: changes - if: needs.changes.outputs.code == 'true' + if: needs.changes.outputs.core == 'true' strategy: fail-fast: false matrix: os: [ubuntu-24.04, windows-2025] runs-on: ${{ matrix.os }} timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - run: bun run --cwd packages/core test + + web: + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - uses: ./.github/actions/setup - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - if: runner.os == 'Linux' with: path: node_modules/.vitest-cache key: vitest-${{ runner.os }}-${{ hashFiles('bun.lock', 'packages/ui/vitest.config.ts', 'packages/ui/svelte.config.js') }}-${{ github.sha }} restore-keys: vitest-${{ runner.os }}-${{ hashFiles('bun.lock', 'packages/ui/vitest.config.ts', 'packages/ui/svelte.config.js') }}- - run: bun run check - if: runner.os == 'Linux' - - run: bun run --cwd packages/core test - run: bun run --cwd packages/ui test --maxWorkers=8 - if: runner.os == 'Linux' desktop: needs: changes - if: needs.changes.outputs.code == 'true' + if: needs.changes.outputs.desktop == 'true' runs-on: windows-2025 timeout-minutes: 35 steps: @@ -118,6 +132,8 @@ jobs: bun run apps/shell/scripts/stage-sidecar.ts - run: bun run test:shell --release - run: bun test tests/e2e + env: + BOITE_E2E_PREBUILT_UI: '1' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: windows-installer @@ -135,7 +151,7 @@ jobs: server: needs: changes - if: needs.changes.outputs.code == 'true' + if: needs.changes.outputs.server == 'true' permissions: contents: read packages: write @@ -144,7 +160,7 @@ jobs: required: name: CI required if: always() - needs: [changes, checks, desktop, server] + needs: [changes, core, web, desktop, server] runs-on: ubuntu-24.04 steps: - name: Require all applicable checks @@ -155,8 +171,8 @@ jobs: import json, os jobs = json.loads(os.environ['RESULTS']) assert jobs['changes']['result'] == 'success', jobs - required = jobs['changes']['outputs']['code'] == 'true' - for name in ('checks', 'desktop', 'server'): + for name in ('core', 'web', 'desktop', 'server'): + required = jobs['changes']['outputs'][name] == 'true' assert jobs[name]['result'] == ('success' if required else 'skipped'), jobs print('All applicable checks passed') PY diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b89c135..43f5a23 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -58,6 +58,7 @@ jobs: if: needs.revision.outputs.changed == 'true' uses: ./.github/workflows/ci.yml with: + skip-server: true channel: nightly version: ${{ needs.revision.outputs.version }} permissions: diff --git a/docs/ci.md b/docs/ci.md index 137dd06..fad9b6d 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -9,12 +9,16 @@ when a dependency never starts. Documentation-only changes still produce it. | Change | Checks | | --- | --- | | Markdown docs, license, issue templates, topics | Local documentation links and CI decision tests | -| Code, dependencies, build files, workflows, unknown paths | Above, type checks, core tests on Windows and Linux, UI tests, Windows shell tests, installer build, full end-to-end suite, Docker smoke tests on x64 and ARM64 | +| Shell files or end-to-end tests | Windows shell tests, installer build and full end-to-end suite | +| Dockerfile, .dockerignore, docker/ | Docker smoke tests on native x64 and ARM64 | +| UI files | Type checks, UI tests, desktop checks and Docker smoke tests | +| Core, contracts, dependencies, shared build files, workflows, unknown paths | All checks, including core tests on Windows and Linux | | Version tag | Complete checks, then a draft Windows release | | Enabled nightly with an unpublished commit | Complete checks, development installer, development server image, prerelease | Pull requests against any branch run CI. A newer commit cancels an older run of -that same PR. Release and publication jobs finish instead of being interrupted +that same PR. New main commits also cancel superseded ordinary CI runs. +Release and publication jobs finish instead of being interrupted halfway through an upload. Live-provider tests stay disabled. ## Build cost @@ -33,6 +37,8 @@ their merge ref. It builds the installer once, then copies the existing sidecar beside the shell for end-to-end testing. It does not recompile the core just to stage it again. The tested installer becomes the release artifact, with no second release build. +CI sets `BOITE_E2E_PREBUILT_UI=1` to test the UI already built for that installer. +The test refuses a missing UI build. Local end-to-end runs rebuild it by default. When Cargo uses a shared target directory, staging snapshots its shell into the checkout before the tests. Another checkout's later build cannot replace it. @@ -82,6 +88,17 @@ The desktop uses the development identifier and data directory, shared with local Boite Dev builds and separate from stable Boite. The server uses the `dev` channel. Use a separate Compose project for nightly volumes. Nightly publication never changes the stable Docker `latest` tag or GitHub's latest stable release. +Nightly verification skips the stable Docker job. Its publication job builds, +smoke-tests and pushes the development image once per architecture after the +other checks pass. + +## Pull request reviews + +`.coderabbit.yaml` configures CodeRabbit with advisory reviews. It does not +request changes or become a required merge check. Draft PRs and generated build +artifacts are excluded. Installing the GitHub App on this repository is a +separate prerequisite. CodeRabbit controls free-plan eligibility and review +limits; repository configuration does not override them. ## Releases and server images diff --git a/scripts/ci/changes.test.ts b/scripts/ci/changes.test.ts index 8c53b52..ff63796 100644 --- a/scripts/ci/changes.test.ts +++ b/scripts/ci/changes.test.ts @@ -1,5 +1,20 @@ import { expect, test } from 'bun:test'; -import { needsCodeChecks, needsWorkflowLint } from './changes.ts'; +import { affectedChecks, needsCodeChecks, needsWorkflowLint } from './changes.ts'; + +test('checks follow runtime boundaries and combine changed paths', () => { + expect(affectedChecks(['apps/shell/src-tauri/src/main.rs'])).toEqual({ core: false, web: false, desktop: true, server: false }); + expect(affectedChecks(['Dockerfile'])).toEqual({ core: false, web: false, desktop: false, server: true }); + expect(affectedChecks(['packages/ui/src/app.css'])).toEqual({ core: false, web: true, desktop: true, server: true }); + expect(affectedChecks(['packages/core/src/main.ts'])).toEqual({ core: true, web: true, desktop: true, server: true }); + expect(affectedChecks(['tests/e2e/ui.test.ts', 'docker/compose.yml'])).toEqual({ core: false, web: false, desktop: true, server: true }); +}); + +test('shared inputs and unknown files fail open to every check', () => { + for (const file of ['bun.lock', 'package.json', 'packages/contracts/src/index.ts', '.github/workflows/ci.yml', 'new-config.json']) { + expect(Object.values(affectedChecks([file])).every(Boolean)).toBe(true); + } + expect(Object.values(affectedChecks(['README.md', '.coderabbit.yaml'])).some(Boolean)).toBe(false); +}); test('docs-only changes keep the cheap check path', () => { expect(needsCodeChecks(['README.md', 'docs/server.md', 'LICENSE'])).toBe(false); diff --git a/scripts/ci/changes.ts b/scripts/ci/changes.ts index 64cbf4b..d86b98b 100644 --- a/scripts/ci/changes.ts +++ b/scripts/ci/changes.ts @@ -2,11 +2,29 @@ import { appendFileSync } from 'node:fs'; // Unknown files run the complete suite. Documentation-only PRs still get the // required job, so branch protection never waits for a filtered-out workflow. +export function affectedChecks(files: string[]) { + const checks = { core: false, web: false, desktop: false, server: false }; + for (const file of files) { + if ( + /^(docs\/.*\.md|README\.md|AGENTS\.md|CONTRIBUTING\.md|LICENSE)$/.test(file) || + /^\.github\/(ISSUE_TEMPLATE\/|pull_request_template\.md$|topics\.json$)/.test(file) || file === '.coderabbit.yaml' + ) continue; + if (/^(apps\/shell\/|tests\/e2e\/)/.test(file)) { + checks.desktop = true; + } else if (/^(Dockerfile$|\.dockerignore$|docker\/)/.test(file)) { + checks.server = true; + } else if (file.startsWith('packages/ui/')) { + checks.web = checks.desktop = checks.server = true; + } else { + // Core and contracts are used by both clients; unknown inputs stay safe. + checks.core = checks.web = checks.desktop = checks.server = true; + } + } + return checks; +} + export function needsCodeChecks(files: string[]): boolean { - return files.some((file) => !( - /^(docs\/.*\.md|README\.md|AGENTS\.md|CONTRIBUTING\.md|LICENSE)$/.test(file) || - /^\.github\/(ISSUE_TEMPLATE\/|pull_request_template\.md$|topics\.json$)/.test(file) - )); + return Object.values(affectedChecks(files)).some(Boolean); } export function needsWorkflowLint(files: string[]): boolean { @@ -15,16 +33,17 @@ export function needsWorkflowLint(files: string[]): boolean { if (import.meta.main) { const base = process.env.BASE_SHA; - let code = true; + let checks = affectedChecks(['unknown']); let workflows = true; if (process.env.FORCE_CHECKS !== 'true' && base && /^[a-f0-9]{40}$/.test(base) && !/^0+$/.test(base)) { - const diff = Bun.spawnSync(['git', 'diff', '--name-only', '-z', base, 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }); + // A move between packages affects both its old and new runtime. + const diff = Bun.spawnSync(['git', 'diff', '--no-renames', '--name-only', '-z', base, 'HEAD'], { stdout: 'pipe', stderr: 'pipe' }); if (diff.exitCode !== 0) throw new Error(diff.stderr.toString()); const files = diff.stdout.toString().split('\0').filter(Boolean); - code = needsCodeChecks(files); + checks = affectedChecks(files); workflows = needsWorkflowLint(files); } - const output = `code=${code}\nworkflows=${workflows}\n`; + const output = Object.entries({ ...checks, workflows }).map(([key, value]) => `${key}=${value}\n`).join(''); if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, output); process.stdout.write(output); } diff --git a/tests/e2e/composer-activity.test.ts b/tests/e2e/composer-activity.test.ts index a14a44c..5e69509 100644 --- a/tests/e2e/composer-activity.test.ts +++ b/tests/e2e/composer-activity.test.ts @@ -29,8 +29,8 @@ beforeAll(async () => { page = await BrowserPage.launch({ url: `http://127.0.0.1:${port}/?fake=1` }); await page.waitFor(`document.querySelector('${id('new-thread')}')`); await size(false); -}); -afterAll(async () => { await page?.close(); await server?.close(); }); +}, 30_000); +afterAll(async () => { await page?.close(); await server?.close(); }, 15_000); test('the draft sentence follows worktree, permissions, model and effort on desktop and phone', async () => { await page.click(id('new-thread')); diff --git a/tests/e2e/ui.test.ts b/tests/e2e/ui.test.ts index 3c85d0f..36b7ba4 100644 --- a/tests/e2e/ui.test.ts +++ b/tests/e2e/ui.test.ts @@ -56,7 +56,7 @@ async function clickWhenEnabled(selector: string): Promise { } beforeAll(async () => { - { + if (process.env.BOITE_E2E_PREBUILT_UI !== '1') { const built = Bun.spawnSync({ cmd: ['bun', 'run', '--cwd', 'packages/ui', 'build'], cwd: ROOT, @@ -66,6 +66,7 @@ beforeAll(async () => { }); if (!built.success) throw new Error(`the ui did not build:\n${built.stderr.toString()}`); } + if (!existsSync(UI_INDEX)) throw new Error(`Missing prebuilt UI: ${UI_INDEX}. Run bun run build:ui first.`); core = await startCore(); projectDir = mkdtempSync(join(tmpdir(), 'boite-e2e-ui-')); worktreesDir = join(tmpdir(), '.boite-worktrees', basename(projectDir));