name: Release on: push: tags: - 'v*.*.*' workflow_dispatch: inputs: version: description: 'Version to release (e.g. 2.2.0). Must match project.yml MARKETING_VERSION.' required: true type: string dry_run: description: 'Build + sign + notarize but skip release upload + homebrew bump.' required: false default: false type: boolean permissions: contents: write env: APP_NAME: PureMac BUNDLE_ID: com.puremac.app TEAM_ID: H3WXHVTP97 SCHEME: PureMac PROJECT: PureMac.xcodeproj CONFIGURATION: Release jobs: release: name: Build, sign, notarize, release runs-on: macos-15 timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Resolve version id: ver run: | if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then VERSION="${{ inputs.version }}" else VERSION="${GITHUB_REF_NAME#v}" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" echo "Releasing version: ${VERSION}" - name: Verify version matches project.yml run: | PROJ_VERSION=$(grep -E '^\s*MARKETING_VERSION:' project.yml | sed -E 's/.*"([^"]+)".*/\1/') if [[ "${PROJ_VERSION}" != "${{ steps.ver.outputs.version }}" ]]; then echo "::error::project.yml MARKETING_VERSION (${PROJ_VERSION}) != release version (${{ steps.ver.outputs.version }}). Bump project.yml first." exit 1 fi - name: Select Xcode run: sudo xcode-select -s /Applications/Xcode_16.4.app - name: Install xcodegen + create-dmg run: | brew install xcodegen create-dmg - name: Generate Xcode project run: xcodegen generate - name: Import Developer ID signing certificate env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | set -euo pipefail CERT_PATH="${RUNNER_TEMP}/certificate.p12" KEYCHAIN_PATH="${RUNNER_TEMP}/app-signing.keychain-db" echo -n "${BUILD_CERTIFICATE_BASE64}" | base64 --decode -o "${CERT_PATH}" security create-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" security set-keychain-settings -lut 21600 "${KEYCHAIN_PATH}" security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" security import "${CERT_PATH}" -P "${P12_PASSWORD}" -A -t cert -f pkcs12 -k "${KEYCHAIN_PATH}" security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${KEYCHAIN_PASSWORD}" "${KEYCHAIN_PATH}" >/dev/null security list-keychain -d user -s "${KEYCHAIN_PATH}" $(security list-keychain -d user | xargs) # Verify identity loaded security find-identity -v -p codesigning "${KEYCHAIN_PATH}" | grep "Developer ID Application: Moamen Basel" - name: Archive (universal arm64 + x86_64) run: | set -euo pipefail xcodebuild \ -project "${PROJECT}" \ -scheme "${SCHEME}" \ -configuration "${CONFIGURATION}" \ -destination 'generic/platform=macOS' \ -archivePath build/PureMac.xcarchive \ ARCHS="arm64 x86_64" \ ONLY_ACTIVE_ARCH=NO \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="Developer ID Application: Moamen Basel (H3WXHVTP97)" \ DEVELOPMENT_TEAM="${TEAM_ID}" \ OTHER_CODE_SIGN_FLAGS="--timestamp --options=runtime" \ archive | xcbeautify - name: Export signed app run: | set -euo pipefail cat > build/ExportOptions.plist <<'PLIST' methoddeveloper-id teamIDH3WXHVTP97 signingStylemanual signingCertificateDeveloper ID Application PLIST xcodebuild -exportArchive \ -archivePath build/PureMac.xcarchive \ -exportPath build/export \ -exportOptionsPlist build/ExportOptions.plist | xcbeautify - name: Verify codesign + hardened runtime run: | set -euo pipefail APP="build/export/PureMac.app" codesign --verify --deep --strict --verbose=2 "${APP}" codesign -dvv "${APP}" 2>&1 | tee /tmp/cs-info.txt grep -q "flags=0x10000(runtime)" /tmp/cs-info.txt || { echo "::error::Hardened runtime missing"; exit 1; } spctl --assess --type execute --verbose=4 "${APP}" || true # Universal arch check lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "x86_64" && lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "arm64" - name: Build DMG env: VERSION: ${{ steps.ver.outputs.version }} run: | set -euo pipefail DMG="build/PureMac-${VERSION}.dmg" create-dmg \ --volname "PureMac ${VERSION}" \ --window-size 540 360 \ --icon-size 100 \ --icon "PureMac.app" 140 180 \ --hide-extension "PureMac.app" \ --app-drop-link 400 180 \ --no-internet-enable \ "${DMG}" \ build/export/PureMac.app codesign --sign "Developer ID Application: Moamen Basel (H3WXHVTP97)" --timestamp "${DMG}" codesign --verify --verbose=2 "${DMG}" - name: Write App Store Connect API key env: APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} run: | set -euo pipefail KEY_PATH="${RUNNER_TEMP}/asc_api_key.p8" printf '%s' "${APP_STORE_CONNECT_PRIVATE_KEY}" > "${KEY_PATH}" chmod 600 "${KEY_PATH}" echo "ASC_KEY_PATH=${KEY_PATH}" >> "$GITHUB_ENV" - name: Notarize DMG env: ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} VERSION: ${{ steps.ver.outputs.version }} run: | set -euo pipefail DMG="build/PureMac-${VERSION}.dmg" xcrun notarytool submit "${DMG}" \ --key "${ASC_KEY_PATH}" \ --key-id "${ASC_KEY_ID}" \ --issuer "${ASC_ISSUER_ID}" \ --wait \ --timeout 30m xcrun stapler staple "${DMG}" xcrun stapler validate "${DMG}" spctl --assess --type install --verbose=4 "${DMG}" - name: Build notarized zip env: ASC_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} ASC_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} VERSION: ${{ steps.ver.outputs.version }} run: | set -euo pipefail ZIP="build/PureMac-${VERSION}.zip" # Notarize the .app inside a zip too (homebrew cask uses zip) ditto -c -k --keepParent --sequesterRsrc build/export/PureMac.app build/PureMac-app.zip xcrun notarytool submit build/PureMac-app.zip \ --key "${ASC_KEY_PATH}" \ --key-id "${ASC_KEY_ID}" \ --issuer "${ASC_ISSUER_ID}" \ --wait --timeout 30m xcrun stapler staple build/export/PureMac.app # Final shippable zip with stapled ticket ditto -c -k --keepParent --sequesterRsrc build/export/PureMac.app "${ZIP}" - name: Compute SHA256 checksums id: sha env: VERSION: ${{ steps.ver.outputs.version }} run: | set -euo pipefail DMG_SHA=$(shasum -a 256 "build/PureMac-${VERSION}.dmg" | awk '{print $1}') ZIP_SHA=$(shasum -a 256 "build/PureMac-${VERSION}.zip" | awk '{print $1}') echo "dmg=${DMG_SHA}" >> "$GITHUB_OUTPUT" echo "zip=${ZIP_SHA}" >> "$GITHUB_OUTPUT" { echo "## Checksums" echo "" echo "| Asset | SHA-256 |" echo "|-------|---------|" echo "| PureMac-${VERSION}.dmg | \`${DMG_SHA}\` |" echo "| PureMac-${VERSION}.zip | \`${ZIP_SHA}\` |" } > build/CHECKSUMS.md - name: Upload artifacts (always, for inspection) uses: actions/upload-artifact@v4 with: name: puremac-${{ steps.ver.outputs.version }} path: | build/PureMac-${{ steps.ver.outputs.version }}.dmg build/PureMac-${{ steps.ver.outputs.version }}.zip build/CHECKSUMS.md retention-days: 14 - name: Create or update GitHub release if: ${{ github.event_name == 'push' || inputs.dry_run == false }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.ver.outputs.version }} TAG: ${{ steps.ver.outputs.tag }} run: | set -euo pipefail NOTES_FILE=build/release-notes.md { echo "## PureMac ${VERSION}" echo "" echo "Universal binary (Intel + Apple Silicon). Signed with Developer ID + notarized + stapled by Apple." echo "" cat build/CHECKSUMS.md echo "" echo "### Install" echo "" echo "\`\`\`" echo "brew tap momenbasel/tap" echo "brew install --cask puremac" echo "\`\`\`" } > "${NOTES_FILE}" if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then gh release upload "${TAG}" \ "build/PureMac-${VERSION}.dmg" \ "build/PureMac-${VERSION}.zip" \ --repo "${GITHUB_REPOSITORY}" --clobber else gh release create "${TAG}" \ "build/PureMac-${VERSION}.dmg" \ "build/PureMac-${VERSION}.zip" \ --title "PureMac ${TAG}" \ --notes-file "${NOTES_FILE}" \ --repo "${GITHUB_REPOSITORY}" fi - name: Bump in-repo homebrew formula if: ${{ github.event_name == 'push' || inputs.dry_run == false }} env: VERSION: ${{ steps.ver.outputs.version }} ZIP_SHA: ${{ steps.sha.outputs.zip }} run: | set -euo pipefail FORMULA=homebrew/puremac.rb /usr/bin/sed -i '' -E "s/^ version \".*\"/ version \"${VERSION}\"/" "${FORMULA}" /usr/bin/sed -i '' -E "s/^ sha256 \".*\"/ sha256 \"${ZIP_SHA}\"/" "${FORMULA}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add "${FORMULA}" if git diff --cached --quiet; then echo "No formula changes to commit." else git commit -m "homebrew: bump to ${VERSION} (sha256 ${ZIP_SHA:0:12})" git push origin HEAD:main fi - name: Bump tap formula (momenbasel/homebrew-tap) # `env.HOMEBREW_TAP_TOKEN != ''` evaluated empty because env was # step-scoped — referencing the secret directly works in step `if` # per GitHub Actions docs. Also emits a loud warning when the secret # is absent so future runs don't silently skip again. if: ${{ github.event_name == 'push' || inputs.dry_run == false }} env: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} VERSION: ${{ steps.ver.outputs.version }} ZIP_SHA: ${{ steps.sha.outputs.zip }} run: | set -euo pipefail if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then echo "::warning title=Tap bump skipped::HOMEBREW_TAP_TOKEN secret is not set. Tap will stay at its previous version. Set the secret to enable auto-bumps." exit 0 fi TAP_DIR="${RUNNER_TEMP}/homebrew-tap" git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/momenbasel/homebrew-tap.git" "${TAP_DIR}" cd "${TAP_DIR}" # Cask path may be Casks/puremac.rb or puremac.rb depending on tap layout FORMULA=$(find . -name 'puremac.rb' -not -path './.git/*' | head -1) if [[ -z "${FORMULA}" ]]; then echo "::error::puremac.rb not found in tap" exit 1 fi /usr/bin/sed -i '' -E "s/^ version \".*\"/ version \"${VERSION}\"/" "${FORMULA}" /usr/bin/sed -i '' -E "s/^ sha256 \".*\"/ sha256 \"${ZIP_SHA}\"/" "${FORMULA}" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add "${FORMULA}" if git diff --cached --quiet; then echo "No tap formula changes." else git commit -m "puremac: bump to ${VERSION} (sha256 ${ZIP_SHA:0:12})" git push origin HEAD fi - name: Cleanup keychain + ASC key if: always() run: | security delete-keychain "${RUNNER_TEMP}/app-signing.keychain-db" || true rm -f "${RUNNER_TEMP}/asc_api_key.p8" || true