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
122 changes: 91 additions & 31 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,38 +1,96 @@
name: Verify desktop
on:
pull_request:
# `labeled` lets the ci:all-os label start a full run. Other labels skip every job.
types: [opened, synchronize, reopened, labeled]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# A label other than ci:all-os gets its own group, so adding it cannot cancel a real run.
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}${{ github.event.action == 'labeled' && github.event.label.name != 'ci:all-os' && '-label' || '' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# scripts/ci-changes.ts maps the changed paths to lanes. Linux checks and unit-tests every run,
# and unless only docs changed, it packages the app and runs the Electron suite against it.
# Windows and macOS (1.7x and 10x a Linux minute) run only for platform-sensitive paths.
# Installers are built only when electron-builder.json or CI changes. Pushes to main run the
# Linux lanes. Release tags run all three operating systems before publishing. The ci:all-os
# label or a manual run selects everything. If the change cannot be listed, or this job fails,
# every lane runs.
changes:
name: Changes
if: github.event.action != 'labeled' || github.event.label.name == 'ci:all-os'
runs-on: ubuntu-26.04
timeout-minutes: 5
outputs:
app: ${{ steps.detect.outputs.app }}
cross_os: ${{ steps.detect.outputs.cross_os }}
installers: ${{ steps.detect.outputs.installers }}
steps:
# Depth 2 brings the pull request's base, the merge commit's first parent.
- uses: actions/checkout@v7
with:
fetch-depth: 2
sparse-checkout: |
/.bun-version
/scripts/ci-changes.ts
sparse-checkout-cone-mode: false
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- name: Detect affected lanes
id: detect
env:
EVENT_NAME: ${{ github.event_name }}
ALL_OS: ${{ github.event.label.name == 'ci:all-os' || contains(github.event.pull_request.labels.*.name, 'ci:all-os') }}
run: |
set -uo pipefail
paths="$RUNNER_TEMP/changed-paths"
args=(everything)
if [[ "$EVENT_NAME" == push ]]; then
args=(main)
elif [[ "$EVENT_NAME" == pull_request && "$ALL_OS" != true ]]; then
# --no-renames lists a rename as its old and new path, so moving a file counts for both.
if git diff --name-only --no-renames HEAD^1 HEAD > "$paths"; then
args=(pull-request "$paths")
else
echo "Could not list the pull request's changes. Running every lane."
fi
fi
bun scripts/ci-changes.ts "${args[@]}" | tee -a "$GITHUB_OUTPUT"
exit "${PIPESTATUS[0]}"

verify:
name: Build (${{ matrix.os }})
timeout-minutes: 30
needs: changes
if: ${{ !cancelled() && needs.changes.result != 'skipped' }}
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 3
matrix:
os: [ubuntu-26.04, windows-2025, macos-26]
os: ${{ fromJSON(needs.changes.outputs.cross_os == 'false' && '["ubuntu-26.04"]' || '["ubuntu-26.04","windows-2025","macos-26"]') }}
runs-on: ${{ matrix.os }}
# An output that is missing because Changes failed runs its lane.
env:
APP: ${{ needs.changes.outputs.app != 'false' }}
INSTALLERS: ${{ needs.changes.outputs.installers != 'false' }}
steps:
# repos/ holds vendored reference source that no check, build or test reads.
- uses: actions/checkout@v7
with:
sparse-checkout: |
/*
!/repos/
sparse-checkout-cone-mode: false
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
- name: Cache Bun dependencies
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('.bun-version', 'bun.lock') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-bun-
- run: bun install --frozen-lockfile
# Tests open SQLite with synchronous=FULL in the temporary directory, which defaults to C:.
# The hosted runner's D: drive, which also holds the workspace, is faster for this I/O.
# Bun's package store moves there too, so installs hard-link into node_modules on one drive.
- name: Use the D drive for temporary files on Windows
if: runner.os == 'Windows'
shell: pwsh
Expand All @@ -41,29 +99,31 @@ jobs:
New-Item -ItemType Directory -Force -Path $temp | Out-Null
"TEMP=$temp" >> $env:GITHUB_ENV
"TMP=$temp" >> $env:GITHUB_ENV
- run: bun run check
- if: runner.os != 'Windows'
run: bun run test
# Windows runner disk stalls can slow tests tenfold for tens of seconds. Tests with their
# own limit allow for that; this gives the rest 20 seconds instead of 5.
- if: runner.os == 'Windows'
run: bun run test --timeout 20000
- run: bun run build
"BUN_INSTALL_CACHE_DIR=$(Join-Path $env:RUNNER_TEMP 'bun-cache')" >> $env:GITHUB_ENV
# No dependency cache: a fresh install took 4-19 seconds, less than restoring and saving the
# cache, which took up to two minutes on Windows.
- run: bun install --frozen-lockfile
# Type checks, lint and formatting give the same result on every OS.
- if: runner.os == 'Linux'
run: xvfb-run -a bun run test:e2e
- if: runner.os != 'Linux'
run: bun run test:e2e
- name: Package desktop installers
run: bun --bun electron-builder --publish never
run: bun run check
# The test script runs files in one worker per core and gives tests without their own
# limit 20 seconds: Windows runner disk stalls and parallel workers both slow some past 5.
- run: bun run test
- if: env.APP == 'true'
run: bun run build
# The Electron suite runs once, against the packaged app. `--dir` builds the unpacked app in
# seconds. Installers are built when their own inputs change, on manual runs, and for
# releases.
- name: Package the desktop app
if: env.APP == 'true'
run: bun --bun electron-builder --publish never ${{ env.INSTALLERS != 'true' && '--dir' || '' }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Test packaged application on Linux
if: runner.os == 'Linux'
run: xvfb-run -a bun run test:packaged
- name: Test packaged application on Windows and macOS
if: runner.os != 'Linux'
run: bun run test:packaged
- name: Test the packaged desktop app
if: env.APP == 'true'
run: ${{ runner.os == 'Linux' && 'xvfb-run -a ' || '' }}bun run test:packaged
- name: Upload desktop installers
if: github.event_name == 'workflow_dispatch'
uses: actions/upload-artifact@v7
with:
name: desktop-${{ runner.os }}-${{ runner.arch }}
Expand Down
34 changes: 16 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,19 @@ jobs:
matrix:
os: [ubuntu-26.04, windows-2025, macos-26]
runs-on: ${{ matrix.os }}
# Pull requests run Windows and macOS only for platform-sensitive changes, and main runs Linux
# alone, so every release tag repeats the unit tests on all three before publishing. Each OS
# then builds its installers and drives the packaged app through the Electron suite, as
# ci.yml does. Type checks, lint and formatting give the same result on every OS and run on
# Linux for every pull request and push to main.
steps:
# repos/ holds vendored reference source that no check, build or test reads.
- uses: actions/checkout@v7
with:
sparse-checkout: |
/*
!/repos/
sparse-checkout-cone-mode: false
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: .bun-version
Expand All @@ -24,15 +35,9 @@ jobs:
env:
RELEASE_TAG: ${{ github.ref_name }}
run: bun -e 'const p=require("./package.json"); if (process.env.RELEASE_TAG !== `v${p.version}`) process.exit(1)'
- name: Cache Bun dependencies
uses: actions/cache@v6
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('.bun-version', 'bun.lock') }}
restore-keys: ${{ runner.os }}-${{ runner.arch }}-bun-
- run: bun install --frozen-lockfile
# Tests open SQLite with synchronous=FULL in the temporary directory, which defaults to C:.
# The hosted runner's D: drive, which also holds the workspace, is faster for this I/O.
# Bun's package store moves there too, so installs hard-link into node_modules on one drive.
- name: Use the D drive for temporary files on Windows
if: runner.os == 'Windows'
shell: pwsh
Expand All @@ -41,18 +46,11 @@ jobs:
New-Item -ItemType Directory -Force -Path $temp | Out-Null
"TEMP=$temp" >> $env:GITHUB_ENV
"TMP=$temp" >> $env:GITHUB_ENV
- run: bun run check
- if: runner.os != 'Windows'
run: bun run test
# Windows runner disk stalls can slow tests tenfold for tens of seconds. Tests with their
# own limit allow for that; this gives the rest 20 seconds instead of 5.
- if: runner.os == 'Windows'
run: bun run test --timeout 20000
"BUN_INSTALL_CACHE_DIR=$(Join-Path $env:RUNNER_TEMP 'bun-cache')" >> $env:GITHUB_ENV
# No dependency cache, as in ci.yml: a fresh install is faster than restoring one.
- run: bun install --frozen-lockfile
- run: bun run test
- run: bun run build
- if: runner.os == 'Linux'
run: xvfb-run -a bun run test:e2e
- if: runner.os != 'Linux'
run: bun run test:e2e
- run: bun --bun electron-builder --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
Expand Down
50 changes: 34 additions & 16 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,38 @@ Development uses a loopback Vite server. Release builds package a static SvelteK
chooser operations. The main process authorizes the trusted top-level frame before
connecting it, then forwards calls through the same shared schemas to Bun.

GitHub CI checks and packages Windows, macOS, and Linux builds concurrently on
pull requests, pushes to main, and manual runs of the Verify desktop workflow.
All three jobs can run at once, and a failure on one platform leaves the others
running. New commits cancel superseded CI runs. Each job uploads its installers
as a `desktop-<OS>-<architecture>` artifact, available on the workflow run for seven
days. CI builds Windows NSIS installers, macOS DMG and ZIP files, and Linux
AppImage and Debian packages using each runner's native architecture. Repository
text files use LF line endings on every OS so Windows passes the same format
checks. Windows runner disk stalls have slowed tests tenfold for tens of seconds, so
Windows CI keeps temporary files on the faster D: drive and gives smoke tests without
their own limit 20 seconds instead of 5. Tests with their own limit allow for these
stalls on every OS.

Releases run from version tags matching package.json. They publish unsigned
The Verify desktop workflow runs on pull requests, pushes to main, and manual runs.
Its Changes job, `scripts/ci-changes.ts`, maps a pull request's changed paths to lanes:

- Linux checks types, lint and formatting and runs the smoke tests on every run. Unless
the change touches only documentation or other paths no build reads, it also builds
the app, packages the unpacked app with `electron-builder --dir`, and drives that
packaged app through the Electron flows with `bun run test:packaged`. CI does not run
the unpackaged `bun run test:e2e`; local runs still do.
- Windows and macOS repeat the smoke tests, packaging and Electron flows when the
change touches platform-sensitive code: the Electron main process, backend file,
process and SQLite handling, the PDF worker, dependencies, packaging, the toolchain,
the desktop test harness, or CI itself.
- Installers are built only when `electron-builder.json` or CI changes.

Pushes to main run the Linux lanes but not Windows or macOS. Label a pull request
`ci:all-os`, or run the workflow manually, to run everything on all three operating
systems. Manual runs also upload each runner's installers as a `desktop-<OS>-<architecture>` artifact, kept for
seven days. CI builds Windows NSIS installers, macOS DMG and ZIP files, and Linux
AppImage and Debian packages using each runner's native architecture. New commits
cancel superseded pull request runs, and a failure on one platform leaves the others
running.

Smoke tests run in one worker per core and give tests without their own limit 20
seconds instead of Bun's 5. Windows runner disk stalls have slowed tests tenfold for
tens of seconds, and parallel workers slow CPU-heavy tests. CI keeps no dependency
cache, because a fresh install is faster than restoring one. Windows CI keeps
temporary files and Bun's package store on the faster D: drive. Repository text files
use LF line endings on every OS.

Releases run from version tags matching package.json. Because Windows and macOS do
not run on main, each release runs the smoke tests on all three, builds the installers
and drives the packaged app through every Electron flow. It publishes unsigned
installers and SHA256SUMS to GitHub Releases after all builds succeed. Code signing and notarization are not
configured; the workflow does not require Apple or Windows developer credentials.
Unsigned applications retain the platform's normal installation prompts.
Expand Down Expand Up @@ -48,8 +66,8 @@ minified duplicates and the web viewer are not shipped. `app.asar` carries only
the production packages the Electron bundle leaves external, Effect and
electron-effect-rpc. The Modal SDK is a development dependency compiled into
`app-backend`. No system Bun or Node is
required by the packaged app. CI launches the packaged application after building
its installers on each OS, verifying the compiled backend and renderer together.
required by the packaged app. CI and releases launch the packaged application,
verifying the compiled backend and renderer together.

Electron starts one backend and sends its schema-validated startup configuration
over a private stdin pipe. The backend migrates the database, binds an ephemeral
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"lint": "bun --bun oxlint -c oxlint.config.ts --disable-nested-config src desktop backend scripts tests tools *.ts",
"format": "bun fmt",
"format:check": "bun fmt --check",
"test": "bun test ./tests/smoke",
"test": "bun test ./tests/smoke --parallel --no-isolate --timeout 20000",
"test:sam": "bun scripts/test-sam.ts",
"test:lint-rule-cli": "bun tools/oxlint/anti-slop/rules/require-readable-spacing-cli.test.ts",
"reconcile:fixtures": "bun scripts/reconcile-fixtures.ts",
Expand Down
Loading
Loading