Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/code-update-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +10 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Temporary push trigger not removed before merge

The PR description explicitly states this push trigger "is meant to be removed before merge." It's still present. If the source branch (test/macos-auto-update-e2e) continues to exist in the repo after the merge, any future push to it will fire this workflow — which runs two full signed macOS builds and exercises the real install. That's expensive (and confusing) for what should be a nightly-only job.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/code-update-e2e.yml
Line: 10-14

Comment:
**Temporary push trigger not removed before merge**

The PR description explicitly states this push trigger "is meant to be removed before merge." It's still present. If the source branch (`test/macos-auto-update-e2e`) continues to exist in the repo after the merge, any future push to it will fire this workflow — which runs two full signed macOS builds and exercises the real install. That's expensive (and confusing) for what should be a nightly-only job.

How can I resolve this? If you propose a fix, please make it concise.

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
33 changes: 33 additions & 0 deletions apps/code/scripts/dev-update/build-pair.sh
Original file line number Diff line number Diff line change
@@ -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)"
86 changes: 86 additions & 0 deletions apps/code/scripts/dev-update/run-from-ci.sh
Original file line number Diff line number Diff line change
@@ -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"
73 changes: 73 additions & 0 deletions apps/code/scripts/dev-update/serve.mjs
Original file line number Diff line number Diff line change
@@ -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 <dir> [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 <dir> [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;
Comment on lines +50 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Suffix-range requests (bytes=-N) compute wrong offsets

When the range header is bytes=-500 (last 500 bytes), the regex captures match[1] = "" and match[2] = "500". Because "" is falsy, start is set to 0 instead of stat.size - 500, so the response sends the wrong slice of the file. The electron-updater differential/blockmap code can emit suffix ranges during verification. In practice electron-updater on this path likely uses open-ended ranges (bytes=0-), but a defensive fix would be: const start = match?.[1] ? Number(match[1]) : match?.[2] ? stat.size - Number(match[2]) : 0.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/scripts/dev-update/serve.mjs
Line: 50-52

Comment:
**Suffix-range requests (`bytes=-N`) compute wrong offsets**

When the range header is `bytes=-500` (last 500 bytes), the regex captures `match[1] = ""` and `match[2] = "500"`. Because `""` is falsy, `start` is set to `0` instead of `stat.size - 500`, so the response sends the wrong slice of the file. The electron-updater differential/blockmap code can emit suffix ranges during verification. In practice `electron-updater` on this path likely uses open-ended ranges (`bytes=0-`), but a defensive fix would be: `const start = match?.[1] ? Number(match[1]) : match?.[2] ? stat.size - Number(match[2]) : 0`.

How can I resolve this? If you propose a fix, please make it concise.

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}`);
});
13 changes: 13 additions & 0 deletions apps/code/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UpdatesService>(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", () => {
Expand Down
Loading
Loading