Skip to content
41 changes: 37 additions & 4 deletions .github/workflows/code-update-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ name: Code Update E2E (macOS)
# 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.
#
# Two legs run against the same new (2.0.0) feed:
# 1. electron-builder -> electron-builder: the forward-compatibility baseline.
# 2. Forge -> electron-builder: a real Electron Forge build (v0.55.132, the last
# Three legs run:
# 1. electron-builder -> electron-builder: the forward-compatibility baseline
# against the new (2.0.0) feed.
# 2. Chained re-stage: with 2.0.0 already staged, the feed bumps to 3.0.0 and
# the background poll downloads and re-stages it; the restart must land on
# 3.0.0. Proves replacing a staged Squirrel.Mac update is safe.
# 3. Forge -> electron-builder: a real Electron Forge build (v0.55.132, the last
# Forge release, what users run today) updating via the genuine built-in
# Squirrel.Mac client to the electron-builder build we ship now.

Expand Down Expand Up @@ -109,8 +113,10 @@ jobs:
pnpm --filter @posthog/harness run build
pnpm --filter @posthog/agent run build

- name: Build old + new update pair
- name: Build old + new update pair (and 3.0.0 chain feed)
working-directory: apps/code
env:
CHAIN_VERSION: "3.0.0"
run: bash scripts/dev-update/build-pair.sh

- name: Install Playwright
Expand All @@ -132,6 +138,24 @@ jobs:
}
'

# Chained re-stage leg: reuses the same old app and feeds built above, so
# it only costs one more download/install cycle, not another build.
- name: Run macOS chained update E2E
working-directory: apps/code
env:
PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/apps/code/out/update-chain-report.json
run: |
pnpm exec playwright test --config=tests/e2e/playwright.update-chain.config.ts
node -e '
const r = require(process.env.GITHUB_WORKSPACE + "/apps/code/out/update-chain-report.json");
const s = r.stats || {};
console.log("chain update e2e stats:", JSON.stringify(s));
if (s.expected !== 1 || s.skipped || s.unexpected || s.flaky) {
console.error("FAIL: expected exactly one passing chained update test");
process.exit(1);
}
'

