Skip to content

Instantly share code, notes, and snippets.

@rampatra
Created September 1, 2025 18:49
Show Gist options
  • Select an option

  • Save rampatra/08c6e895e051f35c9cb05f3789f243e0 to your computer and use it in GitHub Desktop.

Select an option

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
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'
@rampatra
Copy link
Author

rampatra commented Sep 5, 2025

For details on how to use this Workflow, please read my blog post.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment