Skip to content

fix: add GitHub upload verification to release script #34

fix: add GitHub upload verification to release script

fix: add GitHub upload verification to release script #34

Workflow file for this run

name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version to release (e.g., 1.0.0)'
required: true
permissions:
contents: write
env:
XCODE_VERSION: '16.0'
APP_NAME: MyMacCleaner
SCHEME: MyMacCleaner
BUNDLE_ID: com.mymaccleaner.MyMacCleaner
jobs:
build-signed:
name: Build, Sign & Notarize
runs-on: macos-15
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Select Xcode version
run: |
sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app || \
sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version
- name: Get Version
id: version
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
else
VERSION="${GITHUB_REF#refs/tags/v}"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Version: $VERSION"
- name: Import Apple Developer Certificate
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
KEYCHAIN_PWD: ${{ secrets.CI_KEYCHAIN_PWD }}
run: |
# Create variables
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# Decode certificate from base64
echo "$MACOS_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH"
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
# Import certificate to keychain
security import "$CERTIFICATE_PATH" -P "$MACOS_CERTIFICATE_PWD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PWD" "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
# Verify certificate was imported
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# Clean up certificate file
rm "$CERTIFICATE_PATH"
- name: Store Notarization Credentials
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
xcrun notarytool store-credentials "notary-profile" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_PASSWORD"
- name: Resolve Swift Package Dependencies
run: |
xcodebuild -resolvePackageDependencies \
-project ${{ env.APP_NAME }}.xcodeproj \
-scheme ${{ env.SCHEME }}
- name: Build and Archive (Signed)
env:
CERTIFICATE_NAME: ${{ secrets.MACOS_CERTIFICATE_NAME }}
run: |
xcodebuild archive \
-project ${{ env.APP_NAME }}.xcodeproj \
-scheme ${{ env.SCHEME }} \
-archivePath build/${{ env.APP_NAME }}.xcarchive \
-configuration Release \
CODE_SIGN_IDENTITY="$CERTIFICATE_NAME" \
DEVELOPMENT_TEAM="${{ secrets.APPLE_TEAM_ID }}" \
CODE_SIGN_STYLE=Manual \
OTHER_CODE_SIGN_FLAGS="--options runtime --timestamp" \
ONLY_ACTIVE_ARCH=NO \
SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) CI_BUILD'
- name: Export Signed App
env:
CERTIFICATE_NAME: ${{ secrets.MACOS_CERTIFICATE_NAME }}
run: |
mkdir -p build/export
# Create export options plist
cat > build/ExportOptions.plist << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>developer-id</string>
<key>teamID</key>
<string>${{ secrets.APPLE_TEAM_ID }}</string>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Developer ID Application</string>
</dict>
</plist>
EOF
xcodebuild -exportArchive \
-archivePath build/${{ env.APP_NAME }}.xcarchive \
-exportPath build/export \
-exportOptionsPlist build/ExportOptions.plist
- name: Notarize App
run: |
VERSION="${{ steps.version.outputs.version }}"
# Create ZIP for notarization
cd build/export
ditto -c -k --keepParent "${{ env.APP_NAME }}.app" "../notarization.zip"
cd ..
# Show ZIP size
ls -lh notarization.zip
echo "Submitting for notarization..."
# Submit without --wait, then poll manually with longer timeout
SUBMIT_OUTPUT=$(xcrun notarytool submit notarization.zip \
--keychain-profile "notary-profile" \
--output-format json 2>&1)
echo "$SUBMIT_OUTPUT"
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
if [ -z "$SUBMISSION_ID" ]; then
echo "Failed to get submission ID"
exit 1
fi
echo "Submission ID: $SUBMISSION_ID"
echo "SUBMISSION_ID=$SUBMISSION_ID" >> $GITHUB_ENV
# Poll for completion (up to 60 minutes, checking every 30 seconds)
MAX_ATTEMPTS=120
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
ATTEMPT=$((ATTEMPT + 1))
echo "Checking status (attempt $ATTEMPT/$MAX_ATTEMPTS)..."
STATUS_OUTPUT=$(xcrun notarytool info "$SUBMISSION_ID" \
--keychain-profile "notary-profile" \
--output-format json 2>&1)
STATUS=$(echo "$STATUS_OUTPUT" | grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*: *"\([^"]*\)".*/\1/')
echo "Status: $STATUS"
if [ "$STATUS" = "Accepted" ]; then
echo "Notarization successful!"
break
elif [ "$STATUS" = "Invalid" ] || [ "$STATUS" = "Rejected" ]; then
echo "Notarization failed with status: $STATUS"
xcrun notarytool log "$SUBMISSION_ID" --keychain-profile "notary-profile"
exit 1
fi
sleep 30
done
if [ "$STATUS" != "Accepted" ]; then
echo "Notarization timed out after 60 minutes"
exit 1
fi
echo "Stapling notarization ticket..."
xcrun stapler staple "export/${{ env.APP_NAME }}.app"
# Verify stapling
xcrun stapler validate "export/${{ env.APP_NAME }}.app"
- name: Create DMG
run: |
VERSION="${{ steps.version.outputs.version }}"
# Create DMG staging directory
mkdir -p build/dmg-contents
cp -R "build/export/${{ env.APP_NAME }}.app" build/dmg-contents/
ln -s /Applications build/dmg-contents/Applications
# Create DMG
hdiutil create -volname "${{ env.APP_NAME }}" \
-srcfolder build/dmg-contents \
-ov -format UDZO \
"build/${{ env.APP_NAME }}-v${VERSION}.dmg"
# Sign the DMG
codesign --force --sign "${{ secrets.MACOS_CERTIFICATE_NAME }}" \
--options runtime --timestamp \
"build/${{ env.APP_NAME }}-v${VERSION}.dmg"
# Notarize the DMG
echo "Notarizing DMG..."
ls -lh "build/${{ env.APP_NAME }}-v${VERSION}.dmg"
SUBMIT_OUTPUT=$(xcrun notarytool submit "build/${{ env.APP_NAME }}-v${VERSION}.dmg" \
--keychain-profile "notary-profile" \
--output-format json 2>&1)
echo "$SUBMIT_OUTPUT"
DMG_SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
if [ -z "$DMG_SUBMISSION_ID" ]; then
echo "Failed to get DMG submission ID"
exit 1
fi
echo "DMG Submission ID: $DMG_SUBMISSION_ID"
# Poll for completion (up to 60 minutes)
MAX_ATTEMPTS=120
ATTEMPT=0
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
ATTEMPT=$((ATTEMPT + 1))
echo "Checking DMG status (attempt $ATTEMPT/$MAX_ATTEMPTS)..."
STATUS_OUTPUT=$(xcrun notarytool info "$DMG_SUBMISSION_ID" \
--keychain-profile "notary-profile" \
--output-format json 2>&1)
STATUS=$(echo "$STATUS_OUTPUT" | grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*: *"\([^"]*\)".*/\1/')
echo "Status: $STATUS"
if [ "$STATUS" = "Accepted" ]; then
echo "DMG notarization successful!"
break
elif [ "$STATUS" = "Invalid" ] || [ "$STATUS" = "Rejected" ]; then
echo "DMG notarization failed with status: $STATUS"
xcrun notarytool log "$DMG_SUBMISSION_ID" --keychain-profile "notary-profile"
exit 1
fi
sleep 30
done
if [ "$STATUS" != "Accepted" ]; then
echo "DMG notarization timed out after 60 minutes"
exit 1
fi
# Staple the DMG
xcrun stapler staple "build/${{ env.APP_NAME }}-v${VERSION}.dmg"
- name: Create ZIP for Sparkle
run: |
VERSION="${{ steps.version.outputs.version }}"
cd build/export
zip -r -y "../${{ env.APP_NAME }}-v${VERSION}.zip" "${{ env.APP_NAME }}.app"
- name: Sign Update for Sparkle (EdDSA)
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
run: |
VERSION="${{ steps.version.outputs.version }}"
# Save private key to temp file
echo "$SPARKLE_PRIVATE_KEY" > $RUNNER_TEMP/sparkle_private_key
# Get Sparkle from the project's dependencies
SPARKLE_PATH=$(find ~/Library/Developer/Xcode/DerivedData -name "sign_update" -path "*/Sparkle.framework/*" 2>/dev/null | head -1)
if [ -z "$SPARKLE_PATH" ]; then
echo "Sparkle sign_update not found in DerivedData, downloading..."
curl -L -o sparkle.tar.xz "https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz"
mkdir -p sparkle
tar -xf sparkle.tar.xz -C sparkle
SPARKLE_PATH="sparkle/bin/sign_update"
fi
# Sign the update
SIGNATURE=$("$SPARKLE_PATH" --ed-key-file "$RUNNER_TEMP/sparkle_private_key" "build/${{ env.APP_NAME }}-v${VERSION}.zip")
echo "SPARKLE_SIGNATURE=$SIGNATURE" >> $GITHUB_ENV
echo "Sparkle signature: $SIGNATURE"
# Get file size
FILE_SIZE=$(stat -f%z "build/${{ env.APP_NAME }}-v${VERSION}.zip")
echo "FILE_SIZE=$FILE_SIZE" >> $GITHUB_ENV
echo "File size: $FILE_SIZE bytes"
# Clean up private key
rm -f $RUNNER_TEMP/sparkle_private_key
- name: Generate Release Notes
id: release_notes
run: |
VERSION="${{ steps.version.outputs.version }}"
# Get commits since last tag
PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREVIOUS_TAG" ]; then
COMMITS=$(git log --pretty=format:"- %s" $PREVIOUS_TAG..HEAD)
else
COMMITS=$(git log --pretty=format:"- %s" -n 20)
fi
cat > release_notes.md << EOF
## What's New in v${VERSION}
$COMMITS
---
### Installation
1. Download \`${{ env.APP_NAME }}-v${VERSION}.dmg\`
2. Open the DMG and drag **MyMacCleaner** to your **Applications** folder
3. Launch MyMacCleaner from Applications
**This release is signed and notarized by Apple** - no security warnings!
---
### For Developers: Sparkle Update Info
\`\`\`xml
<enclosure
url="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${{ env.APP_NAME }}-v${VERSION}.zip"
sparkle:edSignature="${SPARKLE_SIGNATURE}"
length="${FILE_SIZE}"
type="application/octet-stream"/>
\`\`\`
EOF
cat release_notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "MyMacCleaner v${{ steps.version.outputs.version }}"
body_path: release_notes.md
files: |
build/${{ env.APP_NAME }}-v${{ steps.version.outputs.version }}.dmg
build/${{ env.APP_NAME }}-v${{ steps.version.outputs.version }}.zip
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Update Website releases.json
run: |
VERSION="${{ steps.version.outputs.version }}"
DATE=$(date +%Y-%m-%d)
DMG_SIZE=$(ls -lh "build/${{ env.APP_NAME }}-v${VERSION}.dmg" | awk '{print $5}')
# Update releases.json using Python
python3 << PYTHON_SCRIPT
import json
import os
version = "${VERSION}"
date = "${DATE}"
dmg_size = "${DMG_SIZE}"
repo = "${{ github.repository }}"
with open("website/public/data/releases.json", "r") as f:
data = json.load(f)
# Mark all existing releases as not latest
for release in data["releases"]:
release["latest"] = False
# Create new release entry
new_release = {
"version": version,
"date": date,
"latest": True,
"minOS": "macOS 14.0+",
"architecture": "Universal (Apple Silicon + Intel)",
"downloads": {
"dmg": {
"url": f"https://github.com/{repo}/releases/download/v{version}/MyMacCleaner-v{version}.dmg",
"size": dmg_size
}
},
"changelog": [
{"type": "added", "description": "See GitHub release notes for full changelog"}
]
}
# Insert at beginning
data["releases"].insert(0, new_release)
# Keep only last 5 releases
data["releases"] = data["releases"][:5]
with open("website/public/data/releases.json", "w") as f:
json.dump(data, f, indent=2)
print(f"Updated releases.json with v{version}")
PYTHON_SCRIPT
- name: Commit and Push Website Update
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add website/public/data/releases.json
git commit -m "chore: update website releases.json for v${{ steps.version.outputs.version }}" || echo "No changes to commit"
git push origin HEAD:main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
- name: Upload Build Artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ env.APP_NAME }}-v${{ steps.version.outputs.version }}-signed
path: |
build/${{ env.APP_NAME }}-v${{ steps.version.outputs.version }}.dmg
build/${{ env.APP_NAME }}-v${{ steps.version.outputs.version }}.zip