# Forge -> electron-builder leg. The old Forge app is a fixed pinned build
# (cb0ca68db), byte-identical on every run, so cache it and skip the ~11 min
# rebuild that dominates this job. The key tracks the build script, which
Expand Down Expand Up @@ -186,6 +210,7 @@ jobs:
const fs = require("fs");
const proofs = [
["out/update-proof/proof.json", "macOS auto-update proof (builder -> builder)"],
["out/update-proof-chain/proof.json", "macOS auto-update proof (chained re-stage)"],
["out/update-proof-forge/proof.json", "macOS auto-update proof (Forge -> builder)"],
];
const cell = (v) => v === undefined || v === null ? "-" : String(v).replace(/\|/g, "\\|").replace(/\n/g, " ");
Expand All @@ -198,6 +223,8 @@ jobs:
["New version", d.newVersion],
["Booted on", d.bootedOn],
["Feed offered", d.feedAvailableVersion],
["Intermediate version", d.intermediateVersion],
["Re-staged version", d.restagedVersion],
["Downloaded", d.downloaded],
["Bundle after swap", d.bundleVersionAfterSwap],
["Auto-relaunched exe", d.autoRelaunchedExecutable],
Expand All @@ -222,12 +249,18 @@ jobs:
name: update-e2e-macos
path: |
apps/code/out/update-proof/
apps/code/out/update-proof-chain/
apps/code/out/update-proof-forge/
apps/code/out/update-report.json
apps/code/out/update-chain-report.json
apps/code/out/update-forge-report.json
apps/code/playwright-results/
/Users/runner/.posthog-code/logs/
/Users/runner/Library/Caches/com.posthog.array.ShipIt/
!/Users/runner/Library/Caches/com.posthog.array.ShipIt/update.*/**
# The app log dir lives under hidden ~/.posthog-code and is silently
# dropped without this.
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7

Expand Down
20 changes: 20 additions & 0 deletions apps/code/scripts/dev-update/build-pair.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,36 @@
# 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 CHAIN_VERSION (e.g. 3.0.0) to also build a second, newer feed into
# out/dev-update-feed-chain for the chained update E2E (update-chain.spec.ts).
set -euo pipefail

cd "$(dirname "$0")/../.."

OLD_VERSION="${OLD_VERSION:-1.0.0}"
NEW_VERSION="${NEW_VERSION:-2.0.0}"
CHAIN_VERSION="${CHAIN_VERSION:-}"
FEED_DIR="out/dev-update-feed"
CHAIN_FEED_DIR="out/dev-update-feed-chain"

export SKIP_NOTARIZE="${SKIP_NOTARIZE:-1}"

echo "==> electron-vite build"
pnpm exec electron-vite build

if [[ -n "$CHAIN_VERSION" ]]; then
echo "==> build CHAIN $CHAIN_VERSION (second feed artifacts)"
pnpm exec electron-builder build --mac zip --arm64 --publish never \
-c.extraMetadata.version="$CHAIN_VERSION" --config electron-builder.ts

rm -rf "$CHAIN_FEED_DIR"
mkdir -p "$CHAIN_FEED_DIR"
cp "out/PostHog-Code-${CHAIN_VERSION}-arm64-mac.zip" "$CHAIN_FEED_DIR/"
cp "out/PostHog-Code-${CHAIN_VERSION}-arm64-mac.zip.blockmap" "$CHAIN_FEED_DIR/"
cp "out/latest-mac.yml" "$CHAIN_FEED_DIR/"
fi

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
Expand All @@ -30,4 +47,7 @@ pnpm exec electron-builder build --mac zip --arm64 --publish never \
-c.extraMetadata.version="$OLD_VERSION" --config electron-builder.ts

echo "==> feed=$FEED_DIR"
if [[ -n "$CHAIN_VERSION" ]]; then
echo "==> chain feed=$CHAIN_FEED_DIR ($CHAIN_VERSION)"
fi
echo "==> app=out/mac-arm64/PostHog.app ($OLD_VERSION)"
16 changes: 6 additions & 10 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -537,21 +537,17 @@ snapshots:
git-dialogs--sync-success--light:
hash: v1.k4693efd2.8d79d77c9338aeaa73734836f0e17fde987f6d3239914f4013889d3110e5088d.Rq-w2o1uhes_a5hUWqMXeiDkfnoPCZPt932LkEQXCco
loops-loopslistview--comprehensive--dark:
hash: v1.k4693efd2.c128012b7f910839e65e8eaf14d5456c93f90d77747d0625ca35fddd8d5adf7a.fuugB5Ov6boP7fg6fWP7A7IG2yYnPUAAWLKZLuV8rZ0
hash: v1.k4693efd2.25744a9471ec664c70e346ac5cb330c025a031626bc486abe8820b7b6dbc1a19.Shh8Yl7SdombnNc4-uJjlIbx-n4C0MGXxtOGB1bjJTY
loops-loopslistview--comprehensive--light:
hash: v1.k4693efd2.4d25a48e0bd7e60d3bbb433e246486e4c9ac95979db86e20eb8bf5cb119d1eb2.EzrENjVHwVMyV00pEA-71TeVomJtchiym5DqWFkujlA
hash: v1.k4693efd2.ef8e012cc506a0bf5e761ec8e89943820d4b391f7023f506da7d79586b49a356.ATiN8Y0hUmfGvr0rUytYWX-fITyHRTgeN1i_HOOGZuc
loops-loopslistview--long-mixed-list--dark:
hash: v1.k4693efd2.fdbf0c24cb386c4345279db3e648efe72aa8a2a984d491361522f62074a771c9.2QmRxRMWYFFfBFrbMm0Dm9rWcvd1lfRbc0egfzoESnY
hash: v1.k4693efd2.c9915e22655fe1f6b77eabaf11d8876648c387ceebac6cd8be1880e7255bd7a5.y-gyTKSp7wYbuS57TABRbEyMSL9_U1wvHYvbyCeAbiQ
loops-loopslistview--long-mixed-list--light:
hash: v1.k4693efd2.aa17d9b6f40fbd3914bd742951db9f90add0bd233c54170803c8483aeb45b43b.yXpqqGczOq1uW5k4Ie7ddNTnne-cFTglZ9cL800pXB4
loops-loopslistview--shared-page-header--dark:
hash: v1.k4693efd2.14ab860dcb0df51b4019f012288e45f3a2c4c23fa08e809efef8c04b5687fe36.qoMLHprZmV2wON156NBbl3sqP84a4nXgdYm1H6WpTVM
loops-loopslistview--shared-page-header--light:
hash: v1.k4693efd2.23be5408ff3b4a912a0ce0457af8638124b99a01c88a499d0f4204cee2c62487.dGBP1K08MA2Fvhml-BPl5eFra8U83JXmy04NK9wOQnM
hash: v1.k4693efd2.5fec9e1a3c14f1bc319cd7e5abc24b3d66ed787986a103b5330f49341d4983ad.mpD-eTS6vkSbsJdOYaHXASHQd5IiJGWRCb6uDGlzhpk
loops-loopslistview--with-builder-sessions--dark:
hash: v1.k4693efd2.df4a1a4764e1de7b69905814b09c30d9b43f705f5e66c7ad83829c567d4faa1a.jthufoX_KPbbxScjj3pPBA4lEtT9Ym6zUCH-v1759GM
hash: v1.k4693efd2.06b404e4016ba0eb86769331a574bc640be1ad26ebf3a04d0256334d1da041a3.fPfF8c2wNFREfy8dVRdsw7W9Fpqj7HEtuaERMroNHMA
loops-loopslistview--with-builder-sessions--light:
hash: v1.k4693efd2.eb65c38bb0de7efedbc0d7a6c27a6695528e2e6b60949617e7d22335b1e68c8b.fJr9uykvc8S7LI_ASXk5KRqxXCwIqh6ld9zfzs4shP0
hash: v1.k4693efd2.30f89915a12cadf313216403fb0f3e221f3c33cee763f0692ae2b96b4332e2b9.j00vexnH4QS0wJAHn7pQNpeITYYRtOxlvB2ngyzqU0c
primitives-pageheader--title-only--dark:
hash: v1.k4693efd2.f7f8c560b1e5cf20050189e10fc0f129e555ba672300a7a09bef5827c171a743.yP9Pp_k51agu_9Ri55Y7uCAPBQ1UTeUQJA9U9dPMKfQ
primitives-pageheader--title-only--light:
Expand Down
5 changes: 5 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,9 +435,14 @@ app.whenReady().then(async () => {

if (process.env.POSTHOG_E2E_UPDATE_FEED) {
const updates = container.get<UpdatesService>(UPDATES_SERVICE);
// Pin the re-staging rollout on: the packaged e2e app boots posthog with
// an anonymous user whose flags would otherwise sync this off mid-test.
updates.setStagedUpdatesEnabled(true);
updates.setStagedUpdatesEnabled = () => {};
Object.assign(globalThis, {
__e2eUpdates: {
check: () => updates.checkForUpdates(),
periodicCheck: () => updates.checkForUpdates("periodic"),
download: () => updates.requestDownload(),
install: () => updates.installUpdate(),
status: () => updates.getStatus(),
Expand Down
18 changes: 9 additions & 9 deletions apps/code/src/main/platform-adapters/electron-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,8 @@ export class ElectronUpdater implements IUpdater {
constructor() {
autoUpdater.logger = log;
autoUpdater.disableDifferentialDownload = true;
// Default to manual download; the "Download updates automatically" setting
// flips this via setAutoDownload(). A downloaded update always installs on the
// next quit, with an in-app Restart button for immediate install.
// Must stay false: UpdatesService is the sole caller of download(). If true,
// every checkForUpdates() poll re-downloads and re-stages the same build.
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;

Expand All @@ -62,18 +61,19 @@ export class ElectronUpdater implements IUpdater {
void autoUpdater.checkForUpdates().catch(() => undefined);
}

public download(): void {
void autoUpdater.downloadUpdate().catch(() => undefined);
// Failures surface through the "error" event; the returned promise only
// signals settlement so the service can serialize downloads.
public download(): Promise<void> {
return autoUpdater.downloadUpdate().then(
() => undefined,
() => undefined,
);
}

public quitAndInstall(): void {
autoUpdater.quitAndInstall(false, true);
}

public setAutoDownload(enabled: boolean): void {
autoUpdater.autoDownload = enabled;
}

public onCheckStart(handler: () => void): () => void {
autoUpdater.on("checking-for-update", handler);
return () => autoUpdater.off("checking-for-update", handler);
Expand Down
17 changes: 17 additions & 0 deletions apps/code/src/renderer/platform-adapters/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
updateStore,
} from "@posthog/core/updates/updateStore";
import { resolveService } from "@posthog/di/container";
import { STAGED_UPDATES_FLAG } from "@posthog/shared";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import {
UPDATES_CLIENT,
Expand All @@ -14,6 +15,7 @@ import {
import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore";
import { toast } from "@posthog/ui/primitives/toast";
import { logger } from "@posthog/ui/shell/logger";
import { posthogFeatureFlags } from "@posthog/ui/shell/posthogAnalyticsImpl";
import { hostTrpcClient } from "@renderer/trpc/client";

const log = logger.scope("updates-host");
Expand Down Expand Up @@ -129,6 +131,21 @@ function syncAutoDownload(enabled: boolean): void {
);
}

// Bridge the staged-updates rollout flag to the core updater; the service
// defaults to off until posthog flags load and this sync lands.
let lastSyncedStagedUpdates: boolean | null = null;
function syncStagedUpdates(): void {
const enabled = posthogFeatureFlags.isEnabled(STAGED_UPDATES_FLAG);
if (enabled === lastSyncedStagedUpdates) return;
lastSyncedStagedUpdates = enabled;
void hostTrpcClient.updates.setStagedUpdates
.mutate({ enabled })
.catch((error: unknown) =>
log.error("Failed to sync staged-updates flag", { error }),
);
}
posthogFeatureFlags.onFlagsLoaded(syncStagedUpdates);

// Auto-show "What's New" once on the first launch after the version changes.
function maybeShowWhatsNew(): void {
void hostTrpcClient.os.getAppVersion
Expand Down
35 changes: 33 additions & 2 deletions apps/code/tests/e2e/fixtures/update.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
import {
cpSync,
mkdirSync,
readdirSync,
readFileSync,
Expand All @@ -13,6 +14,8 @@ const OUT_DIR = path.join(__dirname, "../../../out");

export const PRISTINE_APP = path.join(OUT_DIR, "mac-arm64/PostHog.app");
export const FEED_DIR = path.join(OUT_DIR, "dev-update-feed");
export const CHAIN_FEED_DIR = path.join(OUT_DIR, "dev-update-feed-chain");
export const CHAIN_RUN_FEED_DIR = path.join(OUT_DIR, "e2e-chain-feed");
export const RUN_DIR = path.join(OUT_DIR, "e2e-update-run");
export const RUN_APP = path.join(RUN_DIR, "PostHog.app");
export const RUN_APP_BIN = path.join(RUN_APP, "Contents/MacOS/PostHog");
Expand Down Expand Up @@ -53,6 +56,9 @@ const PROOF_FILE = path.join(PROOF_DIR, "proof.json");
export const FORGE_PROOF_DIR = path.join(OUT_DIR, "update-proof-forge");
const FORGE_PROOF_FILE = path.join(FORGE_PROOF_DIR, "proof.json");

export const CHAIN_PROOF_DIR = path.join(OUT_DIR, "update-proof-chain");
const CHAIN_PROOF_FILE = path.join(CHAIN_PROOF_DIR, "proof.json");

const SERVE_SCRIPT = path.join(
__dirname,
"../../../scripts/dev-update/serve.mjs",
Expand All @@ -66,6 +72,8 @@ export type UpdateProof = {
newVersion: string;
bootedOn?: string;
feedAvailableVersion?: string;
intermediateVersion?: string;
restagedVersion?: string;
downloaded?: boolean;
bundleVersionAfterSwap?: string;
autoRelaunchedExecutable?: string;
Expand All @@ -87,6 +95,26 @@ export function writeForgeProof(proof: UpdateProof): void {
writeFileSync(FORGE_PROOF_FILE, `${JSON.stringify(proof, null, 2)}\n`);
}

export function writeChainProof(proof: UpdateProof): void {
mkdirSync(CHAIN_PROOF_DIR, { recursive: true });
writeFileSync(CHAIN_PROOF_FILE, `${JSON.stringify(proof, null, 2)}\n`);
}

// The chain leg serves a disposable copy of the feed so bumping it mid-test
// never mutates dev-update-feed, which the other legs and the CI feed artifact
// rely on staying at the intermediate version.
export function prepareChainFeed(): void {
rmSync(CHAIN_RUN_FEED_DIR, { recursive: true, force: true });
cpSync(FEED_DIR, CHAIN_RUN_FEED_DIR, { recursive: true });
}

// Overlay the newer feed onto the served dir mid-test. serve.mjs re-reads
// latest-mac.yml on every request, so no server restart is needed; the old zip
// stays behind harmlessly while the yml now points at the newer one.
export function bumpChainFeed(): void {
cpSync(CHAIN_FEED_DIR, CHAIN_RUN_FEED_DIR, { recursive: true });
}

// 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.
Expand All @@ -102,8 +130,11 @@ export function prepareForgeRunApp(): void {
execFileSync("ditto", [FORGE_PRISTINE_APP, FORGE_RUN_APP]);
}

export function startFeedServer(port: number): ChildProcess {
return spawn("node", [SERVE_SCRIPT, FEED_DIR, String(port)], {
export function startFeedServer(
port: number,
dir: string = FEED_DIR,
): ChildProcess {
return spawn("node", [SERVE_SCRIPT, dir, String(port)], {
stdio: "inherit",
});
}
Expand Down
20 changes: 20 additions & 0 deletions apps/code/tests/e2e/playwright.update-chain.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig } from "@playwright/test";

// Dedicated config for the macOS chained auto-update E2E (a newer update
// re-staged over an already staged one). The general suite excludes update
// specs by path, so this only runs here and cannot silently skip. retries are
// 0 so a broken re-stage surfaces immediately. In CI the JSON reporter lets
// the workflow assert exactly one test actually ran.
export default defineConfig({
testDir: "./tests",
testMatch: "**/update-chain.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",
},
});
Loading
Loading