diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccb549bf..6656639e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,31 @@ name: CI +# Pull requests, main and Release run the checks. A pull request or push to +# main that can change the desktop app also launches it: the unpacked app on +# Apple silicon and Linux, or every installer when a pull request changes the +# installer itself. Desktop Dev (nightly) and Release build and test every +# installer before publishing. on: push: + branches: [main] pull_request: workflow_dispatch: + workflow_call: permissions: contents: read concurrency: - group: ci-${{ github.ref }} + # A new push to a pull request replaces its run; any other run has a group of + # its own, so no commit on main waits or is skipped. + group: ci-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: true jobs: check: name: Node ${{ matrix.node }} runs-on: ubuntu-latest + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -40,3 +50,49 @@ jobs: - run: npm run site - name: Pack run: npm pack --ignore-scripts --dry-run + + # Which desktop check a pull request or a push to main needs, from the files + # it changes. Release calls CI for a tag and builds every installer itself. + changes: + if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + outputs: + desktop: ${{ steps.scope.outputs.desktop }} + steps: + - uses: actions/checkout@v5 + with: + # A pull request's merge commit and the base it merges into. + fetch-depth: 2 + - uses: actions/setup-node@v5 + with: + node-version: 24 + - id: scope + env: + BEFORE: ${{ github.event_name == 'push' && github.event.before || '' }} + run: | + # A push to main compares with the previous tip (the parent after a force push). + base=HEAD^1 + if [ -n "$BEFORE" ] && git fetch -q --depth=1 origin "$BEFORE"; then base=$BEFORE; fi + scope=$(bash scripts/desktop-scope.sh "$base") + # Main gets the quick check; the nightly Desktop Dev build makes its installers. + if [ "$GITHUB_EVENT_NAME" = push ] && [ "$scope" = installers ]; then scope=quick; fi + echo "Desktop check: $scope" + echo "desktop=$scope" >> "$GITHUB_OUTPUT" + + desktop: + needs: changes + if: needs.changes.outputs.desktop != 'none' + uses: ./.github/workflows/desktop.yml + with: + quick: ${{ needs.changes.outputs.desktop == 'quick' }} + + # The check to require: it passes when every job above passed or was not needed. + passed: + name: CI passed + if: always() + needs: [check, changes, desktop] + runs-on: ubuntu-latest + steps: + - name: Fail when a job failed or was cancelled + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/desktop-dev.yml b/.github/workflows/desktop-dev.yml new file mode 100644 index 00000000..1acfb818 --- /dev/null +++ b/.github/workflows/desktop-dev.yml @@ -0,0 +1,66 @@ +name: Desktop Dev + +# The desktop Dev channel: a nightly build of main (03:00 in UTC+8), or one +# started with Run workflow on main. Once every installer has built and passed +# its smoke test, the rolling desktop-dev prerelease that desktop apps on the +# Dev channel check moves to this commit. +on: + schedule: + - cron: '0 19 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-dev + cancel-in-progress: false + +jobs: + # A nightly run without new commits on main has nothing to publish. + changes: + runs-on: ubuntu-latest + outputs: + build: ${{ steps.check.outputs.build }} + steps: + - id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "$GITHUB_REF" != refs/heads/main ]; then + echo "::error::Desktop Dev publishes main. Run the Desktop workflow to build another branch." + exit 1 + fi + published=$(gh release download desktop-dev --repo "$GITHUB_REPOSITORY" --pattern desktop.json --output - 2>/dev/null | jq -r '.version // empty' || true) + if [ "$GITHUB_EVENT_NAME" = schedule ] && [ "${published##*-dev.}" = "$GITHUB_SHA" ]; then + echo "desktop-dev already has $published." + echo build=false >> "$GITHUB_OUTPUT" + else + echo build=true >> "$GITHUB_OUTPUT" + fi + + desktop: + needs: changes + if: needs.changes.outputs.build == 'true' + uses: ./.github/workflows/desktop.yml + with: + dev: true + secrets: inherit + + publish: + needs: desktop + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + - name: Download the tested installers and update layer + uses: actions/download-artifact@v4 + with: + pattern: Papilot-* + merge-multiple: true + path: release/desktop + - name: Publish the dev build + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/publish-desktop-dev.mjs release/desktop diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 81ae8aed..5250fefe 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -1,70 +1,44 @@ name: Desktop +# Builds the desktop app on native runners and launches it to check that it +# works. It never publishes: CI, Desktop Dev and Release call it. Run workflow +# builds every installer for any branch; the quick option instead checks only +# the unpacked app on Apple silicon and Linux, like most pull requests. on: workflow_dispatch: + inputs: + quick: + description: Check only the unpacked app on Apple silicon and Linux + type: boolean + default: false workflow_call: - # The desktop Dev channel: a nightly build of main (03:00 in UTC+8), or one - # started with Run workflow on main. - schedule: - - cron: '0 19 * * *' - push: - branches: [main, codex/electron-desktop] - paths: ['desktop/**', 'scripts/*desktop*', 'src/**', 'engine/**', 'examples/**', 'bin/**', 'skills/**', 'package*.json', '.github/workflows/desktop.yml'] - pull_request: - paths: ['desktop/**', 'scripts/*desktop*', 'src/**', 'engine/**', 'examples/**', 'bin/**', 'skills/**', 'package*.json', '.github/workflows/desktop.yml'] + inputs: + quick: + description: Check only the unpacked app on Apple silicon and Linux + type: boolean + default: false + dev: + description: Report VERSION-dev.COMMIT, for the Dev channel + type: boolean + default: false permissions: contents: read -# A push to main does not cancel the nightly dev build. -concurrency: - group: desktop-${{ github.ref }}-${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && 'dev' || 'ci' }} - cancel-in-progress: ${{ github.ref_type != 'tag' && github.event_name != 'schedule' && github.event_name != 'workflow_dispatch' }} - jobs: - # A nightly run without new commits on main has nothing to publish. - changes: - runs-on: ubuntu-latest - outputs: - build: ${{ steps.check.outputs.build }} - steps: - - id: check - env: - GH_TOKEN: ${{ github.token }} - run: | - published=$(gh release download desktop-dev --repo "$GITHUB_REPOSITORY" --pattern desktop.json --output - 2>/dev/null | jq -r '.version // empty' || true) - if [ "$GITHUB_EVENT_NAME" = schedule ] && [ "${published##*-dev.}" = "$GITHUB_SHA" ]; then - echo "desktop-dev already has $published." - echo build=false >> "$GITHUB_OUTPUT" - else - echo build=true >> "$GITHUB_OUTPUT" - fi - build: - name: ${{ matrix.platform }} ${{ matrix.arch }} - needs: changes - if: needs.changes.outputs.build == 'true' - runs-on: ${{ matrix.os }} + name: ${{ matrix.target }} + runs-on: ${{ fromJSON('{"mac-arm64":"macos-15","mac-x64":"macos-15-intel","linux-x64":"ubuntu-22.04"}')[matrix.target] }} timeout-minutes: 45 env: # Dev builds: their server and update layer report the commit. - PAPILOT_DESKTOP_REVISION: ${{ github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && github.sha || '' }} + PAPILOT_DESKTOP_REVISION: ${{ inputs.dev && github.sha || '' }} strategy: fail-fast: false matrix: - include: - - os: macos-15 - platform: mac - arch: arm64 - executable: mac-arm64/Papilot.app/Contents/MacOS/Papilot - - os: macos-15-intel - platform: mac - arch: x64 - executable: mac/Papilot.app/Contents/MacOS/Papilot - - os: ubuntu-22.04 - platform: linux - arch: x64 - executable: linux-unpacked/papilot + # Intel Macs run the same code on the slowest runner, so quick checks + # leave them to full builds. + target: ${{ fromJSON(inputs.quick && '["mac-arm64","linux-x64"]' || '["mac-arm64","mac-x64","linux-x64"]') }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -80,29 +54,36 @@ jobs: sudo apt-get update -qq sudo apt-get install -y libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 libgbm1 libnotify4 libsecret-1-0 libfuse2 xvfb xauth openbox - run: npm ci - - name: Build the app and installers + # Making the installers takes most of a full build. The smoke tests + # launch the unpacked app, which --dir builds by itself. + - name: Build the app${{ !inputs.quick && ' and installers' || '' }} env: CSC_LINK: ${{ secrets.MAC_CSC_LINK }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: npm run desktop:build - # Papilot's own code for in-place updates of installers with the same shell. + run: npm run desktop:build${{ inputs.quick && ' -- --dir' || '' }} + # Papilot's own code for in-place updates of installers with the same + # shell. The smoke test builds its own copy. - name: Build the update layer + if: ${{ !inputs.quick }} run: node scripts/desktop.mjs layer - name: Launch the packaged app and check its backend if: runner.os == 'macOS' + timeout-minutes: 10 env: - PAPILOT_DESKTOP_EXECUTABLE: ${{ github.workspace }}/release/desktop/${{ matrix.executable }} + PAPILOT_DESKTOP_EXECUTABLE: ${{ github.workspace }}/release/desktop/${{ matrix.target == 'mac-x64' && 'mac' || 'mac-arm64' }}/Papilot.app/Contents/MacOS/Papilot run: npm run test:desktop - - name: Test the Linux directory, AppImage and installed deb + - name: Test the Linux directory${{ !inputs.quick && ', AppImage and installed deb' || '' }} if: runner.os == 'Linux' - run: xvfb-run -a -s '-screen 0 1600x1000x24' bash scripts/check-desktop-linux.sh + timeout-minutes: 15 + run: xvfb-run -a -s '-screen 0 1600x1000x24' bash scripts/check-desktop-linux.sh${{ inputs.quick && ' --dir' || '' }} - name: Save installers + if: ${{ !inputs.quick }} uses: actions/upload-artifact@v4 with: - name: Papilot-${{ matrix.platform }}-${{ matrix.arch }} + name: Papilot-${{ matrix.target }} path: | release/desktop/*.dmg release/desktop/*.zip @@ -111,7 +92,7 @@ jobs: if-no-files-found: error retention-days: 7 - name: Save the update layer - if: matrix.platform == 'linux' + if: ${{ !inputs.quick && matrix.target == 'linux-x64' }} uses: actions/upload-artifact@v4 with: name: Papilot-app @@ -124,31 +105,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: desktop-check-${{ matrix.platform }}-${{ matrix.arch }} + name: desktop-check-${{ matrix.target }} path: test-results/desktop*.png if-no-files-found: ignore retention-days: 7 - - # The tested nightly build updates the rolling desktop-dev prerelease that - # desktop apps on the Dev channel check. - publish-dev: - needs: build - if: github.ref == 'refs/heads/main' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - runs-on: ubuntu-latest - permissions: - contents: write - concurrency: - group: desktop-dev-release - cancel-in-progress: false - steps: - - uses: actions/checkout@v5 - - name: Download the tested installers and update layer - uses: actions/download-artifact@v4 - with: - pattern: Papilot-* - merge-multiple: true - path: release/desktop - - name: Publish the dev build - env: - GH_TOKEN: ${{ github.token }} - run: node scripts/publish-desktop-dev.mjs release/desktop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a78a0d30..e8fb7b0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,17 +15,18 @@ concurrency: cancel-in-progress: false jobs: + # CI's checks on both supported Node versions; a tag pushed without a pull + # request gets them here. + ci: + uses: ./.github/workflows/ci.yml + desktop: - # The reusable workflow's dev publishing job (skipped for tags) declares - # contents: write; its build jobs keep read-only tokens. - permissions: - contents: write uses: ./.github/workflows/desktop.yml secrets: inherit release: - # Publish only after every native installer has built and passed its smoke test. - needs: desktop + # Publish only after CI and every native installer have passed. + needs: [ci, desktop] runs-on: ubuntu-latest env: ELECTRON_SKIP_BINARY_DOWNLOAD: '1' @@ -59,10 +60,6 @@ jobs: printf '```sh\nnpm install -g papilot@latest\n```\n' >> "$RUNNER_TEMP/notes.md" - run: npm ci - run: npm run setup:review - - run: npm run typecheck - - run: npm test - - run: npm run i18n -- check - - run: npm run i18n:check-app - name: Download tested desktop installers uses: actions/download-artifact@v4 with: diff --git a/desktop/README.md b/desktop/README.md index 81415d3c..ee260153 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -38,16 +38,16 @@ The smoke test opens the real app with temporary data, exercises the local API a - One app instance owns the server. Closing the window on macOS keeps tasks running; Quit stops the backend. Closing the Linux window also quits the app and stops its backend. A crashed Electron parent disconnects its backend. - The backend listens only on `127.0.0.1`, remembers its assigned port to preserve local drafts/preferences, and picks another port if the remembered one is occupied. That exceptional port change starts a new browser origin; database state remains intact. - The app finds common agent and MacTeX/Homebrew locations even when opened from Finder. It uses existing agent sign-ins. An explicit proxy environment takes precedence; otherwise it passes the system HTTP proxy to backend tools. -- Updates download only Papilot's own code when they can. An installer is a shell (Electron, the bundled Node, Codex and the production dependencies) around Papilot's code, and the shell's fingerprint (`scripts/desktop-layer.mjs`) covers everything except that code: the Node major version, Electron, electron-builder and Codex versions, the production entries of both lockfiles, and the files in `desktop/` and `scripts/desktop.mjs`. Every desktop build also packs Papilot's code as an update layer, `papilot-app-VERSION.tgz` (about 5 MB, versus about 330 MB for an installer), with a `desktop.json` manifest naming its version, fingerprint, size and SHA-256. +- Updates download only Papilot's own code when they can. An installer is a shell (Electron, the bundled Node, Codex and the production dependencies) around Papilot's code, and the shell's fingerprint (`scripts/desktop-layer.mjs`) covers everything except that code: the Node major version, Electron, electron-builder and Codex versions, the production entries of both lockfiles, and the files in `desktop/` and `scripts/desktop.mjs`. Every full desktop build also packs Papilot's code as an update layer, `papilot-app-VERSION.tgz` (about 5 MB, versus about 330 MB for an installer), with a `desktop.json` manifest naming its version, fingerprint, size and SHA-256. - Settings → Updates has the same **Stable** and **Dev (GitHub main)** channels as the web server. Stable reads `desktop.json` from the latest release (`releases/latest/download/desktop.json`), Dev from the rolling `desktop-dev` prerelease, which a nightly build of main updates. When the newest version's layer has this installer's fingerprint, **Update now** downloads it into `updates/desktop/layers`, verifies its size and hash, checks that it starts, and switches once tasks and PDF builds are idle; the page reconnects by itself. The layer runs on the installer's Node and dependencies through `node_modules` links, renewed at each launch because an AppImage mounts at a new path. Otherwise the update links to that release's installers. Returning to the installer's own version (for example from Dev back to Stable) needs no download. - Electron owns the switch (`desktop/runtime.ts`): it backs up the database, starts the new code on the same port, and restores the database and the previous version if it does not start. A layer that later fails at launch falls back to the installer's own server with a notice. Installing another app version retires downloaded layers. The development app (`npm run desktop`) always runs its checkout and has no channel. - Desktop update state lives in `updates/desktop/` (status, channel, active layer, lock and log). Legacy `updates/active.json` records and npm packages in `updates/releases` are ignored and left intact; web/CLI updates keep their own status, lock and stable/dev preference. ## Releases and signing -The Desktop workflow builds and tests on native macOS ARM64, macOS x64 and Ubuntu x64 runners. Branch/PR checks save installers and screenshots as Actions artifacts for seven days. It can also be run manually. The version-tag release workflow first builds and smoke-tests all desktop targets, then publishes npm, creates the GitHub release and attaches the tested `Papilot-VERSION-mac-ARCH.dmg/.zip`, `Papilot-VERSION-linux-x86_64.AppImage` and `Papilot-VERSION-linux-amd64.deb`. Rerunning a failed release job replaces its assets. No separate Electron version is maintained. +The Desktop workflow builds the app on native macOS ARM64, macOS x64 and Ubuntu x64 runners and launches it; it never publishes. A full build also makes every installer, launches the packaged app on all three platforms (on Linux also from the AppImage and the installed deb), and saves the installers and screenshots as Actions artifacts for seven days. Its quick form builds only the unpacked app (`npm run desktop:build -- --dir`) on macOS ARM64 and Ubuntu and launches it, skipping the installers that take most of a full build. CI decides with `scripts/desktop-scope.sh`: pull requests and pushes to main that can change the app get the quick form, and a pull request that changes the installer itself (its shell fingerprint) or how installers are made and tested gets a full build. Run workflow builds any branch, fully unless quick is checked. The version-tag release workflow first runs CI and a full build, then publishes npm, creates the GitHub release and attaches the tested `Papilot-VERSION-mac-ARCH.dmg/.zip`, `Papilot-VERSION-linux-x86_64.AppImage` and `Papilot-VERSION-linux-amd64.deb`. Rerunning a failed release job replaces its assets. No separate Electron version is maintained. -Each build job also runs `node scripts/desktop.mjs layer`, and the packaged smoke test installs that layer from a local stand-in for GitHub, restarts on it and checks the fallback (on Linux once, with the AppImage, whose path changes at every launch). Stable releases attach `desktop.json` after the installers and `papilot-app-VERSION.tgz`, so a manifest that apps can read names only uploaded files. Dev builds run nightly at 19:00 UTC when main has new commits, or when the workflow is run manually on main. They carry `PAPILOT_DESKTOP_REVISION`, so the bundled server and layer report `VERSION-dev.COMMIT`, and after every platform passes, `scripts/publish-desktop-dev.mjs` updates the `desktop-dev` prerelease: the layer and manifest every time, the six installers only when the fingerprint changed, and the tag moves to the commit. Pushes and pull requests build and test without publishing. Assets the previous manifest names stay until the next build, so an app that just read it can still download them. Installed apps read the manifest and layer format: keep `desktop.json` and the layer layout compatible, or raise `LAYER_FORMAT` so older apps ask for an installer. +Each full build also runs `node scripts/desktop.mjs layer`. Every packaged smoke test builds a layer of its own, installs it from a local stand-in for GitHub, restarts on it and checks the fallback (on Linux once, with the AppImage, whose path changes at every launch; quick checks do this on macOS only). Stable releases attach `desktop.json` after the installers and `papilot-app-VERSION.tgz`, so a manifest that apps can read names only uploaded files. The Desktop Dev workflow runs a full build of main nightly at 19:00 UTC when main has new commits, or when it is run manually on main. Dev builds carry `PAPILOT_DESKTOP_REVISION`, so the bundled server and layer report `VERSION-dev.COMMIT`, and after every platform passes, `scripts/publish-desktop-dev.mjs` updates the `desktop-dev` prerelease: the layer and manifest every time, the six installers only when the fingerprint changed, and the tag moves to the commit. Assets the previous manifest names stay until the next build, so an app that just read it can still download them. Installed apps read the manifest and layer format: keep `desktop.json` and the layer layout compatible, or raise `LAYER_FORMAT` so older apps ask for an installer. `desktopShell: 2` in package.json is a compatibility fence for older desktop installers that used npm updates. Their shell level is 1, so they direct users to a new desktop installer instead of activating this npm package. Keep this fence in future web releases. Current desktop apps do not use shell levels or npm overlays; only installing a desktop release changes their running version. diff --git a/scripts/check-desktop-linux.sh b/scripts/check-desktop-linux.sh index 936584d1..e9ca3d58 100644 --- a/scripts/check-desktop-linux.sh +++ b/scripts/check-desktop-linux.sh @@ -13,6 +13,9 @@ app="$PWD/release/desktop/$unpacked/papilot" PAPILOT_DESKTOP_UPDATE_CHECK=0 PAPILOT_DESKTOP_EXECUTABLE="$app" npm run test:desktop cp test-results/desktop-demo-packaged.png test-results/desktop-linux-directory.png +# --dir: a build without installers (npm run desktop:build -- --dir). +if [[ "${1:-}" == --dir ]]; then exit 0; fi + # Extract-and-run exercises the actual AppImage without depending on FUSE on CI. # The binary preflight uses the matching build resources; the launched backend # and renderer below come from the AppImage's own temporary extraction. diff --git a/scripts/desktop-scope.sh b/scripts/desktop-scope.sh new file mode 100755 index 00000000..a08cbaea --- /dev/null +++ b/scripts/desktop-scope.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Prints the desktop check that HEAD needs compared with BASE (default HEAD^1): +# none nothing that ships with the app changed +# quick the unpacked app on Apple silicon and Linux +# installers every installer: the installer's contents changed (its shell +# fingerprint), or how installers are made and tested +set -euo pipefail + +base=${1:-HEAD^1} +# Read the list first: a failed diff must fail, not look like an empty change. +files=$(git diff --name-only --no-renames "$base" HEAD) + +scope=none +while IFS= read -r file; do + case "$file" in + '') ;; + # Shipped with the app, Markdown included. + skills/* | examples/*) [[ $scope != none ]] || scope=quick ;; + # Never part of the app. + *.md | LICENSE | docs/* | site/* | tests/* | i18n/* | .github/ISSUE_TEMPLATE/* | .github/workflows/pages.yml | \ + scripts/build-site.mjs | scripts/check-site.mjs | scripts/site-* | scripts/i18n.ts | scripts/i18n/*) ;; + # How installers are made and tested. + scripts/desktop-layer.mjs | scripts/check-desktop.mjs | scripts/check-desktop-linux.sh | .github/workflows/desktop.yml) + scope=installers ;; + *) [[ $scope != none ]] || scope=quick ;; + esac +done <<< "$files" + +# The installer's contents, as the Dev channel decides on new installers: +# Electron, Node, Codex, production dependencies and desktop/. +if [[ $scope == quick ]]; then + tree=$(mktemp -d) + git worktree add -q --detach "$tree" "$base" + # shellcheck disable=SC2016 # ${root} is JavaScript + node --input-type=module -e ' + const [root, base] = process.argv.slice(1); + const { shellFingerprint } = await import(`${root}/scripts/desktop-layer.mjs`); + process.exit(shellFingerprint(base) === shellFingerprint(root) ? 0 : 1); + ' "$PWD" "$tree" || scope=installers + git worktree remove --force "$tree" +fi + +echo "$scope"