Skip to content

Instantly share code, notes, and snippets.

@rampatra
Last active June 6, 2026 21:16
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]
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"
@rampatra

rampatra commented Sep 5, 2025

Copy link
Copy Markdown
Author

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