diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index eda12c3f..3b4b5b76 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -63,6 +63,15 @@ env: SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # The C/C++ half of the cache. The one that pays here is the CMake-built vendored libopus + # (audiopus_sys), which kit/build.gradle.kts drives through cargo-ndk once per ABI — three + # from-scratch libopus builds per run until now. The per-ABI compilers come from the NDK via + # cargo-ndk's own CC_ vars, which this does not touch; CC_x86_64_unknown_linux_gnu + # covers only the HOST build scripts and proc macros. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ # sccache and incremental compilation are mutually exclusive; CI wants the shared # cache, dev boxes keep incremental. CARGO_INCREMENTAL: "0" @@ -265,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 97dda13d..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: >- @@ -85,17 +168,10 @@ jobs: dirname "$RUSTUP" >> "$GITHUB_PATH" "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin - # 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. + # Shared compile cache. The script handles the macOS side (user-prefix install + + # GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh. - 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 + run: sh scripts/ci/ensure-sccache.sh # `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling # `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a @@ -127,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. @@ -155,17 +700,10 @@ jobs: "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \ aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - # 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. + # Shared compile cache. The script handles the macOS side (user-prefix install + + # GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh. - 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 + run: sh scripts/ci/ensure-sccache.sh # See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake. - name: CMake (for the vendored libopus audiopus_sys builds) @@ -183,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/arch.yml b/.gitea/workflows/arch.yml index b8f5ae2a..0b76fdc9 100644 --- a/.gitea/workflows/arch.yml +++ b/.gitea/workflows/arch.yml @@ -81,6 +81,14 @@ env: SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # The C/C++ half of the cache (aws-lc-sys, vendored libopus, openh264's C++). Safe at workflow + # level — no cross-compiling job here (see ci.yml's `rust` job for that trap). NOTE these ALSO + # have to be named in the makepkg step's `sudo -u builder env …` list: sudo's env_reset drops + # everything not listed, which is why the sccache vars are already spelled out there. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ CARGO_INCREMENTAL: "0" jobs: @@ -220,6 +228,39 @@ jobs: echo "REPO=$REPO" >> "$GITHUB_ENV" echo "pacman $V-$R -> repo '$REPO'" + # ── The web console, built once per (web+sdk content, bun) instead of once per job ───────── + # Shares deb.yml's key family — see the fuller note there. Unlike the RPM leg this needs no + # hand-off macro: makepkg builds with PF_SRCDIR pointing at this workspace, so a restored + # web/.output is already exactly where PKGBUILD's build-if-missing guard looks for it. + # + # Built here as root, BEFORE the makepkg step's `chown -R builder:` sweeps the tree, so the + # bundle ends up owned like everything else the builder user is handed. + - name: Web console cache key + run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV" + - name: Cache the built web console + id: webconsole + uses: actions/cache@v4 + with: + path: web/.output + key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }} + + - name: Build the web console (cache miss only) + if: steps.webconsole.outputs.cache-hit != 'true' + run: | + cd web + bun install --frozen-lockfile --ignore-scripts + bun run build + + - name: The console must exist (cache hit or fresh build) + run: | + if [ ! -f web/.output/server/index.mjs ]; then + echo "::error::web/.output is missing — neither the cache restore nor the build produced it" + exit 1 + fi + grep -q 'Bun\.serve' web/.output/server/index.mjs || { + echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; } + echo "web console present: $(du -sh web/.output | cut -f1)" + - name: Build packages (makepkg) run: | git config --global --add safe.directory "$PWD" @@ -252,8 +293,19 @@ jobs: SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \ SCCACHE_REGION="$SCCACHE_REGION" \ AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ + CMAKE_C_COMPILER_LAUNCHER="$CMAKE_C_COMPILER_LAUNCHER" \ + CMAKE_CXX_COMPILER_LAUNCHER="$CMAKE_CXX_COMPILER_LAUNCHER" \ + CC_x86_64_unknown_linux_gnu="$CC_x86_64_unknown_linux_gnu" \ + CXX_x86_64_unknown_linux_gnu="$CXX_x86_64_unknown_linux_gnu" \ makepkg -f -d --holdver ls -lh "$GITHUB_WORKSPACE/dist" + # Visibility only. The stats have to be read as the SAME user that ran the compiles — + # sccache keeps its stats in a per-user server process, so a root `--show-stats` here + # would report an idle server and zero everything. + sudo -u builder env SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \ + SCCACHE_REGION="$SCCACHE_REGION" \ + AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \ + sccache --show-stats || true # The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves # it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting diff --git a/.gitea/workflows/bench.yml b/.gitea/workflows/bench.yml index 4f26eafd..91307e74 100644 --- a/.gitea/workflows/bench.yml +++ b/.gitea/workflows/bench.yml @@ -38,14 +38,9 @@ jobs: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh - name: Prep run: | diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fdc8db2f..15054f61 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -23,6 +23,12 @@ on: # 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. +# +# RUSTC_WRAPPER covers RUST compilations and nothing else. The C/C++ half of this workspace — +# aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys, pyrowave — +# was paid in full on every run until the CMAKE_*_COMPILER_LAUNCHER / CC_* wiring below existed. +# Linking is the third phase and is cacheable by nothing: that one is addressed in the builder +# images with mold (ci/cargo-config-mold.toml). env: RUSTC_WRAPPER: sccache SCCACHE_BUCKET: unom-ci-sccache @@ -30,6 +36,11 @@ env: SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # Route CMake-driven C/C++ through the same cache. Safe at workflow level: it names no + # triple, and cmake-rs overrides it per-invocation with a `-D` flag when cc-rs reports a + # wrapper, so the two can never double-wrap into `sccache sccache cc`. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache # sccache and incremental compilation are mutually exclusive; CI wants the shared # cache, dev boxes keep incremental. CARGO_INCREMENTAL: "0" @@ -40,18 +51,25 @@ jobs: container: image: 192.168.1.58:5010/punktfunk-rust-ci:latest timeout-minutes: 90 + env: + # cc-rs recognises `sccache` as a compiler wrapper when it leads CC/CXX, and cmake-rs then + # forwards it as -DCMAKE_C_COMPILER_LAUNCHER, so this covers both build-script styles. + # + # ⚠ JOB-scoped, NOT workflow-scoped, and it must stay that way: the `rust-arm64` job below + # runs in the cross image, which sets CC_x86_64_unknown_linux_gnu=/usr/local/bin/pf-host-cc + # (ci/rust-ci-arm64cross.Dockerfile) — a wrapper that strips arm64 include dirs off + # HOST-targeted compiles so ffmpeg-sys-next's probe resolves against the amd64 headers. + # Setting this at workflow level would silently overwrite that wrapper and break the cross + # build in a way that looks like a header mismatch, not a CI config error. + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ steps: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh # punktfunk-client-linux link deps. Also baked into rust-ci.Dockerfile — but ci.yml # runs against the image from the PREVIOUS push (docker.yml bootstrap note), so this @@ -105,8 +123,17 @@ jobs: # out of disk mid-build and actions/cache saved a truncated target/ (a dep's .rmeta # went missing -> E0463 "can't find crate"). A suffix bump wouldn't help — restore-keys # would fall back to the poisoned prefix — so the prefix itself is versioned. - key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} - restore-keys: cargo-target-v3-${{ env.rustc }}- + # + # `-debug-`: THIS JOB BUILDS DEBUG. deb.yml builds RELEASE and used to share this exact + # key, with a comment claiming the release build "reuses ci.yml's clean artifacts" — it + # never could. actions/cache is first-saver-wins on an exact key and this job is the + # faster of the two, so what landed under the shared key was always a debug-only target/ + # (target/debug, no target/release). deb.yml restored a tree containing nothing it could + # use and, because the key was already taken, never got to save its own — so every + # release build re-linked from scratch, forever. Splitting the profiles into separate key + # families is the fix; do not merge them again, however tempting the dedupe looks. + key: cargo-target-debug-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-target-debug-v3-${{ env.rustc }}- - name: Format run: cargo fmt --all --check @@ -220,14 +247,9 @@ jobs: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh - name: Cache keys run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV" @@ -242,8 +264,16 @@ jobs: with: path: target # Its OWN prefix: aarch64 artifacts must never share the amd64 jobs' target cache. - key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} - restore-keys: cargo-target-arm64-v1-${{ env.rustc }}- + # + # `-debug-` in the name is load-bearing. This job builds DEBUG (clippy + a + # `cargo build`), while deb.yml's arm64 leg builds RELEASE into the same + # target/aarch64-unknown-linux-gnu tree. They used to share this exact key, and + # actions/cache is first-saver-wins on an exact key: this job finishes in ~1.5 min and + # saved a debug-only tree, so the .deb leg's release artifacts were NEVER persisted and + # it re-linked everything from sccache on every run. Same disease as the amd64 pair — + # see the note on deb.yml's `cargo-target-release-v1-` key. + key: cargo-target-arm64-debug-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-target-arm64-debug-v1-${{ env.rustc }}- - name: Clippy for aarch64 (deny warnings) run: | @@ -259,6 +289,14 @@ jobs: cargo build --release --target aarch64-unknown-linux-gnu --locked \ -p punktfunk-client-session --no-default-features + # Visibility only — but the ONLY way to tell a warm cache from a cold one after the fact. + # Every Rust job in this repo ends with this line for that reason; a hit rate that quietly + # collapses (a toolchain bump, a flag change, an S3 outage) is otherwise invisible and just + # looks like "CI got slower". + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + web: runs-on: ubuntu-24.04 container: diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index 58ab0f13..64f71ca3 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -70,6 +70,11 @@ env: SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # CMake-driven C/C++ through the same cache (aws-lc-sys, the vendored libopus). Workflow level + # is safe — it names no triple; the CC_*/CXX_* half is per-job below, because the arm64 leg's + # image needs its own CC_x86_64 wrapper. See ci.yml's `rust` job for the full note. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache # sccache and incremental compilation are mutually exclusive; CI wants the shared # cache, dev boxes keep incremental. CARGO_INCREMENTAL: "0" @@ -80,18 +85,18 @@ jobs: container: image: 192.168.1.58:5010/punktfunk-rust-ci:latest timeout-minutes: 90 + env: + # ⚠ Job-scoped, never workflow-scoped: build-publish-client-arm64 runs in the cross image, + # whose own CC_x86_64_unknown_linux_gnu=pf-host-cc must survive. See ci.yml's `rust` job. + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ steps: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh - name: Version + channel # vX.Y.Z tag -> X.Y.Z, published to the `stable` apt distribution (a real release). @@ -129,7 +134,9 @@ jobs: apt-get install -y --no-install-recommends dpkg-dev python3 \ libgtk-4-dev libadwaita-1-dev libsdl3-dev - # Share ci.yml's cache keys so the release build reuses its registry + target artifacts. + # The cargo-home (registry/git) cache IS shared with ci.yml — those are pure downloads, + # profile-independent, and deduping them across the fleet is the whole point. The target + # cache is NOT; see below. - name: Cache keys run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV" - uses: actions/cache@v4 @@ -142,10 +149,20 @@ jobs: - uses: actions/cache@v4 with: path: target - # -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml). Shares the - # key with ci.yml so the release build reuses its clean artifacts. - key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} - restore-keys: cargo-target-v3-${{ env.rustc }}- + # -v3-: bypass a target cache poisoned by a disk-full build (see ci.yml). + # + # `-release-`, and NOT ci.yml's key. This step used to read + # `cargo-target-v3--` — byte-identical to ci.yml's — under a comment saying + # the release build "reuses its clean artifacts". It never did, and could not: ci.yml + # builds DEBUG, this job builds RELEASE, and actions/cache is first-saver-wins on an + # exact key. ci.yml's `rust` job finishes in ~6 min against this job's ~7-8, so ci.yml + # always won the save, this job always restored a target/ with an empty target/release, + # and — the expensive half — its own release artifacts were then never persisted, + # because the key it would have saved under was already taken. Every canary .deb has + # therefore been a from-scratch release build (sccache-assisted, but every link and + # every build script re-run) for as long as both keys have existed. + key: cargo-target-release-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-target-release-v3-${{ env.rustc }}- - name: Build release clients env: @@ -163,7 +180,31 @@ jobs: cargo build --release --locked \ -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update + # ── The web console, built ONCE per (web+sdk content, bun) rather than once per job ──────── + # This bundle was being rebuilt six times on every push — ci.yml, here, both RPM legs, arch, + # and the docker app image — at ~2.5 min each, for output that is a pure function of web/ and + # sdk/. windows-host.yml has cached it this way for a while; this is the same arrangement for + # the Linux packaging legs, sharing one key family so a hit in one warms the others. + # + # The bun version is IN THE KEY. Each builder image installs its own bun (rust-ci, fedora-rpm + # and arch-ci each run the bun.sh installer at image-build time), so without it a bundle built + # by one image's bun could be shipped by a job running a different one. They are usually the + # same version and do share; when they diverge, they simply stop sharing instead of silently + # mixing. + - name: Web console cache key + run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV" + - name: Cache the built web console + id: webconsole + uses: actions/cache@v4 + with: + path: web/.output + key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }} + - name: Build + smoke-boot web console (bun preset) + # Skipped when the cache already holds this exact (web+sdk, bun) bundle — the assertion step + # below is what makes that safe. The bundle in the cache was smoke-booted by the run that + # produced it, and ci.yml's `web` job still builds and tests the console on every push. + if: steps.webconsole.outputs.cache-hit != 'true' # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset # (our Bun.serve TLS entry), so prove the build IS a bun bundle and serves /login. # No TLS env here, so the custom entry binds plain HTTP — the smoke curl stays simple. @@ -176,7 +217,12 @@ jobs: } export PATH="$HOME/.bun/bin:$PATH" cd web - bun install --frozen-lockfile + # --ignore-scripts, like every other web install in CI (ci.yml, web-screenshots.yml, + # windows-host.yml, the SDK installs). This was the ONE site still running lifecycle + # scripts, and web's `postinstall` is `bun2nix -o bun.nix` — a Nix codegen step this job + # neither consumes nor commits, whose only effect here is to make the install depend on + # bun2nix resolving. `build` re-runs its own `prebuild` codegen regardless. + bun install --frozen-lockfile --ignore-scripts bun run build if ! grep -q 'Bun\.serve' .output/server/index.mjs; then echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry"; exit 1 @@ -188,6 +234,22 @@ jobs: echo "web console smoke: /login -> $code" [ "$code" = 200 ] || { echo "ERROR: web console failed to boot under bun"; exit 1; } + # ⚠ NOT optional, and it must run on BOTH paths (cache hit and fresh build). The packaging + # scripts treat a missing web/.output as "build it yourself", which is right for a local run + # and wrong here: it would silently turn a broken cache restore into a slow, quiet rebuild, or + # — with the build step skipped and the restore empty — into a package with no console at all. + # windows-host.yml shipped exactly that in 0.22.1/0.22.2 (an unset WEB_OUTPUT_DIR behind a + # single Write-Host), which is why its equivalent step throws too. Fail loudly instead. + - name: The console must exist (cache hit or fresh build) + run: | + if [ ! -f web/.output/server/index.mjs ]; then + echo "::error::web/.output is missing — neither the cache restore nor the build produced it" + exit 1 + fi + grep -q 'Bun\.serve' web/.output/server/index.mjs || { + echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; } + echo "web console present: $(du -sh web/.output | cut -f1)" + - name: Build .debs run: | export PATH="$HOME/.bun/bin:$PATH" @@ -198,6 +260,13 @@ jobs: # The plugin/script runner (bun-bundled Effect SDK) — same vendored-bun mechanics. VERSION="$VERSION" BUN_BIN="$(command -v bun || true)" bash packaging/debian/build-scripting-deb.sh + # Visibility only. With the target cache now actually saving release artifacts (see the + # cache key note above), this is how a regression in that arrangement becomes visible: + # a run that suddenly reports thousands of misses is a cache that stopped restoring. + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + - name: Publish to the Gitea apt registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} @@ -242,18 +311,17 @@ jobs: container: image: 192.168.1.58:5010/punktfunk-rust-ci-noble:latest timeout-minutes: 90 + env: + # ⚠ Job-scoped — see build-publish above and ci.yml's `rust` job. + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ steps: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh - name: Version + channel run: | @@ -289,9 +357,12 @@ jobs: with: path: target # Own key: this target dir is built against 24.04's glibc/toolchain and must NOT share - # ci.yml's 26.04 target cache (mixing would poison both). - key: cargo-target-noble-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} - restore-keys: cargo-target-noble-v1-${{ env.rustc }}- + # ci.yml's 26.04 target cache (mixing would poison both). It is also the only job using + # this prefix, so — unlike the amd64/arm64 pairs above — it has always been able to save + # and restore its own release artifacts. `-release-` is spelled out anyway so the whole + # file reads consistently and nobody "unifies" it back into a shared key later. + key: cargo-target-noble-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-target-noble-release-v1-${{ env.rustc }}- - name: Build release host env: @@ -326,6 +397,12 @@ jobs: run: | VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh + # Visibility only — placed here, right after the last compile, rather than at the end of the + # job: the gamescope gate below must stay the final step (see its own note). + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + # Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no # capability metadata — the postinst applies them — so this reads the postinst that will # actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep @@ -476,14 +553,14 @@ jobs: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. + # + # NOTE this job deliberately sets no CC_x86_64_unknown_linux_gnu: the cross image already + # points it at /usr/local/bin/pf-host-cc, which is what keeps ffmpeg-sys-next's HOST probe + # from picking up arm64 include dirs. The target-side compiles go through + # CC_aarch64_unknown_linux_gnu (also set by the image) and are not sccache-wrapped. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh # Byte-identical to build-publish's version step (pf-version.sh is deterministic per # commit), so the arm64 package always shares the amd64 version line. @@ -520,8 +597,13 @@ jobs: path: target # Its OWN key — these are aarch64 artifacts under target/aarch64-unknown-linux-gnu/ # and must never share the amd64 jobs' target cache. - key: cargo-target-arm64-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} - restore-keys: cargo-target-arm64-v1-${{ env.rustc }}- + # + # `-release-`: this key was previously identical to ci.yml's `rust-arm64` key, which + # builds DEBUG (clippy) and finishes in ~1.5 min against this job's ~5. Exactly the + # amd64 collision described on the release key above — ci.yml won every save, this job + # restored a tree with no release artifacts and could never persist its own. + key: cargo-target-arm64-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-target-arm64-release-v1-${{ env.rustc }}- - name: Build the arm64 client .deb env: @@ -534,6 +616,10 @@ jobs: readelf -h target/aarch64-unknown-linux-gnu/release/punktfunk-session \ | grep -q AArch64 || { echo "ERROR: session binary is not AArch64"; exit 1; } + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + - name: Publish to the Gitea apt registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} 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/linux-client-screenshots.yml b/.gitea/workflows/linux-client-screenshots.yml index 32653cbc..fc42f3ca 100644 --- a/.gitea/workflows/linux-client-screenshots.yml +++ b/.gitea/workflows/linux-client-screenshots.yml @@ -29,6 +29,12 @@ env: SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # The C/C++ half of the cache — same wiring as ci.yml/deb.yml. Safe at workflow level: no + # cross-compiling job here. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ # sccache and incremental compilation are mutually exclusive; CI wants the shared # cache, dev boxes keep incremental. CARGO_INCREMENTAL: "0" @@ -45,14 +51,9 @@ jobs: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh # Client link deps (baked into the image; kept here so the job is green across image # rebuilds — a no-op once present) PLUS the headless-render extras: a virtual X server, @@ -67,7 +68,8 @@ jobs: libgl1-mesa-dri mesa-vulkan-drivers \ adwaita-icon-theme fonts-cantarell fonts-dejavu-core - # Reuse the workspace cargo caches (same keys as ci.yml/deb.yml). + # Reuse the workspace cargo caches. The cargo-home (download) cache is shared verbatim — + # it is profile-independent. - name: Cache keys run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV" - uses: actions/cache@v4 @@ -80,8 +82,20 @@ jobs: - uses: actions/cache@v4 with: path: target - key: cargo-target-v3-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} - restore-keys: cargo-target-v3-${{ env.rustc }}- + # This job builds RELEASE (see the build step) in the same image and target layout as + # deb.yml's `build-publish`, so it wants THAT tree — it used to name ci.yml's key, which + # holds a debug build and gave it nothing. (Third participant in the collision documented + # on ci.yml's `cargo-target-debug-v3-` key.) + # + # Its OWN exact key with deb's prefix as a FALLBACK restore-key, deliberately: both + # workflows run on a v* tag, and an exact-key match would make them race for the single + # save slot — this job builds one crate, so if it won that race it would replace deb's + # full release tree with a nearly empty one for the rest of the lockfile's life. This way + # it always READS the warm tree and never blocks the job that fills it. + key: cargo-target-shots-release-v1-${{ env.rustc }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-target-shots-release-v1-${{ env.rustc }}- + cargo-target-release-v3-${{ env.rustc }}- - name: Build client run: cargo build --release -p punktfunk-client-linux --locked 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/rpm.yml b/.gitea/workflows/rpm.yml index 1c15a474..f660e120 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -51,6 +51,14 @@ env: SCCACHE_REGION: home-central AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # The C/C++ half of the cache (aws-lc-sys, the vendored libopus, openh264's C++). Safe at + # workflow level here: unlike ci.yml/deb.yml this workflow has no cross-compiling job whose + # image sets its own CC_x86_64_unknown_linux_gnu. See ci.yml's `rust` job for that trap. + # This matters twice per push — the f43 and f44 legs are the two longest jobs in the fleet. + CMAKE_C_COMPILER_LAUNCHER: sccache + CMAKE_CXX_COMPILER_LAUNCHER: sccache + CC_x86_64_unknown_linux_gnu: sccache cc + CXX_x86_64_unknown_linux_gnu: sccache c++ # sccache and incremental compilation are mutually exclusive; CI wants the shared # cache, dev boxes keep incremental. CARGO_INCREMENTAL: "0" @@ -80,14 +88,9 @@ jobs: - uses: actions/checkout@v4 # Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder - # images; this fetch keeps the job green while the running :latest predates the bake. + # images; this heals the job while the running :latest predates the bake. - name: sccache (no-op once the image bakes it) - run: | - command -v sccache >/dev/null 2>&1 || { - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' - } - sccache --version + run: sh scripts/ci/ensure-sccache.sh # rpmbuild + git archive need the checkout trusted; cache the crates download. # The client link deps are also baked into the fedora-rpm image, but this job runs @@ -145,11 +148,63 @@ jobs: echo "GROUP=$GROUP" >> "$GITHUB_ENV" echo "rpm $V-$R -> group '$GROUP'" + # ── The web console, built once per (web+sdk content, bun) instead of once per leg ───────── + # Two legs run here (f43 + f44) and each built its own identical copy of a bundle that is a + # pure function of web/ and sdk/ — see the fuller note in deb.yml, whose key family this + # shares, so whichever job builds it first warms the rest of the fleet. + # + # ⚠ The build has to happen HERE, in the workspace, rather than being left to the spec. Two + # reasons, and both are load-bearing: + # * build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a + # bundle sitting in the workspace is invisible to rpmbuild — it must be handed over by + # absolute path (PF_PREBUILT_WEB_OUTPUT -> the spec's pf_prebuilt_web macro). + # * the reverse direction is worse: the spec builds into rpmbuild's %{_topdir}, which + # build-rpm.sh creates with mktemp and removes on EXIT. A console built in there is gone + # before actions/cache's post step runs, so the cache would never populate and every run + # would be a miss that quietly rebuilt — the cache would look present and do nothing. + - name: Web console cache key + run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV" + - name: Cache the built web console + id: webconsole + uses: actions/cache@v4 + with: + path: web/.output + key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }} + + - name: Build the web console (cache miss only) + if: steps.webconsole.outputs.cache-hit != 'true' + run: | + cd web + bun install --frozen-lockfile --ignore-scripts + bun run build + + # Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not + # become a quietly console-less RPM. The spec re-checks the marker on whatever it packages. + - name: The console must exist (cache hit or fresh build) + run: | + if [ ! -f web/.output/server/index.mjs ]; then + echo "::error::web/.output is missing — neither the cache restore nor the build produced it" + exit 1 + fi + grep -q 'Bun\.serve' web/.output/server/index.mjs || { + echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; } + echo "web console present: $(du -sh web/.output | cut -f1)" + - name: Build RPM # PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the # punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM # Recommends both). Both need bun (ensured in Prep). - run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh + run: | + PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" \ + PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \ + PF_PREBUILT_WEB_OUTPUT="$GITHUB_WORKSPACE/web/.output" \ + bash packaging/rpm/build-rpm.sh + + # Visibility only — the two RPM legs are the longest jobs in the fleet, so a cache + # regression here is the most expensive one to leave undetected. + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats # Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a # missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo. 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/ci/arch-ci.Dockerfile b/ci/arch-ci.Dockerfile index 64d158a5..570e8498 100644 --- a/ci/arch-ci.Dockerfile +++ b/ci/arch-ci.Dockerfile @@ -48,6 +48,11 @@ RUN pacman -Syu --noconfirm --needed \ hwdata luajit seatd sdl2-compat vulkan-icd-loader \ xcb-util-errors xcb-util-wm xorg-xwayland \ meson glm wayland-protocols benchmark libxcursor \ + # mold: link-phase accelerator (sccache cannot cache linking). makepkg links the release + # host, client, worker and tray on every arch.yml run. Wired via cargo-config-mold.toml + # below. It does NOT affect the gamescope companion leg — that is meson + its own linker, + # and its `-static-libstdc++` link is untouched. + mold \ && pacman -Scc --noconfirm # bun builds the punktfunk-web console + the punktfunk-scripting runner AND is vendored @@ -64,3 +69,16 @@ ARG SCCACHE_VERSION=0.10.0 RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \ && sccache --version + +# CARGO_HOME is declared here only so this image agrees with what arch.yml already sets at job +# level (and so `cargo` finds the config below when the image is used by hand). The workflow still +# passes CARGO_HOME explicitly across the `sudo -u builder env …` boundary, which strips ambient +# env — that is why the C/C++ sccache wiring has to be re-exported there by name while THIS file, +# being a file, crosses the boundary for free. +ENV CARGO_HOME=/usr/local/cargo +RUN mkdir -p /usr/local/cargo && chmod -R a+w /usr/local/cargo + +# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and +# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY. +COPY cargo-config-mold.toml /usr/local/cargo/config.toml +RUN mold --version && test -r /usr/local/cargo/config.toml diff --git a/ci/cargo-config-mold.toml b/ci/cargo-config-mold.toml new file mode 100644 index 00000000..ef92fc02 --- /dev/null +++ b/ci/cargo-config-mold.toml @@ -0,0 +1,42 @@ +# Installed as $CARGO_HOME/config.toml in every Linux CI builder image (ci/*.Dockerfile). +# +# WHAT: link the x86_64 Linux targets with mold instead of GNU ld. Linking is the one phase of a +# Rust build that sccache CANNOT cache — every job relinks punktfunk-host, punktfunk-client-linux, +# punktfunk-client-session, punktfunk-cli, pf-update and punktfunk-encode-worker from scratch on +# every run, and the packaging legs (deb/rpm/arch) do it for release binaries with full debug info. +# mold is the only lever that touches that phase. +# +# ⚠ THE TRAP THIS FILE HAS TO STAY CLEAR OF — read before editing, and before adding rustflags +# anywhere else in this repo: +# +# 1. A `RUSTFLAGS` ENVIRONMENT VARIABLE OVERRIDES CONFIG RUSTFLAGS ENTIRELY. It does not merge +# and it does not append. Any job that sets RUSTFLAGS silently loses mold here (it still +# builds — just with the default linker), and, far worse, would lose the aarch64 +# `--cfg aes_armv8` / `--cfg polyval_armv8` flags from the workspace's own .cargo/config.toml, +# which are worth a measured ~3x on the decrypt path. audit.yml's miri gf8 step is the one +# place in the repo that sets RUSTFLAGS, and its comment already carries this warning; keep it +# that way. Never "simplify" this file into a RUSTFLAGS export. +# +# 2. CONFIG FILES MERGE PER KEY, HIGHEST-PRECEDENCE FILE WINS — they do not concatenate. The +# workspace's .cargo/config.toml outranks this one ($CARGO_HOME is the LOWEST precedence). +# Today that is harmless because the two files touch DISJOINT keys: the workspace file defines +# only `target.'cfg(target_arch = "aarch64")'.rustflags`, this one only +# `target.x86_64-unknown-linux-gnu.rustflags`, and cargo JOINS a matching cfg-spec table with +# the triple table rather than picking one. But the moment someone adds an x86_64 rustflags +# entry to the workspace .cargo/config.toml, IT WINS and mold silently stops being used here. +# If that ever happens, move the link-arg into that file instead of duplicating it. +# +# 3. aarch64 IS DELIBERATELY NOT WIRED. The cross image links with aarch64-linux-gnu-gcc against a +# multiarch sysroot (ci/rust-ci-arm64cross.Dockerfile); pointing that driver at mold is a +# separate thing to prove, and those legs are already the fast ones (~1.5 min of clippy, ~5 min +# for the arm64 .deb). Add it only with a measurement, and in a commit of its own. +# +# Requires GCC >= 12.1 (or clang) for `-fuse-ld=mold`; every base here ships far newer. mold itself +# is installed in the same Dockerfile layer that copies this file, so an image can never carry the +# flag without the linker — see the `mold --version` assertion there. +# +# NOTE this affects the HOST-targeted compiles of build scripts and proc macros too (they are +# x86_64-unknown-linux-gnu), which is exactly what we want: those link constantly and are pure +# overhead. +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/ci/fedora-rpm.Dockerfile b/ci/fedora-rpm.Dockerfile index 112c7c26..ed68013c 100644 --- a/ci/fedora-rpm.Dockerfile +++ b/ci/fedora-rpm.Dockerfile @@ -22,6 +22,12 @@ RUN dnf -y install \ rpm-build rpmdevtools systemd-rpm-macros git tar gzip nodejs unzip \ # build toolchain + bindgen gcc gcc-c++ clang clang-devel cmake nasm pkgconf-pkg-config curl ca-certificates \ + # mold: link-phase accelerator (sccache cannot cache linking). This image links the release + # host, client, worker and tray on every rpm.yml run, TWICE per push (f43 + f44). Wired via + # cargo-config-mold.toml below. Note the linker DRIVER is unchanged — still gcc, so Fedora's + # default `-Wl,--build-id` still reaches the link and rpmbuild's debuginfo extraction (which + # hard-requires a build-id) behaves exactly as before; mold implements --build-id natively. + mold \ # ffmpeg (NVENC), capture/audio/display link deps ffmpeg-devel pipewire-devel wayland-devel libxkbcommon-devel opus-devel \ mesa-libGL-devel mesa-libgbm-devel \ @@ -76,3 +82,8 @@ ARG SCCACHE_VERSION=0.10.0 RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \ && sccache --version + +# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and +# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY. +COPY cargo-config-mold.toml /usr/local/cargo/config.toml +RUN mold --version && test -r /usr/local/cargo/config.toml diff --git a/ci/rust-ci-noble.Dockerfile b/ci/rust-ci-noble.Dockerfile index 1b3d0aa1..03b64a38 100644 --- a/ci/rust-ci-noble.Dockerfile +++ b/ci/rust-ci-noble.Dockerfile @@ -26,6 +26,9 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ # toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip for the rustup installer's deps build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \ + # mold: link-phase accelerator (sccache cannot cache linking). This image links the release + # host + encode worker on every deb.yml run. Wired via cargo-config-mold.toml below. + mold \ # .deb assembly: dpkg-shlibdeps/dpkg-deb; patchelf repoints the binary's rpath at the bundled FFmpeg dpkg-dev patchelf \ # FFmpeg 8 build deps: nasm (asm), VAAPI (libva/libdrm) so the built libav* keep the AMD/Intel @@ -99,3 +102,10 @@ ARG SCCACHE_VERSION=0.10.0 RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \ && sccache --version + +# Link x86_64 with mold — see cargo-config-mold.toml's header for the rustflags traps, and +# rust-ci.Dockerfile for why the `mold --version` assertion sits next to the COPY. +# ⚠ This does NOT touch the from-source FFmpeg built above: that is a plain ./configure && make in +# an earlier layer, linked by GNU ld exactly as before. Only cargo's links move to mold. +COPY cargo-config-mold.toml /usr/local/cargo/config.toml +RUN mold --version && test -r /usr/local/cargo/config.toml diff --git a/ci/rust-ci.Dockerfile b/ci/rust-ci.Dockerfile index 9ca327ca..b2f36ee6 100644 --- a/ci/rust-ci.Dockerfile +++ b/ci/rust-ci.Dockerfile @@ -13,6 +13,9 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ # toolchain + bindgen; nodejs runs the JS actions (checkout/cache); unzip is for the bun installer build-essential clang libclang-dev pkg-config cmake git curl ca-certificates nodejs unzip \ + # mold: the link-phase accelerator. Linking is the one thing sccache cannot cache, and this + # image relinks the whole workspace on every job. Wired via cargo-config-mold.toml below. + mold \ # ffmpeg-next 9, built against whatever libav* 26.04 ships (FFmpeg 8 / libavcodec 62 today). # The crate major is a CEILING — ffmpeg-sys-next 9 spans libavcodec 56..63 — so this image does # not need to move in lockstep with Arch's FFmpeg 9; it just links what the distro has. @@ -61,3 +64,12 @@ ARG SCCACHE_VERSION=0.10.0 RUN curl -fsSL "https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ | tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache' \ && sccache --version + +# Link x86_64 with mold (see the file's own header for the rustflags-precedence traps). +# +# The assertion is the point: an image carrying the flag but NOT the linker would fail every cargo +# invocation in every consuming job, which is a catastrophic way to find out that a base image +# renamed the package. `mold --version` fails the docker build instead, so nothing is pushed and +# `:latest` keeps pointing at the previous working image — consumers never see it. +COPY cargo-config-mold.toml /usr/local/cargo/config.toml +RUN mold --version && test -r /usr/local/cargo/config.toml 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/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 5fb9b71c..6c0481e0 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -126,8 +126,23 @@ build() { fi # Management web console (opt-in): the Nitro `bun`-preset .output bundle (Bun.serve TLS), # built AND run with bun. + # + # Build-if-missing, matching packaging/debian/build-web-deb.sh. An in-tree CI build runs with + # PF_SRCDIR pointing at the workspace, so when arch.yml has restored web/.output from the shared + # actions cache it is already sitting right here and rebuilding it would be pure duplication — + # this console was being built six times per push across the fleet. A local `makepkg` has no + # .output and builds exactly as before. + # + # The Bun.serve assertion covers BOTH paths: a reused bundle is one more thing that can be wrong, + # and node cannot run a Bun.serve entry, so shipping the wrong preset is a dead console. if [ "${PF_WITH_WEB:-0}" = 1 ]; then - ( cd web && bun install --frozen-lockfile && bun run build ) + if [ -f web/.output/server/index.mjs ]; then + echo "==> reusing the already-built web console at web/.output" + else + ( cd web && bun install --frozen-lockfile && bun run build ) + fi + grep -q 'Bun\.serve' web/.output/server/index.mjs \ + || { echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2; exit 1; } fi # Plugin/script runner (opt-in): one self-contained JS via `bun build --target=bun` (effect + the # SDK inlined; the dynamic plugin import stays a runtime import). bun is also its vendored runtime. diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index bdd87386..60e9fc4d 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -15,6 +15,23 @@ PF_RELEASE="${PF_RELEASE:-1}" # builder image, not in a plain mock chroot). Default off so a bare `rpmbuild`/COPR still works. WEB_OPT=() [ "${PF_WITH_WEB:-0}" = "1" ] && WEB_OPT=(--with web) +# PF_PREBUILT_WEB_OUTPUT: an already-built web/.output to package instead of building the console a +# second time (see the pf_prebuilt_web note in the spec's %build). CI restores one from the shared +# actions cache and points this at it; a plain build leaves it unset and nothing changes. +# Deliberately validated HERE rather than trusted: an empty or half-restored cache directory must +# fall back to building, not produce a console-less package. The spec re-asserts the Bun.serve +# marker on whatever it ends up with. +PREBUILT_WEB_OPT=() +if [ "${PF_WITH_WEB:-0}" = "1" ] && [ -n "${PF_PREBUILT_WEB_OUTPUT:-}" ]; then + if [ -f "${PF_PREBUILT_WEB_OUTPUT}/server/index.mjs" ]; then + # rpmbuild resolves the macro inside the extracted tarball dir, so it must be absolute. + PREBUILT_WEB_ABS="$(cd "$PF_PREBUILT_WEB_OUTPUT" && pwd)" + PREBUILT_WEB_OPT=(--define "pf_prebuilt_web ${PREBUILT_WEB_ABS}") + echo "==> reusing prebuilt web console: $PREBUILT_WEB_ABS" + else + echo "==> PF_PREBUILT_WEB_OUTPUT=$PF_PREBUILT_WEB_OUTPUT has no server/index.mjs — building the console" >&2 + fi +fi # PF_WITH_SCRIPTING=1 builds the punktfunk-scripting subpackage (the plugin/script runner). Same bun # requirement as web; default off so a bare `rpmbuild`/COPR still works. SCRIPTING_OPT=() @@ -61,6 +78,7 @@ fi # rust-toolchain.toml's pinned channel works) and the -devel libs via dnf, neither of which # rpmbuild's RPM-level check sees — skip it; a genuinely missing dep fails the compile/link. rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" "${HOST_OPT[@]}" \ + "${PREBUILT_WEB_OPT[@]}" \ --define "_topdir $TOP" \ --define "pf_version ${PF_VERSION}" \ --define "pf_release ${PF_RELEASE}" \ diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index f8b0d0a6..a9f808d8 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -265,9 +265,30 @@ cargo build --release --locked -p punktfunk-tray %endif %if %{with web} -# Management web console: build the Nitro SSR bundle with bun (the `bun` preset + our Bun.serve -# TLS entry). bun is both the build tool AND the runtime (vendored in %%install below). +# Management web console: the Nitro SSR bundle (the `bun` preset + our Bun.serve TLS entry). bun is +# both the build tool AND the runtime (vendored in %%install below). +# +# `pf_prebuilt_web` (optional, absolute path to an already-built web/.output) lets CI hand over a +# bundle it has already produced instead of building a second, identical one here. It exists because +# this console was being rebuilt SIX times per push — ci.yml, deb, both RPM legs, arch, and the +# docker app image — at ~2.5 min each. rpm.yml restores it from the shared actions cache and passes +# this macro; see build-rpm.sh. Undefined (a plain rpmbuild, or COPR) takes the build path exactly +# as before, so nothing outside CI changes. +# +# It has to be a MACRO carrying an absolute path, not simply a pre-populated web/.output: this spec +# builds from the `git archive` tarball build-rpm.sh generates, and web/.output is gitignored — it +# is not in the tarball and cannot be, so there is nothing here to find without being told where to +# look. +%if %{defined pf_prebuilt_web} +echo "==> reusing the prebuilt web console from %{pf_prebuilt_web}" +mkdir -p web/.output +cp -a %{pf_prebuilt_web}/. web/.output/ +%else (cd web && bun install --frozen-lockfile && bun run build) +%endif +# Asserted for BOTH paths on purpose. This is the check that says the artifact about to be packaged +# is the bun preset (node cannot run Bun.serve) — a handed-over bundle deserves it at least as much +# as a freshly built one, because a cache is one more place a wrong artifact can come from. if ! grep -q 'Bun\.serve' web/.output/server/index.mjs; then echo "ERROR: web build is not a bun bundle — need the 'bun' preset + custom entry" >&2 exit 1 diff --git a/scripts/ci/ensure-sccache.sh b/scripts/ci/ensure-sccache.sh new file mode 100644 index 00000000..f8cc0f9a --- /dev/null +++ b/scripts/ci/ensure-sccache.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Ensure `sccache` is on PATH, self-healing on a runner/image that does not already carry it. +# +# WHY THIS EXISTS: this block was copy-pasted into ten jobs across six workflows (ci.yml x2, +# deb.yml x3, rpm.yml, bench.yml, apple.yml x2, the Apple release leg), in two dialects that had +# already drifted apart — the Linux copies pass `--wildcards` to GNU tar, the macOS copies must NOT +# (bsdtar globs by default and rejects the flag). One copy per platform, here, so a version bump or +# a mirror change is one edit rather than ten. +# +# The builder images (ci/*.Dockerfile) BAKE sccache, so on Linux this is a no-op in the normal case; +# it stays because a job runs against the image from the PREVIOUS push (docker.yml's bootstrap lag), +# and the macOS runner is a persistent host with no image at all. +# +# POSIX sh on purpose: Gitea's act_runner executes a step's `run:` under `sh -e` (dash) inside the +# Linux job containers — no bashisms, no process substitution (see the shader-gate note in ci.yml +# for what that cost the last time someone assumed bash). +# +# Usage: sh scripts/ci/ensure-sccache.sh +set -e + +# Keep in step with the ARG SCCACHE_VERSION in ci/*.Dockerfile — the images bake this same version, +# and a job that heals to a DIFFERENT one would quietly split the shared cache's key universe in two +# (sccache's cache keys are not versioned across incompatible releases). +SCCACHE_VERSION="${SCCACHE_VERSION:-0.10.0}" + +if command -v sccache >/dev/null 2>&1; then + sccache --version + exit 0 +fi + +BASE="https://github.com/mozilla/sccache/releases/download/v${SCCACHE_VERSION}" + +case "$(uname -s)" in + Darwin) + # The macOS runner is a LaunchAgent in the user's Aqua session, not root — install into the + # user prefix. ~/.local/bin is already on the runner daemon's PATH; GITHUB_PATH is + # belt-and-braces for the steps that follow in THIS job. + DEST="$HOME/.local/bin" + mkdir -p "$DEST" + case "$(uname -m)" in + arm64|aarch64) ARCH=aarch64-apple-darwin ;; + *) ARCH=x86_64-apple-darwin ;; + esac + # bsdtar globs by default and does not accept --wildcards. + curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-${ARCH}.tar.gz" \ + | tar -xz --strip-components=1 -C "$DEST" '*/sccache' + chmod 0755 "$DEST/sccache" + PATH="$DEST:$PATH" + export PATH + if [ -n "${GITHUB_PATH:-}" ]; then + echo "$DEST" >> "$GITHUB_PATH" + fi + ;; + *) + # Linux job containers run as root; /usr/local/bin is on PATH already, so no GITHUB_PATH + # dance is needed. The musl build is static — one binary serves the Ubuntu, Fedora and Arch + # images alike. + DEST=/usr/local/bin + curl -fsSL "$BASE/sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + | tar -xz --wildcards --strip-components=1 -C "$DEST" '*/sccache' + chmod 0755 "$DEST/sccache" + ;; +esac + +sccache --version 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"