Skip to content

Commit 8f2c339

Browse files
authored
test(updates): macOS auto-update E2E test pipeline with real install + relaunch (#2828)
## Problem The electron-builder + electron-updater migration gave us a real auto-update path (download, install, relaunch), but nothing tests it end to end. ## Changes Two tiny test-gated hooks in the main process, both inert unless `POSTHOG_E2E_UPDATE_FEED` is set (no production behavior change): - `electron-updater.ts` points the updater at a local feed via `setFeedURL`. `disableDifferentialDownload`, `autoDownload`, `autoInstallOnAppQuit` and `isSupported` are untouched. - `main/index.ts` exposes `globalThis.__e2eUpdates` (check/download/install/status) wired to the real `UpdatesService`. Harness: - `scripts/dev-update/serve.mjs`: dependency-free, range-capable static feed server. - `scripts/dev-update/build-pair.sh`: builds a signed `2.0.0` feed and a runnable signed `1.0.0` app (`SKIP_NOTARIZE=1`). - `tests/e2e/fixtures/update.ts`: `ditto` run-copy so a retry restarts from `1.0.0`, plist version read, pkill helpers. - `tests/e2e/tests/update.spec.ts`: phase 1 drives download then install via the main-process hook; phase 2 waits for the on-disk bundle to swap, kills the auto-relaunched instance, then fresh-launches and asserts `app.getVersion()` is `2.0.0`. CI: a dedicated `code-update-e2e.yml`, macOS only, reusing the existing `build-macos` steps and Apple signing secrets. It is not on the PR gate (nightly + `workflow_dispatch`); a temporary push trigger on this branch lets it run on the PR and is meant to be removed before merge. The swap is signature-gated (Squirrel.Mac matches the designated requirement), which is why this runs in CI where the signing cert is present rather than as a local unit test. Notarization is skipped on purpose: it is a Gatekeeper concern, not what the in-place update verifies. ## How did you test this? Ran locally: - `pnpm --filter code typecheck` passes with the hooks. - Biome clean on all new and changed files. - `node --check serve.mjs` and `bash -n build-pair.sh` pass. - The e2e tsconfig typechecks the two new e2e files (the only errors are pre-existing in `smoke.spec.ts`). Not run locally: the full signed build plus end-to-end swap (two signed mac builds, needs the Developer ID cert). That runs in the new workflow on this branch via the temporary push trigger, which is the real validation. ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams?
1 parent 10e8d0d commit 8f2c339

24 files changed

Lines changed: 1819 additions & 22 deletions

.claude/skills/test-electron-app/SKILL.md

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,18 @@ pnpm build:deps # turbo build of @posthog/code deps
5050
tail -f /dev/null | pnpm dev:code # run in the background; leave it running
5151
```
5252

53-
`pnpm dev:code` is `electron-forge start -- --remote-debugging-port=9222` — just
54-
the app, no TUI and no watch rebuilders (fine for screenshotting/interacting).
55-
56-
**The `tail -f /dev/null |` is required, do not drop it.** `electron-forge start`
57-
runs an interactive "type `rs` to restart" stdin reader. With no stdin it hits
58-
EOF immediately, treats that as quit, tears down the Electron window, and exits
59-
**0** before the CDP port ever opens — so the app looks like it launched
60-
("✔ Launched Electron app") and then silently vanishes. Piping from
61-
`tail -f /dev/null` gives it a stdin that never closes, so it stays up. A
62-
pseudo-TTY via `script` does **not** help: `script` forwards the EOF (`^D`) and
63-
the app still quits.
53+
`pnpm dev:code` is `pnpm --filter code start`, i.e. `electron-vite dev --watch` — just
54+
the app, no `phrocs` TUI; it watches and rebuilds main/preload and hot-reloads the
55+
renderer (fine for screenshotting/interacting). The CDP port (`:9222`) is opened by
56+
the app itself in dev (the `remote-debugging-port` switch in
57+
`apps/code/src/main/bootstrap.ts`), not by a CLI flag.
58+
59+
The `tail -f /dev/null |` prefix is a harmless guard and no longer strictly required.
60+
The old `electron-forge start` ran an interactive "type `rs` to restart" stdin reader
61+
that hit EOF in a no-stdin shell, treated it as quit, and tore the Electron window
62+
down before the CDP port ever opened. `electron-vite dev` has no such reader, so a
63+
backgrounded `pnpm dev:code` with no stdin stays up on its own; keeping the pipe does
64+
no harm.
6465

6566
Then wait for the port and connect (poll, don't sleep blindly):
6667

@@ -113,7 +114,7 @@ rm -f /tmp/posthog-dev-lastuse
113114
```
114115

115116
Group-killing the launcher (`kill -TERM -PGID`) takes down `tail`, `pnpm`,
116-
`electron-forge` and Electron together, so nothing — not even the
117+
`electron-vite` and Electron together, so nothing — not even the
117118
`tail -f /dev/null` stdin pipe — lingers. Matching `remote-debugging-port=9222`
118119
hits only your dev instance (prod has no debug port and a separate
119120
`posthog-code-dev` profile), so it never touches the user's app. Verify with
@@ -212,11 +213,12 @@ PostHog Code orchestrates the agent, so the usual loop is: **prod** (the install
212213
- **Connection refused on :9222:** the app isn't running with the debug flag.
213214
Start it (see *Launching the app yourself* for the headless recipe). Verify the
214215
port: `lsof -i :9222` or `curl -s localhost:9222/json/version`.
215-
- **App launches then immediately exits 0; CDP never opens:** you started it with
216-
no stdin (background / no TTY) and `electron-forge` quit on stdin EOF. Relaunch
217-
with `tail -f /dev/null | pnpm dev:code` so stdin never closes (see *Launching
218-
the app yourself*). Note `pnpm dev` cannot run headlessly at all — its `phrocs`
219-
TUI needs a TTY; use `pnpm dev:code`.
216+
- **App launches then immediately exits; CDP never opens:** the old `electron-forge`
217+
"quit on stdin EOF" behavior is gone under `electron-vite`, so the
218+
`tail -f /dev/null |` prefix is optional and not the fix here. This is now usually a
219+
build error, the single-instance lock (another dev instance running) or a crash —
220+
check the `pnpm dev:code` output. Note `pnpm dev` cannot run headlessly at all — its
221+
`phrocs` TUI needs a TTY; use `pnpm dev:code`.
220222
- **Snapshot is empty / wrong window:** you're on the wrong target. Run
221223
`agent-browser tab` and switch to the "PostHog Code" page.
222224
- **Can't type into an input:** try `agent-browser keyboard type "text"` (types at

.github/workflows/code-build-test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ jobs:
9090
pnpm --filter @posthog/enricher run build
9191
pnpm --filter @posthog/agent run build
9292
93+
# build/Assets.car is gitignored; regenerate it so the packaged app ships
94+
# the liquid-glass icon (the script falls back to .icns if actool fails).
95+
- name: Compile macOS liquid-glass icon
96+
working-directory: apps/code
97+
run: bash scripts/compile-glass-icon.sh
98+
9399
- name: Build app
94100
env:
95101
MATRIX_ARCH: ${{ matrix.arch }}

.github/workflows/code-release.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ jobs:
143143
- name: Build agent package
144144
run: pnpm --filter @posthog/agent run build
145145

146+
# build/Assets.car is gitignored; regenerate it on the runner so the packaged
147+
# app ships the liquid-glass icon (the script falls back to .icns if actool fails).
148+
- name: Compile macOS liquid-glass icon
149+
working-directory: apps/code
150+
run: bash scripts/compile-glass-icon.sh
151+
146152
- name: Build release artifacts
147153
env:
148154
APP_VERSION: ${{ steps.version.outputs.version }}
@@ -235,6 +241,9 @@ jobs:
235241
NODE_ENV: production
236242
VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }}
237243
VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }}
244+
POSTHOG_SOURCEMAP_API_KEY: ${{ secrets.POSTHOG_SOURCEMAP_API_KEY }}
245+
POSTHOG_ENV_ID: ${{ secrets.POSTHOG_ENV_ID }}
246+
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
238247
steps:
239248
- name: Get app token
240249
id: app-token
@@ -349,6 +358,9 @@ jobs:
349358
NODE_ENV: production
350359
VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }}
351360
VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }}
361+
POSTHOG_SOURCEMAP_API_KEY: ${{ secrets.POSTHOG_SOURCEMAP_API_KEY }}
362+
POSTHOG_ENV_ID: ${{ secrets.POSTHOG_ENV_ID }}
363+
POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }}
352364
steps:
353365
- name: Get app token
354366
id: app-token
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
name: Code Update E2E (macOS)
2+
3+
# Real macOS auto-update end to end: build a signed old (1.0.0) app and a signed
4+
# new (2.0.0) feed, serve the feed on localhost, then drive a packaged build
5+
# through download -> install -> Squirrel.Mac swap -> relaunch and assert the
6+
# installed app became 2.0.0. Nightly and on demand only, never on PRs: it builds
7+
# twice and exercises a real install, so it is too slow and too flaky for the gate.
8+
#
9+
# Two legs run against the same new (2.0.0) feed:
10+
# 1. electron-builder -> electron-builder: the forward-compatibility baseline.
11+
# 2. Forge -> electron-builder: a real Electron Forge build (v0.55.132, the last
12+
# Forge release, what users run today) updating via the genuine built-in
13+
# Squirrel.Mac client to the electron-builder build we ship now.
14+
15+
on:
16+
# Temporary: also run on this branch so the harness can be exercised on the PR.
17+
# Remove this push trigger once merged; nightly + dispatch is the steady state.
18+
# Docs and the local-only run-from-ci helper do not affect CI, so skip rebuilds
19+
# for those and use workflow_dispatch / the nightly schedule on demand instead.
20+
push:
21+
branches:
22+
- test/macos-auto-update-e2e
23+
paths-ignore:
24+
- "docs/**"
25+
- "**/*.md"
26+
- "apps/code/scripts/dev-update/run-from-ci.sh"
27+
- "apps/code/scripts/dev-update/run-from-ci-forge.sh"
28+
schedule:
29+
- cron: "0 7 * * *"
30+
workflow_dispatch:
31+
32+
concurrency:
33+
group: code-update-e2e-${{ github.ref }}
34+
cancel-in-progress: true
35+
36+
jobs:
37+
update-e2e-macos:
38+
runs-on: macos-15
39+
permissions:
40+
id-token: write
41+
contents: read
42+
env:
43+
NODE_OPTIONS: "--max-old-space-size=8192"
44+
NODE_ENV: production
45+
npm_config_arch: arm64
46+
npm_config_platform: darwin
47+
VITE_POSTHOG_API_KEY: ${{ secrets.VITE_POSTHOG_API_KEY }}
48+
VITE_POSTHOG_API_HOST: ${{ secrets.VITE_POSTHOG_API_HOST }}
49+
# Sign both builds with the same identity so Squirrel.Mac accepts the swap.
50+
# Notarization is skipped: it is a Gatekeeper concern, not what the in-place
51+
# update verifies, and locally built bundles carry no quarantine attribute.
52+
CSC_LINK: ${{ secrets.APPLE_CODESIGN_CERT_BASE64 }}
53+
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }}
54+
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
55+
SKIP_NOTARIZE: "1"
56+
steps:
57+
- name: Checkout
58+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
59+
with:
60+
fetch-depth: 0
61+
persist-credentials: false
62+
63+
- name: Setup pnpm
64+
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
65+
66+
- name: Setup Node.js
67+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
68+
with:
69+
node-version: 22
70+
cache: "pnpm"
71+
72+
- name: Cache Electron binary
73+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
74+
with:
75+
path: ~/Library/Caches/electron
76+
key: electron-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
77+
restore-keys: |
78+
electron-${{ runner.os }}-
79+
80+
- name: Cache Playwright browsers
81+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
82+
with:
83+
path: ~/Library/Caches/ms-playwright
84+
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
85+
restore-keys: |
86+
playwright-${{ runner.os }}-
87+
88+
- name: Install dependencies
89+
run: pnpm install --frozen-lockfile
90+
91+
- name: Configure AWS credentials
92+
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
93+
with:
94+
role-to-assume: ${{ secrets.AWS_TWIG_APP_ASSETS_ROLE_ARN }}
95+
aws-region: ${{ secrets.AWS_TWIG_APP_ASSETS_REGION }}
96+
mask-aws-account-id: true
97+
unset-current-credentials: true
98+
99+
- name: Download BerkeleyMono fonts from S3
100+
run: aws s3 cp s3://${{ secrets.AWS_TWIG_APP_ASSETS_BUCKET }}/fonts/BerkeleyMono/ apps/code/assets/fonts/BerkeleyMono/ --recursive
101+
102+
- name: Build workspace packages
103+
run: |
104+
pnpm --filter @posthog/electron-trpc run build
105+
pnpm --filter @posthog/platform run build
106+
pnpm --filter @posthog/shared run build
107+
pnpm --filter @posthog/git run build
108+
pnpm --filter @posthog/enricher run build
109+
pnpm --filter @posthog/agent run build
110+
111+
- name: Build old + new update pair
112+
working-directory: apps/code
113+
run: bash scripts/dev-update/build-pair.sh
114+
115+
- name: Install Playwright
116+
run: pnpm --filter code exec playwright install
117+
118+
- name: Run macOS update E2E
119+
working-directory: apps/code
120+
env:
121+
PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/apps/code/out/update-report.json
122+
run: |
123+
pnpm exec playwright test --config=tests/e2e/playwright.update.config.ts
124+
node -e '
125+
const r = require(process.env.GITHUB_WORKSPACE + "/apps/code/out/update-report.json");
126+
const s = r.stats || {};
127+
console.log("update e2e stats:", JSON.stringify(s));
128+
if (s.expected !== 1 || s.skipped || s.unexpected || s.flaky) {
129+
console.error("FAIL: expected exactly one passing update test");
130+
process.exit(1);
131+
}
132+
'
133+
134+
# Forge -> electron-builder leg. The old Forge app is a fixed pinned build
135+
# (cb0ca68db), byte-identical on every run, so cache it and skip the ~11 min
136+
# rebuild that dominates this job. The key tracks the build script, which
137+
# pins the ref; bump the script to force a rebuild. Built after the baseline
138+
# leg so a flake in the (riskier) old-build step can never mask the baseline.
139+
- name: Cache old Forge app (v0.55.132)
140+
id: forge-cache
141+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
142+
with:
143+
path: apps/code/out/old-forge
144+
key: forge-app-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/code/scripts/dev-update/build-old-forge.sh') }}
145+
146+
# Reuse the cached app only if its signature still verifies, otherwise rebuild.
147+
# This makes a missing or tar-corrupted restore degrade to today's behaviour
148+
# (a full build) instead of a confusing swap failure in the Forge leg.
149+
- name: Build old Forge app if not cached
150+
working-directory: apps/code
151+
env:
152+
CACHE_HIT: ${{ steps.forge-cache.outputs.cache-hit }}
153+
run: |
154+
APP="out/old-forge/PostHog Code.app"
155+
if [ "$CACHE_HIT" = "true" ] && codesign --verify --strict "$APP" 2>/dev/null; then
156+
echo "Reusing cached Forge app with a valid signature"
157+
codesign --verify --strict --verbose=2 "$APP"
158+
else
159+
echo "No usable cached Forge app; building from the pinned ref"
160+
rm -rf out/old-forge
161+
bash scripts/dev-update/build-old-forge.sh
162+
fi
163+
164+
- name: Run macOS Forge to builder update E2E
165+
working-directory: apps/code
166+
env:
167+
PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/apps/code/out/update-forge-report.json
168+
run: |
169+
pnpm exec playwright test --config=tests/e2e/playwright.update-forge.config.ts
170+
node -e '
171+
const r = require(process.env.GITHUB_WORKSPACE + "/apps/code/out/update-forge-report.json");
172+
const s = r.stats || {};
173+
console.log("forge update e2e stats:", JSON.stringify(s));
174+
if (s.expected !== 1 || s.skipped || s.unexpected || s.flaky) {
175+
console.error("FAIL: expected exactly one passing forge update test");
176+
process.exit(1);
177+
}
178+
'
179+
180+
- name: Render update proof summaries
181+
if: always()
182+
working-directory: apps/code
183+
run: |
184+
node -e '
185+
const fs = require("fs");
186+
const proofs = [
187+
["out/update-proof/proof.json", "macOS auto-update proof (builder -> builder)"],
188+
["out/update-proof-forge/proof.json", "macOS auto-update proof (Forge -> builder)"],
189+
];
190+
const cell = (v) => v === undefined || v === null ? "-" : String(v).replace(/\|/g, "\\|").replace(/\n/g, " ");
191+
for (const [p, title] of proofs) {
192+
if (!fs.existsSync(p)) { console.log("no proof file at " + p); continue; }
193+
const d = JSON.parse(fs.readFileSync(p, "utf8"));
194+
const rows = [
195+
["Result", d.result],
196+
["Old version", d.oldVersion],
197+
["New version", d.newVersion],
198+
["Booted on", d.bootedOn],
199+
["Feed offered", d.feedAvailableVersion],
200+
["Downloaded", d.downloaded],
201+
["Bundle after swap", d.bundleVersionAfterSwap],
202+
["Auto-relaunched exe", d.autoRelaunchedExecutable],
203+
["Fresh launch version", d.freshLaunchVersion],
204+
["ShipIt cache", d.shipItExists ? (d.shipItEntries || []).join(", ") : "missing"],
205+
["Failed step", d.failedStep],
206+
["Error", d.error],
207+
["Finished", d.finishedAt],
208+
];
209+
const md = ["## " + title + ": " + d.result, "", "| Check | Value |", "| --- | --- |"]
210+
.concat(rows.map((r) => "| " + r[0] + " | " + cell(r[1]) + " |"))
211+
.join("\n");
212+
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n\n");
213+
console.log(md);
214+
}
215+
'
216+
217+
- name: Upload proof, report and logs
218+
if: always()
219+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
220+
with:
221+
name: update-e2e-macos
222+
path: |
223+
apps/code/out/update-proof/
224+
apps/code/out/update-proof-forge/
225+
apps/code/out/update-report.json
226+
apps/code/out/update-forge-report.json
227+
apps/code/playwright-results/
228+
/Users/runner/.posthog-code/logs/
229+
/Users/runner/Library/Caches/com.posthog.array.ShipIt/
230+
if-no-files-found: ignore
231+
retention-days: 7
232+
233+
- name: Upload old build (1.0.0)
234+
if: always()
235+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
236+
with:
237+
name: update-old-build-1.0.0
238+
path: |
239+
apps/code/out/PostHog-Code-1.0.0-arm64-mac.zip
240+
apps/code/out/PostHog-Code-1.0.0-arm64-mac.zip.blockmap
241+
if-no-files-found: warn
242+
retention-days: 7
243+
244+
- name: Upload new build feed (2.0.0)
245+
if: always()
246+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
247+
with:
248+
name: update-new-build-2.0.0
249+
path: apps/code/out/dev-update-feed/
250+
if-no-files-found: warn
251+
retention-days: 7
252+
253+
# upload-artifact follows symlinks and repacks them as real files, which
254+
# breaks the framework layout and the code signature. ditto -c -k preserves
255+
# both, so the pulled zip extracts to a bundle Squirrel still accepts (the
256+
# same way the baseline old app travels). run-from-ci-forge.sh consumes this.
257+
- name: Package old Forge app for upload
258+
if: always()
259+
working-directory: apps/code
260+
run: |
261+
APP="out/old-forge/PostHog Code.app"
262+
ZIP="out/PostHog-Code-forge-1.0.0-arm64-mac.zip"
263+
if [ ! -d "$APP" ]; then
264+
echo "no Forge app to package (an earlier step likely failed); skipping"
265+
exit 0
266+
fi
267+
ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"
268+
# Prove the zip round-trips to a still-valid signature, so the artifact
269+
# run-from-ci-forge.sh pulls is usable and not silently corrupt.
270+
VERIFY_DIR="$(mktemp -d)"
271+
ditto -x -k "$ZIP" "$VERIFY_DIR"
272+
codesign --verify --strict "$VERIFY_DIR/PostHog Code.app"
273+
rm -rf "$VERIFY_DIR"
274+
echo "OK: packaged Forge app zip verifies"
275+
276+
- name: Upload old Forge build (v0.55.132)
277+
if: always()
278+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
279+
with:
280+
name: update-old-forge-build-1.0.0
281+
path: apps/code/out/PostHog-Code-forge-1.0.0-arm64-mac.zip
282+
if-no-files-found: warn
283+
retention-days: 7

apps/code/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pnpm build-native
6868

6969
### Auto Updates & Releases
7070

71-
PostHog Code uses Electron's built-in `autoUpdater` pointed at the public `update.electronjs.org` service for `PostHog/code`. Every time a non-draft GitHub release is published with the platform archives, packaged apps will automatically download and install the newest version on macOS and Windows.
71+
PostHog Code auto-updates on macOS and Windows. Current builds use `electron-updater`, which reads the `latest-mac.yml` and `latest.yml` manifests published with each non-draft GitHub release and downloads the matching archive. Builds made by the old Electron Forge toolchain (`v0.55.x` and earlier) take a single bridge update through the public `update.electronjs.org` service, which serves the same GitHub releases to their built-in Squirrel client, and become `electron-updater` clients from the next launch. See [docs/UPDATES.md](../../docs/UPDATES.md) and [docs/AUTO-UPDATE-TESTING.md](../../docs/AUTO-UPDATE-TESTING.md).
7272

7373
There are three ways a release can fire:
7474

0 commit comments

Comments
 (0)