From b254c096905d7be00ebe202c1e5d3a973c5ea778 Mon Sep 17 00:00:00 2001 From: Jeffrey Alan Scudder Date: Mon, 26 Jan 2026 20:48:09 +0000 Subject: [PATCH] feat(electron): add code signing and GitHub Actions release pipeline --- .github/workflows/electron-release.yml | 213 +++++++++++++++--- ac-electron/CODE-SIGNING.md | 134 +++++++++++ .../build/entitlements.mac.inherit.plist | 14 ++ ac-electron/build/entitlements.mac.plist | 9 +- ac-electron/package.json | 12 +- ac-electron/release.fish | 42 ++++ ac-electron/scripts/notarize.js | 55 +++++ 7 files changed, 440 insertions(+), 39 deletions(-) create mode 100644 ac-electron/CODE-SIGNING.md create mode 100644 ac-electron/build/entitlements.mac.inherit.plist create mode 100755 ac-electron/release.fish create mode 100644 ac-electron/scripts/notarize.js diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 08faeaa7b..3e8cc3378 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -1,65 +1,206 @@ name: Electron Release on: + push: + tags: + - 'electron-v*' workflow_dispatch: inputs: - release_notes: - description: "Release notes (optional)" + version: + description: 'Version to release (e.g., 0.1.31)' required: false - default: "" - -permissions: - contents: write + type: string jobs: - build-and-publish: - name: Build and publish (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest, ubuntu-latest] - - defaults: - run: + build-macos: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ac-electron/package-lock.json + + - name: Install dependencies + working-directory: ac-electron + run: npm ci + + - name: Import Code Signing Certificate + env: + MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + # Create variables + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + + # Import certificate from secrets + echo -n "$MACOS_CERTIFICATE" | base64 --decode -o $CERTIFICATE_PATH + + # Create temporary keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # Import certificate to keychain + security import $CERTIFICATE_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security list-keychain -d user -s $KEYCHAIN_PATH + + # Verify certificate + security find-identity -v $KEYCHAIN_PATH + + - name: Build & Sign macOS (Universal) working-directory: ac-electron + env: + # Code Signing + CSC_LINK: ${{ secrets.MACOS_CERTIFICATE }} + CSC_KEY_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + # Notarization + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_NOTARIZE_PWD: ${{ secrets.APPLE_NOTARIZE_PWD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + # GitHub Token for publishing + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Tell electron-builder we're in CI + CI: true + run: npm run build:mac + - name: Upload macOS Artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-build + path: | + ac-electron/dist/*.dmg + ac-electron/dist/*.zip + ac-electron/dist/latest-mac.yml + retention-days: 7 + + build-windows: + runs-on: windows-latest steps: - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Setup Node + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 - cache: npm + node-version: '20' + cache: 'npm' cache-dependency-path: ac-electron/package-lock.json - name: Install dependencies + working-directory: ac-electron run: npm ci - - name: Build (macOS) - if: runner.os == 'macOS' + - name: Build Windows + working-directory: ac-electron env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - npm run prebuild - npx electron-builder --mac --universal --publish always + run: npm run build:win + + - name: Upload Windows Artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-build + path: | + ac-electron/dist/*.exe + ac-electron/dist/latest.yml + retention-days: 7 - - name: Build (Windows) - if: runner.os == 'Windows' + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: ac-electron/package-lock.json + + - name: Install dependencies + working-directory: ac-electron + run: npm ci + + - name: Build Linux + working-directory: ac-electron env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run build:linux + + - name: Upload Linux Artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-build + path: | + ac-electron/dist/*.AppImage + ac-electron/dist/*.deb + ac-electron/dist/*.rpm + ac-electron/dist/latest-linux.yml + retention-days: 7 + + release: + needs: [build-macos, build-windows, build-linux] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: dist + + - name: Display structure of downloaded files + run: ls -la dist/*/ + + - name: Get version from tag or input + id: version run: | - npm run prebuild - npx electron-builder --win --x64 --publish always + if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.version }}" ]; then + echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF#refs/tags/electron-v}" >> $GITHUB_OUTPUT + fi - - name: Build (Linux) - if: runner.os == 'Linux' + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: electron-v${{ steps.version.outputs.version }} + name: Aesthetic Computer v${{ steps.version.outputs.version }} + draft: true + prerelease: false + files: | + dist/macos-build/* + dist/windows-build/* + dist/linux-build/* + body: | + ## Aesthetic Computer v${{ steps.version.outputs.version }} + + ### Downloads + + **macOS** (Universal - Intel + Apple Silicon) + - `.dmg` - Disk image installer + + **Windows** + - `.exe` - NSIS installer + + **Linux** + - `.AppImage` - Portable application + - `.deb` - Debian/Ubuntu package + - `.rpm` - Fedora/RHEL package + + ### Auto-Updates + The app will automatically check for updates and notify you when a new version is available. env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - npm run prebuild - npx electron-builder --linux --publish always \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/ac-electron/CODE-SIGNING.md b/ac-electron/CODE-SIGNING.md new file mode 100644 index 000000000..3992147d1 --- /dev/null +++ b/ac-electron/CODE-SIGNING.md @@ -0,0 +1,134 @@ +# Code Signing & Release Setup + +## Required GitHub Secrets + +To enable automated code signing and notarization, add these secrets to your GitHub repository: + +### macOS Code Signing & Notarization + +1. **MACOS_CERTIFICATE** - Base64-encoded `.p12` certificate file +2. **MACOS_CERTIFICATE_PASSWORD** - Password for the `.p12` certificate +3. **KEYCHAIN_PASSWORD** - Any secure password for the temporary keychain +4. **APPLE_ID** - Your Apple ID email (e.g., `hi@aesthetic.computer`) +5. **APPLE_NOTARIZE_PWD** - App-specific password from appleid.apple.com +6. **APPLE_TEAM_ID** - Your Apple Developer Team ID + +### How to Get These Values + +#### 1. Export Your Developer ID Certificate + +```bash +# List available signing identities +security find-identity -v -p codesigning + +# Export to .p12 (you'll be prompted for a password) +security export -t identities -f pkcs12 -o certificate.p12 -k ~/Library/Keychains/login.keychain-db +``` + +Then base64 encode it: +```bash +base64 -i certificate.p12 | pbcopy +``` + +Paste that into `MACOS_CERTIFICATE` secret. + +#### 2. Find Your Team ID + +Your Team ID can be found at: +- https://developer.apple.com/account → Membership Details +- Or in Xcode: Preferences → Accounts → Select team → View Details + +#### 3. Create App-Specific Password + +1. Go to https://appleid.apple.com/account/manage +2. Sign in with your Apple ID +3. Under "App-Specific Passwords", click "Generate Password" +4. Label it "Aesthetic Computer Electron" +5. Copy the generated password + +## Local Development Signing + +For local testing with signing (without notarization): + +```bash +# Set your identity +export CSC_NAME="Developer ID Application: Your Name (TEAM_ID)" + +# Build +npm run build:mac +``` + +To test notarization locally: + +```bash +export APPLE_ID="your@email.com" +export APPLE_NOTARIZE_PWD="xxxx-xxxx-xxxx-xxxx" +export APPLE_TEAM_ID="YOUR_TEAM_ID" +export FORCE_NOTARIZE=1 + +npm run build:mac +``` + +## Releasing + +### Automatic (via Git tags) + +```fish +# Bump version and create tag +./release.fish patch # or minor, major, or specific version + +# Push to trigger release +git push origin main && git push origin electron-v +``` + +### Manual (via GitHub Actions) + +1. Go to Actions → "Electron Release" +2. Click "Run workflow" +3. Optionally specify a version +4. Click "Run workflow" + +The release will be created as a **draft** so you can review before publishing. + +## Verifying the Signed App + +After building, verify the signature: + +```bash +# Check code signature +codesign -dv --verbose=4 "dist/mac-universal/Aesthetic Computer.app" + +# Verify notarization +spctl -a -t exec -vv "dist/mac-universal/Aesthetic Computer.app" + +# Check stapled ticket +stapler validate "dist/mac-universal/Aesthetic Computer.app" +``` + +## Troubleshooting + +### "Developer ID Application" identity not found + +Make sure you have a valid Developer ID Application certificate installed: +1. Open Keychain Access +2. Look in "login" keychain → "My Certificates" +3. You should see "Developer ID Application: Your Name (TEAM_ID)" + +If missing, download from https://developer.apple.com/account/resources/certificates/list + +### Notarization fails with "invalid credentials" + +1. Verify your Apple ID is correct +2. Regenerate the app-specific password +3. Make sure your Apple Developer account is in good standing + +### "The signature of the binary is invalid" + +The app wasn't signed correctly. Check: +1. Certificate is valid and not expired +2. Entitlements file exists and is valid +3. Hardened runtime is enabled + +### Build succeeds but app won't open on other Macs + +The app might not be notarized. Check the GitHub Actions logs for notarization errors. diff --git a/ac-electron/build/entitlements.mac.inherit.plist b/ac-electron/build/entitlements.mac.inherit.plist new file mode 100644 index 000000000..faf2370e3 --- /dev/null +++ b/ac-electron/build/entitlements.mac.inherit.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.inherit + + + diff --git a/ac-electron/build/entitlements.mac.plist b/ac-electron/build/entitlements.mac.plist index fb3c830f3..96816b732 100644 --- a/ac-electron/build/entitlements.mac.plist +++ b/ac-electron/build/entitlements.mac.plist @@ -2,6 +2,7 @@ + com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory @@ -10,11 +11,17 @@ com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.network.client com.apple.security.network.server - com.apple.security.inherit + + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write diff --git a/ac-electron/package.json b/ac-electron/package.json index e8e82c039..4fb6da5eb 100644 --- a/ac-electron/package.json +++ b/ac-electron/package.json @@ -51,6 +51,7 @@ "to": "tray-icon.png" } ], + "afterSign": "scripts/notarize.js", "mac": { "category": "public.app-category.graphics-design", "target": [ @@ -59,12 +60,19 @@ "arch": [ "universal" ] + }, + { + "target": "zip", + "arch": [ + "universal" + ] } ], - "hardenedRuntime": false, + "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", - "entitlementsInherit": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.inherit.plist", + "notarize": false, "extendInfo": { "NSDesktopFolderUsageDescription": "Aesthetic Computer needs access to your Desktop to manage the workspace.", "NSDocumentsFolderUsageDescription": "Aesthetic Computer needs access to Documents for file operations.", diff --git a/ac-electron/release.fish b/ac-electron/release.fish new file mode 100755 index 000000000..5d4725541 --- /dev/null +++ b/ac-electron/release.fish @@ -0,0 +1,42 @@ +#!/usr/bin/env fish + +# Release script for Aesthetic Computer Electron app +# Usage: ./release.fish [patch|minor|major|] + +set -l bump_type $argv[1] + +if test -z "$bump_type" + set bump_type "patch" +end + +cd (dirname (status -f)) + +# Get current version +set -l current_version (node -p "require('./package.json').version") +echo "Current version: $current_version" + +# Calculate new version +switch $bump_type + case patch minor major + set -l new_version (npm version $bump_type --no-git-tag-version | tr -d 'v') + case '*' + # Assume it's a specific version + npm version $bump_type --no-git-tag-version + set new_version $bump_type +end + +set -l new_version (node -p "require('./package.json').version") +echo "New version: $new_version" + +# Commit and tag +git add package.json package-lock.json +git commit -m "chore(electron): bump version to $new_version" +git tag "electron-v$new_version" + +echo "" +echo "Version bumped to $new_version" +echo "" +echo "To release, push the tag:" +echo " git push origin main && git push origin electron-v$new_version" +echo "" +echo "Or trigger a manual release from GitHub Actions." diff --git a/ac-electron/scripts/notarize.js b/ac-electron/scripts/notarize.js new file mode 100644 index 000000000..65259cf1c --- /dev/null +++ b/ac-electron/scripts/notarize.js @@ -0,0 +1,55 @@ +// @ts-check +const { notarize } = require("@electron/notarize"); +const path = require("path"); + +/** + * Notarize the macOS app after signing + * Called by electron-builder via afterSign hook + */ +exports.default = async function notarizing(context) { + const { electronPlatformName, appOutDir } = context; + + // Only notarize on macOS + if (electronPlatformName !== "darwin") { + console.log("Skipping notarization - not macOS"); + return; + } + + // Skip if not in CI or explicitly disabled + if (!process.env.CI && !process.env.FORCE_NOTARIZE) { + console.log("Skipping notarization - not in CI (set FORCE_NOTARIZE=1 to override)"); + return; + } + + // Check for required environment variables + const appleId = process.env.APPLE_ID; + const appleIdPassword = process.env.APPLE_NOTARIZE_PWD; + const teamId = process.env.APPLE_TEAM_ID; + + if (!appleId || !appleIdPassword || !teamId) { + console.log("Skipping notarization - missing credentials"); + console.log(" APPLE_ID:", appleId ? "✓" : "✗"); + console.log(" APPLE_NOTARIZE_PWD:", appleIdPassword ? "✓" : "✗"); + console.log(" APPLE_TEAM_ID:", teamId ? "✓" : "✗"); + return; + } + + const appName = context.packager.appInfo.productFilename; + const appPath = path.join(appOutDir, `${appName}.app`); + + console.log(`Notarizing ${appPath}...`); + + try { + await notarize({ + tool: "notarytool", + appPath, + appleId, + appleIdPassword, + teamId, + }); + console.log("Notarization complete!"); + } catch (error) { + console.error("Notarization failed:", error); + throw error; + } +}; -- 2.51.2