diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml
new file mode 100644
index 0000000000..fd8af919f1
--- /dev/null
+++ b/.github/workflows/code-update-e2e.yml
@@ -0,0 +1,175 @@
+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.
+ # 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:
+
+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
+ env:
+ 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(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) {
+ console.error("FAIL: expected exactly one passing update test");
+ process.exit(1);
+ }
+ '
+
+ - 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/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/scripts/dev-update/build-pair.sh b/apps/code/scripts/dev-update/build-pair.sh
new file mode 100644
index 0000000000..c4c934afda
--- /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 --publish never \
+ -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 --publish never \
+ -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/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/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..e4157cc1d8
--- /dev/null
+++ b/apps/code/tests/e2e/fixtures/update.ts
@@ -0,0 +1,158 @@
+import { type ChildProcess, execFileSync, spawn } from "node:child_process";
+import {
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { homedir } from "node:os";
+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");
+
+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",
+);
+
+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.
+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 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" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// 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"]);
+ } 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/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
new file mode 100644
index 0000000000..05d5234f43
--- /dev/null
+++ b/apps/code/tests/e2e/tests/update.spec.ts
@@ -0,0 +1,232 @@
+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,
+ RUN_DIR,
+ readBundleVersion,
+ readMainLog,
+ runningAppExecutables,
+ SHIPIT_DIR,
+ shipItEvidence,
+ startFeedServer,
+ type UpdateProof,
+ waitUntil,
+ writeProof,
+} 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 OLD_VERSION = "1.0.0";
+const NEW_VERSION = "2.0.0";
+
+test.describe("macOS auto-update", () => {
+ // 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);
+
+ 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: [],
+ 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");
+
+ // 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(() => {
+ 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",
+ );
+ 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: ${proof.autoRelaunchedExecutable}`,
+ );
+
+ killApp();
+ await waitUntil(
+ () => !isAppRunning(),
+ 30_000,
+ "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,
+ "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();
+ proof.shipItExists = shipIt.exists;
+ proof.shipItEntries = shipIt.entries;
+ 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);
+
+ proof.failedStep = undefined;
+ proof.result = "PASS";
+ } catch (err) {
+ proof.error = err instanceof Error ? err.message : String(err);
+ throw err;
+ } finally {
+ feed?.kill();
+ killApp();
+ proof.finishedAt = new Date().toISOString();
+ writeProof(proof);
+ }
+ });
+});
+
+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);
+}
diff --git a/docs/AUTO-UPDATE-TESTING.md b/docs/AUTO-UPDATE-TESTING.md
new file mode 100644
index 0000000000..5f37680588
--- /dev/null
+++ b/docs/AUTO-UPDATE-TESTING.md
@@ -0,0 +1,118 @@
+# 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, 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
+
+| 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) |
+| `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` |
+
+## Build the pair locally
+
+```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.
+
+## 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. 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
+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
+```
+
+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)
+
+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
+pnpm --filter code exec playwright test \
+ --config=tests/e2e/playwright.update.config.ts
+```
+
+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)
+
+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, 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
+
+```bash
+rm -rf apps/code/out/dev-update-feed apps/code/out/e2e-update-run
+```