Files
openchamber/docs/TAURI_TO_ELECTRON_CUTOVER.md
T

17 KiB

Tauri → Electron auto-update cutover

Self-contained playbook. The branch and conversation where this plan was designed will not be around when the cutover happens — read this file top to bottom and execute; do not assume prior context.

Current status: the release workflow cutover is implemented. Desktop releases now build Electron and repackage that Electron .app into the old Tauri updater format for existing Tauri installs. The next safe engineering step is Step 5 — Remove Tauri-specific code, but only after the transition release has shipped and lived for at least 2 weeks with no rollback.

What this is

OpenChamber historically shipped as a Tauri app. A parallel Electron shell was added on branch electron-app (merged to main as part of a larger migration). Since then, both desktop shells have been released in the same GitHub release and each has its own auto-update channel:

Shell Manifest Update format Secret used to sign
Tauri latest.json .tar.gz + .sig TAURI_SIGNING_PRIVATE_KEY (Tauri signer / minisign format)
Electron latest-mac.yml .zip + blockmap Developer ID codesign (APPLE_* secrets)

Existing Tauri installs keep their own auto-update path (latest.json). Electron installs auto-update through latest-mac.yml. They coexist without conflict because filenames and manifests differ.

At some point the user wants to stop maintaining the Tauri build and make the Tauri installs migrate themselves into Electron via auto-update. This document describes how to do that in a single "transition release".

The core trick

Tauri's updater downloads whatever .tar.gz the latest.json points at, verifies the minisign signature, unpacks the contents over the existing .app directory, and restarts. It does not introspect the payload — it just replaces files.

So: produce a .tar.gz of the Electron .app, sign it with the existing Tauri minisign key, point latest.json at it. Tauri users receive the update, their OpenChamber.app becomes the Electron bundle in-place, and next launch starts Electron. Subsequent updates go through latest-mac.yml (electron-updater). One-way migration, one-shot workflow change.

Prerequisites before running the cutover

Check all of these before making any release:

  1. Electron has shipped stable through its own latest-mac.yml path for at least 2 releases. Verify:

    gh release list --repo btriapitsyn/openchamber
    gh release view vX.Y.Z --repo btriapitsyn/openchamber \
      | grep -E 'OpenChamber-.*\.zip|latest-mac\.yml'
    

    A user on Electron should have successfully auto-updated at least once. If not, pause and stabilise that path first — don't stack risk.

  2. ~/.config/openchamber/settings.json is still the shared state path. Tauri src-tauri/src/main.rs:settings_file_path and Electron packages/electron/main.mjs:settingsFilePath must both resolve to $HOME/.config/openchamber/settings.json. If either has moved, data parity breaks and this migration loses user data. Audit both paths, update the non-migrated shell to match before proceeding.

  3. Electron appId is dev.openchamber.desktop (check packages/electron/package.json build.appId). Tauri identifier is ai.opencode.openchamber. These differ intentionally — it means macOS LaunchServices will re-register after the in-place replace. That's fine but see "Risks" below.

  4. All GitHub secrets still valid: APPLE_CERTIFICATE, APPLE_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID, TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD. A workflow_dispatch dry-run should succeed before the real tag.

  5. minisign CLI is available on the macOS runner (or installable via brew). Used to sign the Electron tarball with the Tauri key.

Current release workflow

The release workflow no longer builds a Tauri desktop app. It now does this:

create-release
├── build-desktop-electron-macos        (Electron .dmg/.zip/blockmap/latest-mac.yml)
├── repackage-electron-as-tauri-update  (Electron .app -> Tauri .app.tar.gz/.sig)
├── publish-npm
├── combine-manifests                   (Tauri latest.json for migration only)
├── combine-electron-manifests          (Electron latest-mac.yml)
└── finalize-release

The transition works like this:

  1. build-desktop-electron-macos builds, signs, and notarizes the Electron app.
  2. It uploads the signed OpenChamber.app as a short-lived Actions artifact.
  3. repackage-electron-as-tauri-update downloads that Electron .app.
  4. It packs it into OpenChamber-<version>-darwin-*.app.tar.gz.
  5. It signs that tarball with tauri signer sign and the existing Tauri signing key.
  6. It uploads the tarball and .sig to the GitHub release.
  7. It generates Tauri-compatible manifests and combine-manifests merges them into latest.json.

So old Tauri installs still see the update contract they expect:

latest.json -> .app.tar.gz -> .sig

But the payload inside the .app.tar.gz is Electron, not Tauri. Tauri's updater only verifies the signature and extracts the bundle over the existing /Applications/OpenChamber.app. After restart, the app is Electron and future updates use latest-mac.yml through electron-updater.

Note: actions/upload-artifact can download a .app directory artifact as its inner Contents/ folder instead of OpenChamber.app/Contents. The repackage job handles both shapes and wraps Contents/ back into OpenChamber.app before creating the tarball.

Historical release workflow changes

The file edited for the cutover was .github/workflows/release.yml.

Before the cutover it had these jobs (simplified):

create-release
├── build-desktop-macos           (Tauri  .dmg/.tar.gz/.tar.gz.sig)
├── build-desktop-electron-macos  (Electron .dmg/.zip/blockmap/latest-mac.yml)
├── publish-npm
├── combine-manifests             (merges Tauri per-arch JSONs → latest.json)
├── combine-electron-manifests    (merges Electron per-arch YMLs → latest-mac.yml)
└── finalize-release

Step 1 — Remove the Tauri build

Status: done.

Delete these jobs entirely:

  • build-desktop-macos
  • combine-manifests

They are replaced by the repackage job (below). finalize-release needs: list must be updated to drop both.

Step 2 — Add a repackage job

Status: done.

Insert after build-desktop-electron-macos:

repackage-electron-as-tauri-update:
  needs: [create-release, build-desktop-electron-macos]
  runs-on: macos-26
  strategy:
    fail-fast: false
    matrix:
      include:
        - arch: arm64
          platform: darwin-aarch64
        - arch: x64
          platform: darwin-x86_64
  steps:
    - uses: actions/checkout@v4
    - uses: oven-sh/setup-bun@v2
    - uses: actions/setup-node@v4
      with:
        node-version: '20'

    # Pull the signed+notarized Electron .app that build-desktop-electron-macos
    # already produced. Either re-download the dmg and mount+copy the .app, or
    # (cleaner) modify build-desktop-electron-macos to upload the .app itself
    # as an artifact so this job can download it. Prefer the latter — adds one
    # `actions/upload-artifact@v4` step uploading `packages/electron/dist/mac-<arch>/OpenChamber.app`.

    - name: Download signed Electron .app
      uses: actions/download-artifact@v4
      with:
        name: electron-app-${{ matrix.arch }}
        path: staged

    - name: Tar and sign Electron .app as Tauri update payload
      env:
        TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
        TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
        VERSION: ${{ needs.create-release.outputs.version }}
      run: |
        set -euo pipefail
        cd staged
        # The tarball name convention Tauri's updater expects. Must end in
        # `.app.tar.gz`. Name stays stable — Tauri updater does not care about
        # the inner .app name.
        TARBALL="OpenChamber.app.tar.gz"
        tar -czf "$TARBALL" OpenChamber.app

        # Use Tauri's signer instead of minisign directly. The CI secret is in
        # the format consumed by TAURI_SIGNING_PRIVATE_KEY.
        bun run --cwd ../packages/desktop tauri signer sign "$PWD/$TARBALL"

        # Rename per platform so the release has distinct names for arm64/x64.
        mv "$TARBALL" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz"
        mv "${TARBALL}.sig" "OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig"

    - name: Generate Tauri latest-<platform>.json
      env:
        VERSION: ${{ needs.create-release.outputs.version }}
        REPO: ${{ github.repository }}
      run: |
        SIG=$(cat staged/OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz.sig)
        TAR=OpenChamber-${VERSION}-${{ matrix.platform }}.app.tar.gz
        cat > staged/latest-${{ matrix.platform }}.json <<EOF
        {
          "version": "${VERSION}",
          "notes": "OpenChamber has moved to Electron. This update replaces the Tauri shell with the Electron build. Subsequent updates will be delivered via the Electron auto-updater.",
          "pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
          "platforms": {
            "${{ matrix.platform }}": {
              "signature": "${SIG}",
              "url": "https://github.com/${REPO}/releases/download/v${VERSION}/${TAR}"
            }
          }
        }
        EOF

    - name: Upload tarball + sig to release
      uses: softprops/action-gh-release@v2
      with:
        tag_name: v${{ needs.create-release.outputs.version }}
        files: |
          staged/*.app.tar.gz
          staged/*.app.tar.gz.sig
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

    - name: Upload per-platform manifest as artifact for merge
      uses: actions/upload-artifact@v4
      with:
        name: tauri-manifest-${{ matrix.platform }}
        path: staged/latest-${{ matrix.platform }}.json
        retention-days: 1

Step 3 — Re-add the combine-manifests job

Status: done.

Bring it back (it was deleted in Step 1) but sourcing artifacts from the repackage job instead of the old Tauri build. The merging logic is identical to what the old job did. Minimum job shape:

combine-manifests:
  needs: [create-release, repackage-electron-as-tauri-update]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/download-artifact@v4
      with:
        pattern: tauri-manifest-*
        path: artifacts
    - name: Merge
      run: |
        # Copy the original merge logic from git history. It takes the two
        # per-platform JSONs and produces a single `latest.json` with both
        # platform entries. Upload as a release asset.
        # Search git history: git log --all --diff-filter=D -- .github/workflows/release.yml
        # Find the commit that deleted the old merge step and copy its shell block.
        ...
    - uses: softprops/action-gh-release@v2
      with:
        tag_name: v${{ needs.create-release.outputs.version }}
        files: artifacts/latest.json
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Step 4 — Update finalize-release.needs

Status: done.

finalize-release:
  needs: [create-release, build-desktop-electron-macos, repackage-electron-as-tauri-update, publish-npm, combine-manifests, combine-electron-manifests]

Step 5 — Remove Tauri-specific code

Status: next safe refactoring step, after the transition release ships and has been out at least 2 weeks with no rollback.

Do not do this in the same release as the migration. Once the transition release has proved stable, remove:

  • packages/desktop/ (entire package — Tauri Rust + UI glue)
  • Any isTauriShell() branches that are now dead code in packages/ui/src/ (search for the symbol; most call sites already fall through to the Electron path because our preload exposes a __TAURI__ shim; audit each before removing).
  • This file (docs/TAURI_TO_ELECTRON_CUTOVER.md) — mission accomplished.

Do this in a separate PR. Keep the transition release workflow intact until the cleanup lands; rolling the cleanup into the transition release itself makes debugging much harder if the migration misbehaves for a user.

The manual arm64 macOS DMG workflow has already been changed to build Electron only, so there should be no GitHub Actions path that accidentally produces a new Tauri DMG.

When rerunning the same release version with workflow_dispatch, use dry_run=true if npm and marketplace packages are already published. In that mode the workflow still rebuilds release assets, but skips publishing to npm and skips re-uploading the npm tarball asset.

Validation before tagging the transition release

You must manually validate with a real Tauri install. Do NOT skip this.

  1. Have the previous Tauri release installed locally (/Applications/OpenChamber.app with Contents/Info.plist showing CFBundleIdentifier = ai.opencode.openchamber).
  2. Tag the transition release to a test tag (e.g. v2.0.0-migration-test) and push.
  3. Let the workflow complete. Do not merge cleanup PR yet.
  4. In the running Tauri app, use the built-in "Check for updates".
  5. Accept the update. The app should download, verify, extract, restart.
  6. After restart, Info.plist under /Applications/OpenChamber.app/ should now show CFBundleIdentifier = dev.openchamber.desktop.
  7. Settings should be intact: hosts list, default host, sessions history.
  8. In the new Electron app, "Check for updates" should report no update available (it's now at the transition version, which is the latest).
  9. Produce a dummy v2.0.1 Electron-only release to prove the subsequent Electron-path update works. Accept it. App relaunches into v2.0.1.

If any step fails:

  • Delete the test tag and GitHub release.
  • Do not delete yet-shipped artifacts from a real tag until rollback below.

Rollback if the transition release misbehaves

If users report the Tauri → Electron update bricks their install:

  1. Immediately delete the latest release asset OpenChamber-*.app.tar.gz and latest.json from the GitHub release (keep the DMGs so manual download still works).
  2. Re-upload the previous version's latest.json as the current latest so Tauri updaters see "up to date" instead of a broken update on next check.
  3. Post a support note: users who already applied the broken update can download a fresh Electron .dmg manually and drag-replace. Their ~/.config/openchamber/settings.json survives.
  4. Investigate, fix the workflow, retry with a new version number.

Risks & edge cases

Different CFBundleIdentifier at same path

macOS LaunchServices caches identifier ↔ path mappings. When we replace ai.opencode.openchamber with dev.openchamber.desktop at the same .app path, LaunchServices will rebuild on next launch (automatic). Usually fine. If a user's system is in a weird state, a killall Dock or logout/login fixes it. Worth noting in the release notes.

macOS notification permissions

Notification permission is per-bundle-id. After migration, the app has a new bundle-id, so the first notification will re-prompt the user. Unavoidable. Mention in release notes.

The openchamber:// protocol was registered for ai.opencode.openchamber. After migration, dev.openchamber.desktop registers itself on first launch. LaunchServices updates the handler. Usually seamless. Test with open 'openchamber://session/test' post-migration.

Gatekeeper "damaged app" dialog

Rare. Triggered if the replaced .app fails a mid-extract codesign check. Can happen if Tauri's extractor corrupts xattrs. Mitigation: test on a pristine macOS install before tagging production.

Users on unsupported old Tauri versions

If a user is on a very old Tauri build that doesn't know how to do the fetch-verify-extract flow, they're stuck. Expected: negligibly few users; they'll just stay on their old version forever until they manually download. Acceptable.

Rollback-after-migration-accepted is impossible per-user

Once a user is on Electron, the Tauri updater is gone. If they want to go back to a Tauri build, they must manually download. We don't support this.

Relevant files to understand before making changes

  • .github/workflows/release.yml — the release workflow.
  • packages/electron/package.json — electron-builder config (appId, mac/dmg, publish, artifactName).
  • packages/electron/main.mjs — autoUpdater setup (setupAutoUpdater, desktop_check_for_updates, desktop_download_and_install_update, desktop_restart). Understand this flow before touching the CI.
  • packages/electron/scripts/finalize-latest-yml.mjs — per-arch latest-mac.yml merger. Already wired in combine-electron-manifests.
  • packages/desktop/src-tauri/tauri.conf.json — legacy Tauri identifier, minisign pubkey embedded for updater verification. Don't modify; just reference for context.

Working protocol

Default to a dry-run (test tag like vX.Y.Z-migration-test on a workflow_dispatch run) before the real tag. Surface only business-level decisions — "cutover this release, or hold one more cycle?" — and make technical calls (minisign invocation flags, YAML layout, job dependency order) yourself, documenting each one in the PR description.