# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1). # # Tag v* (or workflow_dispatch): # macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a # Gitea release on tag pushes # macOS (App Store) -> archive + upload to TestFlight (App Store Connect) # iOS -> archive + upload straight to TestFlight (App Store Connect) # tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets, # nightly -Zbuild-std, in build-xcframework.sh) # # One App Store listing for all platforms (universal purchase): every target shares the # bundle ID io.unom.punktfunk. # # The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements — # app-sandbox + network client/server + audio-input + bluetooth/usb device access; the # shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid). # The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test # locally equals what App Store users get. # # macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the # step is continue-on-error until they exist): # * App Store Connect: add the macOS platform to the io.unom.punktfunk app record # (universal purchase). # * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the # runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/). # * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in # the runner's login keychain, in addition to "Apple Distribution" — the App Store # .pkg is installer-signed with it. # # 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: # Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's # own canary channel) — no notarized DMG (that's stable-only; see the per-step gates). # Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are # continue-on-error until the App Store Connect record exists, so this no-ops until then. branches: [main] paths: - 'clients/apple/**' - 'crates/punktfunk-core/**' - 'scripts/build-xcframework.sh' - 'Cargo.lock' - '.gitea/workflows/release.yml' # Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release # + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. 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="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc) *) V="0.3.0" ;; # canary marketing version; the build number disambiguates 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 # The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus # via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices). - name: CMake (for the vendored libopus audiopus_sys builds) run: | # Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH — # locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so # the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`. for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH" command -v cmake >/dev/null || "$BREW" install cmake echo "$BREW_BIN" >> "$GITHUB_PATH" # Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5 # `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake # inherits this from the env during the xcframework build). echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS) # tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on # the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the # same track as iOS/macOS (the nightly toolchain is installed unconditionally above). 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 # Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not # relevant to TestFlight testers (the canary channel). Skipped on canary main pushes. if: startsWith(gitea.ref, 'refs/tags/v') 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 entitlements, no profile (App Sandbox + the network/device # capabilities are self-asserted for Developer ID — no profile entry needed). # 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" \ -skipMacroValidation -skipPackagePluginValidation \ MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ CODE_SIGNING_ALLOWED=NO APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app" # Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build # uses. 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-macOS.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 the Gitea release (stable tags only) if: startsWith(gitea.ref, 'refs/tags/v') env: GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | . scripts/ci/gitea-release.sh RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg" - name: macOS App Store — archive + upload to TestFlight if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' # Best-effort until the App Store Connect record has the macOS platform + the # "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer # Installer" cert are on the runner (see the header). The macOS app is sandboxed # (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store. continue-on-error: true run: | # Separate archive from the Developer ID one above: App Store needs a profile-signed # archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager # ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates # (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile. osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true pkill -x Xcode 2>/dev/null || true PROFILE="Punktfunk macOS App Store Distribution" DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ -project "$PROJECT" -scheme Punktfunk \ -destination 'generic/platform=macOS' \ -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.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-macos-appstore.plist" < methodapp-store-connect destinationupload teamID$TEAM_ID signingStylemanual signingCertificateApple Distribution installerSigningCertificate3rd Party Mac Developer Installer provisioningProfiles io.unom.punktfunk$PROFILE EOF DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ -exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \ -exportPath "$RUNNER_TEMP/export-macos-appstore" \ -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" - 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" # Scope signing to the iOS device SDK via an xcconfig — see the tvOS step below for the # full rationale. A global (CLI) profile specifier would also be forced onto the shared # macOS-host SwiftPM macro plugins, which reject it and fail the archive; [sdk=iphoneos*] # in an xcconfig lands it on the app/framework slices only. SIGN_XCCONFIG="$RUNNER_TEMP/sign-ios.xcconfig" cat > "$SIGN_XCCONFIG" < "$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 # Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built # on every apple push (above), so this matches the iOS step's gate exactly. 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" # Scope signing to the tvOS device SDK via an xcconfig. A global (CLI) profile specifier # hits EVERY target, including the shared SwiftPM macro plugins (OnceMacro/SwizzlingMacro/ # AssociationMacro) which build for the macOS host and reject a provisioning profile # (" does not support provisioning profiles"), failing the archive. Conditionals # work only in an xcconfig (xcodebuild mis-parses a CLI "SETTING[sdk=..]=val"), and a # command-line -xcconfig outranks target settings, so [sdk=appletvos*] puts the profile on # the app/framework slices only — the macosx-host macros get nothing. (The macOS archive # above is immune: its host-SDK macros are CODE_SIGNING_ALLOWED=NO, so a global specifier # is ignored there.) SIGN_XCCONFIG="$RUNNER_TEMP/sign-tvos.xcconfig" cat > "$SIGN_XCCONFIG" < "$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 }}"