From cc4a727a4f66892b5ca5419e0deb2dfc65ba4f7e Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 10:22:31 +1000 Subject: [PATCH 01/10] Create 260311-tauri-auto-update-phase2-feasibility.md --- ...11-tauri-auto-update-phase2-feasibility.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/260311-tauri-auto-update-phase2-feasibility.md diff --git a/docs/260311-tauri-auto-update-phase2-feasibility.md b/docs/260311-tauri-auto-update-phase2-feasibility.md new file mode 100644 index 0000000..9cb30df --- /dev/null +++ b/docs/260311-tauri-auto-update-phase2-feasibility.md @@ -0,0 +1,204 @@ +# 260311: Tauri auto-update phase 2 feasibility + +## Goal + +Ship true in-app app updates for the desktop build with this UX: + +- app checks for updates in the background +- app downloads the update silently after startup settles +- user sees nothing until the update is fully staged +- user gets a single `Restart to update` style toast +- clicking the toast installs/applies the staged update and relaunches the app + +This is an updater/distribution pipeline project, not mainly a UI project. + +## Decisions so far + +- Platform rollout: macOS first is acceptable. +- Feed/distribution: use GitHub Releases if it works; do not build a custom update service unless forced. +- Download timing: start after idle/startup settles, not immediately at launch. +- UX target: silent background download, then one-click restart/apply. + +## Current repo state + +Missing today: + +- no `tauri-plugin-updater` dependency in Rust +- no `@tauri-apps/plugin-updater` dependency in JS +- no updater plugin registration in `src-tauri/src/lib.rs` +- no updater config in `src-tauri/tauri.conf.json` +- no `bundle.createUpdaterArtifacts` +- no updater signing key setup in release automation +- no published updater metadata asset (`latest.json` or equivalent) + +Relevant files: + +- `src-tauri/Cargo.toml` +- `src-tauri/src/lib.rs` +- `src-tauri/tauri.conf.json` +- `.github/workflows/release.yml` +- `scripts/build-prod.js` + +## Answer: can we generate updater artifacts/signatures from the final post-processed bundles? + +Not with the current release flow as written. + +Why: + +1. Tauri updater artifacts/signatures are generated during the Tauri build step. +2. Our workflow mutates the app bundle after that step: + - copies `personal-server/dist/node_modules` into the bundle + - re-signs nested binaries + - re-signs the outer macOS app + - recreates the DMG +3. Updater signatures must match the exact bytes of the artifact being served. + +Implication: + +- Any updater bundle/signature generated before those post-build mutations cannot be trusted as the final updater artifact. +- For macOS specifically, the updater uses a `.app.tar.gz` updater bundle, not the DMG, so the critical question is whether that `.app.tar.gz` was produced before or after the bundle was finalized. In the current flow, it would be produced too early. + +What is still possible: + +- We can likely make it work if we fully own final updater artifact generation/signing after post-processing. +- That means either: + - stop mutating bundles after Tauri build, or + - replace the current `tauri-action` “build then mutate” flow with a custom pipeline that creates the final updater bundle and signature from the finalized app. + +Conclusion: + +- **Current workflow:** no +- **In principle with custom final-artifact signing:** yes + +## Answer: can we stop post-processing bundles by changing how `personal-server` resources are packaged? + +Probably yes, and this is the highest-leverage path. + +Why I think that: + +- Tauri 2 resource docs explicitly support recursive directory bundling with preserved structure. +- `bundle.resources` supports `"dir/"` for recursive copy and object mapping for explicit target paths. +- The current repo comments still talk about old `dist/*` behavior (“only copies files”), but the actual config already uses object mapping, which suggests the current workaround may be partly stale or based on an older failure mode. + +Current likely issue: + +- `personal-server` is a `pkg` binary that still needs real filesystem `node_modules` beside it for native addons like `better-sqlite3`. +- The repo currently compensates by copying those modules into the app bundle after build. + +Best-case outcome: + +- Bundle `../personal-server/dist/` recursively as a normal Tauri resource. +- Ensure the final app ships with: + - `personal-server/dist/personal-server` + - `personal-server/dist/node_modules/**` +- Remove the post-build copy/repack steps. + +If that works, updater support gets much simpler because the Tauri-built bundle becomes the final shipped bundle. + +Conclusion: + +- **Likely yes** +- This should be proven with a packaging spike before doing the full updater integration. + +## GitHub Releases as feed + +GitHub Releases is acceptable. + +Important nuance: + +- “GitHub Releases as feed” does **not** mean “only upload binaries”. +- Tauri updater still needs metadata describing the latest version and per-platform signed artifact URLs/signatures. + +Practical shape: + +- keep release assets on GitHub Releases +- publish updater metadata as a release asset too (`latest.json` or equivalent) +- point Tauri updater endpoints at that static metadata URL + +This keeps the system backend-free while still using the real updater protocol. + +## MacOS-first rollout + +macOS first is a good idea. + +Why: + +- current release workflow is already most customized on macOS +- the desired UX matters most there right now +- it reduces surface area while we prove the packaging/signing/updater model + +Recommended rollout: + +1. prove packaging spike on macOS +2. ship updater on macOS only +3. extend to Windows/Linux once packaging and signing are stable + +## Download timing + +`after idle/startup settles` is safer than immediate launch download. + +Recommended behavior: + +- startup: check availability without blocking startup-critical work +- after app settles / idle delay: start silent download if update exists +- once fully staged: show `Restart to update` + +This avoids competing with startup, connector initialization, and personal-server startup. + +## What blocks shipping today + +The main ship/no-ship questions are: + +1. Can we eliminate post-build bundle mutation by packaging `personal-server/dist` correctly through Tauri resources? +2. If not, can we generate final updater bundles/signatures after all mutations and re-signing are complete? +3. Can GitHub Actions publish the final updater metadata/assets cleanly for macOS-first rollout? + +Question 1 is the best first bet. + +## Recommended next steps + +### Track A: packaging spike + +Goal: remove post-processing. + +- create a branch/spike that packages `../personal-server/dist/` recursively through `bundle.resources` +- build macOS app +- inspect resulting `.app` contents +- verify `personal-server/dist/node_modules` exists in the final bundle +- verify the bundled personal server launches correctly from the packaged app +- if successful, delete the copy/re-sign/rebuild workaround path + +### Track B: updater pipeline spike + +Goal: prove updater mechanics on macOS once Track A works. + +- add updater plugin/config +- generate signing keys +- enable `createUpdaterArtifacts` +- publish updater metadata to GitHub Releases +- wire a minimal check -> download -> restart flow +- verify update from one macOS release to the next + +### Track C: fallback if Track A fails + +Goal: keep current packaging but still support updater. + +- replace current `tauri-action` usage with a custom final-artifact pipeline +- mutate bundle first +- re-sign final app +- create updater bundle from the finalized app +- sign the updater bundle +- publish matching metadata + +This is more work and should only be used if Track A fails. + +## Current recommendation + +Do not start by wiring updater APIs into the app UI. + +Start with a macOS packaging spike to answer one binary question: + +- can `personal-server/dist/node_modules` be bundled correctly by Tauri without post-build mutation? + +If yes, phase 2 becomes straightforward. +If no, we need a custom final-artifact updater pipeline before any product work matters. From fc0b0c2fa4edd7f11d48ea589cf338823944832a Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 10:32:49 +1000 Subject: [PATCH 02/10] docs: reorganize auto-update feasibility spike result Move the macOS packaging spike evidence down to Track A so the doc reads as conclusions first and proof near the recommended next step. Made-with: Cursor --- ...11-tauri-auto-update-phase2-feasibility.md | 71 ++++++++++++++----- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/docs/260311-tauri-auto-update-phase2-feasibility.md b/docs/260311-tauri-auto-update-phase2-feasibility.md index 9cb30df..b2130c6 100644 --- a/docs/260311-tauri-auto-update-phase2-feasibility.md +++ b/docs/260311-tauri-auto-update-phase2-feasibility.md @@ -72,7 +72,7 @@ Conclusion: ## Answer: can we stop post-processing bundles by changing how `personal-server` resources are packaged? -Probably yes, and this is the highest-leverage path. +For macOS resource copying, yes. Why I think that: @@ -80,25 +80,28 @@ Why I think that: - `bundle.resources` supports `"dir/"` for recursive copy and object mapping for explicit target paths. - The current repo comments still talk about old `dist/*` behavior (“only copies files”), but the actual config already uses object mapping, which suggests the current workaround may be partly stale or based on an older failure mode. -Current likely issue: +What we needed to prove: + +- whether Tauri can already package `personal-server/dist/node_modules` into the raw `.app` +- whether the repo's current custom copy step is actually doing anything essential on macOS + +Current remaining issue: - `personal-server` is a `pkg` binary that still needs real filesystem `node_modules` beside it for native addons like `better-sqlite3`. -- The repo currently compensates by copying those modules into the app bundle after build. +- the repo still needs a final signing/notarization strategy for the completed macOS app bundle Best-case outcome: -- Bundle `../personal-server/dist/` recursively as a normal Tauri resource. -- Ensure the final app ships with: - - `personal-server/dist/personal-server` - - `personal-server/dist/node_modules/**` -- Remove the post-build copy/repack steps. +- keep bundling `personal-server/dist` through normal Tauri resources +- remove the macOS resource-copy step from the custom post-processing path +- keep a final-sign step on the completed app bundle before creating updater artifacts / DMG If that works, updater support gets much simpler because the Tauri-built bundle becomes the final shipped bundle. Conclusion: -- **Likely yes** -- This should be proven with a packaging spike before doing the full updater integration. +- **Yes for macOS resource copying** +- **Still need final app signing after bundle completion** ## GitHub Releases as feed @@ -161,12 +164,41 @@ Question 1 is the best first bet. Goal: remove post-processing. -- create a branch/spike that packages `../personal-server/dist/` recursively through `bundle.resources` -- build macOS app -- inspect resulting `.app` contents -- verify `personal-server/dist/node_modules` exists in the final bundle -- verify the bundled personal server launches correctly from the packaged app -- if successful, delete the copy/re-sign/rebuild workaround path +#### Result (2026-03-11) + +I ran a raw local macOS bundle build with no custom post-copy step: + +- command: `CI=true npm run tauri -- build --bundles app` +- output: `src-tauri/target/release/bundle/macos/DataConnect.app` + +Observed: + +- raw Tauri packaging already included: + - `Contents/Resources/personal-server/dist/personal-server` + - `Contents/Resources/personal-server/dist/node_modules/better-sqlite3` + - `Contents/Resources/personal-server/dist/node_modules/bindings` + - `Contents/Resources/personal-server/dist/node_modules/file-uri-to-path` +- the same resource tree also existed earlier in Tauri's staging directory at `src-tauri/target/release/personal-server/dist` +- the bundled `personal-server` binary verified successfully with `codesign --verify --strict` +- the bundled `better_sqlite3.node` addon also verified successfully with `codesign --verify --strict` + +Important failure: + +- the outer raw `.app` failed `codesign --verify --strict` +- `codesign -dv --verbose=4` showed `Sealed Resources=none` +- ad-hoc re-signing the completed `.app` fixed verification immediately and produced `Sealed Resources version=2` + +Interpretation: + +- the current macOS resource-copy workaround is not needed to get `node_modules` into the final `.app` +- the remaining macOS finalization need is signing the completed app bundle after all resources are in place + +Current status: + +- status: partially proven +- raw macOS app already contains `personal-server/dist/node_modules` +- next proof still needed: launch/runtime validation from the packaged app +- if runtime passes, delete the macOS copy step and keep only final-sign/final-artifact steps ### Track B: updater pipeline spike @@ -200,5 +232,8 @@ Start with a macOS packaging spike to answer one binary question: - can `personal-server/dist/node_modules` be bundled correctly by Tauri without post-build mutation? -If yes, phase 2 becomes straightforward. -If no, we need a custom final-artifact updater pipeline before any product work matters. +Current answer: + +- yes for resource packaging +- not yet fully proven for runtime launch behavior +- final-sign/final-artifact generation still needs to happen after the completed app exists From 885f37a044c472d6dc6bc90995bcb25940a333b0 Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 10:49:11 +1000 Subject: [PATCH 03/10] build(macos): remove redundant bundled node_modules copy Record the packaging and signature preservation spikes, drop the macOS resource-copy step, and keep final app signing as the bundle finalization path. Made-with: Cursor --- .github/workflows/release.yml | 34 +++---------- ...11-tauri-auto-update-phase2-feasibility.md | 50 +++++++++++++++++++ scripts/build-prod.js | 38 ++++---------- 3 files changed, 68 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f26fe67..11f9acf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,13 +190,14 @@ jobs: df -h / || true shell: bash - - name: Copy native modules and finalize bundles + - name: Finalize bundles env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -x # Enable verbose debugging - # Copy node_modules into macOS .app bundles (preserving directory structure) + # Re-sign the completed macOS app and recreate the DMG from the final app. + # personal-server/dist/node_modules is already bundled by Tauri resources. if [ "${{ matrix.platform }}" = "macos-latest" ]; then # Debug: Show what's in personal-server/dist echo "=== Contents of personal-server/dist ===" @@ -214,34 +215,15 @@ jobs: "$node_binary" done - for app in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app; do - [ -e "$app" ] || { echo "No .app found at $app"; continue; } - echo "=== Processing $app ===" - - # Show current state - echo "Before copy - Resources contents:" - ls -la "$app/Contents/Resources/personal-server/dist/" || echo "personal-server/dist not in Resources" - - dest="$app/Contents/Resources/personal-server/dist/node_modules" - mkdir -p "$dest" - - # Copy with verbose output - if [ -d "personal-server/dist/node_modules" ]; then - cp -Rv personal-server/dist/node_modules/* "$dest/" - echo "=== After copy - node_modules contents ===" - ls -la "$dest/" || echo "copy failed" - else - echo "ERROR: personal-server/dist/node_modules does not exist!" - exit 1 - fi - - echo "Copied node_modules to $dest" - done - # Re-sign nested binaries with their entitlements, then re-sign the .app for app in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app; do [ -e "$app" ] || continue + echo "=== Processing $app ===" + echo "Bundled resource contents:" + ls -la "$app/Contents/Resources/personal-server/dist/" || echo "personal-server/dist not in Resources" + ls -la "$app/Contents/Resources/personal-server/dist/node_modules/" || { echo "ERROR: node_modules NOT in app bundle!"; exit 1; } + # Sign personal-server binary with JIT entitlements (--deep would strip them) ps_bin="$app/Contents/Resources/personal-server/dist/${{ matrix.ps_binary_name }}" if [ -f "$ps_bin" ]; then diff --git a/docs/260311-tauri-auto-update-phase2-feasibility.md b/docs/260311-tauri-auto-update-phase2-feasibility.md index b2130c6..d34f105 100644 --- a/docs/260311-tauri-auto-update-phase2-feasibility.md +++ b/docs/260311-tauri-auto-update-phase2-feasibility.md @@ -200,6 +200,56 @@ Current status: - next proof still needed: launch/runtime validation from the packaged app - if runtime passes, delete the macOS copy step and keep only final-sign/final-artifact steps +#### Follow-up cleanup (2026-03-11) + +Implemented the first cleanup pass: + +- removed the redundant macOS `node_modules` copy step from `scripts/build-prod.js` +- removed the redundant macOS `node_modules` copy step from `.github/workflows/release.yml` +- kept final app signing in place + +Validation: + +- `node scripts/build-prod.js` completed successfully +- `codesign --verify --strict` passed on the final `.app` +- bundled `personal-server/dist/node_modules` was still present in the final `.app` +- the packaged app binary stayed up during a short local smoke run + +Open question that remains after this cleanup: + +- whether CI still needs the nested in-app re-sign loop for `personal-server` and `.node` files, or whether pre-build signing plus final outer-app signing is sufficient + +#### Nested-signature preservation check (2026-03-11) + +Ran a follow-up preservation test: + +- ad-hoc signed `personal-server/dist/personal-server` with `personal-server/entitlements.plist` +- ad-hoc signed `personal-server/dist/node_modules/better-sqlite3/build/Release/better_sqlite3.node` +- built a raw macOS app with `CI=true npm run tauri -- build --bundles app` +- inspected the bundled copies inside `DataConnect.app` + +Observed: + +- the bundled `personal-server` copy preserved the same signature metadata and CDHash as the pre-signed source binary +- the bundled `personal-server` copy preserved the JIT entitlements from `personal-server/entitlements.plist` +- the bundled `better_sqlite3.node` copy verified successfully with `codesign --verify --strict` + +Interpretation: + +- Tauri resource bundling preserves the nested binary signatures/entitlements we apply before build +- the current CI in-app nested re-sign loop is likely redundant + +Remaining uncertainty: + +- local ad-hoc signature preservation is proven +- Apple notarization with Developer ID signatures is still not yet proven in CI + +Working recommendation: + +- keep pre-build signing of `personal-server` and `.node` files +- keep final outer-app signing +- defer removing the CI in-app nested re-sign loop until one notarization-backed CI run proves it is unnecessary + ### Track B: updater pipeline spike Goal: prove updater mechanics on macOS once Track A works. diff --git a/scripts/build-prod.js b/scripts/build-prod.js index ae96e01..2310a3a 100644 --- a/scripts/build-prod.js +++ b/scripts/build-prod.js @@ -7,16 +7,12 @@ * 1. Builds the playwright-runner into a standalone binary * 2. Builds the personal-server into a standalone binary * 3. Builds the Tauri .app bundle - * 4. Injects personal-server native addons (node_modules/) into the .app + * 4. Re-signs the completed .app so macOS seals bundled resources * 5. Creates the DMG from the complete .app - * - * Tauri's resource glob flattens subdirectories, so we can't include - * node_modules/ via tauri.conf.json. Instead we build the .app first, - * copy node_modules/ in, then create the DMG ourselves. */ import { execSync } from 'child_process'; -import { existsSync, cpSync, readdirSync, mkdirSync, readFileSync } from 'fs'; +import { existsSync, readdirSync, mkdirSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { platform, arch } from 'os'; @@ -42,21 +38,6 @@ function getVersion() { return conf.version; } -/** Copy personal-server native addons (node_modules/) into .app Resources */ -function copyNativeModulesIntoApp(appPath) { - const srcNodeModules = join(PERSONAL_SERVER, 'dist', 'node_modules'); - - if (!existsSync(srcNodeModules)) { - log('WARNING: personal-server dist/node_modules not found, skipping copy'); - return; - } - - const destNodeModules = join(appPath, 'Contents', 'Resources', 'personal-server', 'dist', 'node_modules'); - log(` Copying native addons to ${destNodeModules}`); - mkdirSync(dirname(destNodeModules), { recursive: true }); - cpSync(srcNodeModules, destNodeModules, { recursive: true }); -} - /** Find the .app bundle in the macos bundle directory */ function findAppBundle() { const macosBundle = join(ROOT, 'src-tauri', 'target', 'release', 'bundle', 'macos'); @@ -69,6 +50,11 @@ function findAppBundle() { return null; } +function finalizeAppBundle(appPath) { + log(`Finalizing app signature for ${appPath}...`); + exec(`codesign --force --sign - "${appPath}"`); +} + async function build() { log('Building DataConnect for production...'); @@ -103,19 +89,15 @@ async function build() { exec('npm run build'); // 6. Build the .app bundle only (no DMG). - // Tauri's resource glob flattens directory structures, so node_modules/ - // can't be included via tauri.conf.json. We build .app first, inject - // node_modules, then create the DMG ourselves. log('Building Tauri .app bundle...'); - exec('npx tauri build --bundles app'); + exec('CI=true npm run tauri -- build --bundles app'); - // 7. Inject personal-server native addons into the .app bundle. + // 7. Re-sign the completed .app so the bundled resources are sealed. const appPath = findAppBundle(); if (!appPath) { throw new Error('.app bundle not found after build'); } - log(`Injecting native addons into ${appPath}...`); - copyNativeModulesIntoApp(appPath); + finalizeAppBundle(appPath); // 8. Create DMG from the complete .app. if (PLAT === 'darwin') { From 82f4f9eff137f9f55170aad4849ead53fac5823a Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 11:37:38 +1000 Subject: [PATCH 04/10] docs(updater): formalize phase 2 track B handoff Record that the macOS copy-step question is resolved enough to unblock updater plumbing, and split the next work into a dedicated Track B plan with exact file targets, release-shape decisions, and verification gates. Made-with: Cursor --- ...11-tauri-auto-update-phase2-feasibility.md | 166 ++++++++++++-- ...1-tauri-auto-update-phase2-track-b-plan.md | 211 ++++++++++++++++++ 2 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md diff --git a/docs/260311-tauri-auto-update-phase2-feasibility.md b/docs/260311-tauri-auto-update-phase2-feasibility.md index d34f105..01f20db 100644 --- a/docs/260311-tauri-auto-update-phase2-feasibility.md +++ b/docs/260311-tauri-auto-update-phase2-feasibility.md @@ -18,6 +18,22 @@ This is an updater/distribution pipeline project, not mainly a UI project. - Feed/distribution: use GitHub Releases if it works; do not build a custom update service unless forced. - Download timing: start after idle/startup settles, not immediately at launch. - UX target: silent background download, then one-click restart/apply. +- Packaging assumption: treat the macOS post-build `node_modules` copy step as dead unless runtime or notarization evidence disproves it. +- Spike order: move next to updater plumbing; keep the nested in-app re-sign loop question as a narrower CI notarization follow-up. + +## Spike outcome summary (2026-03-11) + +What we did: + +1. Proved raw Tauri macOS bundling already includes `personal-server/dist/node_modules`. +2. Removed the redundant macOS copy step from local and CI build paths. +3. Proved locally that pre-signed nested binaries keep their signatures/entitlements when Tauri bundles them. + +What that means: + +- The old “can Tauri package `node_modules` at all?” question is answered enough to unblock the next spike. +- The remaining packaging uncertainty is now much smaller: whether Apple notarization in CI still succeeds if we later remove the nested in-app re-sign loop. +- Updater plugin and updater artifact plumbing can now be planned against the simplified assumption that the copy step is gone. ## Current repo state @@ -33,11 +49,16 @@ Missing today: Relevant files: +- `package.json` - `src-tauri/Cargo.toml` - `src-tauri/src/lib.rs` +- `src-tauri/capabilities/default.json` - `src-tauri/tauri.conf.json` - `.github/workflows/release.yml` - `scripts/build-prod.js` +- `src/hooks/app-update/check-app-update.ts` +- `src/hooks/use-app-update.tsx` +- `src/components/ui/sonner.tsx` ## Answer: can we generate updater artifacts/signatures from the final post-processed bundles? @@ -152,11 +173,12 @@ This avoids competing with startup, connector initialization, and personal-serve The main ship/no-ship questions are: -1. Can we eliminate post-build bundle mutation by packaging `personal-server/dist` correctly through Tauri resources? -2. If not, can we generate final updater bundles/signatures after all mutations and re-signing are complete? -3. Can GitHub Actions publish the final updater metadata/assets cleanly for macOS-first rollout? +1. Can the updater plumbing be added cleanly now that the macOS copy step is removed? +2. Can GitHub Actions publish updater artifacts plus updater metadata cleanly for a macOS-first rollout? +3. Does Apple notarization in CI still pass if we later remove the nested in-app re-sign loop? -Question 1 is the best first bet. +Question 3 is now the only unresolved macOS packaging-specific follow-up. +It should not block the updater plumbing spike. ## Recommended next steps @@ -254,12 +276,123 @@ Working recommendation: Goal: prove updater mechanics on macOS once Track A works. -- add updater plugin/config -- generate signing keys -- enable `createUpdaterArtifacts` -- publish updater metadata to GitHub Releases -- wire a minimal check -> download -> restart flow -- verify update from one macOS release to the next +Implementation plan: + +- `docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md` + +#### Scope decision + +Proceed on the simplified assumption that: + +- the macOS copy step is gone +- pre-build signing of nested binaries stays +- final outer-app signing stays +- the nested in-app re-sign loop remains temporarily in CI until notarization evidence proves it can be removed + +#### Exact files to change + +- `package.json` + - add `@tauri-apps/plugin-updater` + - likely add `@tauri-apps/plugin-process` if the relaunch step is owned in JS +- `src-tauri/Cargo.toml` + - add `tauri-plugin-updater` +- `src-tauri/src/lib.rs` + - register the updater plugin on the Tauri builder +- `src-tauri/capabilities/default.json` + - add updater capability permissions (`updater:default`) +- `src-tauri/tauri.conf.json` + - enable `bundle.createUpdaterArtifacts` + - add `plugins.updater.pubkey` + - add `plugins.updater.endpoints` +- `.github/workflows/release.yml` + - inject updater signing private key env vars during build + - upload updater bundle assets and generated signatures + - publish/update static updater metadata asset on the GitHub Release +- `scripts/build-prod.js` + - optionally mirror the updater-artifact path for local macOS smoke builds if we want local end-to-end update testing outside CI +- `src/hooks/app-update/check-app-update.ts` + - either replace the GitHub Releases polling path on macOS or split “release page check” from “Tauri updater check” +- `src/hooks/use-app-update.tsx` + - evolve from `check -> external release URL` into `check -> idle download -> restart toast` +- `src/components/ui/sonner.tsx` + - reuse existing toast surface for the staged `Restart to update` UX + +#### Updater keys and config + +- generate a dedicated updater signing keypair with `npm run tauri signer generate` +- store the private key and optional password in CI secrets +- put the public key content directly in `src-tauri/tauri.conf.json` under `plugins.updater.pubkey` +- do not rely on `.env` files for the private key during build; Tauri reads it from environment variables at build time + +#### GitHub Releases / metadata shape + +For Tauri v2 static metadata, the endpoint can point directly at a JSON asset on GitHub Releases, for example: + +- `https://github.com/vana-com/data-connect/releases/latest/download/latest.json` + +That JSON should contain: + +- top-level `version` +- optional `notes` +- optional `pub_date` +- `platforms` map keyed by platform-arch, for example: + - `darwin-aarch64` + - `darwin-x86_64` +- each platform entry needs: + - `url` pointing to the updater bundle asset + - `signature` containing the literal `.sig` file contents, not a URL + +Important constraint: + +- Tauri validates the JSON before version comparison, so every platform key present in the file must be complete and correct. +- For a macOS-first rollout, the safest static metadata is a macOS-only updater JSON until Windows/Linux updater artifacts are also supported. + +Expected macOS release assets: + +- normal installer: + - `.dmg` +- updater assets: + - `.app.tar.gz` + - `.app.tar.gz.sig` +- static updater metadata: + - `latest.json` + +#### Runtime state machine + +Target runtime behavior for macOS phase 2: + +1. Startup: + - call updater `check()` + - do not block startup-critical work +2. No update: + - stay idle +3. Update available: + - record the available update + - wait for startup-settled / idle delay +4. Idle download: + - call updater download/install path in background + - keep UI silent while downloading +5. Download staged: + - show one persistent toast: `Restart to update` +6. User clicks restart: + - install/apply if needed + - relaunch app +7. Failure at any step: + - fail soft + - log for diagnostics + - do not interrupt normal app usage + +Recommended implementation note: + +- keep the existing `useAppUpdate` provider as the single app-shell orchestration point +- split decision states so phase 1 (`external update available`) and phase 2 (`update downloading`, `update ready to restart`) are not conflated + +#### Exit criteria + +- macOS build produces updater artifacts and signatures from the finalized signed app pipeline +- release workflow uploads `.app.tar.gz`, `.sig`, and `latest.json` +- an older macOS build updates in-app to a newer macOS build through the full staged-download flow +- non-macOS platforms remain on the phase-1 external release flow until explicitly migrated ### Track C: fallback if Track A fails @@ -278,12 +411,11 @@ This is more work and should only be used if Track A fails. Do not start by wiring updater APIs into the app UI. -Start with a macOS packaging spike to answer one binary question: - -- can `personal-server/dist/node_modules` be bundled correctly by Tauri without post-build mutation? +Start with the updater plumbing spike, not another broad packaging spike. -Current answer: +Current alignment: -- yes for resource packaging -- not yet fully proven for runtime launch behavior -- final-sign/final-artifact generation still needs to happen after the completed app exists +- yes, the macOS resource-copy subproblem is solved enough to proceed +- updater plugin + updater artifact plumbing is the next concrete spike +- the only remaining packaging follow-up is whether CI notarization later lets us delete the nested in-app re-sign loop too +- keep that notarization question scoped as a follow-up validation, not as the blocker for updater planning diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md new file mode 100644 index 0000000..febc043 --- /dev/null +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -0,0 +1,211 @@ +# 260311 plan: Tauri auto-update phase 2 Track B + +Source docs: + +- `docs/260311-tauri-auto-update-phase2-feasibility.md` +- `docs/260226-tauri-app-update-toast-overview.md` +- `docs/plans/260226-app-update-toast-phase1-implementation-plan.md` + +Use this doc in two modes: + +- Strategy lock before implementation. +- Execution contract during implementation. + +## Strategy Lock + +### Goal + +Ship macOS-first phase 2 app updates in DataConnect: + +- startup check through Tauri updater +- silent background download after startup settles +- single `Restart to update` toast after staging completes +- apply/relaunch from inside the app + +### Scope + +In scope: + +- Tauri updater plugin wiring in Rust, JS, config, and capabilities +- updater signing-key contract +- macOS updater artifact generation +- GitHub Release asset upload for updater bundles and static metadata +- app-shell runtime state machine for `check -> idle download -> restart` +- focused tests and manual smoke for macOS phase 2 + +Out of scope: + +- Windows/Linux phase 2 rollout +- custom update backend/service +- removal of the CI nested in-app re-sign loop +- broad redesign of the phase-1 toast surface + +### Invariants + +- The deleted macOS post-build copy step stays deleted. +- Final updater artifacts/signatures must match the final shipped bytes. +- macOS phase 2 must not block startup-critical work. +- Non-macOS platforms stay on the existing phase-1 external-release flow. +- Runtime failures fail soft: log, keep app usable, no blocking modal flow. +- `useAppUpdate` remains the single app-shell orchestration seam. + +### Dependencies + +| Dependency | Status | Owner | Target date | Notes | +| ---------- | ------ | ----- | ----------- | ----- | +| Tauri updater signing keypair generated and stored securely | SOFT BLOCKED | release owner | before implementation finish | Need private key + optional password in CI; public key embedded in config | +| GitHub Release workflow can upload updater bundle assets plus `latest.json` | UNBLOCKED | repo/CI | during implementation | Current workflow already uploads release artifacts; needs updater asset/metadata extension | +| Tauri updater artifacts are generated from the finalized signed macOS app path | SOFT BLOCKED | implementation | during spike | Must verify final served `.app.tar.gz` matches the final signed app assumptions | +| Real upgrade smoke path from old macOS build to new macOS build | SOFT BLOCKED | implementation/release | before merge/release | Need a reproducible way to test one released build upgrading to another | +| CI notarization result for removing nested in-app re-sign loop | UNBLOCKED for Track B, unresolved for follow-up | release owner | after Track B or alongside first CI proof | Not a blocker for updater plumbing | + +### Approach + +Chosen approach: + +- macOS-first Tauri v2 updater +- static `latest.json` metadata hosted as a GitHub Release asset +- keep GitHub Releases as the only distribution surface +- keep phase-1 release-page check as the fallback path for non-macOS +- extend `useAppUpdate` instead of creating a second app-update provider +- generate updater metadata in a repo script, not inline shell glue in the workflow + +Rejected alternatives: + +- dynamic update server now: too much new infrastructure for this spike +- cross-platform phase 2 in one pass: adds avoidable surface area +- deleting the nested in-app re-sign loop in the same pass: separate notarization proof question +- replacing the whole release flow before proving static GitHub metadata works: too much churn + +### Replan triggers + +- `createUpdaterArtifacts` outputs do not match the finalized signed macOS app path we need to ship. +- Static GitHub Release metadata cannot express the macOS-first rollout cleanly. +- Updater plugin permissions/config force broader Tauri capability changes than expected. +- Runtime updater API shape forces a larger state-model rewrite than `useAppUpdate` can absorb cleanly. + +## Execution Contract + +### Ordered implementation steps + +1. Add updater dependencies and Tauri capability/config wiring. +2. Add a repo-owned script to build `latest.json` from generated updater assets and `.sig` contents. +3. Extend `.github/workflows/release.yml` to: + - inject updater signing key env vars + - upload macOS updater assets + - upload `latest.json` +4. Add a dedicated runtime seam around the Tauri updater plugin. +5. Refactor `useAppUpdate` from phase-1 `release available` logic into: + - platform-aware decision path + - startup check + - idle download + - staged restart toast +6. Preserve phase-1 behavior for non-macOS and for failure fallback. +7. Add focused tests for config, state transitions, and action behavior. +8. Run local macOS artifact smoke, then release/upgrade proof. + +### Mandatory file edit contract + +Fill `Status` with `PASS` / `NO-OP` / `FAIL` during execution. + +| File | Required change | Status | Evidence | +| ---- | --------------- | ------ | -------- | +| `package.json` | add `@tauri-apps/plugin-updater`; add `@tauri-apps/plugin-process` if relaunch stays in JS | | | +| `src-tauri/Cargo.toml` | add `tauri-plugin-updater` | | | +| `src-tauri/src/lib.rs` | register updater plugin; move runtime config here only if config file is insufficient | | | +| `src-tauri/capabilities/default.json` | add updater permissions (`updater:default`) | | | +| `src-tauri/tauri.conf.json` | add `bundle.createUpdaterArtifacts`; add `plugins.updater.pubkey`; add `plugins.updater.endpoints` | | | +| `scripts/build-updater-manifest.mjs` | new script to generate `latest.json` from release asset inputs | | | +| `.github/workflows/release.yml` | build with updater signing env vars; upload `.app.tar.gz`, `.sig`, `latest.json` | | | +| `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | | | +| `src/hooks/app-update/check-app-update.ts` | preserve or narrow phase-1 external-release check as fallback path | | | +| `src/hooks/app-update/tauri-updater.ts` | new seam around `@tauri-apps/plugin-updater` APIs | | | +| `src/hooks/use-app-update.tsx` | orchestrate phase-2 state machine and preserve non-macOS fallback | | | +| `src/components/ui/sonner.tsx` | reuse existing toast surface; change only if restart UX requires it | | | +| `src/**/*.test.ts(x)` | add/adjust tests for updater seam and restart-toast flow | | | + +### Verification commands + +Use these exact checks during execution: + +```bash +# file-wiring scan +rg -n "plugin-updater|plugin-process|createUpdaterArtifacts|pubkey|endpoints|updater:default" \ + package.json src-tauri/Cargo.toml src-tauri/src/lib.rs src-tauri/tauri.conf.json src-tauri/capabilities/default.json + +# runtime-surface scan +rg -n "check\\(|downloadAndInstall|relaunch|Restart to update|Update available|useAppUpdate" \ + src/hooks src/components src/pages + +# focused tests +npm run test -- \ + src/hooks/use-app-update.test.tsx \ + src/hooks/app-update/check-app-update.test.ts \ + src/hooks/app-update/app-update-ui-debug.test.ts + +# static confidence +npm run typecheck +npm run lint + +# local macOS artifact smoke +TAURI_SIGNING_PRIVATE_KEY="..." TAURI_SIGNING_PRIVATE_KEY_PASSWORD="..." npm run tauri -- build --bundles app + +# inspect generated updater artifacts +rg -n "\\.app\\.tar\\.gz|latest\\.json" src-tauri/target .github/workflows scripts +``` + +Release/upgrade proof commands: + +```bash +# inspect release assets after workflow run +gh release view vX.Y.Z --json assets + +# fetch generated metadata for inspection +gh release download vX.Y.Z --pattern "latest.json" --dir /tmp/dataconnect-updater-check +``` + +### Gate checklist + +- [ ] Code-path gates passed +- [ ] Behavior/runtime gates passed +- [ ] Build/test/lint gates passed +- [ ] CI/release gates passed +- [ ] Real upgrade smoke passed on macOS + +### PR evidence table + +| Gate | Command/evidence | Expected | Actual summary | Status | +| ---- | ---------------- | -------- | -------------- | ------ | +| Config | updater dependency/config/capability scan | all required updater touch points present | | | +| Build | local updater-artifact build | macOS build emits `.app.tar.gz` and `.sig` | | | +| Release | workflow asset upload | Release contains `.dmg`, `.app.tar.gz`, `.sig`, `latest.json` | | | +| Runtime | startup check stays non-blocking | app remains usable while updater checks | | | +| Runtime | idle download path | update downloads without immediate startup contention | | | +| Runtime | staged restart toast | single persistent `Restart to update` toast after staging | | | +| Runtime | restart action | click applies/relaunches successfully | | | +| Fallback | non-macOS behavior | non-macOS still uses phase-1 external-release path | | | +| Build | test/typecheck/lint | no new failures beyond repo baseline | | | + +### Done criteria + +1. No `FAIL` rows in file contract or PR evidence table. +2. macOS updater artifacts and `latest.json` are produced and published by the release flow. +3. `useAppUpdate` supports phase-2 staged update flow on macOS without regressing the phase-1 fallback path elsewhere. +4. A real macOS upgrade proof exists from an older build to a newer build. +5. The nested in-app re-sign loop question remains explicitly scoped as follow-up unless separately proven. + +### Strategy delta + +Record here if implementation changes: + +- updater metadata host +- relaunch ownership (JS vs Rust) +- macOS-only rollout boundary +- release workflow shape + +## Unresolved questions + +- Should `latest.json` live as a single `releases/latest/download/latest.json` asset only, or also be uploaded per tag for audit/debug? +- Should relaunch be owned in JS via `@tauri-apps/plugin-process`, or should Rust own the final restart/apply command path? +- What is the cleanest repeatable upgrade-proof path: disposable tagged releases, a private test repo, or a local static feed? +- Do we want the first Track B pass to show download progress anywhere, or stay intentionally silent until the staged restart toast? From 44d05e5dc8677648c68f828eb23beb73819dbf48 Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 11:58:04 +1000 Subject: [PATCH 05/10] build(updater): generate macOS updater artifacts after final signing Record the release-flow blocker in the Track B plan and add a custom post-finalization macOS updater artifact path so the served updater tarball and signature come from the finalized signed app instead of the earlier Tauri build output. Made-with: Cursor --- .github/workflows/release.yml | 19 +++ ...1-tauri-auto-update-phase2-track-b-plan.md | 45 +++++-- scripts/build-macos-updater-artifacts.mjs | 127 ++++++++++++++++++ 3 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 scripts/build-macos-updater-artifacts.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11f9acf..acb5bf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,6 +193,9 @@ jobs: - name: Finalize bundles env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PATH: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PATH }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -x # Enable verbose debugging @@ -246,6 +249,15 @@ jobs: --sign "Developer ID Application: Corsali, Inc (${{ secrets.APPLE_TEAM_ID }})" \ "$app" echo "Re-signed $app" + + # Create updater artifacts from the finalized .app, not the pre-finalization Tauri output. + if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] || [ -n "$TAURI_SIGNING_PRIVATE_KEY_PATH" ]; then + node scripts/build-macos-updater-artifacts.mjs \ + --app "$app" \ + --output-dir "src-tauri/target/${{ matrix.target }}/release/bundle/macos" + else + echo "::notice::Skipping finalized macOS updater artifact generation because no Tauri updater signing key is configured" + fi done # Recreate DMG with updated .app (including Applications symlink) @@ -360,6 +372,13 @@ jobs: echo "Uploaded $(basename "$f")" fi done + for f in src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz \ + src-tauri/target/${{ matrix.target }}/release/bundle/macos/*.app.tar.gz.sig; do + if [ -f "$f" ]; then + gh release upload "${{ github.ref_name }}" "$f" --clobber + echo "Uploaded $(basename "$f")" + fi + done fi # Upload Linux artifacts diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md index febc043..11b80ab 100644 --- a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -13,6 +13,19 @@ Use this doc in two modes: ## Strategy Lock +### Execution status update (2026-03-11) + +New discovery from execution: + +- Directly enabling Tauri `createUpdaterArtifacts` is not safe in the current macOS release flow. +- Reason: updater artifacts are generated during `tauri build`, but this repo final-signs the macOS `.app` after `tauri build`. +- That means the generated `.app.tar.gz` can drift from the final shipped app bytes. + +Immediate strategy adjustment: + +- Implement a custom post-finalization macOS updater-asset path first. +- Defer updater plugin/config/runtime wiring until the final-asset path is proven. + ### Goal Ship macOS-first phase 2 app updates in DataConnect: @@ -55,7 +68,8 @@ Out of scope: | ---------- | ------ | ----- | ----------- | ----- | | Tauri updater signing keypair generated and stored securely | SOFT BLOCKED | release owner | before implementation finish | Need private key + optional password in CI; public key embedded in config | | GitHub Release workflow can upload updater bundle assets plus `latest.json` | UNBLOCKED | repo/CI | during implementation | Current workflow already uploads release artifacts; needs updater asset/metadata extension | -| Tauri updater artifacts are generated from the finalized signed macOS app path | SOFT BLOCKED | implementation | during spike | Must verify final served `.app.tar.gz` matches the final signed app assumptions | +| Tauri default updater artifact generation happens before repo final-sign step | BLOCKED for direct adoption | implementation | discovered 2026-03-11 | Current workflow cannot safely rely on raw `createUpdaterArtifacts` output alone | +| Custom post-finalization macOS updater asset generation path | SOFT BLOCKED | implementation | first execution slice | Must generate `.app.tar.gz` and `.sig` from finalized signed `.app` | | Real upgrade smoke path from old macOS build to new macOS build | SOFT BLOCKED | implementation/release | before merge/release | Need a reproducible way to test one released build upgrading to another | | CI notarization result for removing nested in-app re-sign loop | UNBLOCKED for Track B, unresolved for follow-up | release owner | after Track B or alongside first CI proof | Not a blocker for updater plumbing | @@ -64,6 +78,7 @@ Out of scope: Chosen approach: - macOS-first Tauri v2 updater +- custom post-finalization macOS updater asset generation before any runtime updater wiring - static `latest.json` metadata hosted as a GitHub Release asset - keep GitHub Releases as the only distribution surface - keep phase-1 release-page check as the fallback path for non-macOS @@ -88,21 +103,24 @@ Rejected alternatives: ### Ordered implementation steps -1. Add updater dependencies and Tauri capability/config wiring. -2. Add a repo-owned script to build `latest.json` from generated updater assets and `.sig` contents. -3. Extend `.github/workflows/release.yml` to: +1. Implement a repo-owned script that creates and signs a macOS updater bundle from the finalized `.app`. +2. Extend `.github/workflows/release.yml` to: + - call that script after final outer-app signing + - upload `.app.tar.gz` and `.app.tar.gz.sig` +3. Only after the custom macOS asset path works, add updater dependencies and Tauri capability/config wiring. +4. Add a repo-owned script to build `latest.json` from final updater assets and `.sig` contents. +5. Extend `.github/workflows/release.yml` to: - inject updater signing key env vars - - upload macOS updater assets - upload `latest.json` -4. Add a dedicated runtime seam around the Tauri updater plugin. -5. Refactor `useAppUpdate` from phase-1 `release available` logic into: +6. Add a dedicated runtime seam around the Tauri updater plugin. +7. Refactor `useAppUpdate` from phase-1 `release available` logic into: - platform-aware decision path - startup check - idle download - staged restart toast -6. Preserve phase-1 behavior for non-macOS and for failure fallback. -7. Add focused tests for config, state transitions, and action behavior. -8. Run local macOS artifact smoke, then release/upgrade proof. +8. Preserve phase-1 behavior for non-macOS and for failure fallback. +9. Add focused tests for config, state transitions, and action behavior. +10. Run local macOS artifact smoke, then release/upgrade proof. ### Mandatory file edit contract @@ -114,10 +132,11 @@ Fill `Status` with `PASS` / `NO-OP` / `FAIL` during execution. | `src-tauri/Cargo.toml` | add `tauri-plugin-updater` | | | | `src-tauri/src/lib.rs` | register updater plugin; move runtime config here only if config file is insufficient | | | | `src-tauri/capabilities/default.json` | add updater permissions (`updater:default`) | | | -| `src-tauri/tauri.conf.json` | add `bundle.createUpdaterArtifacts`; add `plugins.updater.pubkey`; add `plugins.updater.endpoints` | | | +| `src-tauri/tauri.conf.json` | add `bundle.createUpdaterArtifacts`; add `plugins.updater.pubkey`; add `plugins.updater.endpoints` after post-finalization asset path is proven | | | +| `scripts/build-macos-updater-artifacts.mjs` | new script to archive/sign finalized macOS `.app` into `.app.tar.gz` and `.sig` | PASS | repo script added; uses `tauri signer sign` on finalized tarball | | `scripts/build-updater-manifest.mjs` | new script to generate `latest.json` from release asset inputs | | | -| `.github/workflows/release.yml` | build with updater signing env vars; upload `.app.tar.gz`, `.sig`, `latest.json` | | | -| `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | | | +| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | PASS | finalization step now generates updater tarball/signature after outer app re-sign and uploads them when present | +| `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | NO-OP | local build path intentionally unchanged in this slice | | `src/hooks/app-update/check-app-update.ts` | preserve or narrow phase-1 external-release check as fallback path | | | | `src/hooks/app-update/tauri-updater.ts` | new seam around `@tauri-apps/plugin-updater` APIs | | | | `src/hooks/use-app-update.tsx` | orchestrate phase-2 state machine and preserve non-macOS fallback | | | diff --git a/scripts/build-macos-updater-artifacts.mjs b/scripts/build-macos-updater-artifacts.mjs new file mode 100644 index 0000000..8932684 --- /dev/null +++ b/scripts/build-macos-updater-artifacts.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; + +function printHelp() { + console.log(`Usage: node scripts/build-macos-updater-artifacts.mjs --app [--output-dir ] + +Create a finalized macOS updater tarball from a signed .app bundle and sign it +with the Tauri updater private key. + +Required environment: + TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH +Optional environment: + TAURI_SIGNING_PRIVATE_KEY_PASSWORD`); +} + +function parseArgs(argv) { + const args = { app: null, outputDir: null }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + + if (arg === '--app') { + args.app = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (arg === '--output-dir') { + args.outputDir = argv[index + 1] ?? null; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return args; +} + +function log(message) { + console.log(`\n🔨 ${message}`); +} + +function run(command, args, options = {}) { + console.log(` $ ${command} ${args.join(' ')}`); + execFileSync(command, args, { stdio: 'inherit', ...options }); +} + +function resolveNpmCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm'; +} + +function hasSigningKey() { + return Boolean( + process.env.TAURI_SIGNING_PRIVATE_KEY || + process.env.TAURI_SIGNING_PRIVATE_KEY_PATH + ); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + if (!args.app) { + throw new Error('Missing required --app argument'); + } + + if (!hasSigningKey()) { + throw new Error( + 'Missing updater signing key. Set TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH.' + ); + } + + const appPath = resolve(args.app); + if (!existsSync(appPath)) { + throw new Error(`App bundle not found: ${appPath}`); + } + if (!appPath.endsWith('.app')) { + throw new Error(`Expected a .app bundle, received: ${appPath}`); + } + + const outputDir = resolve(args.outputDir ?? dirname(appPath)); + mkdirSync(outputDir, { recursive: true }); + + const appName = basename(appPath); + const tarballPath = join(outputDir, `${appName}.tar.gz`); + const signaturePath = `${tarballPath}.sig`; + + rmSync(tarballPath, { force: true }); + rmSync(signaturePath, { force: true }); + + log(`Creating updater tarball for ${appName}`); + run('tar', ['-czf', tarballPath, '-C', dirname(appPath), appName]); + + log(`Signing updater tarball ${basename(tarballPath)}`); + run(resolveNpmCommand(), ['run', 'tauri', 'signer', 'sign', '--', tarballPath]); + + if (!existsSync(tarballPath)) { + throw new Error(`Updater tarball was not created: ${tarballPath}`); + } + if (!existsSync(signaturePath)) { + throw new Error(`Updater signature was not created: ${signaturePath}`); + } + + console.log(`\nCreated updater artifacts: +- ${tarballPath} +- ${signaturePath}`); +} + +try { + main(); +} catch (error) { + console.error(`\n❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} From f50480c48e088c1cf04e5c7a6bbc63c19b61443d Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 12:03:44 +1000 Subject: [PATCH 06/10] docs(plan): record macOS updater notarization proof Capture that DMG notarization is not sufficient evidence for the separate updater tarball path, and that the updater-served app likely needs to be notarized and stapled before packaging into .app.tar.gz. Made-with: Cursor --- ...1-tauri-auto-update-phase2-track-b-plan.md | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md index 11b80ab..b4a85d6 100644 --- a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -26,6 +26,19 @@ Immediate strategy adjustment: - Implement a custom post-finalization macOS updater-asset path first. - Defer updater plugin/config/runtime wiring until the final-asset path is proven. +Focused notarization proof: + +- DMG notarization is sufficient for first-install from the DMG. +- It is not sufficient evidence for the separately downloaded updater `.app.tar.gz` path. +- Apple `notarytool` only accepts UDIF disk images, signed flat installer packages, and zip files. +- That means the updater `.app.tar.gz` cannot itself be notarized directly. + +Current working implication: + +- The updater-served `.app` must be notarized/stapled before it is packaged into the updater `.app.tar.gz`. +- Therefore the current post-finalization updater script ordering is still incomplete if it runs before app notarization/stapling proof exists. +- Next execution slice should prove or implement: final-sign app -> notarize/staple app-compatible submission -> package stapled app into updater tarball -> sign tarball -> publish metadata. + ### Goal Ship macOS-first phase 2 app updates in DataConnect: @@ -69,7 +82,7 @@ Out of scope: | Tauri updater signing keypair generated and stored securely | SOFT BLOCKED | release owner | before implementation finish | Need private key + optional password in CI; public key embedded in config | | GitHub Release workflow can upload updater bundle assets plus `latest.json` | UNBLOCKED | repo/CI | during implementation | Current workflow already uploads release artifacts; needs updater asset/metadata extension | | Tauri default updater artifact generation happens before repo final-sign step | BLOCKED for direct adoption | implementation | discovered 2026-03-11 | Current workflow cannot safely rely on raw `createUpdaterArtifacts` output alone | -| Custom post-finalization macOS updater asset generation path | SOFT BLOCKED | implementation | first execution slice | Must generate `.app.tar.gz` and `.sig` from finalized signed `.app` | +| Custom post-finalization macOS updater asset generation path | SOFT BLOCKED | implementation | first execution slice | Must generate `.app.tar.gz` and `.sig` from a finalized notarized/stapled `.app`, not merely a finalized signed `.app` | | Real upgrade smoke path from old macOS build to new macOS build | SOFT BLOCKED | implementation/release | before merge/release | Need a reproducible way to test one released build upgrading to another | | CI notarization result for removing nested in-app re-sign loop | UNBLOCKED for Track B, unresolved for follow-up | release owner | after Track B or alongside first CI proof | Not a blocker for updater plumbing | @@ -79,6 +92,7 @@ Chosen approach: - macOS-first Tauri v2 updater - custom post-finalization macOS updater asset generation before any runtime updater wiring +- package the updater tarball from a notarized/stapled `.app`, not only a signed `.app` - static `latest.json` metadata hosted as a GitHub Release asset - keep GitHub Releases as the only distribution surface - keep phase-1 release-page check as the fallback path for non-macOS @@ -95,6 +109,7 @@ Rejected alternatives: ### Replan triggers - `createUpdaterArtifacts` outputs do not match the finalized signed macOS app path we need to ship. +- We cannot produce a notarized/stapled app bundle before creating the updater `.app.tar.gz`. - Static GitHub Release metadata cannot express the macOS-first rollout cleanly. - Updater plugin permissions/config force broader Tauri capability changes than expected. - Runtime updater API shape forces a larger state-model rewrite than `useAppUpdate` can absorb cleanly. @@ -103,24 +118,25 @@ Rejected alternatives: ### Ordered implementation steps -1. Implement a repo-owned script that creates and signs a macOS updater bundle from the finalized `.app`. -2. Extend `.github/workflows/release.yml` to: - - call that script after final outer-app signing +1. Prove or implement an app-level notarization/stapling path compatible with updater delivery. +2. Implement a repo-owned script that creates and signs a macOS updater bundle from the finalized notarized/stapled `.app`. +3. Extend `.github/workflows/release.yml` to: + - call that script after app notarization/stapling - upload `.app.tar.gz` and `.app.tar.gz.sig` -3. Only after the custom macOS asset path works, add updater dependencies and Tauri capability/config wiring. -4. Add a repo-owned script to build `latest.json` from final updater assets and `.sig` contents. -5. Extend `.github/workflows/release.yml` to: +4. Only after the custom macOS asset path works, add updater dependencies and Tauri capability/config wiring. +5. Add a repo-owned script to build `latest.json` from final updater assets and `.sig` contents. +6. Extend `.github/workflows/release.yml` to: - inject updater signing key env vars - upload `latest.json` -6. Add a dedicated runtime seam around the Tauri updater plugin. -7. Refactor `useAppUpdate` from phase-1 `release available` logic into: +7. Add a dedicated runtime seam around the Tauri updater plugin. +8. Refactor `useAppUpdate` from phase-1 `release available` logic into: - platform-aware decision path - startup check - idle download - staged restart toast -8. Preserve phase-1 behavior for non-macOS and for failure fallback. -9. Add focused tests for config, state transitions, and action behavior. -10. Run local macOS artifact smoke, then release/upgrade proof. +9. Preserve phase-1 behavior for non-macOS and for failure fallback. +10. Add focused tests for config, state transitions, and action behavior. +11. Run local macOS artifact smoke, then release/upgrade proof. ### Mandatory file edit contract @@ -135,7 +151,7 @@ Fill `Status` with `PASS` / `NO-OP` / `FAIL` during execution. | `src-tauri/tauri.conf.json` | add `bundle.createUpdaterArtifacts`; add `plugins.updater.pubkey`; add `plugins.updater.endpoints` after post-finalization asset path is proven | | | | `scripts/build-macos-updater-artifacts.mjs` | new script to archive/sign finalized macOS `.app` into `.app.tar.gz` and `.sig` | PASS | repo script added; uses `tauri signer sign` on finalized tarball | | `scripts/build-updater-manifest.mjs` | new script to generate `latest.json` from release asset inputs | | | -| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | PASS | finalization step now generates updater tarball/signature after outer app re-sign and uploads them when present | +| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | FAIL | script hook exists, but notarization proof now indicates final updater tarball likely needs a stapled/notarized app input before packaging | | `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | NO-OP | local build path intentionally unchanged in this slice | | `src/hooks/app-update/check-app-update.ts` | preserve or narrow phase-1 external-release check as fallback path | | | | `src/hooks/app-update/tauri-updater.ts` | new seam around `@tauri-apps/plugin-updater` APIs | | | From 4c787911a9263889fb2d0d4ce7188cc42e303f6b Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 12:24:43 +1000 Subject: [PATCH 07/10] ci(updater): harden macOS release artifact validation Remove shell xtrace around updater secrets, notarize and staple the finalized app before packaging the updater tarball, make updater asset names matrix-safe, and fail the workflow if the extracted updater payload does not pass stapler, spctl, and codesign validation. Made-with: Cursor --- .github/workflows/release.yml | 35 +++- ...1-tauri-auto-update-phase2-track-b-plan.md | 3 +- scripts/build-macos-updater-artifacts.mjs | 22 ++- scripts/notarize-macos-app.mjs | 176 ++++++++++++++++++ 4 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 scripts/notarize-macos-app.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acb5bf7..a920a80 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,11 +197,12 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PATH: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PATH }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - set -x # Enable verbose debugging + set -euo pipefail # Re-sign the completed macOS app and recreate the DMG from the final app. # personal-server/dist/node_modules is already bundled by Tauri resources. if [ "${{ matrix.platform }}" = "macos-latest" ]; then + echo "=== Finalizing macOS bundles for ${{ matrix.target }} ===" # Debug: Show what's in personal-server/dist echo "=== Contents of personal-server/dist ===" ls -la personal-server/dist/ || echo "dist not found" @@ -223,6 +224,10 @@ jobs: [ -e "$app" ] || continue echo "=== Processing $app ===" + app_name=$(basename "$app" .app) + version=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed 's/.*: "\(.*\)".*/\1/') + arch=$(echo "${{ matrix.target }}" | cut -d- -f1) + updater_name="${app_name}_${version}_${arch}.app.tar.gz" echo "Bundled resource contents:" ls -la "$app/Contents/Resources/personal-server/dist/" || echo "personal-server/dist not in Resources" ls -la "$app/Contents/Resources/personal-server/dist/node_modules/" || { echo "ERROR: node_modules NOT in app bundle!"; exit 1; } @@ -250,11 +255,31 @@ jobs: "$app" echo "Re-signed $app" - # Create updater artifacts from the finalized .app, not the pre-finalization Tauri output. + # Notarize/staple the finalized .app before packaging the updater tarball. + APPLE_NOTARY_KEY_PATH="$APPLE_API_KEY_PATH" \ + APPLE_NOTARY_KEY_ID="${{ secrets.APPLE_ASC_API_KEY_ID }}" \ + APPLE_NOTARY_ISSUER="${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }}" \ + node scripts/notarize-macos-app.mjs \ + --app "$app" \ + --output-dir "src-tauri/target/${{ matrix.target }}/release/bundle/macos" + + # Create updater artifacts from the finalized notarized .app, not the pre-finalization Tauri output. if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ] || [ -n "$TAURI_SIGNING_PRIVATE_KEY_PATH" ]; then node scripts/build-macos-updater-artifacts.mjs \ --app "$app" \ - --output-dir "src-tauri/target/${{ matrix.target }}/release/bundle/macos" + --output-dir "src-tauri/target/${{ matrix.target }}/release/bundle/macos" \ + --artifact-name "$updater_name" + + # Smoke-check the updater payload after tar packaging. This must stay a hard gate. + updater_smoke_dir="/tmp/updater_smoke_${arch}" + rm -rf "$updater_smoke_dir" + mkdir -p "$updater_smoke_dir" + tar -xzf "src-tauri/target/${{ matrix.target }}/release/bundle/macos/$updater_name" -C "$updater_smoke_dir" + extracted_app="$updater_smoke_dir/$(basename "$app")" + xcrun stapler validate "$extracted_app" + spctl --assess -vv "$extracted_app" + codesign --verify --strict "$extracted_app" + rm -rf "$updater_smoke_dir" else echo "::notice::Skipping finalized macOS updater artifact generation because no Tauri updater signing key is configured" fi @@ -308,7 +333,7 @@ jobs: # Notarize the DMG using App Store Connect API key echo "=== Notarizing $dmg_path ===" if xcrun notarytool submit "$dmg_path" \ - --key "${{ env.APPLE_API_KEY_PATH }}" \ + --key "$APPLE_API_KEY_PATH" \ --key-id "${{ secrets.APPLE_ASC_API_KEY_ID }}" \ --issuer "${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }}" \ --wait 2>&1 | tee /tmp/notarize_${arch}.log; then @@ -322,7 +347,7 @@ jobs: if [ -n "$submission_id" ]; then echo "=== Fetching notarization log for $submission_id ===" xcrun notarytool log "$submission_id" \ - --key "${{ env.APPLE_API_KEY_PATH }}" \ + --key "$APPLE_API_KEY_PATH" \ --key-id "${{ secrets.APPLE_ASC_API_KEY_ID }}" \ --issuer "${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }}" || true fi diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md index b4a85d6..d7bc5a6 100644 --- a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -149,9 +149,10 @@ Fill `Status` with `PASS` / `NO-OP` / `FAIL` during execution. | `src-tauri/src/lib.rs` | register updater plugin; move runtime config here only if config file is insufficient | | | | `src-tauri/capabilities/default.json` | add updater permissions (`updater:default`) | | | | `src-tauri/tauri.conf.json` | add `bundle.createUpdaterArtifacts`; add `plugins.updater.pubkey`; add `plugins.updater.endpoints` after post-finalization asset path is proven | | | +| `scripts/notarize-macos-app.mjs` | new script to submit a zip of the finalized `.app`, wait for notarization, staple the ticket back onto the `.app`, and validate it | PASS | repo script added; uses `ditto` + `xcrun notarytool submit` + `xcrun stapler` | | `scripts/build-macos-updater-artifacts.mjs` | new script to archive/sign finalized macOS `.app` into `.app.tar.gz` and `.sig` | PASS | repo script added; uses `tauri signer sign` on finalized tarball | | `scripts/build-updater-manifest.mjs` | new script to generate `latest.json` from release asset inputs | | | -| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | FAIL | script hook exists, but notarization proof now indicates final updater tarball likely needs a stapled/notarized app input before packaging | +| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | PASS | workflow now avoids xtrace around updater secrets, notarizes/staples the finalized `.app` before packaging the updater tarball, and hard-fails if the extracted updater payload fails stapler/spctl/codesign checks | | `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | NO-OP | local build path intentionally unchanged in this slice | | `src/hooks/app-update/check-app-update.ts` | preserve or narrow phase-1 external-release check as fallback path | | | | `src/hooks/app-update/tauri-updater.ts` | new seam around `@tauri-apps/plugin-updater` APIs | | | diff --git a/scripts/build-macos-updater-artifacts.mjs b/scripts/build-macos-updater-artifacts.mjs index 8932684..6cf9438 100644 --- a/scripts/build-macos-updater-artifacts.mjs +++ b/scripts/build-macos-updater-artifacts.mjs @@ -5,10 +5,10 @@ import { existsSync, mkdirSync, rmSync } from 'fs'; import { basename, dirname, join, resolve } from 'path'; function printHelp() { - console.log(`Usage: node scripts/build-macos-updater-artifacts.mjs --app [--output-dir ] + console.log(`Usage: node scripts/build-macos-updater-artifacts.mjs --app [--output-dir ] [--artifact-name ] -Create a finalized macOS updater tarball from a signed .app bundle and sign it -with the Tauri updater private key. +Create a finalized macOS updater tarball from a notarized/stapled .app bundle +and sign it with the Tauri updater private key. Required environment: TAURI_SIGNING_PRIVATE_KEY or TAURI_SIGNING_PRIVATE_KEY_PATH @@ -17,7 +17,7 @@ Optional environment: } function parseArgs(argv) { - const args = { app: null, outputDir: null }; + const args = { app: null, outputDir: null, artifactName: null }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -39,6 +39,12 @@ function parseArgs(argv) { continue; } + if (arg === '--artifact-name') { + args.artifactName = argv[index + 1] ?? null; + index += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); } @@ -95,7 +101,13 @@ function main() { mkdirSync(outputDir, { recursive: true }); const appName = basename(appPath); - const tarballPath = join(outputDir, `${appName}.tar.gz`); + const tarballName = args.artifactName ?? `${appName}.tar.gz`; + if (!tarballName.endsWith('.app.tar.gz')) { + throw new Error( + `Expected --artifact-name to end with .app.tar.gz, received: ${tarballName}` + ); + } + const tarballPath = join(outputDir, tarballName); const signaturePath = `${tarballPath}.sig`; rmSync(tarballPath, { force: true }); diff --git a/scripts/notarize-macos-app.mjs b/scripts/notarize-macos-app.mjs new file mode 100644 index 0000000..b044e71 --- /dev/null +++ b/scripts/notarize-macos-app.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { basename, dirname, join, resolve } from 'path'; + +function printHelp() { + console.log(`Usage: node scripts/notarize-macos-app.mjs --app [--output-dir ] + +Submit a signed macOS .app for notarization via a temporary zip archive, then +staple the accepted ticket back onto the .app bundle. + +Required environment: + APPLE_NOTARY_KEY_PATH + APPLE_NOTARY_KEY_ID + APPLE_NOTARY_ISSUER`); +} + +function parseArgs(argv) { + const args = { app: null, outputDir: null, help: false }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + args.help = true; + continue; + } + + if (arg === '--app') { + args.app = argv[index + 1] ?? null; + index += 1; + continue; + } + + if (arg === '--output-dir') { + args.outputDir = argv[index + 1] ?? null; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + return args; +} + +function log(message) { + console.log(`\n🔨 ${message}`); +} + +function run(command, args, options = {}) { + console.log(` $ ${command} ${args.join(' ')}`); + execFileSync(command, args, { stdio: 'inherit', ...options }); +} + +function runWithCapturedOutput(command, args, options = {}) { + console.log(` $ ${command} ${args.join(' ')}`); + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'pipe'], + ...options, + }); +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function extractSubmissionId(output) { + const match = output.match( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + ); + return match?.[0] ?? null; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + + if (args.help) { + printHelp(); + return; + } + + if (!args.app) { + throw new Error('Missing required --app argument'); + } + + const keyPath = requireEnv('APPLE_NOTARY_KEY_PATH'); + const keyId = requireEnv('APPLE_NOTARY_KEY_ID'); + const issuer = requireEnv('APPLE_NOTARY_ISSUER'); + + const appPath = resolve(args.app); + if (!existsSync(appPath)) { + throw new Error(`App bundle not found: ${appPath}`); + } + if (!appPath.endsWith('.app')) { + throw new Error(`Expected a .app bundle, received: ${appPath}`); + } + + const outputDir = resolve(args.outputDir ?? dirname(appPath)); + mkdirSync(outputDir, { recursive: true }); + + const zipPath = join(outputDir, `${basename(appPath)}.notary.zip`); + rmSync(zipPath, { force: true }); + + log(`Creating notarization zip for ${basename(appPath)}`); + run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath]); + + log(`Submitting ${basename(zipPath)} for notarization`); + let notarizeOutput = ''; + try { + notarizeOutput = runWithCapturedOutput('xcrun', [ + 'notarytool', + 'submit', + zipPath, + '--key', + keyPath, + '--key-id', + keyId, + '--issuer', + issuer, + '--wait', + ]); + process.stdout.write(notarizeOutput); + } catch (error) { + const stdout = + typeof error?.stdout === 'string' ? error.stdout : error?.stdout?.toString?.() ?? ''; + const stderr = + typeof error?.stderr === 'string' ? error.stderr : error?.stderr?.toString?.() ?? ''; + notarizeOutput = `${stdout}\n${stderr}`; + process.stdout.write(stdout); + process.stderr.write(stderr); + + const submissionId = extractSubmissionId(notarizeOutput); + if (submissionId) { + console.error(`\n=== Fetching notarization log for ${submissionId} ===`); + try { + run('xcrun', [ + 'notarytool', + 'log', + submissionId, + '--key', + keyPath, + '--key-id', + keyId, + '--issuer', + issuer, + ]); + } catch { + // Best effort only; keep original failure. + } + } + + throw new Error(`Notarization failed for ${zipPath}`); + } finally { + rmSync(zipPath, { force: true }); + } + + log(`Stapling accepted ticket onto ${basename(appPath)}`); + run('xcrun', ['stapler', 'staple', appPath]); + + log(`Validating stapled ticket on ${basename(appPath)}`); + run('xcrun', ['stapler', 'validate', appPath]); +} + +try { + main(); +} catch (error) { + console.error(`\n❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +} From 3d4070149aa670de994742b03fb787aecc0e267c Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 12:49:56 +1000 Subject: [PATCH 08/10] docs(plan): add macOS updater CI proof run checklist Link the Track B plan to a small proof-run doc that records the exact release assets, log markers, pass criteria, and command path for the first real macOS updater CI validation run. Made-with: Cursor --- .../260311-macos-updater-ci-proof-run.md | 119 ++++++++++++++++++ ...1-tauri-auto-update-phase2-track-b-plan.md | 1 + 2 files changed, 120 insertions(+) create mode 100644 docs/plans/260311-macos-updater-ci-proof-run.md diff --git a/docs/plans/260311-macos-updater-ci-proof-run.md b/docs/plans/260311-macos-updater-ci-proof-run.md new file mode 100644 index 0000000..ccfe628 --- /dev/null +++ b/docs/plans/260311-macos-updater-ci-proof-run.md @@ -0,0 +1,119 @@ +# 260311 plan: macOS updater CI proof run + +Goal: + +- run one real release workflow proof for the macOS updater artifact path +- verify asset publishing, notarization ordering, and post-tar validation on GitHub runners + +Important constraint: + +- with current repo tooling, a real proof run is a real version/tag/release +- `scripts/release-github.mjs` enforces: + - clean worktree + - current branch must equal `--target` + - new version must be greater than latest remote tag + - real `gh release create` +- so this proof consumes a real version number unless we later add a separate test-release workflow + +## Preconditions + +- branch pushed and clean +- Apple notarization secrets configured in GitHub Actions +- updater signing key secrets configured in GitHub Actions +- all release-path commits for this spike merged into the branch you are proving + +## Exact proof command + +If proving on the current feature branch: + +```bash +git checkout callum1/bui-249-auto-update-the-app-2 +git pull --ff-only origin callum1/bui-249-auto-update-the-app-2 +npm run release:github -- --version --target callum1/bui-249-auto-update-the-app-2 +``` + +Choose `` as a real unused semver greater than the latest remote tag. + +## Expected release assets + +Minimum macOS proof assets that must exist on the GitHub Release: + +- `DataConnect__aarch64.dmg` +- `DataConnect__x86_64.dmg` +- `DataConnect__aarch64.app.tar.gz` +- `DataConnect__aarch64.app.tar.gz.sig` +- `DataConnect__x86_64.app.tar.gz` +- `DataConnect__x86_64.app.tar.gz.sig` + +Baseline non-macOS artifacts may also be present: + +- Windows installer assets +- Linux `.deb` +- Linux `.AppImage` + +## Exact log checks + +For each macOS matrix job, confirm logs contain: + +- `=== Finalizing macOS bundles for aarch64-apple-darwin ===` or `x86_64-apple-darwin` +- `Re-signed` +- `Submitting` +- `Stapling accepted ticket onto` +- `Validating stapled ticket on` +- `Created updater artifacts:` +- `xcrun stapler validate` +- `spctl --assess -vv` +- `codesign --verify --strict` +- `Created DataConnect__.dmg` +- `Notarized and stapled` +- `Uploaded DataConnect__.app.tar.gz` +- `Uploaded DataConnect__.app.tar.gz.sig` +- `Uploaded DataConnect__.dmg` + +Red flags: + +- `Skipping finalized macOS updater artifact generation` +- `Notarization FAILED` +- `does not have a ticket stapled` +- `rejected` +- `invalid` +- `Permission denied` +- any overwrite/clobber behavior on macOS updater assets + +## Exact post-run inspection commands + +```bash +# inspect release assets +gh release view v --json assets + +# locate the workflow run +gh run list --workflow Release --limit 10 + +# dump full logs for archive/review +gh run view --log > "/tmp/dataconnect-release-proof-v.log" + +# filter the log for proof markers +rg -n "Finalizing macOS bundles|Submitting|Stapling accepted ticket|Validating stapled ticket|Created updater artifacts|spctl --assess|codesign --verify|Uploaded DataConnect_|Notarized and stapled|Skipping finalized macOS updater artifact generation|Notarization FAILED|does not have a ticket stapled|rejected|invalid" "/tmp/dataconnect-release-proof-v.log" +``` + +## Pass criteria + +- both macOS jobs pass +- all 6 required macOS assets exist on the release +- no macOS updater asset overwrite/clobber +- updater tarball smoke gate passes after untar +- app notarization/stapling passes before updater packaging +- DMG notarization/stapling passes afterward + +## If the proof is just for validation + +After review, decide whether to keep or clean up the proof release/tag. + +Cleanup is manual: + +- delete the GitHub Release +- delete the tag locally/remotely if you do not want to keep the proof artifact history + +But note: + +- the consumed version number stays consumed for practical purposes once pushed/shared diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md index d7bc5a6..f776aa5 100644 --- a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -5,6 +5,7 @@ Source docs: - `docs/260311-tauri-auto-update-phase2-feasibility.md` - `docs/260226-tauri-app-update-toast-overview.md` - `docs/plans/260226-app-update-toast-phase1-implementation-plan.md` +- `docs/plans/260311-macos-updater-ci-proof-run.md` Use this doc in two modes: From 9e3b3d1a1951604d5458c705517f06b528217a9a Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 12:53:10 +1000 Subject: [PATCH 09/10] release: v0.7.34 --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d46b8ea..2fa9001 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "DataConnect", - "version": "0.7.33", + "version": "0.7.34", "identifier": "dev.dataconnect", "build": { "frontendDist": "../dist", From 9fb629d9234d804bb97a1f1c02e0741c7ba5cdf5 Mon Sep 17 00:00:00 2001 From: Callum Flack Date: Wed, 11 Mar 2026 13:21:18 +1000 Subject: [PATCH 10/10] ci(updater): disable tauri-action release uploads Record the first proof-run failure mode, stop tauri-action from uploading release assets, and keep release publishing in the explicit upload step so the macOS updater asset contract comes from one path only. Made-with: Cursor --- .github/workflows/release.yml | 6 ---- .../260311-macos-updater-ci-proof-run.md | 35 +++++++++++++++++-- ...1-tauri-auto-update-phase2-track-b-plan.md | 15 +++++++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a920a80..d2e2d72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -154,7 +154,6 @@ jobs: - name: Build Tauri app uses: tauri-apps/tauri-action@v0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VITE_PRIVY_APP_ID: ${{ secrets.VITE_PRIVY_APP_ID }} VITE_PRIVY_CLIENT_ID: ${{ secrets.VITE_PRIVY_CLIENT_ID }} VITE_SESSION_RELAY_URL: ${{ secrets.VITE_SESSION_RELAY_URL }} @@ -166,11 +165,6 @@ jobs: APPLE_API_KEY: ${{ secrets.APPLE_ASC_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_ASC_API_KEY_ISSUER_UUID }} with: - tagName: ${{ github.ref_name }} - releaseName: 'DataConnect v__VERSION__' - releaseBody: 'See the assets to download this version and install.' - releaseDraft: false - prerelease: false args: --target ${{ matrix.target }} - name: Free disk space before finalization diff --git a/docs/plans/260311-macos-updater-ci-proof-run.md b/docs/plans/260311-macos-updater-ci-proof-run.md index ccfe628..06201b9 100644 --- a/docs/plans/260311-macos-updater-ci-proof-run.md +++ b/docs/plans/260311-macos-updater-ci-proof-run.md @@ -22,6 +22,33 @@ Important constraint: - updater signing key secrets configured in GitHub Actions - all release-path commits for this spike merged into the branch you are proving +### Exact GitHub Actions secrets required + +Already required for the current release flow: + +- `APPLE_BUILD_CERTIFICATE_BASE64` +- `APPLE_BUILD_CERTIFICATE_PASSWORD` +- `APPLE_ASC_API_KEY_KEY_BASE64` +- `APPLE_ASC_API_KEY_ID` +- `APPLE_ASC_API_KEY_ISSUER_UUID` +- `APPLE_TEAM_ID` +- `VITE_PRIVY_APP_ID` +- `VITE_PRIVY_CLIENT_ID` +- `VITE_SESSION_RELAY_URL` +- `VITE_GATEWAY_URL` + +Required specifically for the updater proof path: + +- `TAURI_SIGNING_PRIVATE_KEY` + - value: the full contents of your Tauri updater private key file + - preferred over path-based config in GitHub Actions +- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` + - value: the password for that private key + - if the key has no password, set this to an empty string or omit only if you have confirmed the CLI accepts that in CI + +Do not rely on `TAURI_SIGNING_PRIVATE_KEY_PATH` in GitHub Actions for this flow. +The runner does not automatically have your local key file path. + ## Exact proof command If proving on the current feature branch: @@ -42,8 +69,8 @@ Minimum macOS proof assets that must exist on the GitHub Release: - `DataConnect__x86_64.dmg` - `DataConnect__aarch64.app.tar.gz` - `DataConnect__aarch64.app.tar.gz.sig` -- `DataConnect__x86_64.app.tar.gz` -- `DataConnect__x86_64.app.tar.gz.sig` +- `DataConnect__x64.app.tar.gz` +- `DataConnect__x64.app.tar.gz.sig` Baseline non-macOS artifacts may also be present: @@ -78,6 +105,10 @@ Red flags: - `rejected` - `invalid` - `Permission denied` +- generic macOS updater assets from `tauri-action`, for example: + - `DataConnect.app.tar.gz` + - `DataConnect_aarch64.app.tar.gz` + - `DataConnect_x64.app.tar.gz` without matching `.sig` - any overwrite/clobber behavior on macOS updater assets ## Exact post-run inspection commands diff --git a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md index f776aa5..3d6bfba 100644 --- a/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md +++ b/docs/plans/260311-tauri-auto-update-phase2-track-b-plan.md @@ -40,6 +40,19 @@ Current working implication: - Therefore the current post-finalization updater script ordering is still incomplete if it runs before app notarization/stapling proof exists. - Next execution slice should prove or implement: final-sign app -> notarize/staple app-compatible submission -> package stapled app into updater tarball -> sign tarball -> publish metadata. +First real CI proof result: + +- release job passed overall +- DMG notarization path worked +- custom updater artifact path did not run because updater signing secrets were missing +- `tauri-action` still uploaded its own macOS updater tarballs, which polluted the release asset contract + +Follow-up adjustment: + +- stop `tauri-action` from uploading release assets +- keep all release uploads in the explicit manual upload step +- re-run proof only after updater signing secrets are configured + ### Goal Ship macOS-first phase 2 app updates in DataConnect: @@ -153,7 +166,7 @@ Fill `Status` with `PASS` / `NO-OP` / `FAIL` during execution. | `scripts/notarize-macos-app.mjs` | new script to submit a zip of the finalized `.app`, wait for notarization, staple the ticket back onto the `.app`, and validate it | PASS | repo script added; uses `ditto` + `xcrun notarytool submit` + `xcrun stapler` | | `scripts/build-macos-updater-artifacts.mjs` | new script to archive/sign finalized macOS `.app` into `.app.tar.gz` and `.sig` | PASS | repo script added; uses `tauri signer sign` on finalized tarball | | `scripts/build-updater-manifest.mjs` | new script to generate `latest.json` from release asset inputs | | | -| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | PASS | workflow now avoids xtrace around updater secrets, notarizes/staples the finalized `.app` before packaging the updater tarball, and hard-fails if the extracted updater payload fails stapler/spctl/codesign checks | +| `.github/workflows/release.yml` | call post-finalization updater script; upload `.app.tar.gz`, `.sig`, later `latest.json` | PASS | workflow now avoids xtrace around updater secrets, disables `tauri-action` release uploads, notarizes/staples the finalized `.app` before packaging the updater tarball, and hard-fails if the extracted updater payload fails stapler/spctl/codesign checks | | `scripts/build-prod.js` | optional local-macOS parity for updater-artifact smoke; otherwise mark `NO-OP` explicitly | NO-OP | local build path intentionally unchanged in this slice | | `src/hooks/app-update/check-app-update.ts` | preserve or narrow phase-1 external-release check as fallback path | | | | `src/hooks/app-update/tauri-updater.ts` | new seam around `@tauri-apps/plugin-updater` APIs | | |