Enhance macOS DMG workflow with optional Developer ID signing and not… #30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: macOS DMG | |
| on: | |
| workflow_dispatch: | |
| push: | |
| branches: [master] | |
| tags: ["[0-9]*.[0-9]*.[0-9]*"] | |
| pull_request: | |
| paths: | |
| - ".github/workflows/macos-dmg.yml" | |
| - "meson.build" | |
| - "data/**" | |
| - "src/**" | |
| - "macos/**" | |
| jobs: | |
| build-dmg: | |
| strategy: | |
| matrix: | |
| os: [macos-14] | |
| runs-on: ${{ matrix.os }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Show runner info | |
| run: | | |
| uname -a | |
| sw_vers | |
| sysctl -n machdep.cpu.brand_string || true | |
| - name: Setup Homebrew and dependencies | |
| run: | | |
| brew update-reset | |
| brew install meson ninja pkg-config glib gtk4 libadwaita pygobject3 gtksourceview5 [email protected] | |
| brew --version | |
| brew list --versions | |
| - name: Build gtk-mac-bundler from source | |
| run: | | |
| git clone https://gitlab.gnome.org/GNOME/gtk-mac-bundler.git | |
| cd gtk-mac-bundler | |
| make | |
| sudo make install | |
| which gtk-mac-bundler | |
| gtk-mac-bundler --version || true | |
| - name: Build blueprint-compiler from git (v0.18.0) | |
| run: | | |
| set -euxo pipefail | |
| git clone --depth 1 --branch v0.18.0 https://gitlab.gnome.org/GNOME/blueprint-compiler.git | |
| cd blueprint-compiler | |
| meson setup build --prefix "$PWD/../bp_prefix" --buildtype=release | |
| meson compile -C build | |
| meson install -C build | |
| echo "$GITHUB_WORKSPACE/bp_prefix/bin" >> "$GITHUB_PATH" | |
| - name: Build project (Meson) | |
| run: | | |
| set -euxo pipefail | |
| PY_BIN="$(brew --prefix [email protected])/bin/python3" | |
| echo "Using Python: ${PY_BIN}" | |
| export PYTHON="${PY_BIN}" | |
| meson setup build --prefix "$PWD/stage" --buildtype=release | |
| meson compile -C build | |
| meson install -C build | |
| - name: Bundle app with gtk-mac-bundler | |
| run: | | |
| mkdir -p macos | |
| BREW_PREFIX="$(brew --prefix)" | |
| export BREW_PREFIX | |
| echo "BREW_PREFIX=${BREW_PREFIX}" >> "$GITHUB_ENV" | |
| echo "Detected Homebrew prefix: $BREW_PREFIX" | |
| # Write Info.plist required by gtk-mac-bundler | |
| cat > macos/Info.plist << '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>CFBundleIdentifier</key> | |
| <string>io.github.BuddySirJava.SSH-Studio</string> | |
| <key>CFBundleName</key> | |
| <string>SSH Studio</string> | |
| <key>CFBundleExecutable</key> | |
| <string>ssh-studio</string> | |
| <key>CFBundleShortVersionString</key> | |
| <string>1.0.0</string> | |
| <key>CFBundleVersion</key> | |
| <string>1.0.0</string> | |
| <key>CFBundlePackageType</key> | |
| <string>APPL</string> | |
| <key>CFBundleSignature</key> | |
| <string>SSHS</string> | |
| </dict> | |
| </plist> | |
| PLIST | |
| # Bundle description for gtk-mac-bundler | |
| cat > macos/ssh-studio.bundle << 'XML' | |
| <?xml version="1.0"?> | |
| <app-bundle> | |
| <meta> | |
| <prefix>${env:BREW_PREFIX}</prefix> | |
| <destination overwrite="yes">${project}/../build/ssh-studio.bundle</destination> | |
| <gtk>gtk4</gtk> | |
| </meta> | |
| <id>io.github.BuddySirJava.SSH-Studio</id> | |
| <name>SSH Studio</name> | |
| <version>1.0.0</version> | |
| <icon>${project}/../data/media/icon_512.png</icon> | |
| <plist>${env:GITHUB_WORKSPACE}/macos/Info.plist</plist> | |
| <main-binary dest="${bundle}/Contents/MacOS/${name}">${project}/../stage/bin/ssh-studio</main-binary> | |
| <data dest="${bundle}/Contents/Resources/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource">${project}/../stage/share/io.github.BuddySirJava.SSH-Studio/ssh-studio-resources.gresource</data> | |
| <data dest="${bundle}/Contents/Resources/src">${project}/../src</data> | |
| </app-bundle> | |
| XML | |
| - name: Run gtk-mac-bundler | |
| run: | | |
| set -euxo pipefail | |
| BREW_PREFIX="$(brew --prefix)" | |
| export BREW_PREFIX | |
| gtk-mac-bundler macos/ssh-studio.bundle | |
| - name: Build self-contained .app (vendor Python + GTK) | |
| run: | | |
| set -euxo pipefail | |
| APP="dist/SSH Studio.app" | |
| APPROOT="$APP/Contents" | |
| MACOS="$APPROOT/MacOS" | |
| RES="$APPROOT/Resources" | |
| FRAMEWORKS="$APPROOT/Frameworks" | |
| mkdir -p "$MACOS" "$RES/python" "$FRAMEWORKS" | |
| # 1) Copy your Python sources | |
| rsync -a src/ "$RES/python/ssh_studio/" | |
| # 2) Copy compiled GResource from Meson install | |
| APP_ID="io.github.BuddySirJava.SSH-Studio" | |
| RES_GRES="$RES/ssh-studio-resources.gresource" | |
| SRC_GRES="" | |
| for CAND in \ | |
| "stage/share/$APP_ID/ssh-studio-resources.gresource" \ | |
| "build/data/ssh-studio-resources.gresource" \ | |
| "_build/data/ssh-studio-resources.gresource"; do | |
| if [ -f "$CAND" ]; then | |
| SRC_GRES="$CAND" | |
| break | |
| fi | |
| done | |
| if [ -n "$SRC_GRES" ]; then | |
| install -m 0644 "$SRC_GRES" "$RES_GRES" | |
| else | |
| echo "ERROR: Could not find ssh-studio-resources.gresource in expected locations" >&2 | |
| ls -la stage/share "$PWD"/build/data "$PWD"/_build/data || true | |
| exit 1 | |
| fi | |
| ls -la "$RES" || true | |
| # 2b) Build macOS .icns app icon from our 512px PNG | |
| ICON_SRC="data/media/icon_512.png" | |
| if [ -f "$ICON_SRC" ]; then | |
| ICONSET_DIR="macos/Icon.iconset" | |
| rm -rf "$ICONSET_DIR" | |
| mkdir -p "$ICONSET_DIR" | |
| for sz in 16 32 64 128 256 512; do | |
| sips -s format png "$ICON_SRC" --resampleWidth $sz --out "$ICONSET_DIR/icon_${sz}x${sz}.png" >/dev/null | |
| done | |
| # @2x variants | |
| for sz in 16 32 128 256; do | |
| db=$(($sz*2)) | |
| sips -s format png "$ICON_SRC" --resampleWidth $db --out "$ICONSET_DIR/icon_${sz}x${sz}@2x.png" >/dev/null | |
| done | |
| iconutil -c icns "$ICONSET_DIR" -o "$RES/SSHStudio.icns" | |
| else | |
| echo "WARN: Icon source $ICON_SRC not found; bundle will lack .icns" >&2 | |
| fi | |
| # 3) Vendor Python.framework | |
| BREW_PREFIX="$(brew --prefix)" | |
| # Dereference symlinks so the framework is self-contained inside the app bundle | |
| rsync -aL "$BREW_PREFIX/Frameworks/Python.framework" "$FRAMEWORKS/" | |
| # Ensure a bin/python3 exists inside the vendored framework (Homebrew's may omit it) | |
| PYFW="$FRAMEWORKS/Python.framework" | |
| if [ -d "$PYFW/Versions/3.13" ]; then | |
| PYHOME_DIR="$PYFW/Versions/3.13" | |
| else | |
| PYHOME_DIR="$PYFW/Versions/Current" | |
| fi | |
| if [ ! -x "$PYHOME_DIR/bin/python3" ]; then | |
| mkdir -p "$PYHOME_DIR/bin" | |
| if [ -x "$PYHOME_DIR/Resources/Python.app/Contents/MacOS/Python" ]; then | |
| ln -sf "../Resources/Python.app/Contents/MacOS/Python" "$PYHOME_DIR/bin/python3" | |
| fi | |
| fi | |
| # 4) Vendor GTK & friends (most-used libs) | |
| for p in glib gtk4 libadwaita gtksourceview5 gdk-pixbuf pango cairo harfbuzz fribidi graphite2 libpng jpeg libtiff libepoxy libffi gettext; do | |
| if [ -d "$BREW_PREFIX/opt/$p/lib" ]; then | |
| mkdir -p "$FRAMEWORKS/$p/lib" | |
| rsync -a "$BREW_PREFIX/opt/$p/lib/" "$FRAMEWORKS/$p/lib/" | |
| fi | |
| if [ -d "$BREW_PREFIX/opt/$p/lib/girepository-1.0" ]; then | |
| mkdir -p "$RES/girepository-1.0" | |
| rsync -a "$BREW_PREFIX/opt/$p/lib/girepository-1.0/" "$RES/girepository-1.0/" | |
| fi | |
| if [ -d "$BREW_PREFIX/opt/$p/share" ]; then | |
| mkdir -p "$RES/share/$p" | |
| rsync -a "$BREW_PREFIX/opt/$p/share/" "$RES/share/" | |
| fi | |
| done | |
| # 4b) Vendor PyGObject (gi) and PyCairo into bundled Python path | |
| for SITE in \ | |
| "$BREW_PREFIX/lib/python3.13/site-packages" \ | |
| "$BREW_PREFIX/opt/pygobject3/lib/python3.13/site-packages" \ | |
| "$BREW_PREFIX/opt/py3cairo/lib/python3.13/site-packages"; do | |
| if [ -d "$SITE/gi" ]; then | |
| rsync -a "$SITE/gi" "$RES/python/" | |
| fi | |
| if [ -d "$SITE/cairo" ]; then | |
| rsync -a "$SITE/cairo" "$RES/python/" | |
| fi | |
| done | |
| # 5) Schemas needed by GSettings | |
| mkdir -p "$RES/share/glib-2.0/schemas" | |
| rsync -a "$BREW_PREFIX/opt/glib/share/glib-2.0/schemas/" "$RES/share/glib-2.0/schemas/" | |
| glib-compile-schemas "$RES/share/glib-2.0/schemas" | |
| # 6) Launcher that sets env so app uses bundled runtimes | |
| cat > "$MACOS/ssh-studio" <<'SH' | |
| #!/bin/bash | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| RES="$SCRIPT_DIR/../Resources" | |
| FW="$SCRIPT_DIR/../Frameworks" | |
| export RES | |
| if [ -d "$FW/Python.framework/Versions/3.13" ]; then | |
| export PYTHONHOME="$FW/Python.framework/Versions/3.13" | |
| else | |
| export PYTHONHOME="$FW/Python.framework/Versions/Current" | |
| fi | |
| export PYTHONPATH="$RES/python" | |
| export DYLD_FALLBACK_LIBRARY_PATH="$FW:$FW/glib/lib:$FW/gtk4/lib:$FW/libadwaita/lib:$FW/pango/lib:$FW/cairo/lib:$FW/gtksourceview5/lib" | |
| export GI_TYPELIB_PATH="$RES/girepository-1.0" | |
| export XDG_DATA_DIRS="$RES/share" | |
| export GSETTINGS_SCHEMA_DIR="$RES/share/glib-2.0/schemas" | |
| export GTK_DATA_PREFIX="$RES" | |
| # Register GResource then run app | |
| PYBIN="$PYTHONHOME/bin/python3" | |
| if [ ! -x "$PYBIN" ]; then | |
| # Fallback to framework embedded app binary if bin/python3 is absent | |
| if [ -x "$PYTHONHOME/Resources/Python.app/Contents/MacOS/Python" ]; then | |
| PYBIN="$PYTHONHOME/Resources/Python.app/Contents/MacOS/Python" | |
| fi | |
| fi | |
| "$PYBIN" - <<'PY' | |
| import os, sys | |
| from gi.repository import Gio | |
| res_dir = os.environ.get('RES') | |
| candidates = [] | |
| if res_dir: | |
| candidates.append(os.path.join(res_dir, 'ssh-studio-resources.gresource')) | |
| candidates.append(os.path.join(res_dir, 'share', 'io.github.BuddySirJava.SSH-Studio', 'ssh-studio-resources.gresource')) | |
| res_path = next((c for c in candidates if os.path.exists(c)), None) | |
| if not res_path: | |
| raise SystemExit('ssh-studio-resources.gresource not found in expected locations') | |
| Gio.resources_register(Gio.Resource.load(res_path)) | |
| if res_dir: | |
| sys.path.insert(0, os.path.join(res_dir, 'python')) | |
| from ssh_studio import main as _main | |
| sys.exit(_main.main()) | |
| PY | |
| SH | |
| chmod 0755 "$MACOS/ssh-studio" | |
| # 7) Minimal Info.plist (if you’re not generating it already) | |
| cat > "$APPROOT/Info.plist" <<'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>CFBundleIdentifier</key><string>io.github.BuddySirJava.SSH-Studio</string> | |
| <key>CFBundleName</key><string>SSH Studio</string> | |
| <key>CFBundleExecutable</key><string>ssh-studio</string> | |
| <key>CFBundleIconFile</key><string>SSHStudio</string> | |
| <key>CFBundlePackageType</key><string>APPL</string> | |
| <key>LSMinimumSystemVersion</key><string>11.0</string> | |
| </dict></plist> | |
| PLIST | |
| - name: List .app contents | |
| run: | | |
| set -euxo pipefail | |
| echo 'gtk-mac-bundler app:' || true | |
| ls -R build/ssh-studio.bundle/SSH\ Studio.app/Contents || true | |
| echo 'self-contained app:' || true | |
| ls -R 'dist/SSH Studio.app/Contents' || true | |
| - name: Ad-hoc codesign app bundle | |
| run: | | |
| set -euxo pipefail | |
| APP="dist/SSH Studio.app" | |
| # Deep ad-hoc sign the app and embedded content to improve launch reliability | |
| codesign --force --deep --sign - --timestamp=none "$APP" | |
| codesign --verify --deep --verbose=2 "$APP" || (codesign --display --verbose=5 "$APP"; exit 1) | |
| - name: Verify permissions and Info.plist | |
| run: | | |
| set -euxo pipefail | |
| APP="dist/SSH Studio.app" | |
| chmod +x "$APP/Contents/MacOS/ssh-studio" | |
| plutil -lint "$APP/Contents/Info.plist" | |
| # Show signature and linkage (non-fatal) | |
| codesign -dv --verbose=4 "$APP" || true | |
| otool -L "$APP/Contents/MacOS/ssh-studio" || true | |
| - name: Gatekeeper assessment (non-fatal) | |
| run: | | |
| set -euxo pipefail | |
| APP="dist/SSH Studio.app" | |
| spctl --assess --type execute -v "$APP" || true | |
| - name: Developer ID sign app (optional) | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_DEVELOPER_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_CERT_BASE64 }} | |
| APPLE_DEVELOPER_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERT_PASSWORD }} | |
| run: | | |
| set -euxo pipefail | |
| APP="dist/SSH Studio.app" | |
| if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_BASE64:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_PASSWORD:-}" ]; then | |
| echo "Signing secrets not provided; skipping Developer ID signing."; exit 0; fi | |
| KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" | |
| KEYCHAIN_PWD="$(openssl rand -hex 12)" | |
| security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| echo "$APPLE_DEVELOPER_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/dev_cert.p12" | |
| security import "$RUNNER_TEMP/dev_cert.p12" -k "$KEYCHAIN_PATH" -P "$APPLE_DEVELOPER_CERT_PASSWORD" -A | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db | |
| # Re-sign app with Developer ID (replaces ad-hoc) | |
| codesign --force --deep --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$APP" | |
| codesign --verify --deep --strict --verbose=2 "$APP" | |
| - name: Create DMG | |
| run: | | |
| set -euxo pipefail | |
| VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) | |
| ARCH=$(uname -m) | |
| mkdir -p dmgroot | |
| # Prefer self-contained app for distribution | |
| cp -R "dist/SSH Studio.app" "dmgroot/SSH Studio.app" | |
| ln -s /Applications dmgroot/Applications | |
| hdiutil create -volname "SSH Studio" -srcfolder dmgroot -ov -fs HFS+ "ssh-studio-${VER}-${ARCH}.dmg" | |
| - name: Developer ID sign DMG (optional) | |
| env: | |
| APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} | |
| APPLE_DEVELOPER_CERT_BASE64: ${{ secrets.APPLE_DEVELOPER_CERT_BASE64 }} | |
| APPLE_DEVELOPER_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERT_PASSWORD }} | |
| run: | | |
| set -euxo pipefail | |
| if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_BASE64:-}" ] || [ -z "${APPLE_DEVELOPER_CERT_PASSWORD:-}" ]; then | |
| echo "Signing secrets not provided; skipping DMG signing."; exit 0; fi | |
| VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) | |
| ARCH=$(uname -m) | |
| DMG="ssh-studio-${VER}-${ARCH}.dmg" | |
| KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" | |
| if [ ! -f "$RUNNER_TEMP/dev_cert.p12" ]; then | |
| echo "$APPLE_DEVELOPER_CERT_BASE64" | base64 --decode > "$RUNNER_TEMP/dev_cert.p12" | |
| fi | |
| if [ ! -f "$KEYCHAIN_PATH" ]; then | |
| KEYCHAIN_PWD="$(openssl rand -hex 12)" | |
| security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" | |
| security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH" | |
| security import "$RUNNER_TEMP/dev_cert.p12" -k "$KEYCHAIN_PATH" -P "$APPLE_DEVELOPER_CERT_PASSWORD" -A | |
| security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db | |
| fi | |
| codesign --force --timestamp --sign "$APPLE_SIGNING_IDENTITY" "$DMG" | |
| spctl --assess --type open -v "$DMG" || true | |
| - name: Notarize DMG with notarytool (API key) (optional) | |
| env: | |
| NOTARYTOOL_KEY_ID: ${{ secrets.NOTARYTOOL_KEY_ID }} | |
| NOTARYTOOL_ISSUER_ID: ${{ secrets.NOTARYTOOL_ISSUER_ID }} | |
| NOTARYTOOL_PRIVATE_KEY: ${{ secrets.NOTARYTOOL_PRIVATE_KEY }} | |
| run: | | |
| set -euxo pipefail | |
| if [ -z "${NOTARYTOOL_KEY_ID:-}" ] || [ -z "${NOTARYTOOL_ISSUER_ID:-}" ] || [ -z "${NOTARYTOOL_PRIVATE_KEY:-}" ]; then | |
| echo "Notary API key secrets not provided; skipping API-key notarization."; exit 0; fi | |
| VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) | |
| ARCH=$(uname -m) | |
| DMG="ssh-studio-${VER}-${ARCH}.dmg" | |
| KEYFILE="$RUNNER_TEMP/AuthKey.p8" | |
| echo "$NOTARYTOOL_PRIVATE_KEY" > "$KEYFILE" | |
| xcrun notarytool submit "$DMG" \ | |
| --key "$KEYFILE" \ | |
| --key-id "$NOTARYTOOL_KEY_ID" \ | |
| --issuer "$NOTARYTOOL_ISSUER_ID" \ | |
| --wait | |
| xcrun stapler staple "$DMG" | |
| - name: Notarize DMG with notarytool (Apple ID) (optional) | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} | |
| run: | | |
| set -euxo pipefail | |
| # Only run if API key secrets are missing but Apple ID-based secrets are present | |
| if [ -n "${NOTARYTOOL_KEY_ID:-}" ] && [ -n "${NOTARYTOOL_ISSUER_ID:-}" ] && [ -n "${NOTARYTOOL_PRIVATE_KEY:-}" ]; then | |
| echo "API key provided; skipping Apple ID notarization path."; exit 0; fi | |
| if [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]; then | |
| echo "Apple ID notarization secrets not provided; skipping."; exit 0; fi | |
| VER=$(sed -n "s/.*version: '\([^']*\)'.*/\1/p" meson.build | head -n1) | |
| ARCH=$(uname -m) | |
| DMG="ssh-studio-${VER}-${ARCH}.dmg" | |
| xcrun notarytool submit "$DMG" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_SPECIFIC_PASSWORD" \ | |
| --wait | |
| xcrun stapler staple "$DMG" | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ssh-studio-dmg-${{ matrix.os }} | |
| path: | | |
| *.dmg |