-
Notifications
You must be signed in to change notification settings - Fork 45
test(updates): macOS auto-update E2E with real install + relaunch #2828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/auto-update-ux
Are you sure you want to change the base?
Changes from all commits
c9c05ce
d53bbe5
98debc3
c1e9122
6fc7e22
ec644f5
77dc853
b0b389f
3d4b24d
3e2d915
8257a56
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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 | ||
| 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)" |
| 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" |
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the range header is Prompt To Fix With AIThis 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}`); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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