|
| 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 |
0 commit comments