Code Update E2E (macOS) #39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | |
| # | |
| # 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 | |
| # Forge release, what users run today) updating via the genuine built-in | |
| # Squirrel.Mac client to the electron-builder build we ship now. | |
| 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" | |
| - "apps/code/scripts/dev-update/run-from-ci-forge.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@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 | |
| with: | |
| node-version: 22 | |
| cache: "pnpm" | |
| - name: Cache Electron binary | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/Library/Caches/electron | |
| key: electron-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} | |
| restore-keys: | | |
| electron-${{ runner.os }}- | |
| - name: Cache Playwright browsers | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: ~/Library/Caches/ms-playwright | |
| key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} | |
| restore-keys: | | |
| playwright-${{ runner.os }}- | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Configure AWS credentials | |
| uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.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); | |
| } | |
| ' | |
| # 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 | |
| # pins the ref; bump the script to force a rebuild. Built after the baseline | |
| # leg so a flake in the (riskier) old-build step can never mask the baseline. | |
| - name: Cache old Forge app (v0.55.132) | |
| id: forge-cache | |
| uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: apps/code/out/old-forge | |
| key: forge-app-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/code/scripts/dev-update/build-old-forge.sh') }} | |
| # Reuse the cached app only if its signature still verifies, otherwise rebuild. | |
| # This makes a missing or tar-corrupted restore degrade to today's behaviour | |
| # (a full build) instead of a confusing swap failure in the Forge leg. | |
| - name: Build old Forge app if not cached | |
| working-directory: apps/code | |
| env: | |
| CACHE_HIT: ${{ steps.forge-cache.outputs.cache-hit }} | |
| run: | | |
| APP="out/old-forge/PostHog Code.app" | |
| if [ "$CACHE_HIT" = "true" ] && codesign --verify --strict "$APP" 2>/dev/null; then | |
| echo "Reusing cached Forge app with a valid signature" | |
| codesign --verify --strict --verbose=2 "$APP" | |
| else | |
| echo "No usable cached Forge app; building from the pinned ref" | |
| rm -rf out/old-forge | |
| bash scripts/dev-update/build-old-forge.sh | |
| fi | |
| - name: Run macOS Forge to builder update E2E | |
| working-directory: apps/code | |
| env: | |
| PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/apps/code/out/update-forge-report.json | |
| run: | | |
| pnpm exec playwright test --config=tests/e2e/playwright.update-forge.config.ts | |
| node -e ' | |
| const r = require(process.env.GITHUB_WORKSPACE + "/apps/code/out/update-forge-report.json"); | |
| const s = r.stats || {}; | |
| console.log("forge update e2e stats:", JSON.stringify(s)); | |
| if (s.expected !== 1 || s.skipped || s.unexpected || s.flaky) { | |
| console.error("FAIL: expected exactly one passing forge update test"); | |
| process.exit(1); | |
| } | |
| ' | |
| - name: Render update proof summaries | |
| if: always() | |
| working-directory: apps/code | |
| run: | | |
| node -e ' | |
| const fs = require("fs"); | |
| const proofs = [ | |
| ["out/update-proof/proof.json", "macOS auto-update proof (builder -> builder)"], | |
| ["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, " "); | |
| for (const [p, title] of proofs) { | |
| if (!fs.existsSync(p)) { console.log("no proof file at " + p); continue; } | |
| const d = JSON.parse(fs.readFileSync(p, "utf8")); | |
| 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 = ["## " + title + ": " + d.result, "", "| Check | Value |", "| --- | --- |"] | |
| .concat(rows.map((r) => "| " + r[0] + " | " + cell(r[1]) + " |")) | |
| .join("\n"); | |
| fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, md + "\n\n"); | |
| console.log(md); | |
| } | |
| ' | |
| - name: Upload proof, report and logs | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: update-e2e-macos | |
| path: | | |
| apps/code/out/update-proof/ | |
| apps/code/out/update-proof-forge/ | |
| apps/code/out/update-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/ | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| - name: Upload old build (1.0.0) | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: update-new-build-2.0.0 | |
| path: apps/code/out/dev-update-feed/ | |
| if-no-files-found: warn | |
| retention-days: 7 | |
| # upload-artifact follows symlinks and repacks them as real files, which | |
| # breaks the framework layout and the code signature. ditto -c -k preserves | |
| # both, so the pulled zip extracts to a bundle Squirrel still accepts (the | |
| # same way the baseline old app travels). run-from-ci-forge.sh consumes this. | |
| - name: Package old Forge app for upload | |
| if: always() | |
| working-directory: apps/code | |
| run: | | |
| APP="out/old-forge/PostHog Code.app" | |
| ZIP="out/PostHog-Code-forge-1.0.0-arm64-mac.zip" | |
| if [ ! -d "$APP" ]; then | |
| echo "no Forge app to package (an earlier step likely failed); skipping" | |
| exit 0 | |
| fi | |
| ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP" | |
| # Prove the zip round-trips to a still-valid signature, so the artifact | |
| # run-from-ci-forge.sh pulls is usable and not silently corrupt. | |
| VERIFY_DIR="$(mktemp -d)" | |
| ditto -x -k "$ZIP" "$VERIFY_DIR" | |
| codesign --verify --strict "$VERIFY_DIR/PostHog Code.app" | |
| rm -rf "$VERIFY_DIR" | |
| echo "OK: packaged Forge app zip verifies" | |
| - name: Upload old Forge build (v0.55.132) | |
| if: always() | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: update-old-forge-build-1.0.0 | |
| path: apps/code/out/PostHog-Code-forge-1.0.0-arm64-mac.zip | |
| if-no-files-found: warn | |
| retention-days: 7 |