Skip to content

Commit 502c0a4

Browse files
authored
Add code signing and notarization for macOS app (#1769)
1 parent aaf45c4 commit 502c0a4

File tree

13 files changed

+930
-62
lines changed

13 files changed

+930
-62
lines changed

.github/workflows/ci.yml

Lines changed: 331 additions & 21 deletions
Large diffs are not rendered by default.

.github/workflows/ci/helpers.sh

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ generate_translations_catalogs_archive()
1919

2020
list_cache()
2121
{
22-
"$python" -m plover_build_utils.tree -L 2 .cache
22+
if [ -d .cache ]
23+
then
24+
"$python" -m plover_build_utils.tree -L 2 .cache
25+
else
26+
echo "no .cache directory found; nothing to list"
27+
fi
2328
}
2429

2530
run_tests()
@@ -234,6 +239,18 @@ analyze_set_job_skip_cache_key()
234239

235240
analyze_set_job_skip_job()
236241
{
242+
if [[ "${job_type:-}" == "notarize" ]]; then
243+
case "$GITHUB_EVENT_NAME:$GITHUB_REF" in
244+
push:refs/heads/main|push:refs/heads/maintenance/*|push:refs/tags/v*)
245+
: ;; # allowed; continue to normal skip logic below
246+
*)
247+
skip_job='yes'
248+
info "Skip $job_name? $skip_job (notarize allowed only on push to main, maintenance/*, or v* tag; event=$GITHUB_EVENT_NAME ref=$GITHUB_REF)"
249+
echo "${job_id}_skip_job=$skip_job" >> $GITHUB_OUTPUT
250+
return
251+
;;
252+
esac
253+
fi
237254
if [ "$is_release" = "no" -a -e "$job_skip_cache_path" ]
238255
then
239256
run_link="$(< "$job_skip_cache_path")" || die
@@ -246,6 +263,91 @@ analyze_set_job_skip_job()
246263
echo "${job_id}_skip_job=$skip_job" >> $GITHUB_OUTPUT
247264
}
248265

266+
# Install Developer ID certificate into a temporary keychain
267+
#
268+
# Env vars required:
269+
# MACOS_CODESIGN_CERT_P12_BASE64
270+
# MACOS_CODESIGN_CERT_PASSWORD
271+
# MACOS_TEMP_KEYCHAIN_NAME
272+
# MACOS_TEMP_KEYCHAIN_PASSWORD
273+
#
274+
# Side effects:
275+
# - Creates/unlocks ${MACOS_TEMP_KEYCHAIN_NAME}.keychain
276+
# - Adds it first in the user keychain search list
277+
# - Imports the Developer ID identity (.p12)
278+
# - Configures key partition list for non-interactive codesign
279+
install_dev_id_cert_into_temp_keychain() {
280+
set -euo pipefail
281+
282+
: "${MACOS_CODESIGN_CERT_P12_BASE64:?Missing secret MACOS_CODESIGN_CERT_P12_BASE64}"
283+
: "${MACOS_CODESIGN_CERT_PASSWORD:?Missing secret MACOS_CODESIGN_CERT_PASSWORD}"
284+
: "${MACOS_TEMP_KEYCHAIN_PASSWORD:?Missing secret MACOS_TEMP_KEYCHAIN_PASSWORD}"
285+
: "${MACOS_TEMP_KEYCHAIN_NAME:?MACOS_TEMP_KEYCHAIN_NAME not set}"
286+
287+
KC_FILE="${MACOS_TEMP_KEYCHAIN_NAME}.keychain"
288+
KC_DB="${HOME}/Library/Keychains/${MACOS_TEMP_KEYCHAIN_NAME}.keychain-db"
289+
290+
# Clean any stale keychain (both list entry and on-disk file)
291+
if security list-keychains -d user | grep -q "$KC_FILE"; then
292+
security -q delete-keychain "$KC_FILE" || true
293+
fi
294+
rm -f "$KC_DB" || true
295+
296+
# Create & unlock keychain (6h auto-lock)
297+
security -q create-keychain -p "$MACOS_TEMP_KEYCHAIN_PASSWORD" "$KC_FILE"
298+
security -q set-keychain-settings -lut 21600 "$KC_FILE"
299+
security -q unlock-keychain -p "$MACOS_TEMP_KEYCHAIN_PASSWORD" "$KC_FILE"
300+
301+
# Put our keychain first in the search list (keep existing ones)
302+
existing="$(security list-keychains -d user | tr -d ' \"')"
303+
security -q list-keychains -d user -s "$KC_FILE" $existing
304+
305+
# Decode the .p12 file
306+
echo "$MACOS_CODESIGN_CERT_P12_BASE64" | base64 --decode > signing.p12
307+
308+
# Import identity and always remove the .p12 file
309+
security import signing.p12 -k "$KC_FILE" -P "$MACOS_CODESIGN_CERT_PASSWORD" \
310+
-T /usr/bin/codesign -T /usr/bin/security >/dev/null; rm -f signing.p12
311+
312+
# Allow codesign to use the private key non-interactively
313+
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
314+
-k "$MACOS_TEMP_KEYCHAIN_PASSWORD" "$KC_FILE" >/dev/null
315+
316+
# Sanity check: can we see a codesigning identity in this keychain?
317+
if ! security find-identity -p codesigning -v "$KC_FILE" | grep -q "Developer ID Application"; then
318+
echo "No Developer ID Application identity found in ${MACOS_TEMP_KEYCHAIN_NAME}.keychain" >&2
319+
return 1
320+
fi
321+
}
322+
323+
# Cleanup the temporary keychain created for codesigning
324+
#
325+
# Env vars required:
326+
# MACOS_TEMP_KEYCHAIN_NAME
327+
# Optional env:
328+
# MACOS_CODESIGN_KEYCHAIN # if set, will be used as the keychain file name
329+
#
330+
# Side effects:
331+
# - Deletes the keychain and its on-disk DB
332+
# - Clears MACOS_CODESIGN_KEYCHAIN from the GitHub Actions environment (if available)
333+
cleanup_dev_id_temp_keychain() {
334+
set -euo pipefail
335+
336+
: "${MACOS_TEMP_KEYCHAIN_NAME:?MACOS_TEMP_KEYCHAIN_NAME not set}"
337+
338+
# Respect an explicit keychain override if provided; otherwise derive from the temp name
339+
KC_FILE="${MACOS_CODESIGN_KEYCHAIN:-${MACOS_TEMP_KEYCHAIN_NAME}.keychain}"
340+
KC_DB="${HOME}/Library/Keychains/${MACOS_TEMP_KEYCHAIN_NAME}.keychain-db"
341+
342+
security -q delete-keychain "$KC_FILE" || true
343+
rm -f "$KC_DB" || true
344+
345+
# Clear env for downstream steps only when running in GitHub Actions
346+
if [[ -n "${GITHUB_ENV:-}" && -w "${GITHUB_ENV}" ]]; then
347+
echo "MACOS_CODESIGN_KEYCHAIN=" >> "$GITHUB_ENV"
348+
fi
349+
}
350+
249351
python='python3'
250352

251353
exec 2>&1

.github/workflows/ci/workflow_context.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
cache_epoch: 0 # <- increase number to clear cache.
22

33
action_cache: actions/cache@v4
4+
action_cache_restore: actions/cache/restore@v4
5+
action_cache_save: actions/cache/save@v4
46
action_checkout: actions/checkout@v4
57
action_setup_python: actions/setup-python@v5
68
action_upload_artifact: actions/upload-artifact@v4
@@ -93,9 +95,30 @@ jobs:
9395
skiplists: ["job_build", "os_linux"]
9496
- <<: *build
9597
<<: *dist_macos
98+
variant: macOS App
9699
needs: [test_macos]
97100
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
98101
skiplists: ["job_build", "os_macos"]
102+
- <<: *dist_macos
103+
type: notarize
104+
variant: macOS App
105+
needs: [build_macos_app]
106+
reqs: ["build", "setup"]
107+
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
108+
skiplists: ["job_build", "os_macos"]
109+
- <<: *build
110+
<<: *dist_macos
111+
variant: macOS DMG
112+
needs: [build_macos_app, notarize_macos_app]
113+
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
114+
skiplists: ["job_build", "os_macos"]
115+
- <<: *dist_macos
116+
type: notarize
117+
variant: macOS DMG
118+
needs: [build_macos_dmg]
119+
reqs: ["build", "setup"]
120+
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
121+
skiplists: ["job_build", "os_macos"]
99122
- <<: *build
100123
<<: *dist_win
101124
needs: [test_windows]

.github/workflows/ci/workflow_template.yml

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ jobs:
7777
<@ j.id @>:
7878

7979
name: <@ j.name @>
80+
<% if j.os == 'macOS' and j.type == 'notarize' %>
81+
environment: production
82+
<% endif %>
8083
runs-on: <@ j.platform @>
8184
needs: [analyze, <@ j.needs|join(', ') @>]
8285
if: >-
@@ -85,6 +88,21 @@ jobs:
8588
&& (needs.<@ need_id @>.result == 'success' || needs.<@ need_id @>.result == 'skipped')
8689
<% endfor %>
8790
&& needs.analyze.outputs.<@ j.id @>_skip_job == 'no'
91+
<% if j.os == 'macOS' and j.type == 'notarize' %>
92+
env:
93+
# Code signing
94+
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
95+
MACOS_CODESIGN_CERT_P12_BASE64: ${{ secrets.MACOS_CODESIGN_CERT_P12_BASE64 }}
96+
MACOS_CODESIGN_CERT_PASSWORD: ${{ secrets.MACOS_CODESIGN_CERT_PASSWORD }}
97+
MACOS_TEMP_KEYCHAIN_NAME: plover-build
98+
MACOS_TEMP_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_TEMP_KEYCHAIN_PASSWORD }}
99+
# Notarization
100+
MACOS_NOTARIZE_ENABLED: ${{ secrets.MACOS_NOTARIZE_ENABLED || '0' }}
101+
MACOS_NOTARIZE_TEAM_ID: ${{ secrets.MACOS_NOTARIZE_TEAM_ID }}
102+
MACOS_NOTARIZE_KEY_ID: ${{ secrets.MACOS_NOTARIZE_KEY_ID }}
103+
MACOS_NOTARIZE_ISSUER_ID: ${{ secrets.MACOS_NOTARIZE_ISSUER_ID }}
104+
MACOS_NOTARIZE_KEY_CONTENTS: ${{ secrets.MACOS_NOTARIZE_KEY_CONTENTS }}
105+
<% endif %>
88106

89107
steps:
90108

@@ -120,7 +138,7 @@ jobs:
120138
path: .cache
121139
key: <@ cache_epoch @>_${{ steps.set_cache.outputs.cache_name }}_${{ hashFiles('reqs/constraints.txt'<% for d in (j.reqs + j.cache_extra_deps) %><@ ', %r' % d @><% endfor %>) }}
122140

123-
<% if j.os == 'macOS' %>
141+
<% if j.os == 'macOS' and j.type != 'notarize' %>
124142
# To support older macOS versions, setup Python from an official installer.
125143
- name: Setup Python
126144
run: setup_osx_python '<@ j.python @>'
@@ -131,10 +149,12 @@ jobs:
131149
run: apt_get_install libdbus-1-dev libdbus-glib-1-dev libudev-dev libusb-1.0-0-dev libegl-dev libxkbcommon-x11-0
132150

133151
<% endif %>
152+
<% if j.type != 'notarize' %>
134153
- name: Setup Python environment
135154
run: setup_python_env -c reqs/constraints.txt<% for r in j.reqs %> -r <@ r @><% endfor %>
136155

137156

157+
<% endif %>
138158
<% if j.type == 'test_code_quality' %>
139159
- name: Run Ruff (format check)
140160
run: ruff format --check .
@@ -214,15 +234,49 @@ jobs:
214234

215235
<% endif %>
216236
<% if j.os == 'macOS' %>
217-
- name: Build distribution (macOS DMG)
218-
run: python setup.py -q bdist_dmg
237+
<% if j.type == 'build' and j.variant == 'macOS App' %>
238+
- name: Build distribution (macOS app)
239+
run: python setup.py -q bdist_app
240+
241+
- name: Pack app as tar (preserve symlinks)
242+
run: |
243+
rm -f dist/Plover.app.tgz
244+
tar -C dist -czf dist/Plover.app.tgz Plover.app
245+
246+
- name: Save app tarball to internal cache
247+
uses: <@ action_cache_save @>
248+
with:
249+
path: dist/Plover.app.tgz
250+
key: <@ cache_epoch @>_macos-app-raw-${{ github.run_id }}
251+
252+
<% elif j.type == 'build' and j.variant == 'macOS DMG' %>
253+
- name: Restore app tarball from internal cache (prefer notarized)
254+
uses: <@ action_cache_restore @>
255+
with:
256+
path: dist/Plover.app.tgz
257+
key: <@ cache_epoch @>_macos-app-notarized-${{ github.run_id }}
258+
restore-keys: <@ cache_epoch @>_macos-app-raw-${{ github.run_id }}
259+
260+
- name: Extract app tarball
261+
run: |
262+
mkdir -p dist
263+
tgz="$(ls -1 dist/*.tgz)"
264+
echo "Using: $tgz"
265+
tar -C dist -xzf "$tgz"
219266
220-
- name: Archive artifact (macOS DMG)
267+
- name: Ensure app exists
268+
run: test -d dist/*.app
269+
270+
- name: Build distribution (macOS DMG from existing app)
271+
run: python setup.py -q bdist_dmg --skip-app-build
272+
273+
- name: Upload artifact (macOS DMG)
221274
uses: <@ action_upload_artifact @>
222275
with:
223276
name: macOS DMG
224277
path: dist/*.dmg
225-
278+
overwrite: true
279+
<% endif %>
226280
<% endif %>
227281
<% if j.os == 'Windows' %>
228282
- name: Build distributions (Windows)
@@ -246,6 +300,82 @@ jobs:
246300
<% endif %>
247301
# }}}
248302

303+
<% endif %>
304+
<% if j.os == 'macOS' and j.type == 'notarize' %>
305+
# Notarize {{{
306+
307+
<% if j.variant == 'macOS App' %>
308+
- name: Restore app tarball from internal cache
309+
uses: <@ action_cache_restore @>
310+
with:
311+
path: dist/Plover.app.tgz
312+
key: <@ cache_epoch @>_macos-app-raw-${{ github.run_id }}
313+
314+
- name: Extract app tarball
315+
run: |
316+
mkdir -p dist
317+
tar -C dist -xzf dist/*.tgz
318+
319+
- name: Install Developer ID certificate into temporary keychain
320+
if: ${{ env.MACOS_NOTARIZE_ENABLED == '1' }}
321+
run: install_dev_id_cert_into_temp_keychain
322+
323+
- name: Set codesign keychain env
324+
run: echo "MACOS_CODESIGN_KEYCHAIN=${MACOS_TEMP_KEYCHAIN_NAME}.keychain" >> $GITHUB_ENV
325+
326+
- name: Notarize & staple app
327+
run: |
328+
chmod +x osx/notarize_app.sh
329+
./osx/notarize_app.sh dist/*.app
330+
331+
- name: Cleanup temporary keychain
332+
if: ${{ always() && env.MACOS_NOTARIZE_ENABLED == '1' }}
333+
run: cleanup_dev_id_temp_keychain
334+
335+
- name: Repack notarized app as tar
336+
run: |
337+
rm -f dist/Plover.app.tgz
338+
tar -C dist -czf dist/Plover.app.tgz Plover.app
339+
340+
- name: Save notarized app tarball to internal cache
341+
uses: <@ action_cache_save @>
342+
with:
343+
path: dist/Plover.app.tgz
344+
key: <@ cache_epoch @>_macos-app-notarized-${{ github.run_id }}
345+
346+
<% elif j.variant == 'macOS DMG' %>
347+
- name: Download artifact (macOS DMG)
348+
uses: <@ action_download_artifact @>
349+
with:
350+
name: macOS DMG
351+
path: dist
352+
pattern: "*.dmg"
353+
354+
- name: Install Developer ID certificate into temporary keychain
355+
if: ${{ env.MACOS_NOTARIZE_ENABLED == '1' }}
356+
run: install_dev_id_cert_into_temp_keychain
357+
358+
- name: Set codesign keychain env
359+
run: echo "MACOS_CODESIGN_KEYCHAIN=${MACOS_TEMP_KEYCHAIN_NAME}.keychain" >> $GITHUB_ENV
360+
361+
- name: Notarize & staple DMG
362+
run: |
363+
chmod +x osx/notarize_dmg.sh
364+
./osx/notarize_dmg.sh dist/*.dmg
365+
366+
- name: Cleanup temporary keychain
367+
if: ${{ always() && env.MACOS_NOTARIZE_ENABLED == '1' }}
368+
run: cleanup_dev_id_temp_keychain
369+
370+
- name: Upload artifact (macOS DMG)
371+
uses: <@ action_upload_artifact @>
372+
with:
373+
name: macOS DMG
374+
path: dist/*.dmg
375+
overwrite: true
376+
<% endif %>
377+
378+
# }}}
249379
<% endif %>
250380
<% if skippy_enabled %>
251381
- name: Update skip cache 1

news.d/feature/1769.osx.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add code signing and notarization for macOS app.

0 commit comments

Comments
 (0)