fix: add GitHub upload verification to release script #34
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |