This article has been machine-translated from Chinese. The translation may contain inaccuracies or awkward phrasing. If in doubt, please refer to the original Chinese version.
I recently configured our Electron desktop app for TestFlight on the Mac side, implementing automated builds and uploads with GitHub Actions. I ran into quite a few pitfalls along the way, so here’s a record of the entire process.
I’m not very familiar with Electron or the MAS submission process either — this article was figured out step by step through trial and error. It’s currently working and successfully uploading to TestFlight, but please point out any errors or omissions! Some parts of this record were saved through conversations with AI, so there may be inaccuracies.
Warning: Time Cost: The entire workflow will consume a significant amount of GitHub Actions free credits! Apple’s code signing and notarization process requires waiting 3-5 minutes, during which the runner can only idle waiting for Apple’s server response — very inefficient. If your project builds frequently, consider setting up a self-hosted runner or optimizing your trigger strategy.
Here is Apple’s official documentation Upload builds, which introduces how to use Apple’s official tools and API to upload app builds to App Store Connect.
Let me show the final result first — why does something this complicated have to exist (quiet complaining):
After configuration is complete:
- Manually trigger the workflow
- Check “Upload to App Store Connect” to upload to TestFlight; otherwise, it only packages the build
- View the build in the TestFlight tab of App Store Connect
- Distribute to testers

The entire migration from direct DMG download to TestFlight took about half a day, mostly spent troubleshooting various certificate and Bundle ID configurations.


According to the documentation, Apple’s recommended upload methods are:
- Xcode: Apple’s official integrated development environment (IDE), supporting the full workflow from development, testing, to submission
- Transporter: A macOS application with a graphical interface, suitable for quick and easy uploads with delivery logs and history viewing
- xcrun altool: A command-line tool invoked through Xcode’s built-in xcrun, which can validate app binaries and upload them to App Store Connect
- App Store Connect API: A REST-based API that supports authentication via JSON Web Tokens (JWT), enabling automated upload workflows
Since we’re using Actions, we’ll use xcrun altool for uploading.
Background
Our application is built with Electron + React + TypeScript, and was previously distributed via GitHub Releases as DMG installers. We now want to use TestFlight for beta testing and eventually publish to the Mac App Store. The following steps assume your app name is AppCat.
Prerequisite: Create the App in App Store Connect
Before configuring certificates, you need to create the app in App Store Connect:
- Open App Store Connect - My Apps
- Click ”+” -> New App
- Check macOS for the platform
- Enter the app name
- Select or create a Bundle ID (must match the
appIdinelectron-builder.ymlexactly!) - Fill in the SKU with anything, such as
appcat-macos
Warning: If you skip this step, you’ll get a “No suitable application records were found” error when uploading.
Our app was previously published on iOS, so I won’t go into detail here — it’s just the standard submission process. Now we need to publish to the Mac App Store.
Differences Between Two Distribution Methods
First, you need to understand that Mac apps have two distribution methods, each requiring different certificates:
| Distribution Method | Certificate Type | Purpose |
|---|---|---|
| Direct Download (DMG) | Developer ID Application | Website/GitHub download |
| Mac App Store | 3rd Party Mac Developer Application | App Store / TestFlight |
Additionally, MAS versions require sandboxing, which means some features (such as desktopCapturer screen capture) may be restricted.
Step 1: Create Certificates
1.1 Create a CSR File
Open Keychain Access on your local machine:
Menu Bar -> Keychain Access -> Certificate Assistant -> Request a Certificate From a Certificate Authority...

Then fill in:
- User Email Address: Your email
- Common Name: Anything, such as “AppCat MAS”
- Select “Saved to disk”
- CA Email Address: Leave blank
Save the .certSigningRequest file.

