diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 8f810ea2285..e01a97322dd 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,192 @@ 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: + 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: | + 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 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}`) }