Created
September 1, 2025 18:49
-
-
Save rampatra/08c6e895e051f35c9cb05f3789f243e0 to your computer and use it in GitHub Desktop.
GitHub Workflow to automate building, signing, notorizing, creation of DMG, and generation of appcast for Mac apps
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: Sparkle Publish | |
| on: | |
| release: | |
| types: [published] # Only run when a release is published | |
| jobs: | |
| build: | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Install tools | |
| run: brew install create-dmg jq | |
| - name: Setup Sparkle CLI tools | |
| uses: jozefizso/setup-sparkle@v1 | |
| with: | |
| version: 2.7.1 | |
| - name: Import Developer ID certificate (handles password and no-password p12) | |
| env: | |
| DEV_ID_P12_BASE64: ${{ secrets.DEV_ID_P12_BASE64 }} | |
| DEV_ID_P12_PASSWORD: ${{ secrets.DEV_ID_P12_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN=build.keychain | |
| KEYCHAIN_PASSWORD=$(uuidgen) | |
| echo "Creating and unlocking temporary keychain" | |
| security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN" | |
| security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| security list-keychains -d user -s "$KEYCHAIN" login.keychain | |
| security default-keychain -s "$KEYCHAIN" | |
| echo "Decoding p12 from base64" | |
| # macOS base64 uses -D; GNU uses -d. Try macOS first, then fallback. | |
| (echo "$DEV_ID_P12_BASE64" | base64 -D > dev_id.p12) || (echo "$DEV_ID_P12_BASE64" | base64 -d > dev_id.p12) | |
| echo "Importing certificate into keychain (trying without password first)" | |
| if security import dev_id.p12 -k "$KEYCHAIN" -P "" -T /usr/bin/codesign -T /usr/bin/security; then | |
| echo "Imported p12 without password" | |
| else | |
| if [ -n "${DEV_ID_P12_PASSWORD:-}" ]; then | |
| echo "Retrying import with provided password" | |
| security import dev_id.p12 -k "$KEYCHAIN" -P "$DEV_ID_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security | |
| else | |
| echo "Failed to import p12 without password and no p12 password provided" >&2 | |
| exit 1 | |
| fi | |
| fi | |
| echo "Setting key partition list to allow codesign access" | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| echo "Available signing identities:" | |
| security find-identity -v -p codesigning "$KEYCHAIN" || true | |
| - name: Build App | |
| run: xcodebuild -project Presentify.xcodeproj -scheme Presentify -configuration Release -derivedDataPath build CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Developer ID Application" DEVELOPMENT_TEAM=${{ secrets.APPLE_TEAM_ID }} OTHER_CODE_SIGN_FLAGS="--keychain build.keychain --timestamp --options runtime" | |
| - name: Re-sign Sparkle components with Developer ID (and timestamp) | |
| env: | |
| DEV_ID_SIGN_IDENTITY: ${{ secrets.DEV_ID_SIGN_IDENTITY }} | |
| run: | | |
| set -euo pipefail | |
| APP="build/Build/Products/Release/Presentify.app" | |
| if [ ! -d "$APP" ]; then echo "App not found: $APP" >&2; exit 1; fi | |
| # Resolve signing identity | |
| IDENTITY="${DEV_ID_SIGN_IDENTITY:-}" | |
| if [ -z "$IDENTITY" ]; then | |
| IDENTITY=$(security find-identity -p codesigning build.keychain | grep -m1 'Developer ID Application' | sed -E 's/.*"(.+)"/\1/') || true | |
| fi | |
| if [ -z "$IDENTITY" ]; then | |
| echo "Could not resolve a Developer ID Application identity" >&2 | |
| exit 1 | |
| fi | |
| echo "Using signing identity: $IDENTITY" | |
| sign() { | |
| local target="$1" | |
| if [ -e "$target" ]; then | |
| echo "Signing: $target" | |
| codesign --force --options runtime --timestamp --sign "$IDENTITY" --keychain build.keychain -vvv "$target" | |
| fi | |
| } | |
| # Sparkle nested items | |
| sign "$APP/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Downloader.xpc" | |
| sign "$APP/Contents/Frameworks/Sparkle.framework/Versions/B/XPCServices/Installer.xpc" | |
| sign "$APP/Contents/Frameworks/Sparkle.framework/Versions/B/Autoupdate" | |
| sign "$APP/Contents/Frameworks/Sparkle.framework/Versions/B/Updater.app" | |
| sign "$APP/Contents/Frameworks/Sparkle.framework/Versions/B/Sparkle" | |
| sign "$APP/Contents/Frameworks/Sparkle.framework" | |
| # Finally, re-sign the app itself with entitlements to ensure no get-task-allow | |
| codesign --force --options runtime --timestamp --entitlements Presentify/Presentify.entitlements --sign "$IDENTITY" --keychain build.keychain -vvv "$APP" | |
| - name: Verify code signing before notarization | |
| run: | | |
| echo "=== Final Verification After Re-signing ===" | |
| echo "codesign details:" | |
| codesign -dv --verbose=4 build/Build/Products/Release/Presentify.app 2>&1 | sed 's/^/ /' | |
| echo "entitlements:" | |
| codesign -d --entitlements :- build/Build/Products/Release/Presentify.app 2>/dev/null | sed 's/^/ /' | |
| echo "Verifying Sparkle mach-lookup entitlements:" | |
| codesign -d --entitlements :- build/Build/Products/Release/Presentify.app 2>/dev/null | grep -A 5 "mach-lookup" || echo "WARNING: No mach-lookup entitlements found - this will cause Sparkle update failures in sandboxed apps!" | |
| echo "codesign verify (deep, strict):" | |
| if ! codesign --verify --deep --strict --verbose=4 build/Build/Products/Release/Presentify.app; then | |
| echo "codesign --verify failed" >&2 | |
| exit 1 | |
| fi | |
| echo "spctl assessment:" | |
| spctl -a -vvv --type exec build/Build/Products/Release/Presentify.app || true | |
| - name: Read app version | |
| id: version | |
| run: | | |
| VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "build/Build/Products/Release/Presentify.app/Contents/Info.plist") | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| - name: Zip app for notarization | |
| run: | | |
| mkdir -p output | |
| # Clear extended attributes that can cause unsealed files warnings | |
| xattr -cr build/Build/Products/Release/Presentify.app || true | |
| ditto -c -k --keepParent build/Build/Products/Release/Presentify.app output/Presentify.zip | |
| - name: Notarize app (Apple ID) | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| xcrun notarytool submit output/Presentify.zip \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --wait \ | |
| --output-format json > notary_result.json | |
| echo "Notarization result:" && cat notary_result.json | jq . || true | |
| STATUS=$(jq -r '.status' notary_result.json) | |
| REQUEST_ID=$(jq -r '.id' notary_result.json) | |
| if [ -n "$REQUEST_ID" ] && [ "$REQUEST_ID" != "null" ]; then | |
| echo "Fetching notarization log for request $REQUEST_ID" | |
| xcrun notarytool log "$REQUEST_ID" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| > notary_log.txt || true | |
| echo "--- Notarization Log ---" | |
| sed 's/^/ /' notary_log.txt || true | |
| echo "------------------------" | |
| fi | |
| if [ "$STATUS" != "Accepted" ]; then | |
| echo "Notarization did not complete successfully (status=$STATUS)." >&2 | |
| exit 1 | |
| fi | |
| - name: Staple notarization ticket to app | |
| run: | | |
| set -e | |
| ATTEMPTS=6 | |
| for i in $(seq 1 $ATTEMPTS); do | |
| if xcrun stapler staple -v build/Build/Products/Release/Presentify.app; then | |
| exit 0 | |
| fi | |
| echo "Staple attempt $i failed; retrying in 20s..." | |
| sleep 20 | |
| done | |
| echo "Failed to staple app after $ATTEMPTS attempts" >&2 | |
| exit 1 | |
| - name: Create DMG | |
| run: | | |
| mkdir -p output | |
| create-dmg \ | |
| --volname "Presentify" \ | |
| --window-pos 200 120 \ | |
| --icon Presentify.app 200 190 \ | |
| --hide-extension Presentify.app \ | |
| --app-drop-link 500 185 \ | |
| output/Presentify-${{ steps.version.outputs.version }}.dmg build/Build/Products/Release/Presentify.app | |
| - name: Notarize DMG (Apple ID) | |
| env: | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| xcrun notarytool submit output/Presentify-${{ steps.version.outputs.version }}.dmg \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --wait \ | |
| --output-format json > notary_result_dmg.json | |
| echo "DMG Notarization result:" && cat notary_result_dmg.json | jq . || true | |
| STATUS=$(jq -r '.status' notary_result_dmg.json) | |
| REQUEST_ID=$(jq -r '.id' notary_result_dmg.json) | |
| if [ -n "$REQUEST_ID" ] && [ "$REQUEST_ID" != "null" ]; then | |
| echo "Fetching DMG notarization log for request $REQUEST_ID" | |
| xcrun notarytool log "$REQUEST_ID" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| > notary_log_dmg.txt || true | |
| echo "--- DMG Notarization Log ---" | |
| sed 's/^/ /' notary_log_dmg.txt || true | |
| echo "----------------------------" | |
| fi | |
| if [ "$STATUS" != "Accepted" ]; then | |
| echo "DMG Notarization did not complete successfully (status=$STATUS)." >&2 | |
| exit 1 | |
| fi | |
| - name: Staple notarization ticket to DMG | |
| run: | | |
| set -e | |
| ATTEMPTS=6 | |
| for i in $(seq 1 $ATTEMPTS); do | |
| if xcrun stapler staple -v output/Presentify-${{ steps.version.outputs.version }}.dmg; then | |
| exit 0 | |
| fi | |
| echo "Staple attempt $i failed; retrying in 20s..." | |
| sleep 20 | |
| done | |
| echo "Failed to staple DMG after $ATTEMPTS attempts" >&2 | |
| exit 1 | |
| - name: Sign DMG | |
| run: | | |
| set -euo pipefail | |
| export PATH="/usr/local/bin:/opt/homebrew/bin:$PATH" | |
| # Ensure Sparkle CLI is on PATH | |
| if [ -n "${SPARKLE_BIN:-}" ]; then | |
| export PATH="$SPARKLE_BIN:$PATH" | |
| fi | |
| which sign_update || { echo "sign_update not found on PATH" >&2; exit 1; } | |
| DMG="output/Presentify-${{ steps.version.outputs.version }}.dmg" | |
| if [ ! -f "$DMG" ]; then echo "DMG not found: $DMG" >&2; exit 1; fi | |
| echo "Preparing ed25519 private key for Sparkle" | |
| # Write the key as-is (it's already base64 encoded) | |
| printf "%s" "$SPARKLE_PRIVATE_KEY" > sparkle_ed25519.key | |
| echo "Key file exists: $(test -f sparkle_ed25519.key && echo 'yes' || echo 'no')" | |
| echo "Key file size: $(wc -c < sparkle_ed25519.key) bytes" | |
| echo "Signing $DMG with Sparkle ed25519 key" | |
| SIG=$(sign_update --ed-key-file sparkle_ed25519.key "$DMG" 2>&1) || { echo "sign_update failed with output: $SIG" >&2; exit 1; } | |
| echo "$SIG" | tee signature.txt | |
| env: | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| - name: Extract Metadata | |
| id: vars | |
| run: | | |
| SIZE=$(stat -f%z output/Presentify-*.dmg) | |
| VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "build/Build/Products/Release/Presentify.app/Contents/Info.plist") | |
| BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "build/Build/Products/Release/Presentify.app/Contents/Info.plist") | |
| # Extract just the signature value from the sign_update output | |
| SIG=$(cat signature.txt | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p') | |
| # Extract release notes and write to a temporary file to avoid shell escaping issues | |
| jq -r '.release.body // ""' <<< '${{ toJson(github.event) }}' > notes_temp.txt | |
| echo "size=$SIZE" >> $GITHUB_OUTPUT | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| echo "build=$BUILD" >> $GITHUB_OUTPUT | |
| echo "sig=$SIG" >> $GITHUB_OUTPUT | |
| echo "notes<<EOF" >> $GITHUB_OUTPUT | |
| cat notes_temp.txt >> $GITHUB_OUTPUT | |
| echo "EOF" >> $GITHUB_OUTPUT | |
| - name: Generate appcast.xml | |
| run: | | |
| cat <<EOF > appcast.xml | |
| <?xml version="1.0" encoding="utf-8"?> | |
| <rss version="2.0" | |
| xmlns:sparkle="https://sparkle-project.org/xml-namespaces/sparkle" | |
| xmlns:dc="http://purl.org/dc/elements/1.1/"> | |
| <channel> | |
| <title>Presentify Updates</title> | |
| <link>https://rampatra.github.io/presentify-updates/</link> | |
| <description>Latest updates for Presentify</description> | |
| <language>en</language> | |
| <item> | |
| <title>Version ${{ steps.vars.outputs.version }}</title> | |
| <description><![CDATA[${{ steps.vars.outputs.notes }}]]></description> | |
| <pubDate>$(date -u +"%a, %d %b %Y %H:%M:%S +0000")</pubDate> | |
| <enclosure | |
| url="https://rampatra.github.io/presentify-updates/Presentify-${{ steps.version.outputs.version }}.dmg" | |
| sparkle:version="${{ steps.vars.outputs.build }}" | |
| sparkle:shortVersionString="${{ steps.vars.outputs.version }}" | |
| length="${{ steps.vars.outputs.size }}" | |
| type="application/octet-stream" | |
| sparkle:edSignature="${{ steps.vars.outputs.sig }}"/> | |
| </item> | |
| </channel> | |
| </rss> | |
| EOF | |
| - name: Setup Deploy Key | |
| run: | | |
| mkdir -p ~/.ssh | |
| echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key | |
| chmod 600 ~/.ssh/deploy_key | |
| - name: Push to public updates repo | |
| run: | | |
| git clone [email protected]:rampatra/presentify-updates.git deploy | |
| cp output/Presentify-*.dmg deploy/ | |
| cp appcast.xml deploy/ | |
| cd deploy | |
| git config user.name "GitHub Actions" | |
| git config user.email "[email protected]" | |
| git add . | |
| git commit -m "Release ${{ github.event.release.tag_name }}" | |
| git push origin main | |
| env: | |
| GIT_SSH_COMMAND: 'ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no' |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
For details on how to use this Workflow, please read my blog post.