# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1). # # Tag v* (or workflow_dispatch): # macOS -> Developer ID signed + notarized + stapled .dmg, attached to a Gitea # release on tag pushes # iOS -> archive + upload straight to TestFlight (App Store Connect) # tvOS -> not built: the Rust core needs tier-3 targets (nightly -Zbuild-std) # macOS App Store/TestFlight -> deferred: needs App Sandbox entitlements first. # # One App Store listing for all platforms (universal purchase): every target shares the # bundle ID io.unom.punktfunk. # # Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's # logged-in Aqua session, so it uses the **login keychain** directly. Install the signing # identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer # ID Application + Apple Distribution, with the WWDR intermediate present (so they show as # *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain. # One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access: # security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \ # ~/Library/Keychains/login.keychain-db # # Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API # key — notarization, TestFlight upload, automatic-signing profile fetch). # # Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow # picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a # loud warning. name: release on: push: tags: ['v*'] workflow_dispatch: inputs: testflight: description: "Upload the iOS build to TestFlight (true/false)" required: false default: "true" jobs: apple: runs-on: macos-arm64 timeout-minutes: 120 env: TEAM_ID: F4H37KF6WC PROJECT: clients/apple/Punktfunk.xcodeproj steps: - uses: actions/checkout@v4 - name: Select release Xcode run: | DEV_DIR="" for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do case "$app" in *beta*|*Beta*) continue;; esac [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break done if [ -z "$DEV_DIR" ]; then for app in /Applications/Xcode*.app; do [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break done echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds." fi [ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; } # Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must # keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the # running dyld rejects, killing proc-macro loads (see build-xcframework.sh). echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV" DEVELOPER_DIR="$DEV_DIR" xcodebuild -version - name: Version from tag run: | case "$GITHUB_REF" in refs/tags/v*) V="${GITHUB_REF_NAME#v}" ;; *) V="0.0.${GITHUB_RUN_NUMBER}" ;; esac echo "VERSION=$V" >> "$GITHUB_ENV" echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" echo "version $V build $GITHUB_RUN_NUMBER" - name: Rust toolchain (mac + iOS + tvOS slices) run: | RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" dirname "$RUSTUP" >> "$GITHUB_PATH" "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \ aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios # tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with # nightly + -Zbuild-std, so ensure nightly + rust-src are present. "$RUSTUP" toolchain install nightly --profile minimal "$RUSTUP" component add rust-src --toolchain nightly - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS) run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh - name: Stage App Store Connect API key env: ASC_P8: ${{ secrets.ASC_API_KEY_P8 }} run: | printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8" chmod 600 "$RUNNER_TEMP/asc.p8" - name: macOS — archive, codesign Developer ID, notarize, DMG run: | # Archive UNSIGNED, then codesign with the Developer ID Application identity from the # login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups # provisioning-profile gate; codesign just needs the (now valid) identity + the # team-prefixed entitlement, no profile. Bundle is a single static binary. DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ -project "$PROJECT" -scheme Punktfunk \ -destination 'generic/platform=macOS' \ -archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ CODE_SIGNING_ALLOWED=NO APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app" # codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix. RESOLVED="$RUNNER_TEMP/macos.entitlements" sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \ clients/apple/Config/Punktfunk.entitlements > "$RESOLVED" codesign --force --options runtime --timestamp \ --entitlements "$RESOLVED" \ --sign "Developer ID Application" "$APP" codesign --verify --strict --verbose=2 "$APP" # Notarized DMG. STAGE="$RUNNER_TEMP/dmg-stage" mkdir -p "$STAGE" cp -R "$APP" "$STAGE/" ln -s /Applications "$STAGE/Applications" DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg" hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG" DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \ --key "$RUNNER_TEMP/asc.p8" \ --key-id "${{ secrets.ASC_API_KEY_ID }}" \ --issuer "${{ secrets.ASC_API_ISSUER_ID }}" DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG" echo "DMG=$DMG" >> "$GITHUB_ENV" - name: Attach DMG to Gitea release if: startsWith(gitea.ref, 'refs/tags/') env: TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | API="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" # Create the release (409 -> already exists, fetch it instead). ID=$(curl -sf -X POST "$API/releases" \ -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ -d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\"}" \ | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' \ || curl -sf "$API/releases/tags/$GITHUB_REF_NAME" -H "Authorization: token $TOKEN" \ | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])') curl -sf -X POST "$API/releases/$ID/assets?name=Punktfunk-$VERSION.dmg" \ -H "Authorization: token $TOKEN" \ -F "attachment=@$DMG" >/dev/null echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME" - name: iOS — archive + upload to TestFlight if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' # Best-effort until the App Store Connect app record for io.unom.punktfunk exists. continue-on-error: true run: | # MANUAL App Store signing: the local (valid) Apple Distribution identity + the App # Store provisioning profile. NOT -allowProvisioningUpdates — with an App-Manager-role # ASC key that forces Xcode's CLOUD-managed signing, which the role can't do ("Cloud # signing permission error"). The profile must be installed on the runner under # ~/Library/Developer/Xcode/UserData/Provisioning Profiles/ (install it once with # Xcode.app quit, or it prunes the manually-dropped distribution profile). # A running Xcode.app prunes unrecognized profiles from that dir — quit it so the App # Store profile survives this build; headless xcodebuild doesn't need the GUI app. osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true pkill -x Xcode 2>/dev/null || true PROFILE="Punktfunk iOS App Store Distribution" DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ -project "$PROJECT" -scheme Punktfunk-iOS \ -destination 'generic/platform=iOS' \ -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="Apple Distribution" \ DEVELOPMENT_TEAM="$TEAM_ID" \ PROVISIONING_PROFILE_SPECIFIER="$PROFILE" cat > "$RUNNER_TEMP/export-appstore.plist" < methodapp-store-connect destinationupload teamID$TEAM_ID signingStylemanual signingCertificateApple Distribution provisioningProfiles io.unom.punktfunk$PROFILE EOF DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ -exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \ -exportPath "$RUNNER_TEMP/export-appstore" \ -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" - name: tvOS — archive + upload to TestFlight if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' # Needs tvOS added to the App Store Connect app record + the tvOS platform installed # on the runner (xcodebuild -downloadPlatform tvOS). continue-on-error: true run: | # Same manual App Store signing as iOS (the App-Manager ASC key can't cloud-sign). osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true pkill -x Xcode 2>/dev/null || true PROFILE="Punktfunk tvOS App Store Distribution" DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ -project "$PROJECT" -scheme Punktfunk-tvOS \ -destination 'generic/platform=tvOS' \ -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ CODE_SIGN_STYLE=Manual \ CODE_SIGN_IDENTITY="Apple Distribution" \ DEVELOPMENT_TEAM="$TEAM_ID" \ PROVISIONING_PROFILE_SPECIFIER="$PROFILE" cat > "$RUNNER_TEMP/export-tvos.plist" < methodapp-store-connect destinationupload teamID$TEAM_ID signingStylemanual signingCertificateApple Distribution provisioningProfiles io.unom.punktfunk$PROFILE EOF DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ -exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \ -exportPath "$RUNNER_TEMP/export-tvos" \ -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"