From d359061c02353937201ea8467768be5e49909199 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 21 Jul 2026 15:02:13 -0700 Subject: [PATCH 1/2] ci: add desktop Electron builds to GitHub Actions with unified production gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop (Electron) builds were never migrated when web CI moved from CircleCI to GitHub Actions. This restores them as three jobs — mac, win, linux — behind a single `production-gate` job holding the `production` environment, so one approval releases web and all three desktop platforms instead of the four separate approval gates CircleCI had. web-deploy now depends on production-gate and no longer declares the environment itself. web-deploy-release-candidate is unchanged and still auto-deploys without approval. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/web.yml | 206 +++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 2 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 8f810ea2285..0c860c74c9c 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -754,13 +754,24 @@ jobs: run: | aws s3 sync packages/web/sourcemaps s3://sourcemaps.audius.co --cache-control max-age=604800 - web-deploy: - name: Web Deploy + # Single approval gate for the whole production release. Approving this job + # releases web *and* all three desktop builds, replacing the separate + # per-platform approval gates the old CircleCI pipeline had. + production-gate: + name: Production Release Gate runs-on: ubuntu-latest needs: [web-build, web-check-ssr-bundlesize] if: github.ref == 'refs/heads/main' environment: name: production + steps: + - run: echo "Production release approved — deploying web and desktop" + + web-deploy: + name: Web Deploy + runs-on: ubuntu-latest + needs: [production-gate] + if: github.ref == 'refs/heads/main' steps: - name: Checkout code uses: actions/checkout@v4 @@ -872,3 +883,194 @@ jobs: job_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" json_content="{ \"blocks\": [{ \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": \"Deployed production <${job_url}|v${deploying_version}> to web \n\" } }]}" curl -f -X POST -H 'Content-type: application/json' --data "$json_content" $SLACK_WEBHOOK + + # Desktop (Electron) builds. electron-builder reads packages/web/build-production + # directly (see packages/web/scripts/dist.js), so the `builds` artifact is + # downloaded in place and not renamed. + desktop-build-mac: + name: Desktop Build (Mac) + runs-on: macos-latest + needs: [production-gate] + if: github.ref == 'refs/heads/main' + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: package-lock.json + + # node-gyp needs distutils, removed in Python 3.12+ + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Upgrade npm to 11.10.0 + run: npm install -g npm@11.10.0 + + - name: Install dependencies + env: + CI: true + SKIP_POD_INSTALL: true + SKIP_ANDROID_INSTALL: true + ANDROID_HOME: /tmp/android-sdk-dummy + NODE_OPTIONS: --max-old-space-size=8192 + run: | + mkdir -p /tmp/android-sdk-dummy + npm ci --prefer-offline || npm install --prefer-offline + + # Required by electron-builder to build the .dmg on macOS + - name: Add dmg-license + run: npm run install-dmg-license -w @audius/web + + - name: Download builds + uses: actions/download-artifact@v4 + with: + name: builds + path: packages/web + + - name: Build & publish Mac desktop app + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + ASC_PROVIDER: ${{ secrets.ASC_PROVIDER }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + cd packages/web + npm run dist:mac-publish-production + + - name: Slack notification + if: success() + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_DAILY_DEPLOY_WEBHOOK }} + run: | + cd packages/web + deploying_version=$(jq -r '.version' package.json) + job_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + json_content="{ \"blocks\": [{ \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": \"Deployed production <${job_url}|v${deploying_version}> to desktop mac \n\" } }]}" + curl -f -X POST -H 'Content-type: application/json' --data "$json_content" $SLACK_WEBHOOK + + desktop-build-win: + name: Desktop Build (Windows) + runs-on: ubuntu-latest + needs: [production-gate] + if: github.ref == 'refs/heads/main' + timeout-minutes: 90 + container: + image: electronuserland/builder:18-wine + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # The image ships Node 18; the repo sets engine-strict with node >=24.10.0 + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Upgrade npm to 11.10.0 + run: npm install -g npm@11.10.0 + + - name: Install dependencies + env: + CI: true + SKIP_POD_INSTALL: true + SKIP_ANDROID_INSTALL: true + ANDROID_HOME: /tmp/android-sdk-dummy + NODE_OPTIONS: --max-old-space-size=8192 + run: | + mkdir -p /tmp/android-sdk-dummy + npm ci --prefer-offline || npm install --prefer-offline + + - name: Download builds + uses: actions/download-artifact@v4 + with: + name: builds + path: packages/web + + - name: Build & publish Windows desktop app + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + cd packages/web + npm run dist:win-publish-production + + - name: Slack notification + if: success() + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_DAILY_DEPLOY_WEBHOOK }} + run: | + cd packages/web + deploying_version=$(jq -r '.version' package.json) + job_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + json_content="{ \"blocks\": [{ \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": \"Deployed production <${job_url}|v${deploying_version}> to desktop win \n\" } }]}" + curl -f -X POST -H 'Content-type: application/json' --data "$json_content" $SLACK_WEBHOOK + + desktop-build-linux: + name: Desktop Build (Linux) + runs-on: ubuntu-latest + needs: [production-gate] + if: github.ref == 'refs/heads/main' + timeout-minutes: 90 + container: + image: electronuserland/builder + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Upgrade npm to 11.10.0 + run: npm install -g npm@11.10.0 + + - name: Install dependencies + env: + CI: true + SKIP_POD_INSTALL: true + SKIP_ANDROID_INSTALL: true + ANDROID_HOME: /tmp/android-sdk-dummy + NODE_OPTIONS: --max-old-space-size=8192 + run: | + mkdir -p /tmp/android-sdk-dummy + npm ci --prefer-offline || npm install --prefer-offline + + - name: Download builds + uses: actions/download-artifact@v4 + with: + name: builds + path: packages/web + + - name: Build & publish Linux desktop app + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + run: | + cd packages/web + npm run dist:linux-publish-production + + - name: Slack notification + if: success() + env: + SLACK_WEBHOOK: ${{ secrets.SLACK_DAILY_DEPLOY_WEBHOOK }} + run: | + cd packages/web + deploying_version=$(jq -r '.version' package.json) + job_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + json_content="{ \"blocks\": [{ \"type\": \"section\", \"text\": { \"type\": \"mrkdwn\", \"text\": \"Deployed production <${job_url}|v${deploying_version}> to desktop linux \n\" } }]}" + curl -f -X POST -H 'Content-type: application/json' --data "$json_content" $SLACK_WEBHOOK From a3950684352e64472e9d8db75967e9757ced602f Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 21 Jul 2026 15:48:09 -0700 Subject: [PATCH 2/2] ci: notarize Mac desktop app with App Store Connect API key Switch @electron/notarize from the sunset Apple ID + app-specific password flow to notarytool with an App Store Connect API key, reusing the APP_STORE_CONNECT_API_KEY_* secrets the mobile workflow already uses so no new secrets are needed. The .p8 key content is written to a 0600 temp file since notarytool takes a path, and removed afterwards. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/web.yml | 8 +++---- packages/web/scripts/dist.js | 45 ++++++++++++++++++++++++++++++++---- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 0c860c74c9c..e01a97322dd 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -936,11 +936,9 @@ jobs: - name: Build & publish Mac desktop app env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} - ASC_PROVIDER: ${{ secrets.ASC_PROVIDER }} - CSC_LINK: ${{ secrets.CSC_LINK }} - CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | diff --git a/packages/web/scripts/dist.js b/packages/web/scripts/dist.js index 755fbb4615a..013c240e188 100644 --- a/packages/web/scripts/dist.js +++ b/packages/web/scripts/dist.js @@ -6,6 +6,7 @@ */ const fs = require('fs') +const os = require('os') const path = require('path') const { notarize } = require('@electron/notarize') @@ -36,6 +37,27 @@ program ) .parse(process.argv) +/** + * Writes the App Store Connect API key (.p8) to a temp file, since notarytool + * takes a path rather than the key contents. The secret may hold either the raw + * PEM contents or a base64 encoding of them. + * @returns {string} path to the written .p8 file + */ +const writeApiKeyFile = () => { + const rawKey = process.env.APP_STORE_CONNECT_API_KEY_KEY + const key = rawKey.includes('BEGIN PRIVATE KEY') + ? rawKey + : Buffer.from(rawKey, 'base64').toString('utf8') + + const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asc-api-key-')) + const keyPath = path.join( + keyDir, + `AuthKey_${process.env.APP_STORE_CONNECT_API_KEY_KEY_ID}.p8` + ) + fs.writeFileSync(keyPath, key, { mode: 0o600 }) + return keyPath +} + const notarizeFn = async (appId, params) => { // Only notarize the app on Mac OS. if (process.platform !== 'darwin') { @@ -49,20 +71,33 @@ const notarizeFn = async (appId, params) => { throw new Error(`Cannot find application at: ${appPath}`) } + const missing = [ + 'APP_STORE_CONNECT_API_KEY_KEY', + 'APP_STORE_CONNECT_API_KEY_KEY_ID', + 'APP_STORE_CONNECT_API_KEY_ISSUER_ID' + ].filter((name) => !process.env[name]) + if (missing.length) { + throw new Error( + `Cannot notarize, missing env vars: ${missing.join(', ')}` + ) + } + console.log(`Notarizing ${appId} at ${appPath}`) + const keyPath = writeApiKeyFile() try { await notarize({ - appBundleId: appId, + tool: 'notarytool', appPath, - appleId: process.env.APPLE_ID, - appleIdPassword: process.env.APPLE_ID_PASSWORD, - ascProvider: process.env.ASC_PROVIDER, - teamId: process.env.ASC_PROVIDER + appleApiKey: keyPath, + appleApiKeyId: process.env.APP_STORE_CONNECT_API_KEY_KEY_ID, + appleApiIssuer: process.env.APP_STORE_CONNECT_API_KEY_ISSUER_ID }) } catch (error) { console.error(error) // Propagate the error back up. throw new Error(error) + } finally { + fs.rmSync(path.dirname(keyPath), { recursive: true, force: true }) } console.log(`Finished notarizing ${appId}`) }