name: Release on: push: tags: - "v*.*.*" - "v*.*.*-*" workflow_dispatch: inputs: tag: description: "Tag to release, for example v0.9.2 or v0.9.2-beta.1" required: true type: string permissions: contents: write concurrency: group: release-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: false env: PROJECT_NAME: MacTools SCHEME: MacTools DERIVED_DATA: build/DerivedData ARTIFACT_DIR: build/release jobs: release: name: Build, sign, notarize, and release runs-on: macos-26 timeout-minutes: 60 steps: - name: Checkout uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.tag || github.ref }} fetch-depth: 0 - name: Set release metadata run: | TAG="${{ github.event.inputs.tag || github.ref_name }}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then echo "Release tag must look like v1.2.3 or v1.2.3-beta.1" >&2 exit 1 fi VERSION="$(awk '$1 == "MARKETING_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)" BUILD_NUMBER="$(awk '$1 == "CURRENT_PROJECT_VERSION" && $2 == "=" { print $3; exit }' Configs/AppVersion.xcconfig)" TAG_VERSION="${TAG#v}" if [[ -z "$VERSION" || -z "$BUILD_NUMBER" ]]; then echo "MARKETING_VERSION and CURRENT_PROJECT_VERSION must be set in Configs/AppVersion.xcconfig" >&2 exit 1 fi if [[ "$TAG_VERSION" != "$VERSION" ]]; then echo "Release tag $TAG does not match Configs/AppVersion.xcconfig MARKETING_VERSION $VERSION" >&2 exit 1 fi if [[ ! "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then echo "Configs/AppVersion.xcconfig CURRENT_PROJECT_VERSION must be numeric" >&2 exit 1 fi PRERELEASE="false" if [[ "$VERSION" == *"-"* || "$VERSION" == *"beta"* || "$VERSION" == *"alpha"* || "$VERSION" == *"rc"* ]]; then PRERELEASE="true" fi { echo "TAG=$TAG" echo "VERSION=$VERSION" echo "BUILD_NUMBER=$BUILD_NUMBER" echo "PRERELEASE=$PRERELEASE" echo "DMG_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.dmg" echo "SHA256_PATH=${ARTIFACT_DIR}/${TAG}/${PROJECT_NAME}.sha256" } >> "$GITHUB_ENV" - name: Validate required secrets env: APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }} BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | missing=0 for name in \ APPLE_DEVELOPMENT_TEAM \ BUNDLE_IDENTIFIER_PREFIX \ DEVELOPER_ID_CERT_P12 \ DEVELOPER_ID_CERT_PASSWORD \ ASC_API_KEY_P8_BASE64 \ ASC_API_KEY_ID \ ASC_API_ISSUER_ID \ SPARKLE_PRIVATE_KEY; do if [[ -z "${!name}" ]]; then echo "Missing required secret: $name" >&2 missing=1 fi done exit "$missing" - name: Show Xcode version run: xcodebuild -version - name: Install XcodeGen run: | if ! command -v xcodegen >/dev/null 2>&1; then brew install xcodegen fi - name: Write release local config env: APPLE_DEVELOPMENT_TEAM: ${{ secrets.APPLE_DEVELOPMENT_TEAM }} BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} run: | umask 077 cat > LocalConfig.xcconfig < "$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" \ -k "$KEYCHAIN_PATH" \ -P "$DEVELOPER_ID_CERT_PASSWORD" \ -T /usr/bin/codesign \ -T /usr/bin/security security set-key-partition-list \ -S apple-tool:,apple:,codesign: \ -s \ -k "$KEYCHAIN_PASSWORD" \ "$KEYCHAIN_PATH" security list-keychains -d user -s "$KEYCHAIN_PATH" SIGNING_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | awk -F'"' '/Developer ID Application/ { print $2; exit }')" if [[ -z "$SIGNING_IDENTITY" ]]; then echo "Developer ID Application identity was not found in imported certificate." >&2 exit 1 fi rm -f "$CERT_PATH" echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" echo "SIGNING_IDENTITY=$SIGNING_IDENTITY" >> "$GITHUB_ENV" - name: Generate Xcode project run: make generate - name: Build unsigned Release app run: | ./scripts/xcodebuild-filtered.sh \ -project "${PROJECT_NAME}.xcodeproj" \ -scheme "${SCHEME}" \ -configuration Release \ -destination "generic/platform=macOS" \ -derivedDataPath "${DERIVED_DATA}" \ MARKETING_VERSION="${VERSION}" \ CURRENT_PROJECT_VERSION="${BUILD_NUMBER}" \ CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGN_IDENTITY= \ build \ -quiet - name: Sign app bundle run: | APP_PATH="${DERIVED_DATA}/Build/Products/Release/${PROJECT_NAME}.app" ENTITLEMENTS="Configs/MacTools.entitlements" FINDER_SYNC_ENTITLEMENTS="Sources/Extensions/RightClickFinderSync/RightClickFinderSync.entitlements" SPARKLE_FRAMEWORK="$APP_PATH/Contents/Frameworks/Sparkle.framework" SPARKLE_CURRENT="$SPARKLE_FRAMEWORK/Versions/Current" sign_path() { /usr/bin/codesign \ --force \ --sign "$SIGNING_IDENTITY" \ --keychain "$KEYCHAIN_PATH" \ --options runtime \ --timestamp \ "$1" } sign_path_with_entitlements() { /usr/bin/codesign \ --force \ --sign "$SIGNING_IDENTITY" \ --keychain "$KEYCHAIN_PATH" \ --options runtime \ --timestamp \ --entitlements "$2" \ "$1" } sign_path_preserving_entitlements() { /usr/bin/codesign \ --force \ --sign "$SIGNING_IDENTITY" \ --keychain "$KEYCHAIN_PATH" \ --options runtime \ --timestamp \ --preserve-metadata=entitlements \ "$1" } if [[ -d "$SPARKLE_FRAMEWORK" ]]; then if [[ -d "$SPARKLE_CURRENT/XPCServices/Installer.xpc" ]]; then sign_path "$SPARKLE_CURRENT/XPCServices/Installer.xpc" fi if [[ -d "$SPARKLE_CURRENT/XPCServices/Downloader.xpc" ]]; then sign_path_preserving_entitlements "$SPARKLE_CURRENT/XPCServices/Downloader.xpc" fi if [[ -f "$SPARKLE_CURRENT/Autoupdate" ]]; then sign_path "$SPARKLE_CURRENT/Autoupdate" fi if [[ -d "$SPARKLE_CURRENT/Updater.app" ]]; then sign_path "$SPARKLE_CURRENT/Updater.app" fi fi while IFS= read -r binary; do [[ -n "$binary" ]] || continue sign_path "$binary" done < <(find "$APP_PATH/Contents" -type f \( -name "*.dylib" -o -name "*.so" \) -print) while IFS= read -r bundle; do [[ -n "$bundle" ]] || continue if [[ "$bundle" == *"/Sparkle.framework" || "$bundle" == *"/Sparkle.framework/"* ]]; then continue fi if [[ "$(basename "$bundle")" == "RightClickFinderSync.appex" ]]; then sign_path_with_entitlements "$bundle" "$FINDER_SYNC_ENTITLEMENTS" else sign_path "$bundle" fi done < <(find "$APP_PATH/Contents" -depth -type d \( -name "*.framework" -o -name "*.app" -o -name "*.xpc" -o -name "*.appex" \) -print) if [[ -d "$SPARKLE_FRAMEWORK" ]]; then sign_path "$SPARKLE_FRAMEWORK" fi /usr/bin/codesign \ --force \ --sign "$SIGNING_IDENTITY" \ --keychain "$KEYCHAIN_PATH" \ --options runtime \ --timestamp \ --entitlements "$ENTITLEMENTS" \ "$APP_PATH" FINDER_SYNC_APPEX="$APP_PATH/Contents/PlugIns/RightClickFinderSync.appex" FINDER_SYNC_EXTENSION_POINT="$(/usr/libexec/PlistBuddy -c "Print :NSExtension:NSExtensionPointIdentifier" "$FINDER_SYNC_APPEX/Contents/Info.plist")" if [[ "$FINDER_SYNC_EXTENSION_POINT" != "com.apple.FinderSync" ]]; then echo "Finder Sync extension point is invalid: $FINDER_SYNC_EXTENSION_POINT" >&2 exit 1 fi FINDER_SYNC_SIGNED_ENTITLEMENTS="$(/usr/bin/codesign -d --entitlements :- "$FINDER_SYNC_APPEX" 2>/dev/null || true)" if [[ "$FINDER_SYNC_SIGNED_ENTITLEMENTS" != *"com.apple.security.app-sandbox"* ]]; then echo "Finder Sync extension is missing app sandbox entitlement" >&2 exit 1 fi if [[ "$FINDER_SYNC_SIGNED_ENTITLEMENTS" != *"com.apple.security.temporary-exception.files.home-relative-path.read-only"* ]]; then echo "Finder Sync extension is missing right-click config read entitlement" >&2 exit 1 fi /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" - name: Create and sign DMG env: BUNDLE_IDENTIFIER_PREFIX: ${{ secrets.BUNDLE_IDENTIFIER_PREFIX }} run: | APP_PATH="${DERIVED_DATA}/Build/Products/Release/${PROJECT_NAME}.app" STAGE_DIR="$(mktemp -d "${RUNNER_TEMP}/mactools-dmg.XXXXXX")" mkdir -p "$(dirname "$DMG_PATH")" ditto "$APP_PATH" "$STAGE_DIR/${PROJECT_NAME}.app" ln -s /Applications "$STAGE_DIR/Applications" hdiutil create \ -volname "$PROJECT_NAME" \ -srcfolder "$STAGE_DIR" \ -ov \ -format UDZO \ "$DMG_PATH" >/dev/null /bin/rm -rf "$STAGE_DIR" /usr/bin/codesign \ --force \ --sign "$SIGNING_IDENTITY" \ --keychain "$KEYCHAIN_PATH" \ --timestamp \ --identifier "${BUNDLE_IDENTIFIER_PREFIX}.mactools.disk-image" \ "$DMG_PATH" /usr/bin/codesign --verify --verbose=2 "$DMG_PATH" - name: Notarize and staple DMG env: ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }} ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }} ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }} run: | API_KEY_PATH="$RUNNER_TEMP/AuthKey_${ASC_API_KEY_ID}.p8" echo "$ASC_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH" xcrun notarytool submit "$DMG_PATH" \ --key "$API_KEY_PATH" \ --key-id "$ASC_API_KEY_ID" \ --issuer "$ASC_API_ISSUER_ID" \ --wait \ --timeout 30m xcrun stapler staple "$DMG_PATH" spctl -a -t open --context context:primary-signature -v "$DMG_PATH" rm -f "$API_KEY_PATH" - name: Compute SHA256 run: | shasum -a 256 "$DMG_PATH" | tee "$SHA256_PATH" - name: Extract release notes from CHANGELOG run: | RELEASE_NOTES="$RUNNER_TEMP/release-notes.md" HIGHLIGHTS_PATH=".github/release-highlights/${TAG}.md" GENERATED_NOTES="$RUNNER_TEMP/release-notes.generated.md" python3 scripts/changelog.py extract \ --tag "$TAG" \ --output "$GENERATED_NOTES" if [[ -f "$HIGHLIGHTS_PATH" ]]; then cat "$HIGHLIGHTS_PATH" > "$RELEASE_NOTES" printf '\n\n' >> "$RELEASE_NOTES" cat "$GENERATED_NOTES" >> "$RELEASE_NOTES" else cat "$GENERATED_NOTES" > "$RELEASE_NOTES" fi SPARKLE_RELEASE_NOTES="$RUNNER_TEMP/sparkle-release-notes.md" python3 scripts/changelog.py compose-sparkle \ --tag "$TAG" \ --app-notes "$RELEASE_NOTES" \ --output "$SPARKLE_RELEASE_NOTES" { echo "RELEASE_NOTES_PATH=$RELEASE_NOTES" echo "SPARKLE_RELEASE_NOTES_PATH=$SPARKLE_RELEASE_NOTES" } >> "$GITHUB_ENV" - name: Generate Sparkle appcast env: SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} run: | SIGN_UPDATE="${DERIVED_DATA}/SourcePackages/artifacts/sparkle/Sparkle/bin/sign_update" if [[ ! -x "$SIGN_UPDATE" ]]; then SIGN_UPDATE="$(find "${DERIVED_DATA}/SourcePackages" -path "*/Sparkle/bin/sign_update" -type f -perm -111 | head -n 1)" fi if [[ -z "$SIGN_UPDATE" || ! -x "$SIGN_UPDATE" ]]; then echo "Sparkle sign_update tool was not found." >&2 exit 1 fi SPARKLE_KEY_PATH="$RUNNER_TEMP/sparkle_ed25519_key" printf '%s' "$SPARKLE_PRIVATE_KEY" > "$SPARKLE_KEY_PATH" SIGN_OUTPUT="$("$SIGN_UPDATE" "$DMG_PATH" --ed-key-file "$SPARKLE_KEY_PATH")" rm -f "$SPARKLE_KEY_PATH" ED_SIGNATURE="$(printf '%s' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p')" if [[ -z "$ED_SIGNATURE" ]]; then echo "Failed to extract Sparkle EdDSA signature." >&2 exit 1 fi FILE_SIZE="$(stat -f '%z' "$DMG_PATH")" DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PROJECT_NAME}.dmg" RELEASE_NOTES_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}" FULL_RELEASE_NOTES_URL="https://github.com/${GITHUB_REPOSITORY}/releases" PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')" MINIMUM_SYSTEM_VERSION="14.0" export ED_SIGNATURE FILE_SIZE DOWNLOAD_URL RELEASE_NOTES_URL FULL_RELEASE_NOTES_URL PUB_DATE MINIMUM_SYSTEM_VERSION python3 <<'PY_APPCAST' import os import xml.sax.saxutils as xml from pathlib import Path values = { key: os.environ[key] for key in [ "PROJECT_NAME", "VERSION", "BUILD_NUMBER", "DOWNLOAD_URL", "RELEASE_NOTES_URL", "FULL_RELEASE_NOTES_URL", "PUB_DATE", "FILE_SIZE", "ED_SIGNATURE", "MINIMUM_SYSTEM_VERSION", ] } release_notes = Path(os.environ["SPARKLE_RELEASE_NOTES_PATH"]).read_text(encoding="utf-8") cdata_release_notes = release_notes.replace("]]>", "]]]]>") content = f''' {xml.escape(values["PROJECT_NAME"])} Releases Latest release metadata for {xml.escape(values["PROJECT_NAME"])}. en Version {xml.escape(values["VERSION"])} {xml.escape(values["RELEASE_NOTES_URL"])} {xml.escape(values["BUILD_NUMBER"])} {xml.escape(values["VERSION"])} {xml.escape(values["FULL_RELEASE_NOTES_URL"])} {xml.escape(values["PUB_DATE"])} {xml.escape(values["MINIMUM_SYSTEM_VERSION"])} ''' Path("docs/appcast.xml").write_text(content, encoding="utf-8") PY_APPCAST python3 -c 'import xml.etree.ElementTree as ET; ET.parse("docs/appcast.xml")' - name: Upload notarized DMG artifact uses: actions/upload-artifact@v4 with: name: MacTools-${{ env.TAG }} path: | ${{ env.DMG_PATH }} ${{ env.SHA256_PATH }} if-no-files-found: error retention-days: 30 - name: Create or update GitHub Release env: GH_TOKEN: ${{ github.token }} run: | RELEASE_ARGS=(--title "${PROJECT_NAME} ${VERSION}" --notes-file "$RELEASE_NOTES_PATH") if [[ "$PRERELEASE" == "true" ]]; then RELEASE_ARGS+=(--prerelease) fi if gh release view "$TAG" >/dev/null 2>&1; then gh release upload "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" --clobber gh release edit "$TAG" "${RELEASE_ARGS[@]}" else gh release create "$TAG" "$DMG_PATH#${PROJECT_NAME}.dmg" "$SHA256_PATH#${PROJECT_NAME}.sha256" "${RELEASE_ARGS[@]}" fi - name: Open Homebrew tap update PR env: HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }} run: | TAP_REPOSITORY="ggbond268/homebrew-mactools" if [[ "$PRERELEASE" == "true" ]]; then echo "Skipping Homebrew tap update for prerelease ${TAG}." exit 0 fi if [[ -z "$HOMEBREW_GITHUB_API_TOKEN" ]]; then echo "Skipping Homebrew tap update because HOMEBREW_GITHUB_API_TOKEN is not configured." exit 0 fi export GH_TOKEN="$HOMEBREW_GITHUB_API_TOKEN" SHA256="$(awk '{ print $1; exit }' "$SHA256_PATH")" if [[ -z "$SHA256" ]]; then echo "Unable to read SHA256 from ${SHA256_PATH}." >&2 exit 1 fi DOWNLOAD_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${PROJECT_NAME}.dmg" export SHA256 DOWNLOAD_URL TAP_DIR="$(mktemp -d)" BRANCH="update-mactools-${VERSION}" gh repo clone "$TAP_REPOSITORY" "$TAP_DIR" cd "$TAP_DIR" gh auth setup-git git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -B "$BRANCH" mkdir -p Casks ruby -e 'File.write("Casks/mactools.rb", <<~CASK) cask "mactools" do version "#{ENV.fetch("VERSION")}" sha256 "#{ENV.fetch("SHA256")}" url "#{ENV.fetch("DOWNLOAD_URL")}" name "MacTools" desc "Menu bar toolbox" homepage "https://github.com/#{ENV.fetch("GITHUB_REPOSITORY")}" livecheck do url :url strategy :github_latest end auto_updates true depends_on macos: :sonoma app "MacTools.app" end CASK' brew style Casks/mactools.rb if git diff --quiet -- Casks/mactools.rb; then echo "Homebrew tap cask is already up to date." exit 0 fi git add Casks/mactools.rb git commit -m "Update mactools to ${VERSION}" git push --force-with-lease origin "$BRANCH" pr_number="$(gh pr list --repo "$TAP_REPOSITORY" --head "$BRANCH" --json number --jq '.[0].number // empty')" if [[ -n "$pr_number" ]]; then echo "Homebrew tap update PR already exists: #${pr_number}" else pr_url="$(gh pr create \ --repo "$TAP_REPOSITORY" \ --base main \ --head "$BRANCH" \ --title "Update mactools to ${VERSION}" \ --body "Updates the MacTools cask for ${TAG}.")" pr_number="${pr_url##*/}" echo "Created Homebrew tap update PR: #${pr_number}" fi gh pr merge "$pr_number" \ --repo "$TAP_REPOSITORY" \ --squash \ --delete-branch \ --subject "Update mactools to ${VERSION}" - name: Commit appcast to main run: | cp docs/appcast.xml "$RUNNER_TEMP/appcast.xml" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git fetch origin main git checkout -B main origin/main cp "$RUNNER_TEMP/appcast.xml" docs/appcast.xml git add docs/appcast.xml if git diff --cached --quiet; then echo "No appcast changes to commit." else git commit -m "ci: update appcast for ${TAG}" git push origin main fi - name: Cleanup keychain if: always() run: | if [[ -n "${KEYCHAIN_PATH:-}" ]]; then security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true fi