- EdDSA private key for Sparkle (
SPARKLE_PRIVATE_KEYsecret) - One of the following signing strategies (in priority order):
| Strategy | Required Secrets | Notarization |
|---|---|---|
| Developer ID | DEVELOPER_ID_P12, DEVELOPER_ID_PASSWORD |
Yes (with APPLE_ID, APPLE_ID_PASSWORD, APPLE_TEAM_ID) |
| Self-signed cert | SELF_SIGNED_CERT_P12, SELF_SIGNED_CERT_PASSWORD |
No |
| Ad-hoc | ALLOW_ADHOC_RELEASE=true (repo variable) |
No |
The release workflow uses manual codesign for all signing strategies. The binary is extracted from the Xcode archive via ditto and signed component-by-component (inside-out for Sparkle framework).
- Developer ID builds use
--timestamp(secure timestamp from Apple) and hardened runtime (-o runtime) — both required for notarization. - Self-signed and ad-hoc builds use
--timestamp=none(no Apple server access needed) but still enable hardened runtime.
# In Xcode:
# Product > Archive > Distribute App > Developer ID
# Wait for notarization to complete# Navigate to exported app location
cd /path/to/exported
# Create ZIP archive (preserves code signature)
zip -r ~/Snapzy-Updates/Snapzy-X.Y.Z.zip Snapzy.app
# Optional: Create release notes HTML
cat > ~/Snapzy-Updates/Snapzy-X.Y.Z.html << 'EOF'
<html>
<body>
<h2>What's New in X.Y.Z</h2>
<ul>
<li>Feature 1</li>
<li>Bug fix 2</li>
</ul>
</body>
</html>
EOF# Locate Sparkle tools
SPARKLE_BIN=~/Library/Developer/Xcode/DerivedData/Snapzy-*/SourcePackages/artifacts/sparkle/Sparkle/bin
# Generate appcast (auto-signs and creates deltas)
$SPARKLE_BIN/generate_appcast ~/Snapzy-Updates
# Output:
# - appcast.xml (updated)
# - *.delta files (for incremental updates)# Create and push tag
git tag -a vX.Y.Z -m "Version X.Y.Z"
git push origin vX.Y.Z
# Create release with assets
gh release create vX.Y.Z \
~/Snapzy-Updates/Snapzy-X.Y.Z.zip \
~/Snapzy-Updates/Snapzy-X.Y.Z.html \
--title "Snapzy X.Y.Z" \
--notes "See release notes for details"
# Upload appcast.xml to repo root or GitHub Pages
cp ~/Snapzy-Updates/appcast.xml ./appcast.xml
git add appcast.xml
git commit -m "chore: update appcast for vX.Y.Z"
git push$SPARKLE_BIN/generate_keys -x sparkle_private_key.pem
# Store securely (password manager, encrypted backup)
# NEVER commit to git!$SPARKLE_BIN/generate_keys -f sparkle_private_key.pem$SPARKLE_BIN/generate_keys -pCurrent URL in Info.plist:
https://raw.githubusercontent.com/duongductrong/Snapzy/master/appcast.xml
Update this value in Snapzy/Resources/Info.plist if your release repository changes.
# Clear last check time to force update check
defaults delete com.trongduong.snapzy SULastCheckTime
# Run app and click "Check for Updates..."- Signed with Apple Developer ID certificate
- Hardened runtime enabled
- Notarized by Apple (if credentials configured)
- Users can install without Gatekeeper warnings
- Preserves TCC permissions across Sparkle updates
- Not notarized — users must right-click > Open on first launch
- Move the app to
/Applicationsbefore first launch
- Requires
ALLOW_ADHOC_RELEASE=truerepository variable - TCC permissions and Keychain trust lost after every update
- Not suitable for regular distribution
Release notifications are handled by a separate workflow (release-notify.yml) that triggers automatically after release-publish.yml completes successfully. This keeps the publish workflow focused on build/sign/release, and makes it easy to add new notification channels.
Architecture:
release-publish.yml (build → sign → release)
↓ workflow_run trigger
release-notify.yml
├── prepare (fetch release metadata from GitHub API)
├── discord (parallel)
├── slack (parallel, add when needed)
└── telegram (parallel, add when needed)
- Open your Discord server
- Go to Server Settings → Integrations → Webhooks
- Click New Webhook
- Choose the target channel for release announcements
- (Optional) Set the webhook name (e.g., "Snapzy Releases") and avatar
- Click Copy Webhook URL — it looks like:
https://discord.com/api/webhooks/123456789012345678/abcdefg...
- Go to your GitHub repository → Settings → Secrets and variables → Actions
- Click New repository secret
- Name:
DISCORD_WEBHOOK_URL - Value: paste the webhook URL from step 1
- Click Add secret
Each release notification includes:
- Title: version number with link to the GitHub release page
- Body: full changelog from
CHANGELOG.md(features, bug fixes, etc.) - Quick links: DMG download and release page
- Timestamp: when the release was published
If DISCORD_WEBHOOK_URL is not configured, the job is silently skipped — no failures.
To add a notification channel (e.g., Slack, Telegram):
- Open
.github/workflows/release-notify.yml - Add a new job that depends on
prepare - Use
${{ needs.prepare.outputs.version }},.release_url,.download_url, and.bodyfor release data - Add the required secrets (e.g.,
SLACK_WEBHOOK_URL) to GitHub repository settings
See the commented examples at the bottom of release-notify.yml.
- Button always disabled: Check Info.plist has SUFeedURL and SUPublicEDKey
- Signature errors: Ensure private key matches public key in app
- No updates found: Verify appcast.xml sparkle:version > current CFBundleVersion
- Notification not sent: Verify the channel secret (e.g.,
DISCORD_WEBHOOK_URL) is set correctly in GitHub repository settings. Check therelease-notifyworkflow run logs for HTTP status warnings. - Notarization rejected: Check the notarization log in the GitHub Actions output (printed automatically on failure). Common causes:
- Missing hardened runtime (
flags=line doesn't showruntime) - Missing secure timestamp (
--timestamp=nonewas used) com.apple.security.get-task-allowentitlement present in release build
- Missing hardened runtime (
- Notarization timeout: Apple service can be slow. The workflow uses a 15-minute timeout. If consistently timing out, check DMG size and Apple system status.
- Stapling failed: Ensure the DMG was notarized successfully first.
stapler stapleonly works after Apple issues a ticket.