diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 990642c9..b188d933 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63c46f7f..b97f26e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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" diff --git a/docs/deployment.md b/docs/deployment.md index 7725faeb..f3bc9cfc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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--` 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--` 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. @@ -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 diff --git a/package.json b/package.json index 7b42df52..46efed2b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci-changes.ts b/scripts/ci-changes.ts new file mode 100644 index 00000000..452a6932 --- /dev/null +++ b/scripts/ci-changes.ts @@ -0,0 +1,185 @@ +/** + * Decide which Verify desktop lanes a change needs. The Changes job in .github/workflows/ci.yml + * runs this before any other job, from a sparse checkout without dependencies, so it imports + * nothing. Linux checks and unit-tests every run; each lane below runs only when a changed path + * can affect it. The workflow passes `everything` when it cannot list the changed paths. + * + * Entries ending in `/` match a directory, `*.ext` matches a root-level file, and anything else + * matches one exact path. tests/smoke/ci-changes.test.ts fails when an entry matches no tracked + * file, so a rename cannot silently stop a lane from running. + */ + +/** Paths that no build or end-to-end test reads. Release changes run only at a tag. */ +export const inertPaths = [ + ".github/workflows/release.yml", + "docs/", + "experiments/", + "repos/", + "tools/", + ".project-kit/", + ".vscode/", + "*.md", + "LICENSE", + ".env.example", + ".gitignore", +] as const; + +/** + * Inputs of the distributable installers alone: their targets and settings. Every other run + * packages only the unpacked app, which already embeds the icons and dependencies. Release tags + * build every installer. + */ +export const installerInputs = [ + "electron-builder.json", + ".github/workflows/ci.yml", + "scripts/ci-changes.ts", +] as const; + +/** + * Code whose behavior differs between Linux, Windows and macOS: the Electron main process with + * its packaged resource paths, files, processes and SQLite databases on disk, the PDF worker and + * its native canvas, dependencies and the toolchain, packaging, and the desktop test harness. + * Tests of those modules count as well. + */ +export const platformSensitivePaths = [ + "desktop/", + "backend/main.ts", + "backend/database-files.ts", + "backend/rename.ts", + "backend/ownership.ts", + "backend/documents/", + "backend/cloud/cache.ts", + "backend/cloud/files.ts", + "backend/cloud/transfer.ts", + "backend/review/package-files.ts", + "backend/review/packages.ts", + "package.json", + "bun.lock", + ".bun-version", + "bunfig.toml", + "playwright.config.ts", + ".gitattributes", + "scripts/build-icns.ts", + "scripts/build-icons.ts", + "scripts/desktop-test-environment.ts", + "scripts/isolated-desktop.ts", + "scripts/pinned-bun.ts", + "scripts/prepare-pdf-assets.ts", + "scripts/test-packaged.ts", + "tests/e2e/isolated-electron.ts", + "tests/e2e/rpc-boundary.ts", + "tests/e2e/rpc-control.ts", + "tests/e2e/workbench-fixture.ts", + "tests/smoke/backend-logging.test.ts", + "tests/smoke/backend-request-limits.test.ts", + "tests/smoke/backend.test.ts", + "tests/smoke/credential-files.test.ts", + "tests/smoke/credentials.test.ts", + "tests/smoke/data-directory.test.ts", + "tests/smoke/database-files.test.ts", + "tests/smoke/desktop-test-environment.test.ts", + "tests/smoke/documents.test.ts", + "tests/smoke/drop-imports.test.ts", + "tests/smoke/icons.test.ts", + "tests/smoke/package-files.test.ts", + "tests/smoke/pdf-assets.test.ts", + "tests/smoke/rename.test.ts", +] as const; + +/** What started the run, which fixes some lanes before any path is considered. */ +export type ChangeScope = "pull-request" | "main" | "everything"; + +/** Verify desktop lanes beyond the Linux checks and unit tests that every run performs. */ +export interface Lanes { + /** Build and package the app, then drive the packaged app through the end-to-end suite. */ + readonly app: boolean; + /** Repeat the unit tests, packaging and end-to-end suite on Windows and macOS. */ + readonly crossOs: boolean; + /** Build the distributable installers instead of only the unpacked app. */ + readonly installers: boolean; +} + +/** The lanes to run and the changed paths that selected them. */ +export interface LaneDecision { + readonly lanes: Lanes; + readonly reasons: ReadonlyArray; +} + +/** Whether a path entry from the lists above matches a repository-relative file path. */ +export function pathMatches(entry: string, file: string) { + if (entry.endsWith("/")) return file.startsWith(entry); + + if (entry.startsWith("*.")) return !file.includes("/") && file.endsWith(entry.slice(1)); + + return file === entry; +} + +const matching = (entries: ReadonlyArray, files: ReadonlyArray) => + files.filter((file) => entries.some((entry) => pathMatches(entry, file))); + +const allLanes: Lanes = { app: true, crossOs: true, installers: true }; + +/** + * Choose the lanes for a run. A pull request runs what its changed paths can affect; each lane + * includes the ones below it. Pushes to main run the Linux lanes as the backstop; Windows and + * macOS ran on the pull request when they were needed, and release tags run them again before + * publishing. + */ +export function decideLanes(scope: ChangeScope, files: ReadonlyArray): LaneDecision { + if (scope === "everything") return { lanes: allLanes, reasons: ["Running every lane."] }; + + if (scope === "main") + return { + lanes: { app: true, crossOs: false, installers: false }, + reasons: ["A push to main runs the Linux lanes."], + }; + + const installerPaths = matching(installerInputs, files); + const platformPaths = matching(platformSensitivePaths, files); + const installers = installerPaths.length > 0; + const crossOs = installers || platformPaths.length > 0; + + const app = + crossOs || files.some((file) => !inertPaths.some((entry) => pathMatches(entry, file))); + + const reasons = [ + ["installers", installerPaths], + ["Windows and macOS", platformPaths], + ] as const; + + return { + lanes: { app, crossOs, installers }, + reasons: reasons.flatMap(([lane, paths]) => + paths.length > 0 ? [`${lane}: ${paths.slice(0, 5).join(", ")}`] : [], + ), + }; +} + +/** Format lanes as GITHUB_OUTPUT lines. */ +export function laneOutputs(lanes: Lanes) { + return [`app=${lanes.app}`, `cross_os=${lanes.crossOs}`, `installers=${lanes.installers}`].join( + "\n", + ); +} + +const scopes: ReadonlyArray = ["pull-request", "main", "everything"]; + +if (import.meta.main) { + const [scopeArgument, pathsFile] = process.argv.slice(2); + const scope = scopes.find((candidate) => candidate === scopeArgument); + + if (scope === undefined || (scope === "pull-request" && pathsFile === undefined)) { + console.error("Usage: bun scripts/ci-changes.ts pull-request | main | everything"); + process.exit(1); + } + + const files = + pathsFile === undefined + ? [] + : (await Bun.file(pathsFile).text()).split("\n").filter((line) => line !== ""); + + const decision = decideLanes(scope, files); + + for (const reason of decision.reasons) console.error(reason); + console.log(laneOutputs(decision.lanes)); +} diff --git a/tests/smoke/ci-changes.test.ts b/tests/smoke/ci-changes.test.ts new file mode 100644 index 00000000..3401eed1 --- /dev/null +++ b/tests/smoke/ci-changes.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { + decideLanes, + inertPaths, + installerInputs, + laneOutputs, + pathMatches, + platformSensitivePaths, +} from "../../scripts/ci-changes.ts"; + +const pullRequest = (...files: Array) => decideLanes("pull-request", files).lanes; + +test("pull requests run only the lanes their changed paths can affect", () => { + expect(pullRequest("README.md", "docs/deployment.md")).toEqual({ + app: false, + crossOs: false, + installers: false, + }); + + expect(pullRequest("src/lib/review/Workbench.svelte", "tests/e2e/polish.spec.ts")).toEqual({ + app: true, + crossOs: false, + installers: false, + }); + + expect(pullRequest("backend/rename.ts")).toEqual({ app: true, crossOs: true, installers: false }); + expect(pullRequest("bun.lock")).toEqual({ app: true, crossOs: true, installers: false }); + + expect(pullRequest("electron-builder.json")).toEqual({ + app: true, + crossOs: true, + installers: true, + }); +}); + +test("pushes to main run the Linux lanes and manual or unknown changes run everything", () => { + expect(decideLanes("main", []).lanes).toEqual({ app: true, crossOs: false, installers: false }); + + expect(laneOutputs(decideLanes("everything", []).lanes)).toBe( + "app=true\ncross_os=true\ninstallers=true", + ); + + expect(decideLanes("pull-request", ["desktop/main.ts"]).reasons).toEqual([ + "Windows and macOS: desktop/main.ts", + ]); +}); + +test("every lane entry still matches a tracked file", () => { + const tracked = Bun.spawnSync(["git", "ls-files"], { stdout: "pipe" }).stdout.toString(); + const files = tracked.split("\n").filter((line) => line !== ""); + + expect(files.length).toBeGreaterThan(0); + + for (const entry of [...inertPaths, ...installerInputs, ...platformSensitivePaths]) + expect({ entry, matched: files.some((file) => pathMatches(entry, file)) }).toEqual({ + entry, + matched: true, + }); +});