Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 29 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
force:
type: boolean
default: true
skip-server:
type: boolean
default: false
channel:
type: string
default: stable
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 19 additions & 2 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion scripts/ci/changes.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
35 changes: 27 additions & 8 deletions scripts/ci/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
4 changes: 2 additions & 2 deletions tests/e2e/composer-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async function clickWhenEnabled(selector: string): Promise<void> {
}

beforeAll(async () => {
{
if (process.env.BOITE_E2E_PREBUILT_UI !== '1') {
const built = Bun.spawnSync({
cmd: ['bun', 'run', '--cwd', 'packages/ui', 'build'],
cwd: ROOT,
Expand All @@ -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));
Expand Down
Loading