I had to figure this out for Datasette Desktop.
First step is to pay $99/year for an Apple Developer account.
I had a previous (expired) account with a UK address, and changing to a USA address required a support ticket - so instead I created a brand new Apple ID specifically for the developer account.
Since a later stage here involves storing the account password in a GitHub repository secret, I think this is a better way to go: I don't like the idea of my personal Apple ID account password being needed by anyone else who should be able to sign my application.
First you need to generate a Certificate Signing Request using Keychain Access on a Mac - I was unable to figure out how to do this on the command-line.
Quoting https://help.apple.com/developer-account/#/devbfa00fef7:
- Launch Keychain Access located in
/Applications/Utilities
.- Choose Keychain Access > Certificate Assistant > Request a Certificate from a Certificate Authority.
- In the Certificate Assistant dialog, enter an email address in the User Email Address field.
- In the Common Name field, enter a name for the key (for example, Gita Kumar Dev Key).
- Leave the CA Email Address field empty.
- Choose "Saved to disk", and click Continue.
This produces a CertificateSigningRequest.certSigningRequest
file. Save that somewhere sensible.
The certificate needed is for a "Developer ID Application" - so select that option from the list of options on https://developer.apple.com/account/resources/certificates/add
Upload the CertificateSigningRequest.certSigningRequest
file, and Apple should provide you a developerID_application.cer
to download.
The final signing step requires a .p12
file. It took me quite a while to figure out how to create this - in the end what worked for me was this:
developerID_application.cer
file and import it into my login keychainI saved the resulting file as Developer-ID-Application-Certificates.p12
. It asked me to set a password, so I generated and saved a random one in 1Password.
At this point I turned to electron-builder to do the rest of the work. I installed it with:
npm install electron-builder --save-dev
I added "dist": "electron-builder --publish never"
to my "scripts"
block in package.json
.
Then I ran the following:
CSC_KEY_PASSWORD=... \
CSC_LINK=$(openssl base64 -in Developer-ID-Application-Certificates.p12) \
npm run dist
The CSC_KEY_PASSWORD
was the password I set earlier when I exported the certificate.
That CSC_LINK
variable is set to the base64 encoded version of the certificate file. You can also pass the file itself, but I would need the base64 option later to work with GitHub actions.
This worked! It generated a signed Datasette.app
package.
... which wasn't quite enough. It still wouldn't open without complaints on another machine until I had got it notarized.
Notarizing involves uploading the application bundle to Apple's servers, where they run some automatic scans against it before returning a notarization ticket that can be "stapled" to the binary.
Thankfully electron-notarize does most of the work here, so I installed that:
npm install electron-notarize --save-dev
I then went through an iteration cycle of trying out different combinations of settings until it finally worked.
I'll describe my finished configuration.
I have a file in build/entitlements.mac.plist
containing the following:
<?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.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.debugger</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
The possible entitlements are documented here. I don't fully understand these ones, but they are what I got to after multiple rounds of experimentation.
I have a scripts/notarize.js
file containing this (based on Notarizing your Electron application by
Kilian Valkhof):
/* Based on https://kilianvalkhof.com/2019/electron/notarizing-your-electron-application/ */
const { notarize } = require("electron-notarize");
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== "darwin") {
return;
}
const appName = context.packager.appInfo.productFilename;
return await notarize({
appBundleId: "io.datasette.app",
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLEID,
appleIdPassword: process.env.APPLEIDPASS,
});
};
The "build"
section of my package.json
looks like this:
"build": {
"appId": "io.datasette.app",
"mac": {
"category": "public.app-category.developer-tools",
"hardenedRuntime" : true,
"gatekeeperAssess": false,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
"binaries": [
"./dist/mac/Datasette.app/Contents/Resources/python/bin/python3.9",
"./dist/mac/Datasette.app/Contents/Resources/python/lib/python3.9/lib-dynload/xxlimited.cpython-39-darwin.so",
"./dist/mac/Datasette.app/Contents/Resources/python/lib/python3.9/lib-dynload/_testcapi.cpython-39-darwin.so"
]
},
"afterSign": "scripts/notarize.js",
"extraResources": [
{
"from": "python",
"to": "python",
"filter": [
"**/*"
]
}
]
}
Again, I got here through a process of iteration - in particular, my application bundles a full copy of Python so I had to specify some additional binaries and extraResources
- most applications will not need to do that.
Note that the scripts/notarize.js
file uses two extra environment variables: APPLEID
and APPLEIDPASS
. These are the account credentials for my Apple Developer account's Apple ID.
(I also encountered an error xcrun: error: unable to find utility "altool", not a developer tool or in PATH
- I resolved that by running sudo xcode-select --reset
.)
Another error I encountered was this one:
Please sign in with an app-specific password. You can create one at appleid.apple.com
These can be created in the "Security" section of https://appleid.apple.com/account/home - I created one called "Notarize Apps" which I set as the APPLEIDPASS
environment variable.
With all of the above in place, creating a build on my laptop looked like this:
APPLEID=my-dedicated-appleid \
APPLEIDPASS=app-specific-password \
CSC_KEY_PASSWORD=key-password \
CSC_LINK=$(openssl base64 -in Developer-ID-Application-Certificates.p12) \
npm run dist
This worked! It produced a Datasette.app
package which I could zip up, distribute to another machine, unzip and install - and it then opened without the terrifying security warning.
I decided to build and notarize on every push to my repository, so I could save the resulting build as an artifact and install any in-progress work on a computer to test it.
Apple limit you to 75 notarizations a day so I think this is OK for my projects.
My full test.yml looks like this:
name: Test
on: push
jobs:
test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- name: Configure Node caching
uses: actions/cache@v2
env:
cache-name: cache-node-modules
with:
path: ~/.npm
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- uses: actions/cache@v2
name: Configure pip caching
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install Node dependencies
run: npm install
- name: Download standalone Python
run: |
./download-python.sh
- name: Run tests
run: npm test
timeout-minutes: 5
- name: Build distribution
env:
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLEID: ${{ secrets.APPLEID }}
APPLEIDPASS: ${{ secrets.APPLEIDPASS }}
run: npm run dist
- name: Create zip file
run: |
cd dist/mac
ditto -c -k --keepParent Datasette.app Datasette.app.zip
- name: And a README (to work around GitHub double-zips)
run: |
echo "More information: https://datasette.io" > dist/mac/README.txt
- name: Upload artifact
uses: actions/upload-artifact@v2
with:
name: Datasette-macOS
path: |
dist/mac/Datasette.app.zip
dist/mac/README.txt
The key stuff here is the "Build distribution" step. It sets four values that I have saved on the repository as secrets: CSC_KEY_PASSWORD
, CSC_LINK
, APPLEID
and APPLEIDPASS
.
The CSC_LINK
variable is the base64-encoded contents of my Developer-ID-Application-Certificates.p12
file. I generated that like so:
openssl base64 -in developerID_application.cer
I have a separate release.yml for building tagged releases, described in this TIL.
You can browse the code in my 0.1.0 tag to see all of these parts in their final configuration, as used to create the 0.1.0 initial release of my application.
The original issue threads in which I figured this stuff out are:
Created 2021-09-08T10:41:46-07:00 · Edit