1.2 Create a Mac App Distribution Certificate
- Open Apple Developer - Certificates
- Click the ”+” button
- Select Mac App Distribution
- Upload the CSR file you just created
- Download the certificate and double-click to install it in Keychain (select the “login” keychain)
1.3 Create a Mac Installer Distribution Certificate
Repeat the steps above, but select Mac Installer Distribution. This certificate is used to sign .pkg installer packages.
Step 2: Create a Provisioning Profile
- Open Apple Developer - Profiles
- Click ”+”
- Select Mac App Store Connect
- Select your App ID (must match the Bundle ID in App Store Connect!)
- Select the Mac App Distribution certificate you just created
- Name it and download
Important: The Bundle ID in the Profile must exactly match the one in App Store Connect, otherwise uploading will fail with “No suitable application records were found”.
This step produces the appcat_profile_mas.provisionprofile file (the filename can be customized, but it must be consistent with subsequent configurations).
Step 3: Create an App Store Connect API Key
This is used for CI/CD automated build uploads.
- Open App Store Connect - Users and Access - Keys
- Click ”+” to create a new key
- Name it anything; for permissions, select App Manager or Admin
- Click “Generate”
Finding the Issuer ID
The Issuer ID is displayed at the top of the keys page, as a UUID-format string, for example:
50ce4b17-dd5e-4550-877b-7a7bb0d608d7
All API Keys belonging to the same team share the same Issuer ID. This value is not sensitive information.
Getting the Key ID
After creating the key, the Key ID will be displayed in the key list (Key ID column), for example BU64538829.
Downloading the .p8 Private Key File
Click “Download API Key” to download the .p8 file:
- The filename format is
AuthKey_XXXXX.p8, whereXXXXXis the Key ID - This file is the API authentication private key; leaking it would cause security issues
Extremely Important: The
.p8private key file can only be downloaded once per team! Save it to a secure location (such as a password manager) immediately after downloading. If lost, you can only revoke the current key and create a new one, which means updating all CI/CD configurations that use that key.
This step produces three values:
AuthKey_XXXXX.p8file -> later converted toASC_API_KEY- Key ID -> corresponds to the
ASC_API_KEY_IDenvironment variable we’ll set later - Issuer ID -> corresponds to
ASC_ISSUER_ID
Step 4: Export Certificates as .p12
CI/CD requires certificates in .p12 format.
- Open Keychain Access
- On the left, select “login”; at the top, select “My Certificates”
- Find “3rd Party Mac Developer Application: xxx”
- Right-click -> Export
- Choose
.p12format - Set a password
- Similarly export “3rd Party Mac Developer Installer: xxx”
This step produces the mas_app.p12 and mas_installer.p12 files.
Step 5: electron-builder Configuration
Add MAS configuration in electron-builder.yml:
appId: app.bundlename.AppCat # Must match App Store Connect! Replace with your app bundleId
# Keep existing Developer ID configuration unchanged
mac:
entitlementsInherit: build/entitlements.mac.plist
hardenedRuntime: true
notarize: true
# New Mac App Store configuration
mas:
hardenedRuntime: false # MAS uses sandbox instead
entitlements: build/entitlements.mas.plist
entitlementsInherit: build/entitlements.mas.inherit.plist
provisioningProfile: build/appcat_profile_mas.provisionprofile
notarize: false # MAS doesn't need notarize
category: public.app-category.productivity # Choose your app category here
# PKG configuration
pkg:
isRelocatable: false
overwriteAction: upgrade
artifactName: ${name}-${version}-mas.${ext}
Step 6: Create MAS Entitlements
MAS requires sandboxing. Create build/entitlements.mas.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>
Create build/entitlements.mas.inherit.plist (child process inheritance):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
Step 7: Configure GitHub Secrets
Create a staging environment in the GitHub repository under Settings -> Environments, and add the following Secrets:
# Convert files to base64
base64 -i mas_app.p12 | pbcopy # -> MAS_APP_CERT
base64 -i mas_installer.p12 | pbcopy # -> MAS_INSTALLER_CERT
base64 -i appcat_profile_mas.provisionprofile | pbcopy # -> MAS_PROVISIONING_PROFILE
base64 -i AuthKey_XXXXX.p8 | pbcopy # -> ASC_API_KEY
| Secret | Description |
|---|---|
MAS_APP_CERT | Mac App Distribution cert (base64) |
MAS_INSTALLER_CERT | Mac Installer Distribution cert (base64) |
MAC_CERTS_PASSWORD | Certificate password |
MAS_PROVISIONING_PROFILE | Provisioning Profile (base64) |
ASC_API_KEY | App Store Connect API private key (base64) |
ASC_API_KEY_ID | API Key ID |
ASC_ISSUER_ID | Issuer ID |

Step 8: GitHub Actions Workflow
Create .github/workflows/release-mas.yml:
I actually created my own self-hosted action runner to try it out, so I left it in — you can ignore it. The default behavior uses GitHub’s runner.
name: Build and Release MAS
on:
workflow_dispatch:
inputs:
upload_to_app_store:
description: "Upload to App Store Connect"
type: boolean
default: false
use_self_hosted_runner:
description: "Use self-hosted runner"
type: boolean
default: false
jobs:
build-mas:
runs-on: ${{ inputs.use_self_hosted_runner && fromJSON('["self-hosted", "macOS", "ARM64"]') || 'macos-14' }}
environment: staging
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Import Mac App Store Certificates
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.MAS_APP_CERT }}
p12-password: ${{ secrets.MAC_CERTS_PASSWORD }}
keychain-password: ${{ secrets.MAC_CERTS_PASSWORD }}
- name: Import Mac Installer Certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.MAS_INSTALLER_CERT }}
p12-password: ${{ secrets.MAC_CERTS_PASSWORD }}
create-keychain: false
keychain-password: ${{ secrets.MAC_CERTS_PASSWORD }}
- name: Download Provisioning Profile
run: |
echo "${{ secrets.MAS_PROVISIONING_PROFILE }}" | base64 -d > build/appcat_profile_mas.provisionprofile
- name: Build MAS App
run: MAS_BUILD=true pnpm exec electron-vite build && pnpm exec electron-builder --mac mas --publish never
env:
# Your env variables etc.
VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
- name: Upload to App Store Connect
if: ${{ inputs.upload_to_app_store }}
run: |
# Setup API Key file
mkdir -p ~/.private_keys
echo "${{ secrets.ASC_API_KEY }}" | base64 -d > ~/.private_keys/AuthKey_${{ secrets.ASC_API_KEY_ID }}.p8
# Find and upload the pkg file
PKG_FILE=$(find dist -name "*.pkg" -type f | head -1)
echo "Uploading: $PKG_FILE"
xcrun altool --upload-app \
--type macos \
--file "$PKG_FILE" \
--apiKey ${{ secrets.ASC_API_KEY_ID }} \
--apiIssuer ${{ secrets.ASC_ISSUER_ID }}
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: mas-build
path: |
dist/*.pkg
retention-days: 14
Troubleshooting Notes
Certificate Import Password Issue
When importing the second certificate, you must specify keychain-password, and it must match the password used when creating the keychain the first time:
# First import, creates keychain
- uses: apple-actions/import-codesign-certs@v3
with:
keychain-password: ${{ secrets.MAC_CERTS_PASSWORD }} # Must be specified!
# Second import, reuses keychain
- uses: apple-actions/import-codesign-certs@v3
with:
create-keychain: false
keychain-password: ${{ secrets.MAC_CERTS_PASSWORD }} # Must match!
“No suitable application records” Error
This error can have multiple causes:
Cause 1: Bundle ID Mismatch
The Bundle ID must be exactly the same in all three places:
appIdinelectron-builder.yml- Bundle ID in App Store Connect
- App ID bound to the Provisioning Profile
To check the Bundle ID bound to the Profile:
security cms -D -i appcat_profile_mas.provisionprofile | grep -A1 "application-identifier"
Cause 2: Insufficient API Key Permissions
When creating the API Key, you must select App Manager or Admin permissions.
Cause 3: No macOS App in App Store Connect
Ensure you’ve created an app in App Store Connect and the platform includes macOS.
GH_TOKEN Error
MAS builds don’t need to publish to GitHub. Add --publish never:
electron-builder --mac mas --publish never
Missing High-Resolution Icon
The App Store requires a 1024x1024 icon (512pt @2x). Ensure your .icns file includes this size.
API Key Must Be Written to a File
xcrun altool needs to read the .p8 private key from a file; it cannot accept a base64 string directly. In CI, you need to decode it to the specified directory first:
- name: Upload to App Store Connect
run: |
# altool looks for the private key file in this directory
mkdir -p ~/.private_keys
echo "${{ secrets.ASC_API_KEY }}" | base64 -d > ~/.private_keys/AuthKey_${{ secrets.ASC_API_KEY_ID }}.p8
xcrun altool --upload-app \
--apiKey ${{ secrets.ASC_API_KEY_ID }} \
--apiIssuer ${{ secrets.ASC_ISSUER_ID }} \
...
PKG File Path Issue
xcrun altool --file dist/*.pkg may fail with “file cannot be found” because:
- When there are no matching files: Bash will pass
dist/*.pkgas a literal string by default - When multiple files match: All matching files are passed as arguments, but
--fileonly accepts one
Using the find command to explicitly get the file path is more reliable:
PKG_FILE=$(find dist -name "*.pkg" -type f | head -1)
xcrun altool --upload-app --file "$PKG_FILE" ...
I hope this guide helps you avoid some detours.
One more reminder: This workflow will consume a significant amount of GitHub Actions free credits. Apple’s notarization process requires waiting 3-5 minutes with the runner idling. If your team builds frequently, I strongly recommend setting up a self-hosted runner or optimizing your trigger strategy (for example, only triggering on tag pushes).
喜欢的话,留下你的评论吧~