This guide explains how to create new releases for MyMacCleaner with proper versioning, code signing, notarization, and auto-update support via Sparkle.
Create a .env file in the project root (copy from .env.example):
cp .env.example .envRequired variables in .env:
# Apple Developer credentials
APPLE_TEAM_ID=YOUR_TEAM_ID # e.g., 7K4SKUHU47
MACOS_CERTIFICATE_SHA1=YOUR_CERT_SHA1 # Run: security find-identity -v -p codesigning
# Sparkle EdDSA key for signing updates (base64 encoded)
SPARKLE_PRIVATE_KEY=YOUR_PRIVATE_KEY # Generate with: ./bin/generate_keysSet up a notarization profile (one-time setup):
xcrun notarytool store-credentials "notary-profile" \
--apple-id "[email protected]" \
--team-id "YOUR_TEAM_ID" \
--password "app-specific-password"Create an app-specific password at: https://appleid.apple.com/account/manage
gh auth loginIf you don't have Sparkle keys yet:
# Download Sparkle tools
curl -L -o /tmp/sparkle.tar.xz https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz
mkdir -p ./bin && tar -xf /tmp/sparkle.tar.xz -C ./bin
# Generate new keys
./bin/generate_keysSave the private key to your .env file and add the public key to your app's Info.plist as SUPublicEDKey.
There are two ways to release MyMacCleaner:
| Method | When to Use | Command |
|---|---|---|
| Local Script | Default, full control | ./scripts/release.sh 0.1.2 |
| GitHub CI | Remote/backup option | GitHub Actions → "Build and Release" → Run workflow |
Never use both methods for the same version. Here's why:
Local build → ZIP bytes: abc123... → Signature: XYZ...
CI build → ZIP bytes: def456... → Signature: ABC... (DIFFERENT!)
Even building the exact same code twice produces different files because:
- Compilers embed build timestamps
- Code signing includes Apple's timestamp
- Linkers generate unique UUIDs
The Sparkle signature is a hash of the ZIP file. Different bytes = different signature = "improperly signed" error.
Yes! Each release is independent:
| Release | Method | Works? |
|---|---|---|
| v0.1.0 | Local | ✅ |
| v0.1.1 | CI | ✅ |
| v0.1.2 | Local | ✅ |
| v0.1.3 | CI | ✅ |
Just pick one method per release and stick with it.
The GitHub workflow is configured to NOT trigger automatically on tag push:
on:
# push: # DISABLED - would conflict with local releases
# tags: ['v*']
workflow_dispatch: # Manual trigger onlyThis prevents CI from overwriting locally-created releases with different builds.
If you prefer to release via CI instead of locally:
- Go to GitHub → Actions → "Build and Release"
- Click "Run workflow"
- Enter the version (e.g.,
0.1.2) - Click "Run workflow"
CI will build, sign, notarize, and create the release automatically.
Step 1: Update CHANGELOG.md during development. Add your changes to the [Unreleased] section:
## [Unreleased]
- [added] New feature description
- [fixed] Bug fix description
- [changed] Improvement description
- [removed] Removed feature descriptionStep 2: When ready to release, run:
./scripts/release.sh <version>Examples:
# Patch release (bug fixes)
./scripts/release.sh 0.1.2
# Minor release (new features)
./scripts/release.sh 0.2.0
# Major release
./scripts/release.sh 1.0.0The script automatically:
- Reads changelog from
CHANGELOG.md[Unreleased]section - Increments the build number
- Updates version in Xcode project
- Builds and archives the app
- Signs with Developer ID certificate
- Notarizes with Apple
- Creates DMG and ZIP packages
- Signs ZIP for Sparkle auto-updates
- Updates
appcast.xmlfor Sparkle - Updates
website/public/data/releases.json - Creates GitHub release with assets
- Updates
CHANGELOG.md(moves[Unreleased]to versioned section) - Commits and pushes all changes
If you need to do a manual release or the script fails partway through:
Edit MyMacCleaner.xcodeproj/project.pbxproj:
MARKETING_VERSION= display version (e.g., "0.1.2")CURRENT_PROJECT_VERSION= build number (integer, e.g., 3)
Or use sed:
sed -i '' 's/MARKETING_VERSION = [^;]*;/MARKETING_VERSION = 0.1.2;/g' MyMacCleaner.xcodeproj/project.pbxproj
sed -i '' 's/CURRENT_PROJECT_VERSION = [^;]*;/CURRENT_PROJECT_VERSION = 3;/g' MyMacCleaner.xcodeproj/project.pbxprojxcodebuild archive \
-project MyMacCleaner.xcodeproj \
-scheme MyMacCleaner \
-archivePath build/MyMacCleaner.xcarchive \
-configuration Release \
CODE_SIGN_IDENTITY="Developer ID Application" \
DEVELOPMENT_TEAM="YOUR_TEAM_ID"xcodebuild -exportArchive \
-archivePath build/MyMacCleaner.xcarchive \
-exportPath build/export \
-exportOptionsPlist ExportOptions.plist# Create ZIP for notarization
ditto -c -k --keepParent build/export/MyMacCleaner.app build/notarization.zip
# Submit for notarization
xcrun notarytool submit build/notarization.zip \
--keychain-profile "notary-profile" \
--wait
# Staple the ticket
xcrun stapler staple build/export/MyMacCleaner.appcreate-dmg \
--volname "MyMacCleaner" \
--window-size 600 400 \
--icon-size 100 \
--icon "MyMacCleaner.app" 150 200 \
--app-drop-link 450 200 \
build/MyMacCleaner-v0.1.2.dmg \
build/export/MyMacCleaner.app
# Sign and notarize DMG
codesign --force --sign "Developer ID Application: Your Name (TEAM_ID)" build/MyMacCleaner-v0.1.2.dmg
xcrun notarytool submit build/MyMacCleaner-v0.1.2.dmg --keychain-profile "notary-profile" --wait
xcrun stapler staple build/MyMacCleaner-v0.1.2.dmgcd build/export
zip -r ../MyMacCleaner-v0.1.2.zip MyMacCleaner.app
cd ../..
# Sign with Sparkle EdDSA key
./bin/sign_update build/MyMacCleaner-v0.1.2.zipAdd new item at the TOP of the channel (most recent first):
<item>
<title>Version 0.1.2</title>
<pubDate>Fri, 24 Jan 2026 10:00:00 +0100</pubDate>
<sparkle:version>3</sparkle:version>
<sparkle:shortVersionString>0.1.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<description><![CDATA[
<h2>What's New in Version 0.1.2</h2>
<ul>
<li>Your changelog here</li>
</ul>
]]></description>
<enclosure
url="https://github.com/Prot10/MyMacCleaner/releases/download/v0.1.2/MyMacCleaner-v0.1.2.zip"
sparkle:edSignature="YOUR_SIGNATURE_HERE"
length="FILE_SIZE_IN_BYTES"
type="application/octet-stream"/>
</item>gh release create v0.1.2 \
--title "MyMacCleaner v0.1.2" \
--notes "Your changelog here" \
build/MyMacCleaner-v0.1.2.dmg \
build/MyMacCleaner-v0.1.2.zipgit add -A
git commit -m "release: v0.1.2"
git pushFollow semantic versioning (MAJOR.MINOR.PATCH):
| Type | When to Use | Example |
|---|---|---|
| PATCH | Bug fixes, minor improvements | 0.1.1 → 0.1.2 |
| MINOR | New features, backward compatible | 0.1.2 → 0.2.0 |
| MAJOR | Breaking changes, major rewrites | 0.2.0 → 1.0.0 |
Build numbers are always incremented (never reset) and are used internally for update comparison.
- On app launch:
UpdateManagerfetchesappcast.xmlfrom GitHub - Version comparison: Compares
sparkle:version(build number) with current app'sCFBundleVersion - If update available: Sets
updateAvailable = true, button appears in toolbar - User clicks button: Shows update sheet with version details
- User clicks "Download & Install": Sparkle handles download, verification, and installation
| File | Purpose |
|---|---|
appcast.xml |
Sparkle feed with version info and signatures |
Info.plist → SUFeedURL |
URL to appcast.xml |
Info.plist → SUPublicEDKey |
Public key for signature verification |
UpdateManager.swift |
Fetches appcast, compares versions |
UpdateAvailableButton.swift |
UI for update notification |
- Check build numbers: The appcast
sparkle:versionmust be greater than the app'sCFBundleVersion - Check appcast URL: Verify
SUFeedURLin Info.plist points to raw GitHub URL - Check Console logs: Filter for
[UpdateManager]to see fetch results - Clear cache: The app uses cache-busting, but try quitting and restarting
- Check credentials: Run
xcrun notarytool history --keychain-profile "notary-profile" - Check entitlements: Ensure hardened runtime is enabled
- Check signing: Run
codesign -dvvv build/export/MyMacCleaner.app
Most common cause: Both local script AND CI ran for the same release.
- Check if CI ran:
gh run list --repo Prot10/MyMacCleaner --limit 5 - If CI overwrote your release, delete and re-release:
# Delete the broken release gh release delete vX.X.X --repo Prot10/MyMacCleaner --yes git tag -d vX.X.X git push origin --delete vX.X.X # Re-release (CI won't auto-trigger anymore) ./scripts/release.sh X.X.X
Other causes:
- Check key match: Public key in Info.plist must match private key used for signing
- Re-sign the ZIP:
./bin/sign_update build/MyMacCleaner-vX.X.X.zip - Update appcast: Ensure
sparkle:edSignaturematches the new signature - Verify file size:
appcast.xmllength must match actual ZIP size on GitHub
- Check authentication: Run
gh auth status - Check repository: Ensure you're in the correct repo
- Delete and retry:
gh release delete vX.X.X --yesthen re-run script
If you need to start fresh (delete all releases and re-release):
# Delete all GitHub releases
gh release list | awk '{print $3}' | xargs -I {} gh release delete {} --yes
# Delete all tags
git tag -l | xargs -I {} git tag -d {}
git tag -l | xargs -I {} git push origin --delete {}
# Clear appcast.xml (keep only channel header)
cat > appcast.xml << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>MyMacCleaner Updates</title>
<link>https://github.com/Prot10/MyMacCleaner</link>
<description>Most recent changes with links to updates.</description>
<language>en</language>
</channel>
</rss>
EOF
# Set version to X.Y.Z with build 0 (script will increment to 1)
sed -i '' 's/MARKETING_VERSION = [^;]*;/MARKETING_VERSION = 0.1.0;/g' MyMacCleaner.xcodeproj/project.pbxproj
sed -i '' 's/CURRENT_PROJECT_VERSION = [^;]*;/CURRENT_PROJECT_VERSION = 0;/g' MyMacCleaner.xcodeproj/project.pbxproj
# Reset CHANGELOG.md
cat > CHANGELOG.md << 'EOF'
# Changelog
All notable changes to MyMacCleaner will be documented in this file.
## [Unreleased]
- [added] Initial release with auto-update functionality
EOF
# Commit and push
git add -A && git commit -m "chore: prepare for fresh release" && git push
# Release first version
./scripts/release.sh 0.1.0
# Update CHANGELOG.md for second version
cat > CHANGELOG.md << 'EOF'
# Changelog
All notable changes to MyMacCleaner will be documented in this file.
## [Unreleased]
- [changed] Update notification improvements
## [0.1.0] - 2026-01-23
- [added] Initial release with auto-update functionality
EOF
git add CHANGELOG.md && git commit -m "docs: prepare changelog for v0.1.1" && git push
# Release second version (for testing updates)
./scripts/release.sh 0.1.1# 1. Update CHANGELOG.md with your changes during development
# 2. When ready to release:
./scripts/release.sh 0.1.2
# Check current version
grep -m1 "MARKETING_VERSION" MyMacCleaner.xcodeproj/project.pbxproj
grep -m1 "CURRENT_PROJECT_VERSION" MyMacCleaner.xcodeproj/project.pbxproj
# List GitHub releases
gh release list
# View appcast
cat appcast.xml
# View changelog
cat CHANGELOG.md
# Check notarization history
xcrun notarytool history --keychain-profile "notary-profile"