From c9c05ce0bc3a1b967d1bd9e555da243fa5d3526e Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 19:58:26 -0700 Subject: [PATCH 01/11] add macos auto-update e2e harness and ci --- .github/workflows/code-update-e2e.yml | 102 ++++++++++++ apps/code/scripts/dev-update/build-pair.sh | 33 ++++ apps/code/scripts/dev-update/serve.mjs | 73 +++++++++ apps/code/src/main/index.ts | 13 ++ .../platform-adapters/electron-updater.ts | 8 + apps/code/tests/e2e/fixtures/update.ts | 74 +++++++++ apps/code/tests/e2e/tests/update.spec.ts | 150 ++++++++++++++++++ 7 files changed, 453 insertions(+) create mode 100644 .github/workflows/code-update-e2e.yml create mode 100644 apps/code/scripts/dev-update/build-pair.sh create mode 100644 apps/code/scripts/dev-update/serve.mjs create mode 100644 apps/code/tests/e2e/fixtures/update.ts create mode 100644 apps/code/tests/e2e/tests/update.spec.ts diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml new file mode 100644 index 0000000000..a7e907dc5f --- /dev/null +++ b/.github/workflows/code-update-e2e.yml @@ -0,0 +1,102 @@ +name: Code Update E2E (macOS) + +# Real macOS auto-update end to end: build a signed old (1.0.0) app and a signed +# new (2.0.0) feed, serve the feed on localhost, then drive a packaged build +# through download -> install -> Squirrel.Mac swap -> relaunch and assert the +# installed app became 2.0.0. Nightly and on demand only, never on PRs: it builds +# twice and exercises a real install, so it is too slow and too flaky for the gate. + +on: + # Temporary: also run on this branch so the harness can be exercised on the PR. + # Remove this push trigger once merged; nightly + dispatch is the steady state. + push: + branches: + - test/macos-auto-update-e2e + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +concurrency: + group: code-update-e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + update-e2e-macos: + runs-on: macos-15 + permissions: + id-token: write + contents: read + env: + NODE_OPTIONS: "--max-old-space-size=8192" + NODE_ENV: production + npm_config_arch: arm64 + npm_config_platform: darwin + VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }} + VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }} + # Sign both builds with the same identity so Squirrel.Mac accepts the swap. + # Notarization is skipped: it is a Gatekeeper concern, not what the in-place + # update verifies, and locally built bundles carry no quarantine attribute. + CSC_LINK: ${{ secrets.APPLE_CODESIGN_CERT_BASE64 }} + CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + SKIP_NOTARIZE: "1" + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + role-to-assume: ${{ secrets.AWS_TWIG_APP_ASSETS_ROLE_ARN }} + aws-region: ${{ secrets.AWS_TWIG_APP_ASSETS_REGION }} + mask-aws-account-id: true + unset-current-credentials: true + + - name: Download BerkeleyMono fonts from S3 + run: aws s3 cp s3://${{ secrets.AWS_TWIG_APP_ASSETS_BUCKET }}/fonts/BerkeleyMono/ apps/code/assets/fonts/BerkeleyMono/ --recursive + + - name: Build workspace packages + run: | + pnpm --filter @posthog/electron-trpc run build + pnpm --filter @posthog/platform run build + pnpm --filter @posthog/shared run build + pnpm --filter @posthog/git run build + pnpm --filter @posthog/enricher run build + pnpm --filter @posthog/agent run build + + - name: Build old + new update pair + working-directory: apps/code + run: bash scripts/dev-update/build-pair.sh + + - name: Install Playwright + run: pnpm --filter code exec playwright install + + - name: Run macOS update E2E + working-directory: apps/code + run: pnpm exec playwright test --config=tests/e2e/playwright.config.ts tests/e2e/tests/update.spec.ts + + - name: Upload report and logs on failure + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: update-e2e-macos + path: | + apps/code/playwright-report/ + apps/code/playwright-results/ + /Users/runner/.posthog-code/logs/ + retention-days: 7 diff --git a/apps/code/scripts/dev-update/build-pair.sh b/apps/code/scripts/dev-update/build-pair.sh new file mode 100644 index 0000000000..5fe8a7f88f --- /dev/null +++ b/apps/code/scripts/dev-update/build-pair.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Build a signed OLD (1.0.0) app to run plus a signed NEW (2.0.0) feed for the +# macOS auto-update E2E. Real signing needs CSC_LINK (set in CI); locally it uses +# whatever identity electron-builder finds. Run from apps/code. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +OLD_VERSION="${OLD_VERSION:-1.0.0}" +NEW_VERSION="${NEW_VERSION:-2.0.0}" +FEED_DIR="out/dev-update-feed" + +export SKIP_NOTARIZE="${SKIP_NOTARIZE:-1}" + +echo "==> electron-vite build" +pnpm exec electron-vite build + +echo "==> build NEW $NEW_VERSION (feed artifacts)" +pnpm exec electron-builder build --mac zip --arm64 \ + -c.extraMetadata.version="$NEW_VERSION" --config electron-builder.ts + +rm -rf "$FEED_DIR" +mkdir -p "$FEED_DIR" +cp "out/PostHog-Code-${NEW_VERSION}-arm64-mac.zip" "$FEED_DIR/" +cp "out/PostHog-Code-${NEW_VERSION}-arm64-mac.zip.blockmap" "$FEED_DIR/" +cp "out/latest-mac.yml" "$FEED_DIR/" + +echo "==> build OLD $OLD_VERSION (runnable app left in out/mac-arm64)" +pnpm exec electron-builder build --mac zip --arm64 \ + -c.extraMetadata.version="$OLD_VERSION" --config electron-builder.ts + +echo "==> feed=$FEED_DIR" +echo "==> app=out/mac-arm64/PostHog Code.app ($OLD_VERSION)" diff --git a/apps/code/scripts/dev-update/serve.mjs b/apps/code/scripts/dev-update/serve.mjs new file mode 100644 index 0000000000..0af9d616ac --- /dev/null +++ b/apps/code/scripts/dev-update/serve.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Dependency-free static file server for the auto-update feed. Serves a directory +// (latest-mac.yml + zip + blockmap) over HTTP with Range support, which the macOS +// updater needs. Used by the update E2E and for local manual testing. +// +// Usage: node serve.mjs [port] +import { createReadStream, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize } from "node:path"; + +const root = process.argv[2]; +const port = Number(process.argv[3] ?? 8080); + +if (!root) { + console.error("Usage: serve.mjs [port]"); + process.exit(1); +} + +const CONTENT_TYPES = { + ".yml": "text/yaml; charset=utf-8", + ".yaml": "text/yaml; charset=utf-8", + ".zip": "application/zip", + ".blockmap": "application/octet-stream", + ".json": "application/json; charset=utf-8", +}; + +const server = createServer((req, res) => { + const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]); + const safePath = normalize(urlPath).replace(/^(\.\.[/\\])+/, ""); + const filePath = join(root, safePath); + + let stat; + try { + stat = statSync(filePath); + } catch { + res.writeHead(404); + res.end("Not found"); + return; + } + if (!stat.isFile()) { + res.writeHead(404); + res.end("Not found"); + return; + } + + const type = CONTENT_TYPES[extname(filePath)] ?? "application/octet-stream"; + const range = req.headers.range; + + if (range) { + const match = /bytes=(\d*)-(\d*)/.exec(range); + const start = match?.[1] ? Number(match[1]) : 0; + const end = match?.[2] ? Number(match[2]) : stat.size - 1; + res.writeHead(206, { + "Content-Type": type, + "Content-Range": `bytes ${start}-${end}/${stat.size}`, + "Accept-Ranges": "bytes", + "Content-Length": end - start + 1, + }); + createReadStream(filePath, { start, end }).pipe(res); + return; + } + + res.writeHead(200, { + "Content-Type": type, + "Content-Length": stat.size, + "Accept-Ranges": "bytes", + }); + createReadStream(filePath).pipe(res); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`update feed: http://127.0.0.1:${port} serving ${root}`); +}); diff --git a/apps/code/src/main/index.ts b/apps/code/src/main/index.ts index 5582882c43..699e941424 100644 --- a/apps/code/src/main/index.ts +++ b/apps/code/src/main/index.ts @@ -360,6 +360,19 @@ app.whenReady().then(async () => { container.bind(FS_SERVICE).toService(MAIN_TOKENS.FsService); await initializeServices(); initializeDeepLinks(); + + if (process.env.POSTHOG_E2E_UPDATE_FEED) { + const updates = container.get(MAIN_TOKENS.UpdatesService); + Object.assign(globalThis, { + __e2eUpdates: { + check: () => updates.checkForUpdates(), + download: () => updates.requestDownload(), + install: () => updates.installUpdate(), + status: () => updates.getStatus(), + }, + }); + log.info("E2E update hook installed on globalThis.__e2eUpdates"); + } }); app.on("window-all-closed", () => { diff --git a/apps/code/src/main/platform-adapters/electron-updater.ts b/apps/code/src/main/platform-adapters/electron-updater.ts index d4b8d857f8..b47a34abdc 100644 --- a/apps/code/src/main/platform-adapters/electron-updater.ts +++ b/apps/code/src/main/platform-adapters/electron-updater.ts @@ -40,6 +40,14 @@ export class ElectronUpdater implements IUpdater { // next quit, with an in-app Restart button for immediate install. autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; + + // E2E only: redirect the updater at a local feed so a packaged build can be + // driven through a real download and install against test artifacts. The env + // var is never set in production. + const e2eFeedUrl = process.env.POSTHOG_E2E_UPDATE_FEED; + if (e2eFeedUrl) { + autoUpdater.setFeedURL({ provider: "generic", url: e2eFeedUrl }); + } } public isSupported(): boolean { diff --git a/apps/code/tests/e2e/fixtures/update.ts b/apps/code/tests/e2e/fixtures/update.ts new file mode 100644 index 0000000000..142ccaebce --- /dev/null +++ b/apps/code/tests/e2e/fixtures/update.ts @@ -0,0 +1,74 @@ +import { type ChildProcess, execFileSync, spawn } from "node:child_process"; +import { mkdirSync, rmSync } from "node:fs"; +import path from "node:path"; + +const OUT_DIR = path.join(__dirname, "../../../out"); + +export const PRISTINE_APP = path.join(OUT_DIR, "mac-arm64/PostHog Code.app"); +export const FEED_DIR = path.join(OUT_DIR, "dev-update-feed"); +export const RUN_DIR = path.join(OUT_DIR, "e2e-update-run"); +export const RUN_APP = path.join(RUN_DIR, "PostHog Code.app"); +export const RUN_APP_BIN = path.join(RUN_APP, "Contents/MacOS/PostHog Code"); + +const SERVE_SCRIPT = path.join( + __dirname, + "../../../scripts/dev-update/serve.mjs", +); + +// Copy the pristine built app into a disposable run dir so the in-place update +// swap never mutates the build output, which lets a retry start from 1.0.0 +// again. ditto preserves the code signature that Squirrel.Mac verifies. +export function prepareRunApp(): void { + rmSync(RUN_DIR, { recursive: true, force: true }); + mkdirSync(RUN_DIR, { recursive: true }); + execFileSync("ditto", [PRISTINE_APP, RUN_APP]); +} + +export function startFeedServer(port: number): ChildProcess { + return spawn("node", [SERVE_SCRIPT, FEED_DIR, String(port)], { + stdio: "inherit", + }); +} + +export function readBundleVersion(appPath: string): string { + return execFileSync( + "plutil", + [ + "-extract", + "CFBundleShortVersionString", + "raw", + path.join(appPath, "Contents/Info.plist"), + ], + { encoding: "utf8" }, + ).trim(); +} + +export function isAppRunning(): boolean { + try { + execFileSync("pgrep", ["-x", "PostHog Code"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export function killApp(): void { + try { + execFileSync("pkill", ["-x", "PostHog Code"]); + } catch { + // nothing running, fine + } +} + +export async function waitUntil( + predicate: () => boolean | Promise, + timeoutMs: number, + message: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Timed out after ${timeoutMs}ms: ${message}`); +} diff --git a/apps/code/tests/e2e/tests/update.spec.ts b/apps/code/tests/e2e/tests/update.spec.ts new file mode 100644 index 0000000000..36753347af --- /dev/null +++ b/apps/code/tests/e2e/tests/update.spec.ts @@ -0,0 +1,150 @@ +import { existsSync } from "node:fs"; +import { + type ElectronApplication, + _electron as electron, + expect, + test, +} from "@playwright/test"; +import { + FEED_DIR, + isAppRunning, + killApp, + PRISTINE_APP, + prepareRunApp, + RUN_APP, + RUN_APP_BIN, + readBundleVersion, + startFeedServer, + waitUntil, +} from "../fixtures/update"; + +type UpdateStatus = { + checking?: boolean; + available?: boolean; + availableVersion?: string; + downloading?: boolean; + downloadPercent?: number; + updateReady?: boolean; +}; + +// Installed on globalThis by main/index.ts when POSTHOG_E2E_UPDATE_FEED is set. +// The cast is erased at compile time, so the evaluate closures serialize to plain +// globalThis access in the main process. +type E2eHook = { + check: () => void; + download: () => void; + install: () => Promise; + status: () => UpdateStatus; +}; +type Hooked = typeof globalThis & { __e2eUpdates: E2eHook }; + +const FEED_PORT = 8788; +const FEED_URL = `http://127.0.0.1:${FEED_PORT}`; +const NEW_VERSION = "2.0.0"; + +test.describe("macOS auto-update", () => { + test.skip(process.platform !== "darwin", "macOS-only update flow"); + + test("downloads, installs and relaunches into the new version", async () => { + test.setTimeout(5 * 60_000); + + expect( + existsSync(PRISTINE_APP), + `missing built app at ${PRISTINE_APP}; run scripts/dev-update/build-pair.sh`, + ).toBe(true); + expect( + existsSync(FEED_DIR), + `missing feed at ${FEED_DIR}; run scripts/dev-update/build-pair.sh`, + ).toBe(true); + + prepareRunApp(); + const feed = startFeedServer(FEED_PORT); + + try { + // Phase 1: drive the real download + install on the old build. + const app = await electron.launch({ + executablePath: RUN_APP_BIN, + args: [], + env: { + ...process.env, + ELECTRON_DISABLE_GPU: "1", + POSTHOG_E2E_UPDATE_FEED: FEED_URL, + }, + }); + + await expect + .poll( + () => app.evaluate(() => typeof (globalThis as Hooked).__e2eUpdates), + { + timeout: 30_000, + message: "update hook was never installed", + }, + ) + .toBe("object"); + + await app.evaluate(() => (globalThis as Hooked).__e2eUpdates.check()); + await pollStatus( + app, + (s) => s.available === true && s.availableVersion === NEW_VERSION, + "update never became available", + ); + + await app.evaluate(() => (globalThis as Hooked).__e2eUpdates.download()); + await pollStatus( + app, + (s) => s.updateReady === true, + "update never finished downloading", + ); + + const closed = app.waitForEvent("close"); + void app + .evaluate(() => { + void (globalThis as Hooked).__e2eUpdates.install(); + }) + .catch(() => undefined); + await closed; + + // Phase 2: prove the bundle swapped and a fresh launch is the new version. + await waitUntil( + () => readBundleVersion(RUN_APP) === NEW_VERSION, + 120_000, + "bundle was not swapped to the new version", + ); + killApp(); + await waitUntil( + () => !isAppRunning(), + 30_000, + "relaunched instance did not exit", + ); + + const updated = await electron.launch({ + executablePath: RUN_APP_BIN, + args: [], + env: { ...process.env, ELECTRON_DISABLE_GPU: "1" }, + }); + const version = await updated.evaluate(({ app: a }) => a.getVersion()); + expect(version).toBe(NEW_VERSION); + await updated.close(); + } finally { + feed.kill(); + } + }); +}); + +async function pollStatus( + app: ElectronApplication, + predicate: (status: UpdateStatus) => boolean, + message: string, +): Promise { + await expect + .poll( + async () => + predicate( + await app.evaluate(() => + (globalThis as Hooked).__e2eUpdates.status(), + ), + ), + { timeout: 120_000, message }, + ) + .toBe(true); +} From d53bbe5d857b1c9492927c269d7c04da99db95e0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 20:07:08 -0700 Subject: [PATCH 02/11] pass --publish never in e2e build-pair --- apps/code/scripts/dev-update/build-pair.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/code/scripts/dev-update/build-pair.sh b/apps/code/scripts/dev-update/build-pair.sh index 5fe8a7f88f..c4c934afda 100644 --- a/apps/code/scripts/dev-update/build-pair.sh +++ b/apps/code/scripts/dev-update/build-pair.sh @@ -16,7 +16,7 @@ echo "==> electron-vite build" pnpm exec electron-vite build echo "==> build NEW $NEW_VERSION (feed artifacts)" -pnpm exec electron-builder build --mac zip --arm64 \ +pnpm exec electron-builder build --mac zip --arm64 --publish never \ -c.extraMetadata.version="$NEW_VERSION" --config electron-builder.ts rm -rf "$FEED_DIR" @@ -26,7 +26,7 @@ cp "out/PostHog-Code-${NEW_VERSION}-arm64-mac.zip.blockmap" "$FEED_DIR/" cp "out/latest-mac.yml" "$FEED_DIR/" echo "==> build OLD $OLD_VERSION (runnable app left in out/mac-arm64)" -pnpm exec electron-builder build --mac zip --arm64 \ +pnpm exec electron-builder build --mac zip --arm64 --publish never \ -c.extraMetadata.version="$OLD_VERSION" --config electron-builder.ts echo "==> feed=$FEED_DIR" From 98debc3c5870dd232e53b6fa581981d4274d2943 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 20:20:26 -0700 Subject: [PATCH 03/11] gate update e2e spec behind RUN_UPDATE_E2E --- .github/workflows/code-update-e2e.yml | 2 ++ apps/code/tests/e2e/tests/update.spec.ts | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml index a7e907dc5f..b24738b173 100644 --- a/.github/workflows/code-update-e2e.yml +++ b/.github/workflows/code-update-e2e.yml @@ -88,6 +88,8 @@ jobs: - name: Run macOS update E2E working-directory: apps/code + env: + RUN_UPDATE_E2E: "1" run: pnpm exec playwright test --config=tests/e2e/playwright.config.ts tests/e2e/tests/update.spec.ts - name: Upload report and logs on failure diff --git a/apps/code/tests/e2e/tests/update.spec.ts b/apps/code/tests/e2e/tests/update.spec.ts index 36753347af..0db0156dc0 100644 --- a/apps/code/tests/e2e/tests/update.spec.ts +++ b/apps/code/tests/e2e/tests/update.spec.ts @@ -43,7 +43,12 @@ const FEED_URL = `http://127.0.0.1:${FEED_PORT}`; const NEW_VERSION = "2.0.0"; test.describe("macOS auto-update", () => { - test.skip(process.platform !== "darwin", "macOS-only update flow"); + // Runs only in the dedicated code-update-e2e workflow, which builds the signed + // feed first. The general e2e suite has no feed, so it skips this spec. + test.skip( + process.platform !== "darwin" || process.env.RUN_UPDATE_E2E !== "1", + "macOS-only; set RUN_UPDATE_E2E=1 (needs scripts/dev-update/build-pair.sh)", + ); test("downloads, installs and relaunches into the new version", async () => { test.setTimeout(5 * 60_000); From c1e9122eb7cc44560a39a1645f70825961487ca0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 20:33:10 -0700 Subject: [PATCH 04/11] add local auto-update testing doc --- docs/AUTO-UPDATE-TESTING.md | 106 ++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/AUTO-UPDATE-TESTING.md diff --git a/docs/AUTO-UPDATE-TESTING.md b/docs/AUTO-UPDATE-TESTING.md new file mode 100644 index 0000000000..be0961ea49 --- /dev/null +++ b/docs/AUTO-UPDATE-TESTING.md @@ -0,0 +1,106 @@ +# Testing Auto-Update Locally + +This explains how to exercise the real auto-update flow (check, download, install, relaunch) on your own machine, against a local feed, without cutting a GitHub release. For how releases and versioning actually work in production, see [UPDATES.md](./UPDATES.md). + +The same harness runs nightly in CI (`.github/workflows/code-update-e2e.yml`) on a signed macOS build. + +## What this covers + +Auto-update is macOS and Windows only (`isSupported` in `apps/code/src/main/platform-adapters/electron-updater.ts`). This guide is macOS, which is where the harness and the nightly job run. + +The flow under test: a packaged old build checks a local feed, downloads a newer build, and Squirrel.Mac swaps the app bundle in place and relaunches into the new version. + +## What you need + +- A packaged build (not `pnpm dev`). Auto-update only runs when `app.isPackaged` is true. +- For the full install and relaunch: a Developer ID signing identity. Squirrel.Mac only swaps a bundle whose signature matches the running app's designated requirement, so both builds must be signed with the same identity. Set `CSC_LINK` / `CSC_KEY_PASSWORD`, or have a Developer ID cert in your login keychain. + - Without a matching identity you can still watch check, available, download and ready. The final swap is the part that needs the signature. +- Notarization is intentionally skipped (`SKIP_NOTARIZE=1`). It is a Gatekeeper concern for first launch of a downloaded app, not what the in-place update verifies, and a locally built bundle carries no quarantine attribute. + +## The harness + +| Piece | Role | +| --- | --- | +| `apps/code/scripts/dev-update/build-pair.sh` | Builds a signed `2.0.0` feed plus a runnable signed `1.0.0` app | +| `apps/code/scripts/dev-update/serve.mjs` | Dependency-free, range-capable static server for the feed | +| `apps/code/tests/e2e/tests/update.spec.ts` | Two-phase Playwright test: drive download and install, then assert the swap and relaunch | +| `POSTHOG_E2E_UPDATE_FEED` env | When set, the updater points at this URL instead of GitHub (gated, inert in production) | +| `RUN_UPDATE_E2E` env | When set to `1`, the Playwright spec actually runs; otherwise it skips | +| `globalThis.__e2eUpdates` | Set in the main process when `POSTHOG_E2E_UPDATE_FEED` is present; lets the test drive `check` / `download` / `install` / `status` | + +## 1. Build the pair + +```bash +bash apps/code/scripts/dev-update/build-pair.sh +``` + +This runs `electron-vite build` once, then builds twice with `electron-builder`: + +- The new `2.0.0` artifacts are copied to `apps/code/out/dev-update-feed/` (`latest-mac.yml`, the zip and its blockmap). This is the feed. +- The old `1.0.0` app is left at `apps/code/out/mac-arm64/PostHog Code.app`. This is what you run. + +Override the versions if you want (`2.0.0` must be greater than `1.0.0`): + +```bash +OLD_VERSION=1.0.0 NEW_VERSION=2.0.0 bash apps/code/scripts/dev-update/build-pair.sh +``` + +This takes a few minutes and may prompt for keychain access to sign. + +## 2a. Run it automated (Playwright) + +The spec starts its own feed server, copies the `1.0.0` app to a disposable run dir (so a rerun starts clean), drives the full flow and asserts the relaunched app is `2.0.0`. + +```bash +RUN_UPDATE_E2E=1 pnpm --filter code exec playwright test \ + --config=tests/e2e/playwright.config.ts tests/e2e/tests/update.spec.ts +``` + +Without `RUN_UPDATE_E2E=1` the spec skips, which is why the general e2e suite ignores it. + +## 2b. Run it manually (real UI) + +Serve the feed in one terminal: + +```bash +node apps/code/scripts/dev-update/serve.mjs apps/code/out/dev-update-feed 8788 +``` + +Launch the `1.0.0` app pointed at it in another terminal: + +```bash +POSTHOG_E2E_UPDATE_FEED=http://127.0.0.1:8788 \ + "apps/code/out/mac-arm64/PostHog Code.app/Contents/MacOS/PostHog Code" +``` + +The app checks on launch and the update banner shows `2.0.0` is available. Open it, click Download, watch progress, then Restart. The app quits, swaps and relaunches into `2.0.0`. + +A manual run swaps `out/mac-arm64` in place, so rerun `build-pair.sh` (or just the old build) to reset to `1.0.0` before testing again. + +## Verifying the result + +- Running version: open the in-app About, or read the bundle: + ```bash + plutil -extract CFBundleShortVersionString raw \ + "apps/code/out/mac-arm64/PostHog Code.app/Contents/Info.plist" + ``` +- Update logs are in the main log: + ```bash + tail -f ~/.posthog-code/logs/main.log + ``` + +## CI + +`code-update-e2e.yml` runs the same spec nightly on `macos-15` with the real signing secrets, and on demand: + +```bash +gh workflow run "Code Update E2E (macOS)" +``` + +It builds the pair, serves the feed and runs `update.spec.ts` with `RUN_UPDATE_E2E=1`. + +## Cleanup + +```bash +rm -rf apps/code/out/dev-update-feed apps/code/out/e2e-update-run +``` From 6fc7e22fc6b10dd3ee3ed309e252e3bf8ce49030 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 20:56:55 -0700 Subject: [PATCH 05/11] harden update e2e checks and evidence --- .github/workflows/code-update-e2e.yml | 22 ++++++++--- apps/code/tests/e2e/fixtures/update.ts | 28 +++++++++++++- apps/code/tests/e2e/playwright.config.ts | 4 ++ .../tests/e2e/playwright.update.config.ts | 19 ++++++++++ apps/code/tests/e2e/tests/update.spec.ts | 38 ++++++++++++++++--- docs/AUTO-UPDATE-TESTING.md | 10 ++--- 6 files changed, 104 insertions(+), 17 deletions(-) create mode 100644 apps/code/tests/e2e/playwright.update.config.ts diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml index b24738b173..ac70cdb52e 100644 --- a/.github/workflows/code-update-e2e.yml +++ b/.github/workflows/code-update-e2e.yml @@ -89,16 +89,28 @@ jobs: - name: Run macOS update E2E working-directory: apps/code env: - RUN_UPDATE_E2E: "1" - run: pnpm exec playwright test --config=tests/e2e/playwright.config.ts tests/e2e/tests/update.spec.ts + PLAYWRIGHT_JSON_OUTPUT_NAME: out/update-report.json + run: | + pnpm exec playwright test --config=tests/e2e/playwright.update.config.ts + node -e ' + const r = require("./out/update-report.json"); + const s = r.stats || {}; + console.log("update e2e stats:", JSON.stringify(s)); + if (s.expected !== 1 || s.skipped || s.unexpected || s.flaky) { + console.error("FAIL: expected exactly one passing update test"); + process.exit(1); + } + ' - - name: Upload report and logs on failure - if: failure() + - name: Upload report and logs + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: update-e2e-macos path: | - apps/code/playwright-report/ apps/code/playwright-results/ + apps/code/out/update-report.json /Users/runner/.posthog-code/logs/ + /Users/runner/Library/Caches/com.posthog.array.ShipIt/ + if-no-files-found: ignore retention-days: 7 diff --git a/apps/code/tests/e2e/fixtures/update.ts b/apps/code/tests/e2e/fixtures/update.ts index 142ccaebce..eda3b0c60e 100644 --- a/apps/code/tests/e2e/fixtures/update.ts +++ b/apps/code/tests/e2e/fixtures/update.ts @@ -1,5 +1,6 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process"; -import { mkdirSync, rmSync } from "node:fs"; +import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; import path from "node:path"; const OUT_DIR = path.join(__dirname, "../../../out"); @@ -10,6 +11,12 @@ export const RUN_DIR = path.join(OUT_DIR, "e2e-update-run"); export const RUN_APP = path.join(RUN_DIR, "PostHog Code.app"); export const RUN_APP_BIN = path.join(RUN_APP, "Contents/MacOS/PostHog Code"); +export const MAIN_LOG = path.join(homedir(), ".posthog-code/logs/main.log"); +export const SHIPIT_DIR = path.join( + homedir(), + "Library/Caches/com.posthog.array.ShipIt", +); + const SERVE_SCRIPT = path.join( __dirname, "../../../scripts/dev-update/serve.mjs", @@ -43,6 +50,25 @@ export function readBundleVersion(appPath: string): string { ).trim(); } +export function readMainLog(): string { + try { + return readFileSync(MAIN_LOG, "utf8"); + } catch { + return ""; + } +} + +// Squirrel.Mac's ShipIt helper performs the in-place swap and leaves its cache +// under ~/Library/Caches/.ShipIt, which is direct evidence the install +// went through Squirrel rather than anything the test did itself. +export function shipItEvidence(): { exists: boolean; entries: string[] } { + try { + return { exists: true, entries: readdirSync(SHIPIT_DIR) }; + } catch { + return { exists: false, entries: [] }; + } +} + export function isAppRunning(): boolean { try { execFileSync("pgrep", ["-x", "PostHog Code"], { stdio: "ignore" }); diff --git a/apps/code/tests/e2e/playwright.config.ts b/apps/code/tests/e2e/playwright.config.ts index ff646109c7..e3bf567a91 100644 --- a/apps/code/tests/e2e/playwright.config.ts +++ b/apps/code/tests/e2e/playwright.config.ts @@ -5,6 +5,9 @@ const isCI = !!process.env.CI; export default defineConfig({ testDir: "./tests", testMatch: "**/*.spec.ts", + // The update spec needs a signed feed; it runs only via + // playwright.update.config.ts, never in the general suite. + testIgnore: "**/update.spec.ts", timeout: 60000, retries: isCI ? 2 : 0, // Must run serially - Electron app has single instance lock @@ -19,6 +22,7 @@ export default defineConfig({ { name: "electron", testMatch: "**/*.spec.ts", + testIgnore: "**/update.spec.ts", }, ], }); diff --git a/apps/code/tests/e2e/playwright.update.config.ts b/apps/code/tests/e2e/playwright.update.config.ts new file mode 100644 index 0000000000..1443b6e3e5 --- /dev/null +++ b/apps/code/tests/e2e/playwright.update.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; + +// Dedicated config for the macOS auto-update E2E. The general suite excludes this +// spec by path (see testIgnore in playwright.config.ts), so it only runs here and +// cannot silently skip. retries are 0 so a broken swap surfaces immediately. In +// CI the JSON reporter lets the workflow assert exactly one test actually ran. +export default defineConfig({ + testDir: "./tests", + testMatch: "**/update.spec.ts", + timeout: 60000, + retries: 0, + workers: 1, + reporter: process.env.CI ? [["list"], ["json"]] : [["list"]], + outputDir: "../playwright-results", + use: { + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, +}); diff --git a/apps/code/tests/e2e/tests/update.spec.ts b/apps/code/tests/e2e/tests/update.spec.ts index 0db0156dc0..e06732ece2 100644 --- a/apps/code/tests/e2e/tests/update.spec.ts +++ b/apps/code/tests/e2e/tests/update.spec.ts @@ -14,6 +14,9 @@ import { RUN_APP, RUN_APP_BIN, readBundleVersion, + readMainLog, + SHIPIT_DIR, + shipItEvidence, startFeedServer, waitUntil, } from "../fixtures/update"; @@ -40,15 +43,13 @@ type Hooked = typeof globalThis & { __e2eUpdates: E2eHook }; const FEED_PORT = 8788; const FEED_URL = `http://127.0.0.1:${FEED_PORT}`; +const OLD_VERSION = "1.0.0"; const NEW_VERSION = "2.0.0"; test.describe("macOS auto-update", () => { - // Runs only in the dedicated code-update-e2e workflow, which builds the signed - // feed first. The general e2e suite has no feed, so it skips this spec. - test.skip( - process.platform !== "darwin" || process.env.RUN_UPDATE_E2E !== "1", - "macOS-only; set RUN_UPDATE_E2E=1 (needs scripts/dev-update/build-pair.sh)", - ); + // Runs only via playwright.update.config.ts; the general e2e suite excludes + // this file by path, so there is no env gate that could silently skip it. + test.skip(process.platform !== "darwin", "macOS-only update flow"); test("downloads, installs and relaunches into the new version", async () => { test.setTimeout(5 * 60_000); @@ -87,6 +88,12 @@ test.describe("macOS auto-update", () => { ) .toBe("object"); + // Prove we actually start on the old version, so the swap is real. + const startVersion = await app.evaluate(({ app: a }) => a.getVersion()); + expect(startVersion, "run app should start on the old version").toBe( + OLD_VERSION, + ); + await app.evaluate(() => (globalThis as Hooked).__e2eUpdates.check()); await pollStatus( app, @@ -130,6 +137,25 @@ test.describe("macOS auto-update", () => { const version = await updated.evaluate(({ app: a }) => a.getVersion()); expect(version).toBe(NEW_VERSION); await updated.close(); + + // Mechanism evidence: our updater drove a real download and install, and + // Squirrel.Mac's ShipIt is what performed the in-place swap. + const mainLog = readMainLog(); + expect( + mainLog, + "main.log missing the completed-download marker", + ).toContain("Update downloaded, awaiting user confirmation"); + expect(mainLog, "main.log missing the install marker").toContain( + "Installing update and restarting", + ); + const shipIt = shipItEvidence(); + console.log( + `Squirrel ShipIt cache: exists=${shipIt.exists} entries=[${shipIt.entries.join(", ")}]`, + ); + expect( + shipIt.exists, + `no Squirrel ShipIt cache at ${SHIPIT_DIR}; the swap was not performed by Squirrel`, + ).toBe(true); } finally { feed.kill(); } diff --git a/docs/AUTO-UPDATE-TESTING.md b/docs/AUTO-UPDATE-TESTING.md index be0961ea49..2dbfcf452e 100644 --- a/docs/AUTO-UPDATE-TESTING.md +++ b/docs/AUTO-UPDATE-TESTING.md @@ -25,7 +25,7 @@ The flow under test: a packaged old build checks a local feed, downloads a newer | `apps/code/scripts/dev-update/serve.mjs` | Dependency-free, range-capable static server for the feed | | `apps/code/tests/e2e/tests/update.spec.ts` | Two-phase Playwright test: drive download and install, then assert the swap and relaunch | | `POSTHOG_E2E_UPDATE_FEED` env | When set, the updater points at this URL instead of GitHub (gated, inert in production) | -| `RUN_UPDATE_E2E` env | When set to `1`, the Playwright spec actually runs; otherwise it skips | +| `apps/code/tests/e2e/playwright.update.config.ts` | Dedicated Playwright config; the only place the update spec runs | | `globalThis.__e2eUpdates` | Set in the main process when `POSTHOG_E2E_UPDATE_FEED` is present; lets the test drive `check` / `download` / `install` / `status` | ## 1. Build the pair @@ -52,11 +52,11 @@ This takes a few minutes and may prompt for keychain access to sign. The spec starts its own feed server, copies the `1.0.0` app to a disposable run dir (so a rerun starts clean), drives the full flow and asserts the relaunched app is `2.0.0`. ```bash -RUN_UPDATE_E2E=1 pnpm --filter code exec playwright test \ - --config=tests/e2e/playwright.config.ts tests/e2e/tests/update.spec.ts +pnpm --filter code exec playwright test \ + --config=tests/e2e/playwright.update.config.ts ``` -Without `RUN_UPDATE_E2E=1` the spec skips, which is why the general e2e suite ignores it. +The spec runs only through this dedicated config. The general e2e suite excludes it by path (`testIgnore` in `playwright.config.ts`), so it never runs there without a feed. ## 2b. Run it manually (real UI) @@ -97,7 +97,7 @@ A manual run swaps `out/mac-arm64` in place, so rerun `build-pair.sh` (or just t gh workflow run "Code Update E2E (macOS)" ``` -It builds the pair, serves the feed and runs `update.spec.ts` with `RUN_UPDATE_E2E=1`. +It builds the pair, runs the spec via `playwright.update.config.ts`, and asserts exactly one test actually ran, so a missing feed or a silent skip fails the job. The main log and the Squirrel ShipIt cache are uploaded as artifacts on every run. ## Cleanup From ec644f5aa4a398b540687d1fabd9818e3abd0ad3 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 21:06:30 -0700 Subject: [PATCH 06/11] fix update e2e stats report path --- .github/workflows/code-update-e2e.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml index ac70cdb52e..76d1f4d2ec 100644 --- a/.github/workflows/code-update-e2e.yml +++ b/.github/workflows/code-update-e2e.yml @@ -89,11 +89,11 @@ jobs: - name: Run macOS update E2E working-directory: apps/code env: - PLAYWRIGHT_JSON_OUTPUT_NAME: out/update-report.json + PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/apps/code/out/update-report.json run: | pnpm exec playwright test --config=tests/e2e/playwright.update.config.ts node -e ' - const r = require("./out/update-report.json"); + const r = require(process.env.GITHUB_WORKSPACE + "/apps/code/out/update-report.json"); const s = r.stats || {}; console.log("update e2e stats:", JSON.stringify(s)); if (s.expected !== 1 || s.skipped || s.unexpected || s.flaky) { From 77dc853cdf09a1cc453112e9ed586f1c10909da4 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 21:36:38 -0700 Subject: [PATCH 07/11] assert Squirrel auto-relaunch in update e2e --- apps/code/tests/e2e/fixtures/update.ts | 25 ++++++++++++++++++++++++ apps/code/tests/e2e/tests/update.spec.ts | 14 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/code/tests/e2e/fixtures/update.ts b/apps/code/tests/e2e/fixtures/update.ts index eda3b0c60e..55e79e14f6 100644 --- a/apps/code/tests/e2e/fixtures/update.ts +++ b/apps/code/tests/e2e/fixtures/update.ts @@ -78,6 +78,31 @@ export function isAppRunning(): boolean { } } +// Executable paths of the running main app processes (not helpers). Used to prove +// Squirrel's auto-relaunched process is running from the swapped bundle. +export function runningAppExecutables(): string[] { + let pids: string[]; + try { + pids = execFileSync("pgrep", ["-x", "PostHog Code"], { encoding: "utf8" }) + .trim() + .split("\n") + .filter(Boolean); + } catch { + return []; + } + return pids + .map((pid) => { + try { + return execFileSync("ps", ["-p", pid, "-o", "comm="], { + encoding: "utf8", + }).trim(); + } catch { + return ""; + } + }) + .filter(Boolean); +} + export function killApp(): void { try { execFileSync("pkill", ["-x", "PostHog Code"]); diff --git a/apps/code/tests/e2e/tests/update.spec.ts b/apps/code/tests/e2e/tests/update.spec.ts index e06732ece2..c8c10f73b3 100644 --- a/apps/code/tests/e2e/tests/update.spec.ts +++ b/apps/code/tests/e2e/tests/update.spec.ts @@ -13,8 +13,10 @@ import { prepareRunApp, RUN_APP, RUN_APP_BIN, + RUN_DIR, readBundleVersion, readMainLog, + runningAppExecutables, SHIPIT_DIR, shipItEvidence, startFeedServer, @@ -122,6 +124,18 @@ test.describe("macOS auto-update", () => { 120_000, "bundle was not swapped to the new version", ); + + // Squirrel relaunches the installed app (isForceRunAfter=true); confirm the + // auto-relaunched process actually came up running from the swapped bundle. + await waitUntil( + () => runningAppExecutables().some((exe) => exe.includes(RUN_DIR)), + 60_000, + "Squirrel did not auto-relaunch the updated app", + ); + console.log( + `Auto-relaunched from swapped bundle: ${runningAppExecutables().join(", ")}`, + ); + killApp(); await waitUntil( () => !isAppRunning(), From b0b389f907fc05b730b1d3e337db4243e781f6b5 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 22:00:40 -0700 Subject: [PATCH 08/11] add update proof artifact and per-build uploads --- .github/workflows/code-update-e2e.yml | 57 ++++++++++++++++++++- apps/code/tests/e2e/fixtures/update.ts | 35 ++++++++++++- apps/code/tests/e2e/tests/update.spec.ts | 63 +++++++++++++++++++----- 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml index 76d1f4d2ec..9cb4e2faee 100644 --- a/.github/workflows/code-update-e2e.yml +++ b/.github/workflows/code-update-e2e.yml @@ -102,15 +102,68 @@ jobs: } ' - - name: Upload report and logs + - name: Render update proof summary + if: always() + working-directory: apps/code + run: | + node -e ' + const fs = require("fs"); + const p = "out/update-proof/proof.json"; + if (!fs.existsSync(p)) { console.log("no proof file was written"); process.exit(0); } + const d = JSON.parse(fs.readFileSync(p, "utf8")); + const cell = (v) => v === undefined || v === null ? "-" : String(v).replace(/\|/g, "\\|").replace(/\n/g, " "); + const rows = [ + ["Result", d.result], + ["Old version", d.oldVersion], + ["New version", d.newVersion], + ["Booted on", d.bootedOn], + ["Feed offered", d.feedAvailableVersion], + ["Downloaded", d.downloaded], + ["Bundle after swap", d.bundleVersionAfterSwap], + ["Auto-relaunched exe", d.autoRelaunchedExecutable], + ["Fresh launch version", d.freshLaunchVersion], + ["ShipIt cache", d.shipItExists ? (d.shipItEntries || []).join(", ") : "missing"], + ["Failed step", d.failedStep], + ["Error", d.error], + ["Finished", d.finishedAt], + ]; + const md = ["## macOS auto-update proof: " + d.result, "", "| Check | Value |", "| --- | --- |"] + .concat(rows.map((r) => "| " + r[0] + " | " + cell(r[1]) + " |")) + .join("\n"); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n"); + console.log(md); + ' + + - name: Upload proof, report and logs if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: update-e2e-macos path: | - apps/code/playwright-results/ + apps/code/out/update-proof/ apps/code/out/update-report.json + apps/code/playwright-results/ /Users/runner/.posthog-code/logs/ /Users/runner/Library/Caches/com.posthog.array.ShipIt/ if-no-files-found: ignore retention-days: 7 + + - name: Upload old build (1.0.0) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: update-old-build-1.0.0 + path: | + apps/code/out/PostHog-Code-1.0.0-arm64-mac.zip + apps/code/out/PostHog-Code-1.0.0-arm64-mac.zip.blockmap + if-no-files-found: warn + retention-days: 7 + + - name: Upload new build feed (2.0.0) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: update-new-build-2.0.0 + path: apps/code/out/dev-update-feed/ + if-no-files-found: warn + retention-days: 7 diff --git a/apps/code/tests/e2e/fixtures/update.ts b/apps/code/tests/e2e/fixtures/update.ts index 55e79e14f6..e4157cc1d8 100644 --- a/apps/code/tests/e2e/fixtures/update.ts +++ b/apps/code/tests/e2e/fixtures/update.ts @@ -1,5 +1,11 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process"; -import { mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import path from "node:path"; @@ -17,11 +23,38 @@ export const SHIPIT_DIR = path.join( "Library/Caches/com.posthog.array.ShipIt", ); +export const PROOF_DIR = path.join(OUT_DIR, "update-proof"); +const PROOF_FILE = path.join(PROOF_DIR, "proof.json"); + const SERVE_SCRIPT = path.join( __dirname, "../../../scripts/dev-update/serve.mjs", ); +// A single legible record of the update, written on pass and fail, that the +// workflow turns into a run-page summary and uploads as the proof artifact. +export type UpdateProof = { + result: "PASS" | "FAIL"; + oldVersion: string; + newVersion: string; + bootedOn?: string; + feedAvailableVersion?: string; + downloaded?: boolean; + bundleVersionAfterSwap?: string; + autoRelaunchedExecutable?: string; + freshLaunchVersion?: string; + shipItExists?: boolean; + shipItEntries?: string[]; + failedStep?: string; + error?: string; + finishedAt?: string; +}; + +export function writeProof(proof: UpdateProof): void { + mkdirSync(PROOF_DIR, { recursive: true }); + writeFileSync(PROOF_FILE, `${JSON.stringify(proof, null, 2)}\n`); +} + // Copy the pristine built app into a disposable run dir so the in-place update // swap never mutates the build output, which lets a retry start from 1.0.0 // again. ditto preserves the code signature that Squirrel.Mac verifies. diff --git a/apps/code/tests/e2e/tests/update.spec.ts b/apps/code/tests/e2e/tests/update.spec.ts index c8c10f73b3..05d5234f43 100644 --- a/apps/code/tests/e2e/tests/update.spec.ts +++ b/apps/code/tests/e2e/tests/update.spec.ts @@ -20,7 +20,9 @@ import { SHIPIT_DIR, shipItEvidence, startFeedServer, + type UpdateProof, waitUntil, + writeProof, } from "../fixtures/update"; type UpdateStatus = { @@ -56,20 +58,29 @@ test.describe("macOS auto-update", () => { test("downloads, installs and relaunches into the new version", async () => { test.setTimeout(5 * 60_000); - expect( - existsSync(PRISTINE_APP), - `missing built app at ${PRISTINE_APP}; run scripts/dev-update/build-pair.sh`, - ).toBe(true); - expect( - existsSync(FEED_DIR), - `missing feed at ${FEED_DIR}; run scripts/dev-update/build-pair.sh`, - ).toBe(true); - - prepareRunApp(); - const feed = startFeedServer(FEED_PORT); + const proof: UpdateProof = { + result: "FAIL", + oldVersion: OLD_VERSION, + newVersion: NEW_VERSION, + }; + let feed: ReturnType | undefined; try { + proof.failedStep = "preconditions"; + expect( + existsSync(PRISTINE_APP), + `missing built app at ${PRISTINE_APP}; run scripts/dev-update/build-pair.sh`, + ).toBe(true); + expect( + existsSync(FEED_DIR), + `missing feed at ${FEED_DIR}; run scripts/dev-update/build-pair.sh`, + ).toBe(true); + + prepareRunApp(); + feed = startFeedServer(FEED_PORT); + // Phase 1: drive the real download + install on the old build. + proof.failedStep = "launch"; const app = await electron.launch({ executablePath: RUN_APP_BIN, args: [], @@ -91,25 +102,32 @@ test.describe("macOS auto-update", () => { .toBe("object"); // Prove we actually start on the old version, so the swap is real. + proof.failedStep = "start-version"; const startVersion = await app.evaluate(({ app: a }) => a.getVersion()); + proof.bootedOn = startVersion; expect(startVersion, "run app should start on the old version").toBe( OLD_VERSION, ); + proof.failedStep = "update-available"; await app.evaluate(() => (globalThis as Hooked).__e2eUpdates.check()); await pollStatus( app, (s) => s.available === true && s.availableVersion === NEW_VERSION, "update never became available", ); + proof.feedAvailableVersion = NEW_VERSION; + proof.failedStep = "download"; await app.evaluate(() => (globalThis as Hooked).__e2eUpdates.download()); await pollStatus( app, (s) => s.updateReady === true, "update never finished downloading", ); + proof.downloaded = true; + proof.failedStep = "install-and-swap"; const closed = app.waitForEvent("close"); void app .evaluate(() => { @@ -124,16 +142,21 @@ test.describe("macOS auto-update", () => { 120_000, "bundle was not swapped to the new version", ); + proof.bundleVersionAfterSwap = readBundleVersion(RUN_APP); // Squirrel relaunches the installed app (isForceRunAfter=true); confirm the // auto-relaunched process actually came up running from the swapped bundle. + proof.failedStep = "auto-relaunch"; await waitUntil( () => runningAppExecutables().some((exe) => exe.includes(RUN_DIR)), 60_000, "Squirrel did not auto-relaunch the updated app", ); + proof.autoRelaunchedExecutable = runningAppExecutables().find((exe) => + exe.includes(RUN_DIR), + ); console.log( - `Auto-relaunched from swapped bundle: ${runningAppExecutables().join(", ")}`, + `Auto-relaunched from swapped bundle: ${proof.autoRelaunchedExecutable}`, ); killApp(); @@ -143,17 +166,20 @@ test.describe("macOS auto-update", () => { "relaunched instance did not exit", ); + proof.failedStep = "fresh-launch"; const updated = await electron.launch({ executablePath: RUN_APP_BIN, args: [], env: { ...process.env, ELECTRON_DISABLE_GPU: "1" }, }); const version = await updated.evaluate(({ app: a }) => a.getVersion()); + proof.freshLaunchVersion = version; expect(version).toBe(NEW_VERSION); await updated.close(); // Mechanism evidence: our updater drove a real download and install, and // Squirrel.Mac's ShipIt is what performed the in-place swap. + proof.failedStep = "evidence"; const mainLog = readMainLog(); expect( mainLog, @@ -163,6 +189,8 @@ test.describe("macOS auto-update", () => { "Installing update and restarting", ); const shipIt = shipItEvidence(); + proof.shipItExists = shipIt.exists; + proof.shipItEntries = shipIt.entries; console.log( `Squirrel ShipIt cache: exists=${shipIt.exists} entries=[${shipIt.entries.join(", ")}]`, ); @@ -170,8 +198,17 @@ test.describe("macOS auto-update", () => { shipIt.exists, `no Squirrel ShipIt cache at ${SHIPIT_DIR}; the swap was not performed by Squirrel`, ).toBe(true); + + proof.failedStep = undefined; + proof.result = "PASS"; + } catch (err) { + proof.error = err instanceof Error ? err.message : String(err); + throw err; } finally { - feed.kill(); + feed?.kill(); + killApp(); + proof.finishedAt = new Date().toISOString(); + writeProof(proof); } }); }); From 3d4b24dc8ed8728ba6e750523a74221c05e9b2bd Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 22:25:03 -0700 Subject: [PATCH 09/11] document testing with CI-signed builds --- docs/AUTO-UPDATE-TESTING.md | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/AUTO-UPDATE-TESTING.md b/docs/AUTO-UPDATE-TESTING.md index 2dbfcf452e..99731653ee 100644 --- a/docs/AUTO-UPDATE-TESTING.md +++ b/docs/AUTO-UPDATE-TESTING.md @@ -14,7 +14,7 @@ The flow under test: a packaged old build checks a local feed, downloads a newer - A packaged build (not `pnpm dev`). Auto-update only runs when `app.isPackaged` is true. - For the full install and relaunch: a Developer ID signing identity. Squirrel.Mac only swaps a bundle whose signature matches the running app's designated requirement, so both builds must be signed with the same identity. Set `CSC_LINK` / `CSC_KEY_PASSWORD`, or have a Developer ID cert in your login keychain. - - Without a matching identity you can still watch check, available, download and ready. The final swap is the part that needs the signature. + - Without a matching identity you can still watch check, available, download and ready, but the final swap needs the signature. If you can't sign locally, skip the local build and pull the CI-signed pair instead (see below). - Notarization is intentionally skipped (`SKIP_NOTARIZE=1`). It is a Gatekeeper concern for first launch of a downloaded app, not what the in-place update verifies, and a locally built bundle carries no quarantine attribute. ## The harness @@ -28,7 +28,7 @@ The flow under test: a packaged old build checks a local feed, downloads a newer | `apps/code/tests/e2e/playwright.update.config.ts` | Dedicated Playwright config; the only place the update spec runs | | `globalThis.__e2eUpdates` | Set in the main process when `POSTHOG_E2E_UPDATE_FEED` is present; lets the test drive `check` / `download` / `install` / `status` | -## 1. Build the pair +## Build the pair locally ```bash bash apps/code/scripts/dev-update/build-pair.sh @@ -47,6 +47,31 @@ OLD_VERSION=1.0.0 NEW_VERSION=2.0.0 bash apps/code/scripts/dev-update/build-pair This takes a few minutes and may prompt for keychain access to sign. +## Or: pull a signed pair from CI (no local signing) + +If you don't have a Developer ID cert, `build-pair.sh` produces unsigned builds and the swap won't complete. The nightly run signs both with PostHog's identity and uploads them as two separate artifacts. Squirrel verifies signatures cryptographically (it does not need the cert in your keychain), so the pulled pair updates locally just like a real release. + +Drop them into the same paths the local build produces, then use the run sections below unchanged: + +```bash +# latest green run +RUN=$(gh run list --workflow=code-update-e2e.yml --status success -L 1 \ + --json databaseId -q '.[0].databaseId') + +# old 1.0.0 app -> apps/code/out/mac-arm64/PostHog Code.app +gh run download "$RUN" -n update-old-build-1.0.0 -D /tmp/upd-old +rm -rf apps/code/out/mac-arm64 && mkdir -p apps/code/out/mac-arm64 +ditto -x -k "/tmp/upd-old/PostHog-Code-1.0.0-arm64-mac.zip" apps/code/out/mac-arm64 +# harmless if already clear; needed only if you downloaded via the browser +xattr -dr com.apple.quarantine "apps/code/out/mac-arm64/PostHog Code.app" + +# new 2.0.0 feed -> apps/code/out/dev-update-feed/ +rm -rf apps/code/out/dev-update-feed +gh run download "$RUN" -n update-new-build-2.0.0 -D apps/code/out/dev-update-feed +``` + +The builds are signed but not notarized, so launch by the binary path (the manual section does this); `open`-ing the `.app` may trip Gatekeeper. + ## 2a. Run it automated (Playwright) The spec starts its own feed server, copies the `1.0.0` app to a disposable run dir (so a rerun starts clean), drives the full flow and asserts the relaunched app is `2.0.0`. @@ -97,7 +122,7 @@ A manual run swaps `out/mac-arm64` in place, so rerun `build-pair.sh` (or just t gh workflow run "Code Update E2E (macOS)" ``` -It builds the pair, runs the spec via `playwright.update.config.ts`, and asserts exactly one test actually ran, so a missing feed or a silent skip fails the job. The main log and the Squirrel ShipIt cache are uploaded as artifacts on every run. +It builds the pair, runs the spec via `playwright.update.config.ts`, and asserts exactly one test actually ran, so a missing feed or a silent skip fails the job. Every run renders a proof summary on the run page and uploads, on pass or fail: the proof manifest, main log and Squirrel ShipIt cache (artifact `update-e2e-macos`), plus the two signed builds as their own artifacts (`update-old-build-1.0.0`, `update-new-build-2.0.0`) you can pull as shown above. ## Cleanup From 3e2d915bfc7cf4a14f73d8b1cb3aa2d9c2260d33 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 22:42:48 -0700 Subject: [PATCH 10/11] add run-from-ci local update test script --- apps/code/scripts/dev-update/run-from-ci.sh | 86 +++++++++++++++++++++ docs/AUTO-UPDATE-TESTING.md | 25 ++---- 2 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 apps/code/scripts/dev-update/run-from-ci.sh diff --git a/apps/code/scripts/dev-update/run-from-ci.sh b/apps/code/scripts/dev-update/run-from-ci.sh new file mode 100644 index 0000000000..2b4ed87445 --- /dev/null +++ b/apps/code/scripts/dev-update/run-from-ci.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# One command to try the macOS auto-update flow locally against the CI-signed +# builds, no local signing. It downloads the old (1.0.0) app and the new (2.0.0) +# feed from the latest green Code Update E2E run, serves the feed, and opens the +# old app pointed at it. In the app: open the update banner, click Download, then +# Restart, and watch the real Squirrel swap + relaunch into 2.0.0. +# +# Squirrel verifies signatures cryptographically, so the CI-signed pair updates +# here without the cert being in your keychain. +# +# Usage: +# bash scripts/dev-update/run-from-ci.sh [run-id] +# AUTOMATED=1 bash scripts/dev-update/run-from-ci.sh # run the Playwright spec instead +set -euo pipefail + +cd "$(dirname "$0")/../.." + +command -v gh >/dev/null || { + echo "gh (GitHub CLI) is required and must be authenticated" >&2 + exit 1 +} + +if pgrep -x "PostHog Code" >/dev/null; then + echo "PostHog Code is already running. Quit it first; the test build shares its single-instance lock and data dir." >&2 + exit 1 +fi + +RUN_ID="${1:-$(gh run list --workflow=code-update-e2e.yml --status success -L 1 --json databaseId -q '.[0].databaseId')}" +[[ -n "$RUN_ID" ]] || { + echo "no successful Code Update E2E run found; pass a run id explicitly" >&2 + exit 1 +} +echo "==> using CI run $RUN_ID" + +TMP="$(mktemp -d)" +cleanup() { + [[ -n "${SERVE_PID:-}" ]] && kill "$SERVE_PID" 2>/dev/null || true + rm -rf "$TMP" +} +trap cleanup EXIT + +echo "==> downloading signed builds from CI" +gh run download "$RUN_ID" -n update-old-build-1.0.0 -D "$TMP/old" +gh run download "$RUN_ID" -n update-new-build-2.0.0 -D "$TMP/new" + +OLD_ZIP="$(find "$TMP/old" -name 'PostHog-Code-*-arm64-mac.zip' | head -1)" +FEED_YML="$(find "$TMP/new" -name latest-mac.yml | head -1)" +[[ -n "$OLD_ZIP" ]] || { + echo "old build zip not found in artifact" >&2 + exit 1 +} +[[ -n "$FEED_YML" ]] || { + echo "latest-mac.yml not found in new build artifact" >&2 + exit 1 +} + +echo "==> old 1.0.0 app -> out/mac-arm64" +rm -rf out/mac-arm64 && mkdir -p out/mac-arm64 +ditto -x -k "$OLD_ZIP" out/mac-arm64 +xattr -dr com.apple.quarantine "out/mac-arm64/PostHog Code.app" 2>/dev/null || true + +echo "==> new 2.0.0 feed -> out/dev-update-feed" +rm -rf out/dev-update-feed && mkdir -p out/dev-update-feed +cp "$(dirname "$FEED_YML")"/* out/dev-update-feed/ + +if [[ "${AUTOMATED:-}" == "1" ]]; then + echo "==> running the automated update test" + pnpm exec playwright test --config=tests/e2e/playwright.update.config.ts + exit $? +fi + +PORT="${PORT:-8788}" +node scripts/dev-update/serve.mjs out/dev-update-feed "$PORT" & +SERVE_PID=$! + +APP_LOG="out/run-from-ci-app.log" +echo +echo "==> launching PostHog Code 1.0.0 (feed http://127.0.0.1:$PORT)" +echo " In the app: open the update banner, click Download, then Restart." +echo " It swaps and relaunches into 2.0.0. Quit the app (or Ctrl+C) to finish." +echo " App output: $APP_LOG update log: ~/.posthog-code/logs/main.log" +echo +POSTHOG_E2E_UPDATE_FEED="http://127.0.0.1:$PORT" \ + "out/mac-arm64/PostHog Code.app/Contents/MacOS/PostHog Code" >"$APP_LOG" 2>&1 || true + +echo "==> app exited; cleaning up" diff --git a/docs/AUTO-UPDATE-TESTING.md b/docs/AUTO-UPDATE-TESTING.md index 99731653ee..5f37680588 100644 --- a/docs/AUTO-UPDATE-TESTING.md +++ b/docs/AUTO-UPDATE-TESTING.md @@ -47,30 +47,17 @@ OLD_VERSION=1.0.0 NEW_VERSION=2.0.0 bash apps/code/scripts/dev-update/build-pair This takes a few minutes and may prompt for keychain access to sign. -## Or: pull a signed pair from CI (no local signing) +## Or: one command, against the CI-signed pair (no local signing) -If you don't have a Developer ID cert, `build-pair.sh` produces unsigned builds and the swap won't complete. The nightly run signs both with PostHog's identity and uploads them as two separate artifacts. Squirrel verifies signatures cryptographically (it does not need the cert in your keychain), so the pulled pair updates locally just like a real release. - -Drop them into the same paths the local build produces, then use the run sections below unchanged: +If you don't have a Developer ID cert, `build-pair.sh` produces unsigned builds and the swap won't complete. Instead run one script: it pulls the signed pair from the latest green run, serves the feed, and opens the old app pointed at it. Squirrel verifies signatures cryptographically (it does not need the cert in your keychain), so the CI-signed pair updates locally just like a real release. ```bash -# latest green run -RUN=$(gh run list --workflow=code-update-e2e.yml --status success -L 1 \ - --json databaseId -q '.[0].databaseId') - -# old 1.0.0 app -> apps/code/out/mac-arm64/PostHog Code.app -gh run download "$RUN" -n update-old-build-1.0.0 -D /tmp/upd-old -rm -rf apps/code/out/mac-arm64 && mkdir -p apps/code/out/mac-arm64 -ditto -x -k "/tmp/upd-old/PostHog-Code-1.0.0-arm64-mac.zip" apps/code/out/mac-arm64 -# harmless if already clear; needed only if you downloaded via the browser -xattr -dr com.apple.quarantine "apps/code/out/mac-arm64/PostHog Code.app" - -# new 2.0.0 feed -> apps/code/out/dev-update-feed/ -rm -rf apps/code/out/dev-update-feed -gh run download "$RUN" -n update-new-build-2.0.0 -D apps/code/out/dev-update-feed +bash apps/code/scripts/dev-update/run-from-ci.sh +# a specific run: bash apps/code/scripts/dev-update/run-from-ci.sh +# automated spec instead: AUTOMATED=1 bash apps/code/scripts/dev-update/run-from-ci.sh ``` -The builds are signed but not notarized, so launch by the binary path (the manual section does this); `open`-ing the `.app` may trip Gatekeeper. +Needs the GitHub CLI (`gh`) authenticated, and your normal PostHog Code must be quit (the test build shares its single-instance lock and data dir). The app opens on `1.0.0` with an update available; click Download, then Restart, and it swaps and relaunches into `2.0.0`. ## 2a. Run it automated (Playwright) From 8257a56018684f029950cc05070dcb00cf0ef4d7 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 21 Jun 2026 23:10:42 -0700 Subject: [PATCH 11/11] scope update e2e push trigger to code changes --- .github/workflows/code-update-e2e.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml index 9cb4e2faee..fd8af919f1 100644 --- a/.github/workflows/code-update-e2e.yml +++ b/.github/workflows/code-update-e2e.yml @@ -9,9 +9,15 @@ name: Code Update E2E (macOS) on: # Temporary: also run on this branch so the harness can be exercised on the PR. # Remove this push trigger once merged; nightly + dispatch is the steady state. + # Docs and the local-only run-from-ci helper do not affect CI, so skip rebuilds + # for those and use workflow_dispatch / the nightly schedule on demand instead. push: branches: - test/macos-auto-update-e2e + paths-ignore: + - "docs/**" + - "**/*.md" + - "apps/code/scripts/dev-update/run-from-ci.sh" schedule: - cron: "0 7 * * *" workflow_dispatch: