diff --git a/.gitea/workflows/android-screenshots.yml b/.gitea/workflows/android-screenshots.yml index 444a6e8e..5813bee3 100644 --- a/.gitea/workflows/android-screenshots.yml +++ b/.gitea/workflows/android-screenshots.yml @@ -22,25 +22,15 @@ jobs: screenshots: if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 + # JDK 21 + SDK baked (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 17–21). + # The tests are pure JVM (no NDK), but sharing android.yml's image means one image to + # keep warm instead of a per-run setup-java + sdkmanager download pair. + container: + image: 192.168.1.58:5010/punktfunk-android-ci:latest timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - name: JDK 21 (AGP 9.3 + Robolectric's SDK-36 android-all jar both want 17–21) - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Android SDK - # SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e. - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 - - # No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP - # if the platform channel lacks it (same note as android.yml). - - name: platform-tools + platform 36 + build-tools - run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" - - name: Cache (gradle) uses: actions/cache@v4 with: @@ -49,9 +39,10 @@ jobs: ~/.gradle/wrapper # gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the # Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise - # restore a key that can never hold the new one. - key: android-screenshots-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: android-screenshots- + # restore a key that can never hold the new one. Namespace shared with android.yml — + # it is the same content; two keys just stored it twice in the central cache. + key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle- # Roborazzi renders Compose on the JVM (Robolectric Native Graphics). `-PskipRustBuild` keeps # the cargo-ndk native build out of the graph — the tests never load libpunktfunk_android.so. diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index 325863b8..92be67dd 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -2,11 +2,11 @@ # cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml # but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed. # -# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle -# distribution in-job). If android-actions/setup-android is not mirrored on this Gitea instance, -# replace that step with a manual cmdline-tools download, or bake an `android-ci` image like -# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner -# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests). +# Runs in the punktfunk-android-ci builder image (ci/android-ci.Dockerfile, content-keyed on +# the LAN registry): JDK 21, the Android SDK/NDK/CMake pins, cargo-ndk and sccache are all +# baked, so the multi-GB per-run Google downloads this job used to make are gone. Emulator +# instrumentation tests are deferred until a KVM-capable runner exists (they self-skip +# otherwise, like apple.yml's RemoteFirstLightTests). name: android # 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 @@ -46,66 +46,62 @@ on: - '.gitea/workflows/android.yml' workflow_dispatch: +# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's +# unbound). The NDK clang targets get their own key universes automatically (keys embed +# compiler hash + target), so the three ABI builds share the bucket with everything else. +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: android: runs-on: ubuntu-24.04 + container: + image: 192.168.1.58:5010/punktfunk-android-ci:latest timeout-minutes: 60 steps: - uses: actions/checkout@v4 - - name: JDK 21 (AGP 9.3 runs on JDK 17–21, not the host default) - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" + # Everything below the checkout used to be four download steps (JDK, SDK, + # NDK+CMake, cargo-ndk — the flakiest, heaviest part of the job); it is all baked + # into the image now. This guard only re-asserts the Android targets so a + # rust-toolchain.toml pin bump keeps working against an older image (:latest lags + # one image rebuild, same bootstrap note as ci.yml's dep steps). + - name: Rust Android targets (no-op unless the toolchain pin outran the image) + run: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android - - name: Rust toolchain + Android targets (self-healing on a fresh runner) - run: | - if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ - | sh -s -- -y --no-modify-path --profile minimal - fi - RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" - dirname "$RUSTUP" >> "$GITHUB_PATH" - "$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android - - - name: Android SDK - # SHA-pinned: this workflow's release job carries the signing keystore + Play service-account - # secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e. - uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3 + # Same key namespace as ci.yml/deb.yml ON PURPOSE: identical Cargo.lock, identical + # CARGO_HOME layout (/usr/local/cargo), so the registry/git downloads dedupe with + # the rest of the fleet in the central cache. target/ is deliberately NOT cached + # anymore — sccache covers recompilation without shipping multi-GB tars per run. + - name: Cache (cargo registry) + uses: actions/cache@v4 with: - # Only platform-tools — NOT the action's default legacy `tools`, whose dependency chain - # drags in the ~250 MB emulator nobody here runs (instrumentation tests are deferred). - # That download was the single flakiest piece of this job: the shared runner fleet drops - # packets under parallel-job load and sdkmanager's streamed unzip turns a truncated - # stream into "Error on ZipFile unknown archive" (observed 2026-07-22, twice). - packages: platform-tools - - - name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build) - # cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path - # kit/build.gradle.kts prepends to PATH for cargo-ndk's audiopus_sys (libopus) CMake build. - # Note: platforms;android-37 is sometimes missing from standard channels; AGP will - # auto-download it if needed during the build. - # retry.sh: sdkmanager is a single-shot multi-hundred-MB fetch, exactly the class the - # helper exists for (fleet-load packet drops truncate the stream mid-unzip); a failed - # attempt leaves no partial package behind, so a plain re-invoke is safe. - run: bash scripts/ci/retry.sh 4 sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" "ndk;30.0.14904198" "cmake;3.22.1" + path: | + /usr/local/cargo/registry + /usr/local/cargo/git + key: cargo-home-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-home- - - name: Caches (cargo + gradle) + - name: Cache (gradle) uses: actions/cache@v4 with: path: | - ~/.cargo/registry - ~/.cargo/git ~/.gradle/caches ~/.gradle/wrapper - target - # gradle-wrapper.properties is in the key on purpose — see android-screenshots.yml. - key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }} - restore-keys: android- - - - name: cargo-ndk - run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk + # gradle-wrapper.properties is in the key on purpose: `~/.gradle/wrapper` caches the + # Gradle DISTRIBUTION, so a wrapper bump with no .gradle.kts change would otherwise + # restore a key that can never hold the new one. Namespace shared with + # android-screenshots.yml — same content, one copy in the central store. + key: gradle-${{ hashFiles('clients/android/**/*.gradle.kts', 'clients/android/gradle/wrapper/gradle-wrapper.properties') }} + restore-keys: gradle- - name: assembleDebug (cargo-ndk → jniLibs → APK) working-directory: clients/android diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 600e2e3c..97dda13d 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -44,6 +44,21 @@ on: - '.gitea/workflows/apple.yml' workflow_dispatch: +# 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: # 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 @@ -70,6 +85,18 @@ 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. + - 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 + # `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 # system Opus — so the xcframework is self-contained (no runtime libopus.dylib on end-user Macs). @@ -128,6 +155,18 @@ 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. + - 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 + # 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) run: | @@ -144,6 +183,20 @@ 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) + # 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 + # in the default DerivedData root that no pin owns. + run: | + DD="$HOME/ci/derived-data/screenshots" + mkdir -p "$DD" + echo "PF_SHOT_DERIVED_DATA=$DD" >> "$GITHUB_ENV" + 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 + - name: Build PunktfunkCore.xcframework (mac + iOS slices) run: BUILD_IOS=1 bash scripts/build-xcframework.sh @@ -157,6 +210,10 @@ jobs: bash tools/screenshots.sh ipad || echo "::warning::iPad 13\" screenshots skipped" echo "Produced:"; ls -la screenshots || true + - name: Shut the Simulators down (leaked booted sims once piled up 846 deep) + if: always() + run: xcrun simctl shutdown all || true + - name: Upload screenshots (zip artifact) if: always() # v3, not v4: Gitea's artifact backend identifies as GHES, which @actions/artifact v2+ diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index d7f6b2c2..5985d50d 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -65,6 +65,11 @@ jobs: dockerfile: ci/fedora-rpm.Dockerfile buildargs: --build-arg FEDORA_VERSION=44 keysuffix: -f44 + # Android builder (JDK + SDK/NDK + cargo-ndk + sccache) — android.yml and + # android-screenshots.yml run in it; ~3 GB of per-run Google downloads became + # image layers. + - image: punktfunk-android-ci + dockerfile: ci/android-ci.Dockerfile steps: - uses: actions/checkout@v4 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4f90ca76..c99a2090 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -88,6 +88,21 @@ on: 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 @@ -157,6 +172,18 @@ jobs: # 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 diff --git a/ci/android-ci.Dockerfile b/ci/android-ci.Dockerfile new file mode 100644 index 00000000..17b13053 --- /dev/null +++ b/ci/android-ci.Dockerfile @@ -0,0 +1,59 @@ +# Android CI builder: JDK 21 + Android SDK/NDK/CMake + pinned Rust with the three shipping +# Android targets + cargo-ndk + sccache. Everything android.yml used to download per run +# (~3 GB of NDK + SDK packages from Google, plus a from-source cargo-ndk build) is baked +# here instead; the image is content-keyed and rebuilt only when the ci/ tree changes +# (docker.yml `builders`). +# +# docker build -f ci/android-ci.Dockerfile -t punktfunk-android-ci ci +# +# Version pins mirror what android.yml installed via sdkmanager: AGP 9.3 wants JDK 17–21; +# cmake;3.22.1 because kit/build.gradle.kts prepends $ANDROID_SDK/cmake/3.22.1/bin to PATH +# for cargo-ndk's audiopus_sys (libopus) CMake build; platforms;android-37 is deliberately +# absent (AGP auto-downloads it if a build ever needs it — same note as the old workflow). +FROM ubuntu:26.04 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git unzip zip python3 openjdk-21-jdk-headless \ + build-essential pkg-config \ + && rm -rf /var/lib/apt/lists/* + +ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64 + +# Android SDK: cmdline-tools must land under cmdline-tools/latest for sdkmanager to +# find its own root. +ENV ANDROID_HOME=/opt/android-sdk \ + ANDROID_SDK_ROOT=/opt/android-sdk +ARG CMDLINE_TOOLS=13114758 +RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \ + && curl -fsSL -o /tmp/clt.zip "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip" \ + && unzip -q /tmp/clt.zip -d "$ANDROID_HOME/cmdline-tools" \ + && mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \ + && rm /tmp/clt.zip +ENV PATH=$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH +RUN yes | sdkmanager --licenses >/dev/null \ + && sdkmanager "platform-tools" "platforms;android-36" "build-tools;37.0.0" \ + "ndk;30.0.14904198" "cmake;3.22.1" \ + && chmod -R a+rX "$ANDROID_HOME" + +# Toolchain shared across CI users (jobs may run as different uids) — same shape as +# rust-ci.Dockerfile, plus the Android cross targets and cargo-ndk. The registry/git +# download caches are stripped after the cargo-ndk install: jobs restore those from the +# shared actions cache, and baking them would only bloat every pull. +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal \ + && rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android \ + && cargo install cargo-ndk --locked \ + && rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git" \ + && chmod -R a+w "$RUSTUP_HOME" "$CARGO_HOME" \ + && rustc --version && cargo-ndk --version + +# Shared compile cache: jobs set RUSTC_WRAPPER=sccache (backend = RustFS S3 on the LAN, +# see .gitea/workflows — the env lives there so dev use of this image stays uncached). +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 diff --git a/clients/apple/tools/screenshots.sh b/clients/apple/tools/screenshots.sh index 8119d56a..1b55a2bf 100755 --- a/clients/apple/tools/screenshots.sh +++ b/clients/apple/tools/screenshots.sh @@ -116,7 +116,10 @@ shoot_sim() { xcrun simctl bootstatus "$udid" -b >/dev/null 2>&1 || true log "$prefix — building ($scheme)…" - local dd; dd="$(mktemp -d)" + # PF_SHOT_DERIVED_DATA (optional): a STABLE DerivedData root, so repeat runs reuse the + # incremental build instead of cold-building into a throwaway tmpdir — CI pins this + # (apple.yml); local runs keep the self-cleaning mktemp default. + local dd; dd="${PF_SHOT_DERIVED_DATA:-$(mktemp -d)}"; mkdir -p "$dd" xcodebuild -project Punktfunk.xcodeproj -scheme "$scheme" -configuration Debug \ -sdk "$sdk" -destination "id=$udid" -derivedDataPath "$dd" \ CODE_SIGNING_ALLOWED=NO build >/dev/null \