Last active
June 6, 2026 21:16
-
-
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] | |
| env: | |
| # CHANGE: Your macOS app name, without ".app". | |
| APP_NAME: YourApp | |
| # CHANGE: Your Xcode project file. | |
| PROJECT_FILE: YourApp.xcodeproj | |
| # CHANGE: Your Xcode scheme name. | |
| SCHEME_NAME: YourApp | |
| # CHANGE: Your app bundle identifier. | |
| BUNDLE_IDENTIFIER: com.example.YourApp | |
| # CHANGE if needed: Usually "Release". | |
| CONFIGURATION: Release | |
| # CHANGE if needed: Where xcodebuild should place derived data. | |
| DERIVED_DATA_PATH: build | |
| # CHANGE: Path to your app entitlements file. Leave blank if your app does not use one. | |
| ENTITLEMENTS_PATH: YourApp/YourApp.entitlements | |
| # CHANGE if needed: Xcode version installed on GitHub-hosted macOS runners. | |
| XCODE_VERSION: '26.2' | |
| # CHANGE if needed: Sparkle version to install. | |
| SPARKLE_VERSION: '2.7.1' | |
| # CHANGE if needed: Sparkle framework version folder inside Sparkle.framework/Versions. | |
| SPARKLE_FRAMEWORK_VERSION: B | |
| # CHANGE: Public base URL where appcast.xml and the DMG will be hosted. | |
| UPDATE_BASE_URL: https://your-username.github.io/your-updates-repo | |
| # CHANGE: SSH URL of the repo where appcast.xml and DMGs will be published. | |
| UPDATES_REPO_SSH: git@github.com:your-username/your-updates-repo.git | |
| # CHANGE if needed: Branch of the updates repo to push to. | |
| UPDATES_BRANCH: main | |
| jobs: | |
| build: | |
| runs-on: macos-15 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Select Xcode | |
| uses: maxim-lobanov/setup-xcode@v1 | |
| with: | |
| xcode-version: ${{ env.XCODE_VERSION }} | |
| - name: Install tools | |
| run: brew install create-dmg jq | |
| - name: Setup Sparkle CLI tools | |
| uses: jozefizso/setup-sparkle@v1 | |
| with: | |
| version: ${{ env.SPARKLE_VERSION }} | |
| - name: Import Developer ID certificate | |
| env: | |
| # PROVIDE SECRET: Base64-encoded Developer ID Application .p12 certificate. | |
| DEV_ID_P12_BASE64: ${{ secrets.DEV_ID_P12_BASE64 }} | |
| # PROVIDE SECRET: Password for the .p12 certificate. Can be blank if the .p12 has no password. | |
| DEV_ID_P12_PASSWORD: ${{ secrets.DEV_ID_P12_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN=build.keychain | |
| KEYCHAIN_PASSWORD=$(uuidgen) | |
| 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 "$DEV_ID_P12_BASE64" | base64 -D > dev_id.p12) || (echo "$DEV_ID_P12_BASE64" | base64 -d > dev_id.p12) | |
| 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 | |
| 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 | |
| security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" | |
| security find-identity -v -p codesigning "$KEYCHAIN" || true | |
| - name: Build app | |
| env: | |
| # PROVIDE SECRET: Your Apple Developer Team ID. | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| xcodebuild \ | |
| -project "$PROJECT_FILE" \ | |
| -scheme "$SCHEME_NAME" \ | |
| -configuration "$CONFIGURATION" \ | |
| -derivedDataPath "$DERIVED_DATA_PATH" \ | |
| CODE_SIGN_STYLE=Manual \ | |
| CODE_SIGN_IDENTITY="Developer ID Application" \ | |
| DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ | |
| OTHER_CODE_SIGN_FLAGS="--keychain build.keychain --timestamp --options runtime" | |
| - name: Re-sign Sparkle components with Developer ID | |
| env: | |
| # PROVIDE SECRET if needed: Exact Developer ID signing identity name. | |
| # Optional if the workflow can auto-detect a Developer ID Application identity. | |
| DEV_ID_SIGN_IDENTITY: ${{ secrets.DEV_ID_SIGN_IDENTITY }} | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| if [ ! -d "$APP" ]; then | |
| echo "App not found: $APP" >&2 | |
| exit 1 | |
| fi | |
| 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 | |
| sign() { | |
| local target="$1" | |
| if [ -e "$target" ]; then | |
| codesign --force --options runtime --timestamp --sign "$IDENTITY" --keychain build.keychain -vvv "$target" | |
| fi | |
| } | |
| SPARKLE="$APP/Contents/Frameworks/Sparkle.framework/Versions/$SPARKLE_FRAMEWORK_VERSION" | |
| sign "$SPARKLE/XPCServices/Downloader.xpc" | |
| sign "$SPARKLE/XPCServices/Installer.xpc" | |
| sign "$SPARKLE/Autoupdate" | |
| sign "$SPARKLE/Updater.app" | |
| sign "$SPARKLE/Sparkle" | |
| sign "$APP/Contents/Frameworks/Sparkle.framework" | |
| if [ -n "${ENTITLEMENTS_PATH:-}" ] && [ -f "$ENTITLEMENTS_PATH" ]; then | |
| codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS_PATH" --sign "$IDENTITY" --keychain build.keychain -vvv "$APP" | |
| else | |
| codesign --force --options runtime --timestamp --sign "$IDENTITY" --keychain build.keychain -vvv "$APP" | |
| fi | |
| - name: Verify code signing before notarization | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| codesign -dv --verbose=4 "$APP" 2>&1 | sed 's/^/ /' | |
| codesign -d --entitlements :- "$APP" 2>/dev/null | sed 's/^/ /' || true | |
| APP_ENTITLEMENTS=$(codesign -d --entitlements - "$APP" 2>/dev/null) | |
| echo "$APP_ENTITLEMENTS" | grep -q "${BUNDLE_IDENTIFIER}-spks" | |
| echo "$APP_ENTITLEMENTS" | grep -q "${BUNDLE_IDENTIFIER}-spki" | |
| if echo "$APP_ENTITLEMENTS" | grep -q '\$(PRODUCT_BUNDLE_IDENTIFIER)'; then | |
| echo "Unexpanded PRODUCT_BUNDLE_IDENTIFIER found in signed entitlements" >&2 | |
| exit 1 | |
| fi | |
| codesign --verify --deep --strict --verbose=4 "$APP" | |
| spctl -a -vvv --type exec "$APP" || true | |
| - name: Read app version | |
| id: version | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Contents/Info.plist") | |
| echo "version=$VERSION" >> "$GITHUB_OUTPUT" | |
| - name: Zip app for notarization | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| mkdir -p output | |
| xattr -cr "$APP" || true | |
| ditto -c -k --keepParent "$APP" "output/$APP_NAME.zip" | |
| - name: Notarize app | |
| env: | |
| # PROVIDE SECRET: Apple ID email used for notarization. | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| # PROVIDE SECRET: App-specific password for the Apple ID. | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| # PROVIDE SECRET: Your Apple Developer Team ID. | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| xcrun notarytool submit "output/$APP_NAME.zip" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --wait \ | |
| --output-format json > notary_result.json | |
| 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 | |
| xcrun notarytool log "$REQUEST_ID" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| > notary_log.txt || true | |
| sed 's/^/ /' notary_log.txt || true | |
| fi | |
| if [ "$STATUS" != "Accepted" ]; then | |
| echo "Notarization did not complete successfully: $STATUS" >&2 | |
| exit 1 | |
| fi | |
| - name: Staple notarization ticket to app | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| for i in $(seq 1 6); do | |
| if xcrun stapler staple -v "$APP"; then | |
| exit 0 | |
| fi | |
| echo "Staple attempt $i failed; retrying in 20s..." | |
| sleep 20 | |
| done | |
| echo "Failed to staple app" >&2 | |
| exit 1 | |
| - name: Create DMG | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| DMG="output/$APP_NAME-${{ steps.version.outputs.version }}.dmg" | |
| mkdir -p output | |
| create-dmg \ | |
| --volname "$APP_NAME" \ | |
| --window-pos 200 120 \ | |
| --icon "$APP_NAME.app" 200 190 \ | |
| --hide-extension "$APP_NAME.app" \ | |
| --app-drop-link 500 185 \ | |
| "$DMG" "$APP" | |
| - name: Notarize DMG | |
| env: | |
| # PROVIDE SECRET: Apple ID email used for notarization. | |
| APPLE_ID: ${{ secrets.APPLE_ID }} | |
| # PROVIDE SECRET: App-specific password for the Apple ID. | |
| APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} | |
| # PROVIDE SECRET: Your Apple Developer Team ID. | |
| APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} | |
| run: | | |
| set -euo pipefail | |
| DMG="output/$APP_NAME-${{ steps.version.outputs.version }}.dmg" | |
| xcrun notarytool submit "$DMG" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| --wait \ | |
| --output-format json > notary_result_dmg.json | |
| 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 | |
| xcrun notarytool log "$REQUEST_ID" \ | |
| --apple-id "$APPLE_ID" \ | |
| --team-id "$APPLE_TEAM_ID" \ | |
| --password "$APPLE_APP_PASSWORD" \ | |
| > notary_log_dmg.txt || true | |
| sed 's/^/ /' notary_log_dmg.txt || true | |
| fi | |
| if [ "$STATUS" != "Accepted" ]; then | |
| echo "DMG notarization did not complete successfully: $STATUS" >&2 | |
| exit 1 | |
| fi | |
| - name: Staple notarization ticket to DMG | |
| run: | | |
| set -euo pipefail | |
| DMG="output/$APP_NAME-${{ steps.version.outputs.version }}.dmg" | |
| for i in $(seq 1 6); do | |
| if xcrun stapler staple -v "$DMG"; then | |
| exit 0 | |
| fi | |
| echo "Staple attempt $i failed; retrying in 20s..." | |
| sleep 20 | |
| done | |
| echo "Failed to staple DMG" >&2 | |
| exit 1 | |
| - name: Sign DMG with Sparkle | |
| env: | |
| # PROVIDE SECRET: Sparkle EdDSA private key generated by Sparkle. | |
| SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} | |
| run: | | |
| set -euo pipefail | |
| export PATH="/usr/local/bin:/opt/homebrew/bin:$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/$APP_NAME-${{ steps.version.outputs.version }}.dmg" | |
| printf "%s" "$SPARKLE_PRIVATE_KEY" > 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 | |
| - name: Extract metadata | |
| id: vars | |
| run: | | |
| set -euo pipefail | |
| APP="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME.app" | |
| SIZE=$(stat -f%z output/*.dmg) | |
| VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Contents/Info.plist") | |
| BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$APP/Contents/Info.plist") | |
| SIG=$(sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' signature.txt) | |
| jq -r '.release.body // ""' "$GITHUB_EVENT_PATH" > notes_temp.txt | |
| printf 'size=%s\n' "$SIZE" >> "$GITHUB_OUTPUT" | |
| printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" | |
| printf 'build=%s\n' "$BUILD" >> "$GITHUB_OUTPUT" | |
| printf 'sig=%s\n' "$SIG" >> "$GITHUB_OUTPUT" | |
| - name: Generate appcast.xml | |
| env: | |
| VERSION: ${{ steps.vars.outputs.version }} | |
| BUILD: ${{ steps.vars.outputs.build }} | |
| SIZE: ${{ steps.vars.outputs.size }} | |
| SIG: ${{ steps.vars.outputs.sig }} | |
| DMG_VERSION: ${{ steps.version.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' | |
| from datetime import datetime, timezone | |
| from html import escape as html_escape | |
| from os import environ | |
| from xml.sax.saxutils import escape as xml_escape, quoteattr | |
| def env(name): | |
| return environ[name] | |
| raw_notes = open("notes_temp.txt", encoding="utf-8").read() | |
| release_notes = html_escape(raw_notes, quote=False).replace("\r\n", "\n").replace("\r", "\n") | |
| release_notes = release_notes.replace("\n", "<br />\n") | |
| release_notes = release_notes.replace("]]>", "]]]]><![CDATA[>") | |
| update_base_url = env("UPDATE_BASE_URL").rstrip("/") | |
| dmg_url = f"{update_base_url}/{env('APP_NAME')}-{env('DMG_VERSION')}.dmg" | |
| pub_date = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") | |
| appcast = f"""<?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>{xml_escape(env('APP_NAME'))} Updates</title> | |
| <link>{xml_escape(update_base_url)}/</link> | |
| <description>Latest updates for {xml_escape(env('APP_NAME'))}</description> | |
| <language>en</language> | |
| <item> | |
| <title>Version {xml_escape(env('VERSION'))}</title> | |
| <description><![CDATA[{release_notes}]]></description> | |
| <pubDate>{pub_date}</pubDate> | |
| <enclosure | |
| url={quoteattr(dmg_url)} | |
| sparkle:version={quoteattr(env('BUILD'))} | |
| sparkle:shortVersionString={quoteattr(env('VERSION'))} | |
| length={quoteattr(env('SIZE'))} | |
| type="application/octet-stream" | |
| sparkle:edSignature={quoteattr(env('SIG'))}/> | |
| </item> | |
| </channel> | |
| </rss> | |
| """ | |
| with open("appcast.xml", "w", encoding="utf-8") as appcast_file: | |
| appcast_file.write(appcast) | |
| PY | |
| - name: Setup deploy key | |
| run: | | |
| set -euo pipefail | |
| mkdir -p ~/.ssh | |
| # PROVIDE SECRET: Private deploy key with write access to your public updates repo. | |
| echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy_key | |
| chmod 600 ~/.ssh/deploy_key | |
| - name: Push to public updates repo | |
| env: | |
| GIT_SSH_COMMAND: ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no | |
| run: | | |
| set -euo pipefail | |
| git clone "$UPDATES_REPO_SSH" deploy | |
| cp output/*.dmg deploy/ | |
| cp appcast.xml deploy/ | |
| cd deploy | |
| git config user.name "GitHub Actions" | |
| git config user.email "actions@github.com" | |
| git add . | |
| git commit -m "Release ${{ github.event.release.tag_name }}" | |
| git push origin "$UPDATES_BRANCH" |
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.