diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index b9933beb..3b4b5b76 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -274,7 +274,7 @@ jobs: retention-days: 30 # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. - # Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). + # Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). # main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached # to the unified Gitea Release. - name: Publish to generic registry + attach to Gitea release diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 2dde0ee7..4bd88fe1 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -1,12 +1,86 @@ -# Apple client CI — runs on the self-hosted macOS runner (home-mac-mini-1, host mode; -# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into -# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent -# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST. +# Apple client CI **and** distribution — everything that runs on the self-hosted macOS runner +# (home-mac-mini-1, host mode; see scripts/ci/setup-macos-runner.sh), in dependency order: # -# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI -# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches -# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated -# from the build/test job and best-effort, so a capture gap never reds the core signal. +# swift — build the Rust core into PunktfunkCore.xcframework, then build + test the Swift +# package. Network-dependent tests (RemoteFirstLightTests) self-skip without +# PUNKTFUNK_REMOTE_HOST. Runs on pushes, tags AND pull requests. +# distribute — needs: swift. The signed/notarized artifacts: +# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached +# to the Gitea release on tag pushes +# macOS (App Store) -> archive + upload to TestFlight (App Store Connect) +# iOS -> archive + upload to TestFlight, plus an exported .ipa +# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets, +# nightly -Zbuild-std, in build-xcframework.sh) +# screenshots — needs: swift. App Store Connect screenshots of the REAL UI, attached to the run +# as a zip artifact. Best-effort, so a capture gap never reds the core signal. +# +# ⚠ WHY THIS FILE IS ONE FILE. `distribute` used to live in its own workflow called `release.yml` — +# a name that described neither what it did (Apple only) nor how releases actually work here (every +# platform's packaging workflow attaches to the same Gitea release on a v* tag, and announce.yml is +# the manual "go"). The name was the smaller problem. The real one: Gitea has no cross-workflow +# `needs`, so nothing sequenced it against apple.yml's tests — a canary main push uploaded iOS, +# macOS and tvOS builds to TestFlight even when `swift test` had just failed on the same commit, +# and the two files' `paths:` filters had already drifted apart, so it was possible for one to fire +# without the other. Merging is what makes `needs: swift` expressible. Do not split them again. +# +# The trigger list is deliberately NARROW on crates/: everything here is built from +# `crates/punktfunk-core` (via scripts/build-xcframework.sh) and nothing else in the workspace. +# VERIFY THAT BEFORE WIDENING OR TRUSTING IT — punktfunk-core's only path dependency is its own +# vendored fec-rs, under crates/punktfunk-core/vendor/: +# sed -n '/^\[dependencies\]/,/^\[/p' crates/punktfunk-core/Cargo.toml | grep path +# If punktfunk-core ever gains a path dep on a sibling crate, add that crate here. Cargo.lock is a +# partial safety net (it moves when the dep is ADDED) but not a complete one — later edits to that +# crate would not fire this workflow. This is the same class of gap flatpak.yml documents. +# +# ── Signing / distribution notes (all of these belong to `distribute`) ──────────────────────────── +# +# 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 as the App Store build, +# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a +# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG +# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though +# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the +# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here. +# +# 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. +# +# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it): +# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID, +# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner +# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the +# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch +# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app +# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships +# a launchable app. +# +# 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: apple # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never @@ -19,30 +93,39 @@ concurrency: on: push: + # Canary: a relevant main push builds + tests, then 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, hence the tight paths filter. branches: [main] - # Scope canary builds to what this artifact is built FROM — a docs-only or - # web-only push should not light up the whole fleet. Applies to branch pushes; - # tag runs are matched by `tags:` (proven by flatpak/windows-msix releases). paths: - - 'crates/**' + - 'crates/punktfunk-core/**' - 'clients/apple/**' - 'scripts/build-xcframework.sh' + - 'scripts/ci/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - - 'scripts/ci/**' - '.gitea/workflows/apple.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. Tag runs are matched by + # `tags:` and are NOT subject to the paths filter above. + tags: ['v*'] pull_request: paths: - - 'crates/**' + - 'crates/punktfunk-core/**' - 'clients/apple/**' - 'scripts/build-xcframework.sh' + - 'scripts/ci/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - - 'scripts/ci/**' - '.gitea/workflows/apple.yml' workflow_dispatch: + inputs: + testflight: + description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)" + required: false + default: "true" # Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via # the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc @@ -61,11 +144,11 @@ env: jobs: # SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared - # with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR - # code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's - # "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral - # runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and - # still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI. + # with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could + # persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require + # approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The + # `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs + # same-repo PRs (and where the flag is absent), so it never blocks internal PR CI. swift: runs-on: macos-arm64 if: >- @@ -120,6 +203,475 @@ jobs: working-directory: clients/apple run: swift test + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + + # ── Distribution ──────────────────────────────────────────────────────────────────────────────── + # `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes + # a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job. + distribute: + needs: swift + # Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests. + if: gitea.event_name != 'pull_request' + 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: | + eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth) + 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="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates + esac + echo "VERSION=$V" >> "$GITHUB_ENV" + # GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs + # of different workflows get consecutive numbers. That is why folding the old release.yml + # into this file could not reset the build number and strand TestFlight, which rejects a + # non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1 + # — monotonic either way, which is all App Store Connect asks. + echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" + echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})" + + - 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" + + # Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh. + - name: sccache (self-healing install) + run: sh scripts/ci/ensure-sccache.sh + + - name: Pin + prune Xcode DerivedData + # Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the + # PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace + # (~/.cache/act//hostexecutor), so each rotation minted a brand new ~760 MB tree + # under ~/Library that nothing ever collected. 31 of them piled up in three days + # (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed + # v0.16.0's xcframework build with "No space left on device". Pinning one path makes the + # tree REUSED instead of multiplied — it also keeps the module cache warm between runs. + # + # The directory is still named `release` after the workflow this job used to live in. Left + # alone deliberately: renaming it would orphan a warm ~760 MB tree and buy nothing. + run: | + DD="$HOME/ci/derived-data/release" + mkdir -p "$DD" + echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV" + # Safety net for trees the pin does not own: the legacy per-path ones from before this + # change, and anything another job leaves in the default root. Untouched for a week ⇒ gone. + if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then + find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \ + -mtime +7 -exec rm -rf {} + 2>/dev/null || true + fi + echo "disk after prune:"; df -h /System/Volumes/Data | tail -1 + + - 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). + # + # This repeats the `swift` job's mac-slice build, and that is the intended trade: the two + # jobs share the runner's sccache and DerivedData, so the overlap is cheap, whereas passing + # an xcframework between jobs would mean uploading/downloading it through Gitea's artifact + # backend (the one that already forces upload-artifact@v3) on every run. + 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 at archive time; we re-assert that authorization below by + # EMBEDDING a Developer ID profile before codesign (see the keychain note further down). + # 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" \ + -derivedDataPath "$DERIVED_DATA" \ + -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" + + # keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the + # network/device keys are self-asserted for Developer ID, but a keychain access group + # must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to + # spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163), + # SIGKILL before main() — even though the bundle is validly signed and notarized. Embed + # a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its + # entitlements authorize the access group, exactly like the App Store build's profile + # does. Located by profile Name among the profiles installed on the runner (see header). + DEVID_PROFILE_NAME="Punktfunk macOS Developer ID" + PROFILE_SRC="" + for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \ + "$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do + [ -e "$p" ] || continue + NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true) + [ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break + done + if [ -n "$PROFILE_SRC" ]; then + # Must land BEFORE codesign so it's sealed into the bundle. + cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile" + echo "embedded Developer ID profile: $PROFILE_SRC" + else + # Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the + # managed entitlement and let ClientIdentityStore fall back to the legacy file keychain + # (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable. + echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain." + /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true + fi + + 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 signed, entitled + # archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign + # DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified + # profile (as this step used to do): the in-app license screens added a SwiftPM resource + # bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot + # carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an + # sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support + # provisioning profiles"). Automatic signing assigns a profile only to the app and leaves + # the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox + # entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and + # regenerate the managed *development* profile — needed because the App Groups capability + # (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached + # one. This is DEVELOPMENT signing against the Apple Development cert already in the + # keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export + # step below + # (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store + # distribution profile that export needs. + 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" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + 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: | + # Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full + # rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app + # license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER + # this step used to set matched it and failed the archive ("does not support provisioning + # profiles"). Automatic signing profiles only the app and leaves the resource bundle (and + # the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID + # capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk + # AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups + # capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension) + # invalidated the cached managed dev profile, which had no widgets profile at all. This is + # DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert + # creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles). + # DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected. + # A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed + # App Store distribution profile survives for export. + 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" + # The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second + # distribution artifact in the .ipa, so manual signing must map its App ID to its own + # App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets"). + WIDGET_PROFILE="Punktfunk iOS Widgets 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" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + cat > "$RUNNER_TEMP/export-appstore.plist" < + + + + methodapp-store-connect + destinationupload + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + + io.unom.punktfunk$PROFILE + io.unom.punktfunk.widgets$WIDGET_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: iOS — export .ipa (Gitea release + run artifact) + # The TestFlight step above uploads straight to App Store Connect (destination=upload) and + # leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an + # App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as + # that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent, + # e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs + # only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact. + if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + id: ios_ipa + run: | + ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive" + if [ ! -d "$ARCHIVE" ]; then + echo "::warning::iOS archive not found — skipping .ipa export" + exit 0 + fi + PROFILE="Punktfunk iOS App Store Distribution" + WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution" + # destination=export writes the .ipa to -exportPath; otherwise identical manual signing to + # the upload plist (both profiles, Apple Distribution). No ASC key needed — no network. + cat > "$RUNNER_TEMP/export-appstore-ipa.plist" < + + + + methodapp-store-connect + destinationexport + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + + io.unom.punktfunk$PROFILE + io.unom.punktfunk.widgets$WIDGET_PROFILE + + + + EOF + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \ + -exportPath "$RUNNER_TEMP/export-ipa" + SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1) + [ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; } + mkdir -p "$GITHUB_WORKSPACE/dist" + IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa" + mv "$SRC" "$IPA" + echo "IPA=$IPA" >> "$GITHUB_ENV" + echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT" + echo "exported $IPA" + + - name: Attach .ipa to the workflow run + if: steps.ios_ipa.outputs.ipa != '' + # v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses + # (same reason as android.yml / the screenshots job below). Download is a zip of the .ipa. + uses: actions/upload-artifact@v3 + with: + name: punktfunk-ios-ipa + path: ${{ steps.ios_ipa.outputs.ipa }} + if-no-files-found: warn + retention-days: 30 + + - name: Attach .ipa to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != '' + 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" "$IPA" "Punktfunk-$VERSION.ipa" + + - 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: | + # Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM + # resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the + # sdk-scoped profile this step used to set; Automatic signing profiles only the app and + # leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/ + # AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities + # and regenerate the managed *development* profile — the tvOS app carries the App Groups key + # (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against + # the Apple Development cert already in the keychain, so the App-Manager ASC key suffices. + # DISTRIBUTION signing is the export step below (manual, plist). + 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" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + 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 }}" + + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + # App Store screenshots of the real UI, zipped and attached to the run as a build artifact. # Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green # first, and is a separate job so a capture hiccup can never red the core signal. @@ -169,7 +721,7 @@ jobs: # inherits this from the env during the xcframework build). echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" - - name: Pin + prune DerivedData (same disease release.yml already cures) + - name: Pin + prune DerivedData (same disease the distribute job already cures) # screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two # fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA, # honored by the script) so repeat runs are incremental, and GC anything a week old diff --git a/.gitea/workflows/flatpak.yml b/.gitea/workflows/flatpak.yml index d7c8a3f8..94b02f01 100644 --- a/.gitea/workflows/flatpak.yml +++ b/.gitea/workflows/flatpak.yml @@ -7,7 +7,7 @@ # Gitea has NO flatpak/ostree registry, so the bundle lives in the generic registry: # PUT https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak// # GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak// -# On tags the bundle is ALSO attached to the Gitea release (mirrors release.yml's DMG). +# On tags the bundle is ALSO attached to the Gitea release (mirrors apple.yml's DMG). # # PRIVILEGED-BUILD CONSTRAINT: flatpak-builder runs bubblewrap, which needs user namespaces. # In a Gitea/act_runner Docker executor that means the job container must be --privileged @@ -37,7 +37,7 @@ on: # binary's dependency closure must be listed here — including the native decode rungs, or a # commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly # stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core - # and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead). + # and never enters the Linux closure (windows-client.yml carries it instead). paths: - 'clients/linux/**' - 'clients/session/**' diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index c99a2090..00000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,555 +0,0 @@ -# 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 as the App Store build, -# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a -# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG -# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though -# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the -# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here. -# -# 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. -# -# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it): -# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID, -# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner -# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the -# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch -# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app -# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships -# a launchable app. -# -# 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 -# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels -# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never -# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the -# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - -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" - -# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via -# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc -# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift -# side stays on DerivedData (sccache doesn't cache swiftc). -env: - RUSTC_WRAPPER: sccache - SCCACHE_BUCKET: unom-ci-sccache - SCCACHE_ENDPOINT: https://storage.unom.io - SCCACHE_REGION: home-central - AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} - # sccache and incremental compilation are mutually exclusive; the shared cache makes the - # runner's persistent target/ disposable instead of precious. - CARGO_INCREMENTAL: "0" - -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: | - eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth) - 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="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; 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 (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})" - - - 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" - - # Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is - # belt-and-braces. bsdtar (macOS) globs by default — no --wildcards. - - name: sccache (self-healing install) - run: | - if ! command -v sccache >/dev/null; then - mkdir -p "$HOME/.local/bin" - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \ - | tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache' - fi - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - sccache --version - - - name: Pin + prune Xcode DerivedData - # Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the - # PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace - # (~/.cache/act//hostexecutor), so each rotation minted a brand new ~760 MB tree - # under ~/Library that nothing ever collected. 31 of them piled up in three days - # (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed - # v0.16.0's xcframework build with "No space left on device". Pinning one path makes the - # tree REUSED instead of multiplied — it also keeps the module cache warm between runs. - run: | - DD="$HOME/ci/derived-data/release" - mkdir -p "$DD" - echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV" - # Safety net for trees the pin does not own: the legacy per-path ones from before this - # change, and anything another job leaves in the default root. Untouched for a week ⇒ gone. - if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then - find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \ - -mtime +7 -exec rm -rf {} + 2>/dev/null || true - fi - echo "disk after prune:"; df -h /System/Volumes/Data | tail -1 - - - 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 at archive time; we re-assert that authorization below by - # EMBEDDING a Developer ID profile before codesign (see the keychain note further down). - # 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" \ - -derivedDataPath "$DERIVED_DATA" \ - -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" - - # keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the - # network/device keys are self-asserted for Developer ID, but a keychain access group - # must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to - # spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163), - # SIGKILL before main() — even though the bundle is validly signed and notarized. Embed - # a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its - # entitlements authorize the access group, exactly like the App Store build's profile - # does. Located by profile Name among the profiles installed on the runner (see header). - DEVID_PROFILE_NAME="Punktfunk macOS Developer ID" - PROFILE_SRC="" - for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \ - "$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do - [ -e "$p" ] || continue - NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true) - [ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break - done - if [ -n "$PROFILE_SRC" ]; then - # Must land BEFORE codesign so it's sealed into the bundle. - cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile" - echo "embedded Developer ID profile: $PROFILE_SRC" - else - # Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the - # managed entitlement and let ClientIdentityStore fall back to the legacy file keychain - # (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable. - echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain." - /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true - fi - - 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 signed, entitled - # archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign - # DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified - # profile (as this step used to do): the in-app license screens added a SwiftPM resource - # bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot - # carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an - # sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support - # provisioning profiles"). Automatic signing assigns a profile only to the app and leaves - # the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox - # entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and - # regenerate the managed *development* profile — needed because the App Groups capability - # (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached - # one. This is DEVELOPMENT signing against the Apple Development cert already in the - # keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export - # step below - # (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store - # distribution profile that export needs. - 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" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - 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: | - # Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full - # rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app - # license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER - # this step used to set matched it and failed the archive ("does not support provisioning - # profiles"). Automatic signing profiles only the app and leaves the resource bundle (and - # the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID - # capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk - # AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups - # capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension) - # invalidated the cached managed dev profile, which had no widgets profile at all. This is - # DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert - # creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles). - # DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected. - # A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed - # App Store distribution profile survives for export. - 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" - # The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second - # distribution artifact in the .ipa, so manual signing must map its App ID to its own - # App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets"). - WIDGET_PROFILE="Punktfunk iOS Widgets 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" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - cat > "$RUNNER_TEMP/export-appstore.plist" < - - - - methodapp-store-connect - destinationupload - teamID$TEAM_ID - signingStylemanual - signingCertificateApple Distribution - provisioningProfiles - - io.unom.punktfunk$PROFILE - io.unom.punktfunk.widgets$WIDGET_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: iOS — export .ipa (Gitea release + run artifact) - # The TestFlight step above uploads straight to App Store Connect (destination=upload) and - # leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an - # App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as - # that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent, - # e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs - # only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact. - if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' - id: ios_ipa - run: | - ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive" - if [ ! -d "$ARCHIVE" ]; then - echo "::warning::iOS archive not found — skipping .ipa export" - exit 0 - fi - PROFILE="Punktfunk iOS App Store Distribution" - WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution" - # destination=export writes the .ipa to -exportPath; otherwise identical manual signing to - # the upload plist (both profiles, Apple Distribution). No ASC key needed — no network. - cat > "$RUNNER_TEMP/export-appstore-ipa.plist" < - - - - methodapp-store-connect - destinationexport - teamID$TEAM_ID - signingStylemanual - signingCertificateApple Distribution - provisioningProfiles - - io.unom.punktfunk$PROFILE - io.unom.punktfunk.widgets$WIDGET_PROFILE - - - - EOF - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ - -archivePath "$ARCHIVE" \ - -exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \ - -exportPath "$RUNNER_TEMP/export-ipa" - SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1) - [ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; } - mkdir -p "$GITHUB_WORKSPACE/dist" - IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa" - mv "$SRC" "$IPA" - echo "IPA=$IPA" >> "$GITHUB_ENV" - echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT" - echo "exported $IPA" - - - name: Attach .ipa to the workflow run - if: steps.ios_ipa.outputs.ipa != '' - # v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses - # (same reason as android.yml / apple.yml). Download is a zip of the .ipa. - uses: actions/upload-artifact@v3 - with: - name: punktfunk-ios-ipa - path: ${{ steps.ios_ipa.outputs.ipa }} - if-no-files-found: warn - retention-days: 30 - - - name: Attach .ipa to the Gitea release (stable tags only) - if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != '' - 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" "$IPA" "Punktfunk-$VERSION.ipa" - - - 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: | - # Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM - # resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the - # sdk-scoped profile this step used to set; Automatic signing profiles only the app and - # leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/ - # AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities - # and regenerate the managed *development* profile — the tvOS app carries the App Groups key - # (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against - # the Apple Development cert already in the keychain, so the App-Manager ASC key suffices. - # DISTRIBUTION signing is the export step below (manual, plist). - 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" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - 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 }}" diff --git a/.gitea/workflows/sbom.yml b/.gitea/workflows/sbom.yml index 8cfc4f34..5a58c804 100644 --- a/.gitea/workflows/sbom.yml +++ b/.gitea/workflows/sbom.yml @@ -67,7 +67,7 @@ jobs: . scripts/ci/gitea-release.sh RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) upsert_asset "$RID" "$SBOM_FILE" - # v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml). + # v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see apple.yml). - name: Upload artifact (non-tag runs) if: "!startsWith(github.ref, 'refs/tags/')" uses: actions/upload-artifact@v3 diff --git a/.gitea/workflows/windows-client.yml b/.gitea/workflows/windows-client.yml new file mode 100644 index 00000000..684a0891 --- /dev/null +++ b/.gitea/workflows/windows-client.yml @@ -0,0 +1,318 @@ +# Windows CLIENT — build, lint, test and package, on a self-hosted windows-amd64 runner (host mode; +# the generic runner + MSVC/WinUI toolchain come from unom/infra's windows-runner/, punktfunk's own +# extras — WDK, Inno Setup, the ARM64 rustup target — self-provision via the "Ensure Windows +# toolchain" step, a fast no-op once present, so any runner with that label works). +# +# Covers BOTH client binaries: the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the +# punktfunk-session Vulkan client (pf-presenter/pf-client-core/pf-console-ui — every stream runs in +# it, spawned by the shell), plus punktfunk-cli, whose `punktfunk.exe` alias the MSIX manifest +# references. +# +# ⚠ WHY THIS IS ONE FILE. This was `windows.yml` (build+lint+test, DEBUG, x64 + arm64) and +# `windows-msix.yml` (build+package, RELEASE, x64 + arm64) — four full compiles of the same crates +# per client push, on ONE runner, from three copies of the same `paths:` list that had already +# started to drift. windows-host.yml learned the hard way that debug trees on this machine are pure +# liability: a second dep tree tips it into `cabac_decoder.cpp: fatal error C1069` building +# openh264-sys2's vendored C++, which is disk/temp exhaustion, not a source error. So there is now +# ONE release build per arch, and clippy/fmt/test run against it. Do not reintroduce a debug leg. +# +# Renamed from windows-msix.yml deliberately, and safely: `github.run_number` is REPO-WIDE in Gitea +# (consecutive runs of DIFFERENT workflows get consecutive numbers), so the canary MSIX version +# `..0` keeps climbing across the rename — on GitHub, where run_number is per-workflow, +# this same rename would have reset it to 1 and made every canary sort below the published ones. +# +# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and aarch64-pc-windows-msvc +# by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler +# (VC\Tools\MSVC\\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust target +# with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 compiler from +# the target triple (SDL3 + libopus build-from-source cross-compile fine). The one thing the aarch64 +# build can't do is *run* on the x64 host, so fmt + test run only for x64. +# +# ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the session builds +# --no-default-features there (no Skia console UI; streaming is unaffected) — flip when +# skia-binaries adds the target. +# +# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with +# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets +# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning +# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's +# `amf-qsv` leg link-imports them. +# +# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME, +# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout / per-arch +# vars are set in a step: +# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under +# C:\Windows\System32\config\systemprofile\.cache\act\\hostexecutor\, +# so the default target\ path blows past Windows' MAX_PATH (260) inside the +# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then +# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short +# root keeps every nested path well under the limit (per-arch so the two +# matrix legs don't share a target dir). +# +# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's +# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that +# var silently never gets set). pwsh writes no BOM. +# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh. +# +# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ────────────────────── +# +# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes +# can install a real package (Start tile, clean install/uninstall) instead of a loose exe. +# +# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) +# Packaging internals: clients/windows/packaging/README.md. +# +# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so: +# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX). +# Published to the generic registry + the stable `latest/` alias + attached to the +# unified Gitea Release alongside every other platform's artifact. +# main push / dispatch -> ..0 (canary; base is one minor ahead of the +# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically). +# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). +# +# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD +# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the +# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public +# .cer is published next to the .msix (users import it to Trusted People before install). +# +# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret +# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that +# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself. +name: windows-client +# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels +# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never +# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the +# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + +on: + push: + branches: [main] + # ONE list now, not three. The old windows.yml + windows-msix.yml pair carried this same set + # three times (push, pull_request, and the second file), which is exactly how a crate goes + # missing from one copy — windows-host.yml documents the "Cargo.lock luck" gap that produced. + paths: + - 'clients/windows/**' + - 'clients/session/**' + - 'crates/punktfunk-core/**' + - 'crates/pf-client-core/**' + - 'crates/pf-presenter/**' + - 'crates/pf-console-ui/**' + - 'crates/pf-bitstream/**' + - 'crates/pf-vkdecode/**' + - 'crates/pf-dxvadec/**' + - 'Cargo.lock' + - 'Cargo.toml' + - '.gitea/workflows/windows-client.yml' + tags: ['v*'] + pull_request: + paths: + - 'clients/windows/**' + - 'clients/session/**' + - 'crates/punktfunk-core/**' + - 'crates/pf-client-core/**' + - 'crates/pf-presenter/**' + - 'crates/pf-console-ui/**' + - 'crates/pf-bitstream/**' + - 'crates/pf-vkdecode/**' + - 'crates/pf-dxvadec/**' + - 'Cargo.lock' + - 'Cargo.toml' + - '.gitea/workflows/windows-client.yml' + workflow_dispatch: + +# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's +# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can +# never collide; every Rust job on every host feeds and reads one warm cache. +# +# NOTE the C/C++ launcher wiring the Linux workflows carry (CMAKE_*_COMPILER_LAUNCHER, CC_*) is +# deliberately NOT set here. This runner's failure mode under extra compiler processes is the +# C1069 disk/temp exhaustion documented in windows-host.yml, so sccache-for-MSVC is its own +# change, to be made with a measurement rather than folded into a reorganisation. +env: + REGISTRY: git.unom.io + OWNER: unom + PKG: punktfunk-client-windows + RUSTC_WRAPPER: sccache + SCCACHE_BUCKET: unom-ci-sccache + SCCACHE_ENDPOINT: https://storage.unom.io + SCCACHE_REGION: home-central + AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # sccache and incremental compilation are mutually exclusive; CI wants the shared + # cache, dev boxes keep incremental. + CARGO_INCREMENTAL: "0" + +jobs: + # SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the + # host-mode, persistent `windows-amd64` runner that the release-SIGNING steps below and + # windows-host.yml (which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted + # PR code could therefore persist on that machine or harvest signing material a later job exposes. + # The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to + # run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral + # runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag, + # and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it + # never blocks internal PR CI. + client: + runs-on: windows-amd64 + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.fork != true + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + target: x86_64-pc-windows-msvc + td: C:\t + session_flags: '' + - arch: arm64 + target: aarch64-pc-windows-msvc + td: C:\t-a64 + # No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships + # without the Skia console UI on ARM64 (streaming unaffected) — flip when + # rust-skia adds the target. + session_flags: '--no-default-features' + steps: + - uses: actions/checkout@v4 + + - name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target) + shell: pwsh + run: ./scripts/ci/ensure-windows-toolchain.ps1 + + - name: Configure + version + shell: pwsh + run: | + # CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source + # crates (see this file's header). No FFMPEG_DIR: nothing in this package links libav* + # (M10), and pack-msix.ps1 no longer copies runtime DLLs from one. + "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + rustup target add ${{ matrix.target }} + rustc --version + cargo --version + $pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag + $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') { + # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix. + (($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.') + } else { + # Canary: ...0 — major.minor track one minor ahead of stable, run climbs monotonically. + @($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER) + } + while ($parts.Count -lt 4) { $parts += '0' } + $v = ($parts[0..3] -join '.') + "MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }} target-dir ${{ matrix.td }}" + + # All three client binaries, ONCE, in release. The shell spawns punktfunk-session.exe (a + # package sibling) for every stream, and punktfunk-cli builds the `punktfunk.exe` the manifest + # aliases and pack-msix.ps1 requires (bf981027 added the requirement without the build — the + # same gap 90c84ef4 closed for deb). --no-default-features on ARM64 is a no-op for the shell. + # + # Release, not debug, even for the lint/test legs below: a debug build here would compile the + # whole dep tree into a SECOND target dir and re-run openh264-sys2's vendored C++ through + # cc-rs's cl.exe fan-out, which is what tips this runner into C1069 (see the header). + - name: Build (release) + shell: pwsh + run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }} + + - name: Clippy (-D warnings) + shell: pwsh + run: | + # Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a + # dependency but only LINTS the packages it is given, so a decode crate that starts the + # run but is missing from this list would be gated by nothing. + $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec') + $sf = @() + if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') } + cargo clippy --release @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings + if ($LASTEXITCODE) { throw "clippy" } + + - name: Rustfmt check + if: matrix.arch == 'x64' + shell: pwsh + run: | + cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check + if ($LASTEXITCODE) { throw "rustfmt" } + + - name: Test + # x64 only: the aarch64 binaries cross-compile here but cannot RUN on this host. + if: matrix.arch == 'x64' + shell: pwsh + run: | + cargo test --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }} + if ($LASTEXITCODE) { throw "tests" } + + - name: sccache stats (visibility only) + if: always() + shell: pwsh + run: sccache --show-stats + + # ── Packaging: pushes, tags and dispatch only. A PR gets the build/lint/test signal above and + # stops there — packing would sign with a throwaway cert and publish nothing. + - name: Pack + sign MSIX + if: github.event_name != 'pull_request' + shell: pwsh + env: + MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} + MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} + run: | + & clients/windows/packaging/pack-msix.ps1 ` + -Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} ` + -TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix + + - name: Publish to Gitea generic registry + if: github.event_name != 'pull_request' + shell: pwsh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + $PSNativeCommandUseErrorActionPreference = $false + $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" + # stable release -> `latest/` alias; canary main build -> `canary/` alias. + $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' } + # version-less, arch-suffixed alias names so each channel keeps one predictable URL. + $aliasNames = @{ + "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" + "$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer" + } + $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } + if (-not $files) { throw "pack produced no artifacts to publish" } + function Put($f, $url) { + # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag + # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel + # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the + # status code instead of aborting on it. + $code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url") + if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" } + if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return } + if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" } + Write-Output "published ($code): $url" + } + foreach ($f in $files) { + $name = Split-Path $f -Leaf + # 1) immutable, versioned path + Put $f "$base/$($env:MSIX_VERSION)/$name" + # 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file) + $an = $aliasNames["$f"] + curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null + Put $f "$base/$alias/$an" + } + + # On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both + # arch legs attach to the same release concurrently — the helper's create-or-fetch handles + # the race, and x64/arm64 filenames differ so the assets don't collide. + - name: Attach MSIX to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + shell: pwsh + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.ps1 + $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' + foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) { + if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } + } diff --git a/.gitea/workflows/windows-drivers.yml b/.gitea/workflows/windows-drivers.yml index e77da010..51cf88cd 100644 --- a/.gitea/workflows/windows-drivers.yml +++ b/.gitea/workflows/windows-drivers.yml @@ -9,7 +9,7 @@ # only live NVENC encode does, which defers to the RTX box. # # shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the -# first GITHUB_ENV line — see windows.yml). +# first GITHUB_ENV line — see windows-client.yml). name: windows-drivers # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never @@ -39,7 +39,7 @@ on: jobs: # SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with - # the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full + # the release-signing jobs (windows-host.yml / windows-client.yml). See windows-client.yml for the full # rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR # runners); the `if:` is a fail-open backstop that never blocks internal PR CI. probe-and-proto: @@ -111,7 +111,7 @@ jobs: - name: Build + test pf-driver-proto (MSVC) run: | - # Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml). + # Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows-client.yml). $env:CARGO_TARGET_DIR = "C:\t\drv" cargo build -p pf-driver-proto cargo test -p pf-driver-proto @@ -144,7 +144,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target) - # Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so + # Shared self-provision step (also used by windows-client.yml/windows-host.yml) so # driver-build is self-sufficient on any windows-amd64 runner and never races a manually # dispatched provisioning workflow landing on a different one. Path is relative to the job # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present. diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index e380bf11..8e30c1d1 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -3,7 +3,7 @@ # pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner, # run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted # windows-amd64 runner -# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's +# (host mode; same MSVC/Windows-SDK/LLVM env as windows-client.yml — generic from unom/infra's # windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below). # # Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that @@ -143,7 +143,7 @@ jobs: "CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 # FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by # scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10 - # it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone + # it links no libav* at all (windows-client.yml sets no FFMPEG_DIR), so this tree is the HOST's alone # and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend # (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1 # then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env. diff --git a/.gitea/workflows/windows-msix.yml b/.gitea/workflows/windows-msix.yml deleted file mode 100644 index 59b1d9d2..00000000 --- a/.gitea/workflows/windows-msix.yml +++ /dev/null @@ -1,196 +0,0 @@ -# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to -# Gitea's generic package registry, so Windows boxes can download + install a real package (Start -# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64 -# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest -# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the -# Windows SDK's makeappx/signtool are baked into the runner's daemon env. -# -# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC -# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the -# BOM/MAX_PATH runner gotchas. -# -# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the -# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer -# (windows-host.yml) is unchanged. -# -# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) -# Packaging internals: clients/windows/packaging/README.md. -# -# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so: -# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX). -# Published to the generic registry + the stable `latest/` alias + attached to the -# unified Gitea Release alongside every other platform's artifact. -# main push / dispatch -> ..0 (canary; base is one minor ahead of the -# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically). -# Published to the generic registry + the `canary/` alias. -# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). -# -# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD -# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the -# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public -# .cer is published next to the .msix (users import it to Trusted People before install). -# -# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret -# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that -# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself. -name: windows-msix -# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels -# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never -# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the -# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - -on: - push: - branches: [main] - paths: - - 'clients/windows/**' - - 'clients/session/**' - - 'crates/punktfunk-core/**' - - 'crates/pf-client-core/**' - - 'crates/pf-presenter/**' - - 'crates/pf-console-ui/**' - - 'crates/pf-bitstream/**' - - 'crates/pf-vkdecode/**' - - 'crates/pf-dxvadec/**' - - 'Cargo.lock' - - 'Cargo.toml' - - '.gitea/workflows/windows-msix.yml' - tags: ['v*'] - workflow_dispatch: - -env: - REGISTRY: git.unom.io - OWNER: unom - PKG: punktfunk-client-windows - RUSTC_WRAPPER: sccache - SCCACHE_BUCKET: unom-ci-sccache - SCCACHE_ENDPOINT: https://storage.unom.io - SCCACHE_REGION: home-central - AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} - # sccache and incremental compilation are mutually exclusive; CI wants the shared - # cache, dev boxes keep incremental. - CARGO_INCREMENTAL: "0" - -jobs: - package: - runs-on: windows-amd64 - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - arch: x64 - target: x86_64-pc-windows-msvc - td: C:\t - session_flags: '' - - arch: arm64 - target: aarch64-pc-windows-msvc - td: C:\t-a64 - # No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships - # without the Skia console UI on ARM64 (streaming unaffected) — flip when - # rust-skia adds the target. - session_flags: '--no-default-features' - steps: - - uses: actions/checkout@v4 - - - name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target) - shell: pwsh - run: ./scripts/ci/ensure-windows-toolchain.ps1 - - - name: Configure + version - shell: pwsh - run: | - # CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source - # crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10), - # and pack-msix.ps1 no longer copies runtime DLLs from one. - "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - rustup target add ${{ matrix.target }} - $pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag - $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') { - # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix. - (($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.') - } else { - # Canary: ...0 — major.minor track one minor ahead of stable, run climbs monotonically. - @($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER) - } - while ($parts.Count -lt 4) { $parts += '0' } - $v = ($parts[0..3] -join '.') - "MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}" - - # All three client binaries — the shell spawns punktfunk-session.exe (a package - # sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's - # hand-off shim. --no-default-features on ARM64 is a no-op for the shell. - - name: Build (release) - shell: pwsh - # punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1 - # requires (bf981027 added the requirement without the build — same gap 90c84ef4 - # closed for deb). - run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }} - - - name: Pack + sign MSIX - shell: pwsh - env: - MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} - MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} - run: | - & clients/windows/packaging/pack-msix.ps1 ` - -Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} ` - -TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix - - - name: Publish to Gitea generic registry - shell: pwsh - env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - $PSNativeCommandUseErrorActionPreference = $false - $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" - # stable release -> `latest/` alias; canary main build -> `canary/` alias. - $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' } - # version-less, arch-suffixed alias names so each channel keeps one predictable URL. - $aliasNames = @{ - "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" - "$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer" - } - $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } - if (-not $files) { throw "pack produced no artifacts to publish" } - function Put($f, $url) { - # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag - # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel - # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the - # status code instead of aborting on it. - $code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url") - if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" } - if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return } - if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" } - Write-Output "published ($code): $url" - } - foreach ($f in $files) { - $name = Split-Path $f -Leaf - # 1) immutable, versioned path - Put $f "$base/$($env:MSIX_VERSION)/$name" - # 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file) - $an = $aliasNames["$f"] - curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null - Put $f "$base/$alias/$an" - } - - # On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both - # arch legs attach to the same release concurrently — the helper's create-or-fetch handles - # the race, and x64/arm64 filenames differ so the assets don't collide. - - name: Attach MSIX to the Gitea release (stable tags only) - if: startsWith(gitea.ref, 'refs/tags/v') - shell: pwsh - env: - GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - . scripts/ci/gitea-release.ps1 - $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' - foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) { - if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } - } diff --git a/.gitea/workflows/windows.yml b/.gitea/workflows/windows.yml deleted file mode 100644 index d6d4d7e3..00000000 --- a/.gitea/workflows/windows.yml +++ /dev/null @@ -1,169 +0,0 @@ -# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner + -# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup, -# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast -# no-op once already present, so any runner with that label works with no manual dispatch step -# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell -# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client -# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the -# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the -# session builds --no-default-features there (no Skia console UI; streaming is unaffected) — -# flip when skia-binaries adds the target. -# -# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with -# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets -# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning -# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's -# `amf-qsv` leg link-imports them. -# -# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and -# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler -# (VC\Tools\MSVC\\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust -# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 -# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one -# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64. -# -# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME, -# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout -# / per-arch vars are set in a step: -# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under -# C:\Windows\System32\config\systemprofile\.cache\act\\hostexecutor\, -# so the default target\ path blows past Windows' MAX_PATH (260) inside the -# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then -# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short -# root keeps every nested path well under the limit (per-arch so the two -# matrix legs don't share a target dir). -# -# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's -# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that -# var silently never gets set). pwsh writes no BOM. -# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh. -name: windows -# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels -# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never -# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the -# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - -on: - push: - branches: [main] - paths: - - 'clients/windows/**' - - 'clients/session/**' - - 'crates/punktfunk-core/**' - - 'crates/pf-client-core/**' - - 'crates/pf-presenter/**' - - 'crates/pf-console-ui/**' - - 'crates/pf-bitstream/**' - - 'crates/pf-vkdecode/**' - - 'crates/pf-dxvadec/**' - - 'Cargo.lock' - - 'Cargo.toml' - - '.gitea/workflows/windows.yml' - pull_request: - paths: - - 'clients/windows/**' - - 'clients/session/**' - - 'crates/punktfunk-core/**' - - 'crates/pf-client-core/**' - - 'crates/pf-presenter/**' - - 'crates/pf-console-ui/**' - - 'crates/pf-bitstream/**' - - 'crates/pf-vkdecode/**' - - 'crates/pf-dxvadec/**' - - 'Cargo.lock' - - 'Cargo.toml' - - '.gitea/workflows/windows.yml' - workflow_dispatch: - -# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's -# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can -# never collide; every Rust job on every host feeds and reads one warm cache. -env: - RUSTC_WRAPPER: sccache - SCCACHE_BUCKET: unom-ci-sccache - SCCACHE_ENDPOINT: https://storage.unom.io - SCCACHE_REGION: home-central - AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} - # sccache and incremental compilation are mutually exclusive; CI wants the shared - # cache, dev boxes keep incremental. - CARGO_INCREMENTAL: "0" - -jobs: - # SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the - # host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml / - # windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted - # PR code could therefore persist on that machine or harvest signing material a later job exposes. - # The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to - # run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral - # runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag, - # and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it - # never blocks internal PR CI. - build: - runs-on: windows-amd64 - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork != true - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc] - steps: - - uses: actions/checkout@v4 - - - name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target) - shell: pwsh - run: ./scripts/ci/ensure-windows-toolchain.ps1 - - - name: Configure + toolchain versions - shell: pwsh - run: | - # Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\). - $td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' } - "CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - # No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav* - # since M10 (see this file's header), so nothing here needs import libs or runtime DLLs. - # The HOST still does — windows-host.yml sets them for its amf-qsv leg. - rustup target add ${{ matrix.target }} - rustc --version - cargo --version - Write-Output "target ${{ matrix.target }} target-dir $td" - - # Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session - # drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op - # for the shell, which has no features). - # punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so - # a CLI that only the release workflow compiles is a release-day surprise. Its tests - # RUN the binary (help contract), as the session's contract_smoke runs the session — - # the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber). - - name: Build - shell: pwsh - run: | - $sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } - cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }} - - - name: Clippy (-D warnings) - shell: pwsh - run: | - # Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a - # dependency but only LINTS the packages it is given, so a decode crate that starts the - # run but is missing from this list would be gated by nothing. - $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec') - $sf = @() - if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') } - cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings - - - name: Rustfmt check - if: matrix.target == 'x86_64-pc-windows-msvc' - shell: pwsh - run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check - - - name: Test - if: matrix.target == 'x86_64-pc-windows-msvc' - shell: pwsh - run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }} diff --git a/clients/windows/packaging/README.md b/clients/windows/packaging/README.md index e91d3138..666ef7d6 100644 --- a/clients/windows/packaging/README.md +++ b/clients/windows/packaging/README.md @@ -2,7 +2,7 @@ The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from -[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's +[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's **generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that touches the client (canary) and on `vX.Y.Z` release tags (stable) — see [Release Channels](https://punktfunk.unom.io/docs/channels). @@ -14,7 +14,7 @@ package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed (`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64` stamps the manifest `ProcessorArchitecture` and names the output. See -[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale. +[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale. ## What's in the package diff --git a/scripts/ci/gitea-release.sh b/scripts/ci/gitea-release.sh index 6c890b72..469f9598 100644 --- a/scripts/ci/gitea-release.sh +++ b/scripts/ci/gitea-release.sh @@ -2,7 +2,7 @@ # Shared Gitea Release helpers for the punktfunk CI workflows (Linux + macOS runners). # # Source this file, then call ensure_release / upsert_asset. It replaces the three -# copy-pasted inline blocks that used to live in release.yml / flatpak.yml / decky.yml, +# copy-pasted inline blocks that used to live in apple.yml / flatpak.yml / decky.yml, # and fixes a latent bug those had: the bare asset POST returns 409 if an asset with the # same name already exists, so re-running a workflow — or reusing the rolling `canary` # release with stable filenames — would fail. upsert_asset deletes the old asset first. diff --git a/scripts/ci/provision-windows-punktfunk-extras.ps1 b/scripts/ci/provision-windows-punktfunk-extras.ps1 index e7a34b13..2a79c611 100644 --- a/scripts/ci/provision-windows-punktfunk-extras.ps1 +++ b/scripts/ci/provision-windows-punktfunk-extras.ps1 @@ -1,6 +1,6 @@ # Layers punktfunk-specific tooling onto the shared unom Windows CI runner: FFmpeg (the HOST's # amf-qsv encode leg, x64 only), Inno Setup (the host installer), and the aarch64-pc-windows-msvc -# rustup target (windows-msix.yml's ARM64 leg). The runner itself - act_runner, Node, rustup, +# rustup target (windows-client.yml's ARM64 leg). The runner itself - act_runner, Node, rustup, # VS Build Tools/NASM/CMake/LLVM - is provisioned generically by unom/infra # (windows-runner/windows-runner.pkr.hcl + proxmox/windows-runner's Terraform clone); this script # is what punktfunk adds on top, since FFmpeg/Inno Setup/the ARM64 target aren't every project's @@ -15,7 +15,7 @@ function info($m) { Write-Host "[provision-punktfunk-extras] $m" } $env:RUSTUP_HOME = "C:\Users\Public\.rustup" $env:CARGO_HOME = "C:\Users\Public\.cargo" -# --- ARM64 cross-compile target (windows.yml / windows-msix.yml build aarch64-pc-windows-msvc off +# --- ARM64 cross-compile target (windows-client.yml builds aarch64-pc-windows-msvc off # this x64 box; the ARM64 MSVC cross compiler itself comes from unom/infra's generic VS Build # Tools provisioning, which already includes the ARM64 component). --- $rustup = "C:\Users\Public\.cargo\bin\rustup.exe" @@ -33,7 +33,7 @@ if (Test-Path $rustup) { # bundled DLLs LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the # shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture. # ⚠ The CLIENT no longer links FFmpeg at all (M10, design/client-native-decode.md §6): it decodes -# with pf-vkdecode / pf-dxvadec / openh264 + rav1d. windows.yml and windows-msix.yml set no +# with pf-vkdecode / pf-dxvadec / openh264 + rav1d. windows-client.yml sets no # FFMPEG_DIR and the MSIX bundles no libav* DLLs, so only the x64 tree is fetched now - the ARM64 # one existed solely for the ARM64 client leg. Delete a stale C:\Users\Public\ffmpeg-arm64 by # hand; this script does not remove what it no longer installs. diff --git a/scripts/ci/provision-windows-wdk.ps1 b/scripts/ci/provision-windows-wdk.ps1 index a06d9175..e2faee52 100644 --- a/scripts/ci/provision-windows-wdk.ps1 +++ b/scripts/ci/provision-windows-wdk.ps1 @@ -8,7 +8,7 @@ # installed. Safe to run repeatedly. Runs non-interactively (/q /norestart) — never auto-reboots. # # Invoked by scripts/ci/ensure-windows-toolchain.ps1, the shared self-provision step every Windows -# CI workflow runs at job start (windows-drivers.yml, windows.yml, windows-msix.yml, +# CI workflow runs at job start (windows-drivers.yml, windows-client.yml, # windows-host.yml), on top of the generic runner unom/infra provisions (windows-runner/) and # provision-windows-punktfunk-extras.ps1's FFmpeg/Inno Setup/ARM64-target layer. Run as the # runner's account (SYSTEM) with admin rights. diff --git a/scripts/ci/setup-macos-runner.sh b/scripts/ci/setup-macos-runner.sh index 1b1355a2..81fff083 100644 --- a/scripts/ci/setup-macos-runner.sh +++ b/scripts/ci/setup-macos-runner.sh @@ -94,7 +94,7 @@ fi # Deliberately NO DEVELOPER_DIR here: cargo (rust ld) must stay on the system default — # a newer-than-OS Xcode's ld emits dylibs the running dyld rejects ("mis-aligned # LINKEDIT string pool"), breaking every proc-macro build. Steps that need a full Xcode -# (xcodebuild) resolve it themselves (build-xcframework.sh, release.yml). +# (xcodebuild) resolve it themselves (build-xcframework.sh, apple.yml's `distribute` job). PLIST_STAGE="$RUNNER_HOME/io.gitea.act_runner.plist" PLIST_SYSTEM="/Library/LaunchDaemons/io.gitea.act_runner.plist"