Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
352 changes: 331 additions & 21 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

104 changes: 103 additions & 1 deletion .github/workflows/ci/helpers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ generate_translations_catalogs_archive()

list_cache()
{
"$python" -m plover_build_utils.tree -L 2 .cache
if [ -d .cache ]
then
"$python" -m plover_build_utils.tree -L 2 .cache
else
echo "no .cache directory found; nothing to list"
fi
}

run_tests()
Expand Down Expand Up @@ -234,6 +239,18 @@ analyze_set_job_skip_cache_key()

analyze_set_job_skip_job()
{
if [[ "${job_type:-}" == "notarize" ]]; then
case "$GITHUB_EVENT_NAME:$GITHUB_REF" in
push:refs/heads/main|push:refs/heads/maintenance/*|push:refs/tags/v*)
: ;; # allowed; continue to normal skip logic below
*)
skip_job='yes'
info "Skip $job_name? $skip_job (notarize allowed only on push to main, maintenance/*, or v* tag; event=$GITHUB_EVENT_NAME ref=$GITHUB_REF)"
echo "${job_id}_skip_job=$skip_job" >> $GITHUB_OUTPUT
return
;;
esac
fi
if [ "$is_release" = "no" -a -e "$job_skip_cache_path" ]
then
run_link="$(< "$job_skip_cache_path")" || die
Expand All @@ -246,6 +263,91 @@ analyze_set_job_skip_job()
echo "${job_id}_skip_job=$skip_job" >> $GITHUB_OUTPUT
}

# Install Developer ID certificate into a temporary keychain
#
# Env vars required:
# MACOS_CODESIGN_CERT_P12_BASE64
# MACOS_CODESIGN_CERT_PASSWORD
# MACOS_TEMP_KEYCHAIN_NAME
# MACOS_TEMP_KEYCHAIN_PASSWORD
#
# Side effects:
# - Creates/unlocks ${MACOS_TEMP_KEYCHAIN_NAME}.keychain
# - Adds it first in the user keychain search list
# - Imports the Developer ID identity (.p12)
# - Configures key partition list for non-interactive codesign
install_dev_id_cert_into_temp_keychain() {
set -euo pipefail

: "${MACOS_CODESIGN_CERT_P12_BASE64:?Missing secret MACOS_CODESIGN_CERT_P12_BASE64}"
: "${MACOS_CODESIGN_CERT_PASSWORD:?Missing secret MACOS_CODESIGN_CERT_PASSWORD}"
: "${MACOS_TEMP_KEYCHAIN_PASSWORD:?Missing secret MACOS_TEMP_KEYCHAIN_PASSWORD}"
: "${MACOS_TEMP_KEYCHAIN_NAME:?MACOS_TEMP_KEYCHAIN_NAME not set}"

KC_FILE="${MACOS_TEMP_KEYCHAIN_NAME}.keychain"
KC_DB="${HOME}/Library/Keychains/${MACOS_TEMP_KEYCHAIN_NAME}.keychain-db"

# Clean any stale keychain (both list entry and on-disk file)
if security list-keychains -d user | grep -q "$KC_FILE"; then
security -q delete-keychain "$KC_FILE" || true
fi
rm -f "$KC_DB" || true

# Create & unlock keychain (6h auto-lock)
security -q create-keychain -p "$MACOS_TEMP_KEYCHAIN_PASSWORD" "$KC_FILE"
security -q set-keychain-settings -lut 21600 "$KC_FILE"
security -q unlock-keychain -p "$MACOS_TEMP_KEYCHAIN_PASSWORD" "$KC_FILE"

# Put our keychain first in the search list (keep existing ones)
existing="$(security list-keychains -d user | tr -d ' \"')"
security -q list-keychains -d user -s "$KC_FILE" $existing

# Decode the .p12 file
echo "$MACOS_CODESIGN_CERT_P12_BASE64" | base64 --decode > signing.p12

# Import identity and always remove the .p12 file
security import signing.p12 -k "$KC_FILE" -P "$MACOS_CODESIGN_CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security >/dev/null; rm -f signing.p12

# Allow codesign to use the private key non-interactively
security set-key-partition-list -S apple-tool:,apple:,codesign: -s \
-k "$MACOS_TEMP_KEYCHAIN_PASSWORD" "$KC_FILE" >/dev/null

# Sanity check: can we see a codesigning identity in this keychain?
if ! security find-identity -p codesigning -v "$KC_FILE" | grep -q "Developer ID Application"; then
echo "No Developer ID Application identity found in ${MACOS_TEMP_KEYCHAIN_NAME}.keychain" >&2
return 1
fi
}

# Cleanup the temporary keychain created for codesigning
#
# Env vars required:
# MACOS_TEMP_KEYCHAIN_NAME
# Optional env:
# MACOS_CODESIGN_KEYCHAIN # if set, will be used as the keychain file name
#
# Side effects:
# - Deletes the keychain and its on-disk DB
# - Clears MACOS_CODESIGN_KEYCHAIN from the GitHub Actions environment (if available)
cleanup_dev_id_temp_keychain() {
set -euo pipefail

: "${MACOS_TEMP_KEYCHAIN_NAME:?MACOS_TEMP_KEYCHAIN_NAME not set}"

# Respect an explicit keychain override if provided; otherwise derive from the temp name
KC_FILE="${MACOS_CODESIGN_KEYCHAIN:-${MACOS_TEMP_KEYCHAIN_NAME}.keychain}"
KC_DB="${HOME}/Library/Keychains/${MACOS_TEMP_KEYCHAIN_NAME}.keychain-db"

security -q delete-keychain "$KC_FILE" || true
rm -f "$KC_DB" || true

# Clear env for downstream steps only when running in GitHub Actions
if [[ -n "${GITHUB_ENV:-}" && -w "${GITHUB_ENV}" ]]; then
echo "MACOS_CODESIGN_KEYCHAIN=" >> "$GITHUB_ENV"
fi
}

python='python3'

exec 2>&1
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/ci/workflow_context.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
cache_epoch: 0 # <- increase number to clear cache.

action_cache: actions/cache@v4
action_cache_restore: actions/cache/restore@v4
action_cache_save: actions/cache/save@v4
action_checkout: actions/checkout@v4
action_setup_python: actions/setup-python@v5
action_upload_artifact: actions/upload-artifact@v4
Expand Down Expand Up @@ -93,9 +95,30 @@ jobs:
skiplists: ["job_build", "os_linux"]
- <<: *build
<<: *dist_macos
variant: macOS App
needs: [test_macos]
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
skiplists: ["job_build", "os_macos"]
- <<: *dist_macos
type: notarize
variant: macOS App
needs: [build_macos_app]
reqs: ["build", "setup"]
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
skiplists: ["job_build", "os_macos"]
- <<: *build
<<: *dist_macos
variant: macOS DMG
needs: [build_macos_app, notarize_macos_app]
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
skiplists: ["job_build", "os_macos"]
- <<: *dist_macos
type: notarize
variant: macOS DMG
needs: [build_macos_dmg]
reqs: ["build", "setup"]
cache_extra_deps: ["reqs/dist_*.txt", "osx/deps.sh"]
skiplists: ["job_build", "os_macos"]
- <<: *build
<<: *dist_win
needs: [test_windows]
Expand Down
140 changes: 135 additions & 5 deletions .github/workflows/ci/workflow_template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ jobs:
<@ j.id @>:

name: <@ j.name @>
<% if j.os == 'macOS' and j.type == 'notarize' %>
environment: production
<% endif %>
runs-on: <@ j.platform @>
needs: [analyze, <@ j.needs|join(', ') @>]
if: >-
Expand All @@ -85,6 +88,21 @@ jobs:
&& (needs.<@ need_id @>.result == 'success' || needs.<@ need_id @>.result == 'skipped')
<% endfor %>
&& needs.analyze.outputs.<@ j.id @>_skip_job == 'no'
<% if j.os == 'macOS' and j.type == 'notarize' %>
env:
# Code signing
MACOS_CODESIGN_IDENTITY: ${{ secrets.MACOS_CODESIGN_IDENTITY }}
MACOS_CODESIGN_CERT_P12_BASE64: ${{ secrets.MACOS_CODESIGN_CERT_P12_BASE64 }}
MACOS_CODESIGN_CERT_PASSWORD: ${{ secrets.MACOS_CODESIGN_CERT_PASSWORD }}
MACOS_TEMP_KEYCHAIN_NAME: plover-build
MACOS_TEMP_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_TEMP_KEYCHAIN_PASSWORD }}
# Notarization
MACOS_NOTARIZE_ENABLED: ${{ secrets.MACOS_NOTARIZE_ENABLED || '0' }}
MACOS_NOTARIZE_TEAM_ID: ${{ secrets.MACOS_NOTARIZE_TEAM_ID }}
MACOS_NOTARIZE_KEY_ID: ${{ secrets.MACOS_NOTARIZE_KEY_ID }}
MACOS_NOTARIZE_ISSUER_ID: ${{ secrets.MACOS_NOTARIZE_ISSUER_ID }}
MACOS_NOTARIZE_KEY_CONTENTS: ${{ secrets.MACOS_NOTARIZE_KEY_CONTENTS }}
<% endif %>

steps:

Expand Down Expand Up @@ -120,7 +138,7 @@ jobs:
path: .cache
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 %>) }}

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

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


<% endif %>
<% if j.type == 'test_code_quality' %>
- name: Run Ruff (format check)
run: ruff format --check .
Expand Down Expand Up @@ -214,15 +234,49 @@ jobs:

<% endif %>
<% if j.os == 'macOS' %>
- name: Build distribution (macOS DMG)
run: python setup.py -q bdist_dmg
<% if j.type == 'build' and j.variant == 'macOS App' %>
- name: Build distribution (macOS app)
run: python setup.py -q bdist_app

- name: Pack app as tar (preserve symlinks)
run: |
rm -f dist/Plover.app.tgz
tar -C dist -czf dist/Plover.app.tgz Plover.app

- name: Save app tarball to internal cache
uses: <@ action_cache_save @>
with:
path: dist/Plover.app.tgz
key: <@ cache_epoch @>_macos-app-raw-${{ github.run_id }}

<% elif j.type == 'build' and j.variant == 'macOS DMG' %>
- name: Restore app tarball from internal cache (prefer notarized)
uses: <@ action_cache_restore @>
with:
path: dist/Plover.app.tgz
key: <@ cache_epoch @>_macos-app-notarized-${{ github.run_id }}
restore-keys: <@ cache_epoch @>_macos-app-raw-${{ github.run_id }}

- name: Extract app tarball
run: |
mkdir -p dist
tgz="$(ls -1 dist/*.tgz)"
echo "Using: $tgz"
tar -C dist -xzf "$tgz"

- name: Archive artifact (macOS DMG)
- name: Ensure app exists
run: test -d dist/*.app

- name: Build distribution (macOS DMG from existing app)
run: python setup.py -q bdist_dmg --skip-app-build

- name: Upload artifact (macOS DMG)
uses: <@ action_upload_artifact @>
with:
name: macOS DMG
path: dist/*.dmg

overwrite: true
<% endif %>
<% endif %>
<% if j.os == 'Windows' %>
- name: Build distributions (Windows)
Expand All @@ -246,6 +300,82 @@ jobs:
<% endif %>
# }}}

<% endif %>
<% if j.os == 'macOS' and j.type == 'notarize' %>
# Notarize {{{

<% if j.variant == 'macOS App' %>
- name: Restore app tarball from internal cache
uses: <@ action_cache_restore @>
with:
path: dist/Plover.app.tgz
key: <@ cache_epoch @>_macos-app-raw-${{ github.run_id }}

- name: Extract app tarball
run: |
mkdir -p dist
tar -C dist -xzf dist/*.tgz

- name: Install Developer ID certificate into temporary keychain
if: ${{ env.MACOS_NOTARIZE_ENABLED == '1' }}
run: install_dev_id_cert_into_temp_keychain

- name: Set codesign keychain env
run: echo "MACOS_CODESIGN_KEYCHAIN=${MACOS_TEMP_KEYCHAIN_NAME}.keychain" >> $GITHUB_ENV

- name: Notarize & staple app
run: |
chmod +x osx/notarize_app.sh
./osx/notarize_app.sh dist/*.app

- name: Cleanup temporary keychain
if: ${{ always() && env.MACOS_NOTARIZE_ENABLED == '1' }}
run: cleanup_dev_id_temp_keychain

- name: Repack notarized app as tar
run: |
rm -f dist/Plover.app.tgz
tar -C dist -czf dist/Plover.app.tgz Plover.app

- name: Save notarized app tarball to internal cache
uses: <@ action_cache_save @>
with:
path: dist/Plover.app.tgz
key: <@ cache_epoch @>_macos-app-notarized-${{ github.run_id }}

<% elif j.variant == 'macOS DMG' %>
- name: Download artifact (macOS DMG)
uses: <@ action_download_artifact @>
with:
name: macOS DMG
path: dist
pattern: "*.dmg"

- name: Install Developer ID certificate into temporary keychain
if: ${{ env.MACOS_NOTARIZE_ENABLED == '1' }}
run: install_dev_id_cert_into_temp_keychain

- name: Set codesign keychain env
run: echo "MACOS_CODESIGN_KEYCHAIN=${MACOS_TEMP_KEYCHAIN_NAME}.keychain" >> $GITHUB_ENV

- name: Notarize & staple DMG
run: |
chmod +x osx/notarize_dmg.sh
./osx/notarize_dmg.sh dist/*.dmg

- name: Cleanup temporary keychain
if: ${{ always() && env.MACOS_NOTARIZE_ENABLED == '1' }}
run: cleanup_dev_id_temp_keychain

- name: Upload artifact (macOS DMG)
uses: <@ action_upload_artifact @>
with:
name: macOS DMG
path: dist/*.dmg
overwrite: true
<% endif %>

# }}}
<% endif %>
<% if skippy_enabled %>
- name: Update skip cache 1
Expand Down
1 change: 1 addition & 0 deletions news.d/feature/1769.osx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add code signing and notarization for macOS app.
Loading