From f1dc6c9f94aec486faf965d2475553e289df5544 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 11:57:13 +0200 Subject: [PATCH 1/3] ci: cache the C/C++ half, link with mold, and split the debug/release target caches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent reasons Rust CI stayed slow despite sccache, fixed together because they share the same measurement. 1. sccache only ever covered RUSTC. Every C/C++ dependency in the tree — aws-lc-sys, openh264-sys2's vendored C++, the CMake-built libopus behind audiopus_sys — was compiled from scratch on every job of every workflow. CMAKE_{C,CXX}_COMPILER_LAUNCHER plus CC_/CXX_x86_64_unknown_linux_gnu route both build-script styles (cc-rs and cmake-rs) through the same shared cache. The CC_* vars are JOB-scoped in ci.yml and deb.yml, never workflow-scoped: the arm64 cross image sets its own CC_x86_64_unknown_linux_gnu=pf-host-cc, the wrapper that keeps ffmpeg-sys-next's host probe off the arm64 include dirs. Overwriting it would surface as a header mismatch rather than as a CI config error. 2. Linking is cacheable by nothing, and these jobs relink the host, client, session, cli, worker and tray on every run — twice per push for rpm (f43 + f44). The four Linux builder images now install mold and carry a $CARGO_HOME/config.toml that uses it for x86_64. aarch64 is deliberately left alone (cross driver, already-fast legs). Each image asserts `mold --version` in its build, so an image can never ship the flag without the linker: docker.yml goes red and :latest stays on the last good one. 3. THE EXPENSIVE ONE. ci.yml (debug) and deb.yml (release) named a byte-identical target-cache key, under a comment claiming the release build reused ci.yml's artifacts. It never could. actions/cache is first-saver-wins on an exact key and ci.yml is the faster job, so the shared key always held a debug-only target/ — and, worse, deb.yml could then never save its own, because the key was taken. Every canary .deb has been a from-scratch release build for as long as both keys existed. Same collision on the arm64 pair, and a third participant in linux-client-screenshots.yml. Split into -debug-/-release- key families; that job reads deb's tree via restore-keys but keeps its own exact key so it can never win the save race and replace a full tree with its single-crate one. Also: one scripts/ci/ensure-sccache.sh replaces ten copy-pasted bootstrap blocks that had already drifted into two dialects (GNU tar --wildcards vs bsdtar), every Rust job now ends with --show-stats so a cache regression is visible instead of just "CI got slower", and deb.yml's web install joins every other CI install on --ignore-scripts. No behaviour change to any artifact: same compilers, same flags, same outputs. --- .gitea/workflows/android.yml | 9 ++ .gitea/workflows/apple.yml | 26 +---- .gitea/workflows/arch.yml | 19 +++ .gitea/workflows/bench.yml | 9 +- .gitea/workflows/ci.yml | 74 +++++++++--- .gitea/workflows/deb.yml | 110 +++++++++++++----- .gitea/workflows/linux-client-screenshots.yml | 34 ++++-- .gitea/workflows/rpm.yml | 23 ++-- ci/arch-ci.Dockerfile | 18 +++ ci/cargo-config-mold.toml | 42 +++++++ ci/fedora-rpm.Dockerfile | 11 ++ ci/rust-ci-noble.Dockerfile | 10 ++ ci/rust-ci.Dockerfile | 12 ++ scripts/ci/ensure-sccache.sh | 65 +++++++++++ 14 files changed, 368 insertions(+), 94 deletions(-) create mode 100644 ci/cargo-config-mold.toml create mode 100644 scripts/ci/ensure-sccache.sh diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index eda12c3f..b9933beb 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" diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 97dda13d..2dde0ee7 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -85,17 +85,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 @@ -155,17 +148,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) diff --git a/.gitea/workflows/arch.yml b/.gitea/workflows/arch.yml index b8f5ae2a..45b5e0f2 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: @@ -252,8 +260,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..70556e14 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: @@ -176,7 +193,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 @@ -198,6 +220,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 +271,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 +317,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 +357,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 +513,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 +557,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 +576,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/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/rpm.yml b/.gitea/workflows/rpm.yml index 1c15a474..b25ef37b 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 @@ -151,6 +154,12 @@ jobs: # 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 + # 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. - name: Sign RPMs 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/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 From 3f7fbf1061387db0f653c7a2e6096e999d83a4c1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 12:01:09 +0200 Subject: [PATCH 2/3] ci: build the web console once per push instead of once per packaging job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nitro console bundle is a pure function of web/ and sdk/, and it was being built six times on every push: ci.yml, deb, both RPM legs (f43 + f44), arch, and the docker app image, at roughly 2.5 min each. windows-host.yml has cached it on exactly this shape for a while — this extends the same arrangement to the Linux packaging legs, sharing one key family so whichever job builds it first warms the others. The bun version is part of the key. Each builder image runs the bun.sh installer at image-build time, so rust-ci, fedora-rpm and arch-ci can drift apart; keying on it means they share while they agree and simply stop sharing when they do not, rather than one image's bun silently producing the bundle another image ships. Each packaging path needed a different hand-off: * deb — build-web-deb.sh already builds only if web/.output is missing, so the restore alone is enough; the workflow's build+smoke step is now gated on the miss. * arch — makepkg builds with PF_SRCDIR pointing at the workspace, so a restored bundle is already where it needs to be. PKGBUILD gains the same build-if-missing guard the deb script has. * rpm — neither direction works by default. build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a bundle in the workspace is invisible to rpmbuild; and the spec's own build lands in rpmbuild's %{_topdir}, which build-rpm.sh mktemps and removes on EXIT, so a console built there is gone before the cache's post step and the cache would never populate — every run a miss that quietly rebuilt. So the workflow builds it, and hands it over by absolute path through a new optional `pf_prebuilt_web` macro. Undefined (plain rpmbuild, COPR) takes the original build path. Every path asserts the bundle exists and carries the Bun.serve marker, on cache hits too. A cache is one more place a wrong artifact can come from, and the packaging scripts' build-if-missing behaviour — correct for a local build — would otherwise turn a broken restore into either a silent rebuild or, with the build step skipped, a package with no console in it. That is not hypothetical: windows-host.yml shipped 0.22.1 and 0.22.2 with no console because an unset path variable was handled by a single Write-Host, which is why its equivalent step throws. --- .gitea/workflows/arch.yml | 33 +++++++++++++++++++++++++ .gitea/workflows/deb.yml | 40 ++++++++++++++++++++++++++++++ .gitea/workflows/rpm.yml | 48 +++++++++++++++++++++++++++++++++++- packaging/arch/PKGBUILD | 17 ++++++++++++- packaging/rpm/build-rpm.sh | 18 ++++++++++++++ packaging/rpm/punktfunk.spec | 25 +++++++++++++++++-- 6 files changed, 177 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/arch.yml b/.gitea/workflows/arch.yml index 45b5e0f2..0b76fdc9 100644 --- a/.gitea/workflows/arch.yml +++ b/.gitea/workflows/arch.yml @@ -228,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" diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index 70556e14..64f71ca3 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -180,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. @@ -210,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" diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index b25ef37b..f660e120 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -148,11 +148,57 @@ 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. 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 From 0bfc7fe913d74cb2a3920ece2e14098578464f22 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 13 Aug 2026 12:10:36 +0200 Subject: [PATCH 3/3] ci: fold release.yml into apple.yml and the two Windows client workflows into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two merges, both of which exist to express an ordering Gitea cannot express across files, and both of which delete a duplicated build. release.yml -> apple.yml (as the `distribute` job) The name described neither what it did (Apple only — every other platform's release is its own packaging workflow attaching to the same Gitea release on a v* tag, with announce.yml as the manual "go") nor anything a reader would guess. The name was the smaller problem. 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 that same commit. It is now `needs: swift`, which is only expressible in one file. The two files' paths: filters had also drifted — apple.yml watched crates/**, release.yml watched crates/punktfunk-core/**. The merged filter takes the NARROW one, because that is the correct one: everything on this runner is built from punktfunk-core via build-xcframework.sh, and punktfunk-core's only path dependency is its own vendored fec-rs. That is checkable in one command, and the header says so, and says to widen it if that ever stops being true. Net effect on the shared mac mini: pushes that touch host-side crates no longer build or upload anything Apple. windows.yml + windows-msix.yml -> windows-client.yml The pair built the same three crates FOUR times per client push on ONE runner: debug x64 + arm64 for lint/test, release x64 + arm64 for packaging. windows-host.yml already records why a second (debug) dep tree on this machine is a liability rather than a cost — it re-runs openh264-sys2's vendored C++ through cc-rs's cl.exe fan-out and tips the runner into C1069, which is disk exhaustion wearing a compiler error's clothes. So there is one release build per arch now and clippy/fmt/test run against it, exactly as windows-host.yml does. The paths list went from three copies to one; PRs get the build/lint/test signal and stop before packaging. The rename is safe, and this is worth recording because the GitHub instinct is wrong here: `github.run_number` is REPO-WIDE in Gitea, not per-workflow — consecutive runs of DIFFERENT workflows get consecutive numbers (verified against the API: android 13226, apple 13227, arch 13228, ci 13229, deb 13230). The canary MSIX version ..0 and Apple's CURRENT_PROJECT_VERSION therefore keep climbing across a rename. On GitHub the same rename would reset both to 1, sorting every new canary below the published ones and getting the TestFlight uploads rejected outright. 25 workflows, down from 27, and every `name:` now matches its filename. Cross-references in windows-host.yml, windows-drivers.yml, android.yml, flatpak.yml, sbom.yml, the provisioning scripts, gitea-release.sh and clients/windows/packaging/README.md updated. --- .gitea/workflows/android.yml | 2 +- .gitea/workflows/apple.yml | 594 +++++++++++++++++- .gitea/workflows/flatpak.yml | 4 +- .gitea/workflows/release.yml | 555 ---------------- .gitea/workflows/sbom.yml | 2 +- .gitea/workflows/windows-client.yml | 318 ++++++++++ .gitea/workflows/windows-drivers.yml | 8 +- .gitea/workflows/windows-host.yml | 4 +- .gitea/workflows/windows-msix.yml | 196 ------ .gitea/workflows/windows.yml | 169 ----- clients/windows/packaging/README.md | 4 +- scripts/ci/gitea-release.sh | 2 +- .../ci/provision-windows-punktfunk-extras.ps1 | 6 +- scripts/ci/provision-windows-wdk.ps1 | 2 +- scripts/ci/setup-macos-runner.sh | 2 +- 15 files changed, 909 insertions(+), 959 deletions(-) delete mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/windows-client.yml delete mode 100644 .gitea/workflows/windows-msix.yml delete mode 100644 .gitea/workflows/windows.yml diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml index b9933beb..3b4b5b76 100644 --- a/.gitea/workflows/android.yml +++ b/.gitea/workflows/android.yml @@ -274,7 +274,7 @@ jobs: retention-days: 30 # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. - # Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). + # Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler). # main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached # to the unified Gitea Release. - name: Publish to generic registry + attach to Gitea release diff --git a/.gitea/workflows/apple.yml b/.gitea/workflows/apple.yml index 2dde0ee7..4bd88fe1 100644 --- a/.gitea/workflows/apple.yml +++ b/.gitea/workflows/apple.yml @@ -1,12 +1,86 @@ -# Apple client CI — runs on the self-hosted macOS runner (home-mac-mini-1, host mode; -# see scripts/ci/setup-macos-runner.sh). Builds the Rust core into -# PunktfunkCore.xcframework, then builds + tests the Swift package. Network-dependent -# tests (RemoteFirstLightTests) self-skip without PUNKTFUNK_REMOTE_HOST. +# Apple client CI **and** distribution — everything that runs on the self-hosted macOS runner +# (home-mac-mini-1, host mode; see scripts/ci/setup-macos-runner.sh), in dependency order: # -# A second job (`screenshots`) captures the App Store Connect screenshots of the REAL UI -# (mac window + iOS/iPad/tvOS Simulators, see clients/apple/tools/screenshots.sh) and attaches -# them to the run as a single zip artifact (`punktfunk-appstore-screenshots`). It is isolated -# from the build/test job and best-effort, so a capture gap never reds the core signal. +# swift — build the Rust core into PunktfunkCore.xcframework, then build + test the Swift +# package. Network-dependent tests (RemoteFirstLightTests) self-skip without +# PUNKTFUNK_REMOTE_HOST. Runs on pushes, tags AND pull requests. +# distribute — needs: swift. The signed/notarized artifacts: +# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached +# to the Gitea release on tag pushes +# macOS (App Store) -> archive + upload to TestFlight (App Store Connect) +# iOS -> archive + upload to TestFlight, plus an exported .ipa +# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets, +# nightly -Zbuild-std, in build-xcframework.sh) +# screenshots — needs: swift. App Store Connect screenshots of the REAL UI, attached to the run +# as a zip artifact. Best-effort, so a capture gap never reds the core signal. +# +# ⚠ WHY THIS FILE IS ONE FILE. `distribute` used to live in its own workflow called `release.yml` — +# a name that described neither what it did (Apple only) nor how releases actually work here (every +# platform's packaging workflow attaches to the same Gitea release on a v* tag, and announce.yml is +# the manual "go"). The name was the smaller problem. The real one: Gitea has no cross-workflow +# `needs`, so nothing sequenced it against apple.yml's tests — a canary main push uploaded iOS, +# macOS and tvOS builds to TestFlight even when `swift test` had just failed on the same commit, +# and the two files' `paths:` filters had already drifted apart, so it was possible for one to fire +# without the other. Merging is what makes `needs: swift` expressible. Do not split them again. +# +# The trigger list is deliberately NARROW on crates/: everything here is built from +# `crates/punktfunk-core` (via scripts/build-xcframework.sh) and nothing else in the workspace. +# VERIFY THAT BEFORE WIDENING OR TRUSTING IT — punktfunk-core's only path dependency is its own +# vendored fec-rs, under crates/punktfunk-core/vendor/: +# sed -n '/^\[dependencies\]/,/^\[/p' crates/punktfunk-core/Cargo.toml | grep path +# If punktfunk-core ever gains a path dep on a sibling crate, add that crate here. Cargo.lock is a +# partial safety net (it moves when the dep is ADDED) but not a complete one — later edits to that +# crate would not fire this workflow. This is the same class of gap flatpak.yml documents. +# +# ── Signing / distribution notes (all of these belong to `distribute`) ──────────────────────────── +# +# One App Store listing for all platforms (universal purchase): every target shares the +# bundle ID io.unom.punktfunk. +# +# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements — +# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the +# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid). +# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build, +# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a +# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG +# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though +# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the +# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here. +# +# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the +# step is continue-on-error until they exist): +# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record +# (universal purchase). +# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the +# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/). +# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in +# the runner's login keychain, in addition to "Apple Distribution" — the App Store +# .pkg is installer-signed with it. +# +# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it): +# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID, +# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner +# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the +# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch +# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app +# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships +# a launchable app. +# +# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's +# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing +# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer +# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as +# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain. +# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access: +# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \ +# ~/Library/Keychains/login.keychain-db +# +# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API +# key — notarization, TestFlight upload, automatic-signing profile fetch). +# +# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow +# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a +# loud warning. name: apple # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never @@ -19,30 +93,39 @@ concurrency: on: push: + # Canary: a relevant main push builds + tests, then uploads the iOS + macOS + tvOS builds to + # TestFlight (Apple's own canary channel) — no notarized DMG (that's stable-only; see the + # per-step gates). Heavy on the shared mac-mini runner, hence the tight paths filter. branches: [main] - # Scope canary builds to what this artifact is built FROM — a docs-only or - # web-only push should not light up the whole fleet. Applies to branch pushes; - # tag runs are matched by `tags:` (proven by flatpak/windows-msix releases). paths: - - 'crates/**' + - 'crates/punktfunk-core/**' - 'clients/apple/**' - 'scripts/build-xcframework.sh' + - 'scripts/ci/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - - 'scripts/ci/**' - '.gitea/workflows/apple.yml' + # Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release + # + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. Tag runs are matched by + # `tags:` and are NOT subject to the paths filter above. + tags: ['v*'] pull_request: paths: - - 'crates/**' + - 'crates/punktfunk-core/**' - 'clients/apple/**' - 'scripts/build-xcframework.sh' + - 'scripts/ci/**' - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' - - 'scripts/ci/**' - '.gitea/workflows/apple.yml' workflow_dispatch: + inputs: + testflight: + description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)" + required: false + default: "true" # Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via # the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc @@ -61,11 +144,11 @@ env: jobs: # SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared - # with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR - # code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's - # "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral - # runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and - # still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI. + # with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could + # persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require + # approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The + # `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs + # same-repo PRs (and where the flag is absent), so it never blocks internal PR CI. swift: runs-on: macos-arm64 if: >- @@ -120,6 +203,475 @@ jobs: working-directory: clients/apple run: swift test + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + + # ── Distribution ──────────────────────────────────────────────────────────────────────────────── + # `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes + # a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job. + distribute: + needs: swift + # Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests. + if: gitea.event_name != 'pull_request' + runs-on: macos-arm64 + timeout-minutes: 120 + env: + TEAM_ID: F4H37KF6WC + PROJECT: clients/apple/Punktfunk.xcodeproj + steps: + - uses: actions/checkout@v4 + + - name: Select release Xcode + run: | + DEV_DIR="" + for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do + case "$app" in *beta*|*Beta*) continue;; esac + [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break + done + if [ -z "$DEV_DIR" ]; then + for app in /Applications/Xcode*.app; do + [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break + done + echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds." + fi + [ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; } + # Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must + # keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the + # running dyld rejects, killing proc-macro loads (see build-xcframework.sh). + echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV" + DEVELOPER_DIR="$DEV_DIR" xcodebuild -version + + - name: Version from tag + run: | + eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth) + case "$GITHUB_REF" in + refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc) + *) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates + esac + echo "VERSION=$V" >> "$GITHUB_ENV" + # GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs + # of different workflows get consecutive numbers. That is why folding the old release.yml + # into this file could not reset the build number and strand TestFlight, which rejects a + # non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1 + # — monotonic either way, which is all App Store Connect asks. + echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" + echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})" + + - name: Rust toolchain (mac + iOS + tvOS slices) + run: | + RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" + dirname "$RUSTUP" >> "$GITHUB_PATH" + "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \ + aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios + # tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with + # nightly + -Zbuild-std, so ensure nightly + rust-src are present. + "$RUSTUP" toolchain install nightly --profile minimal + "$RUSTUP" component add rust-src --toolchain nightly + + # The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus + # via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices). + - name: CMake (for the vendored libopus audiopus_sys builds) + run: | + # Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH — + # locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so + # the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`. + for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done + if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi + BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH" + command -v cmake >/dev/null || "$BREW" install cmake + echo "$BREW_BIN" >> "$GITHUB_PATH" + # Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5 + # `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake + # inherits this from the env during the xcframework build). + echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" + + # Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh. + - name: sccache (self-healing install) + run: sh scripts/ci/ensure-sccache.sh + + - name: Pin + prune Xcode DerivedData + # Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the + # PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace + # (~/.cache/act//hostexecutor), so each rotation minted a brand new ~760 MB tree + # under ~/Library that nothing ever collected. 31 of them piled up in three days + # (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed + # v0.16.0's xcframework build with "No space left on device". Pinning one path makes the + # tree REUSED instead of multiplied — it also keeps the module cache warm between runs. + # + # The directory is still named `release` after the workflow this job used to live in. Left + # alone deliberately: renaming it would orphan a warm ~760 MB tree and buy nothing. + run: | + DD="$HOME/ci/derived-data/release" + mkdir -p "$DD" + echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV" + # Safety net for trees the pin does not own: the legacy per-path ones from before this + # change, and anything another job leaves in the default root. Untouched for a week ⇒ gone. + if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then + find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \ + -mtime +7 -exec rm -rf {} + 2>/dev/null || true + fi + echo "disk after prune:"; df -h /System/Volumes/Data | tail -1 + + - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS) + # tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on + # the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the + # same track as iOS/macOS (the nightly toolchain is installed unconditionally above). + # + # This repeats the `swift` job's mac-slice build, and that is the intended trade: the two + # jobs share the runner's sccache and DerivedData, so the overlap is cheap, whereas passing + # an xcframework between jobs would mean uploading/downloading it through Gitea's artifact + # backend (the one that already forces upload-artifact@v3) on every run. + run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh + + - name: Stage App Store Connect API key + env: + ASC_P8: ${{ secrets.ASC_API_KEY_P8 }} + run: | + printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8" + chmod 600 "$RUNNER_TEMP/asc.p8" + + - name: macOS — archive, codesign Developer ID, notarize, DMG + # Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not + # relevant to TestFlight testers (the canary channel). Skipped on canary main pushes. + if: startsWith(gitea.ref, 'refs/tags/v') + run: | + # Archive UNSIGNED, then codesign with the Developer ID Application identity from the + # login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups + # provisioning-profile gate at archive time; we re-assert that authorization below by + # EMBEDDING a Developer ID profile before codesign (see the keychain note further down). + # Bundle is a single static binary. + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ + -project "$PROJECT" -scheme Punktfunk \ + -destination 'generic/platform=macOS' \ + -archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGNING_ALLOWED=NO + APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app" + # Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build + # uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix. + RESOLVED="$RUNNER_TEMP/macos.entitlements" + sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \ + clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED" + + # keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the + # network/device keys are self-asserted for Developer ID, but a keychain access group + # must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to + # spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163), + # SIGKILL before main() — even though the bundle is validly signed and notarized. Embed + # a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its + # entitlements authorize the access group, exactly like the App Store build's profile + # does. Located by profile Name among the profiles installed on the runner (see header). + DEVID_PROFILE_NAME="Punktfunk macOS Developer ID" + PROFILE_SRC="" + for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \ + "$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do + [ -e "$p" ] || continue + NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true) + [ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break + done + if [ -n "$PROFILE_SRC" ]; then + # Must land BEFORE codesign so it's sealed into the bundle. + cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile" + echo "embedded Developer ID profile: $PROFILE_SRC" + else + # Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the + # managed entitlement and let ClientIdentityStore fall back to the legacy file keychain + # (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable. + echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain." + /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true + fi + + codesign --force --options runtime --timestamp \ + --entitlements "$RESOLVED" \ + --sign "Developer ID Application" "$APP" + codesign --verify --strict --verbose=2 "$APP" + # Notarized DMG. + STAGE="$RUNNER_TEMP/dmg-stage" + mkdir -p "$STAGE" + cp -R "$APP" "$STAGE/" + ln -s /Applications "$STAGE/Applications" + DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg" + hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG" + DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \ + --key "$RUNNER_TEMP/asc.p8" \ + --key-id "${{ secrets.ASC_API_KEY_ID }}" \ + --issuer "${{ secrets.ASC_API_ISSUER_ID }}" + DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG" + echo "DMG=$DMG" >> "$GITHUB_ENV" + + - name: Attach DMG to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg" + + - name: macOS App Store — archive + upload to TestFlight + if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + # Best-effort until the App Store Connect record has the macOS platform + the + # "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer + # Installer" cert are on the runner (see the header). The macOS app is sandboxed + # (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store. + continue-on-error: true + run: | + # Separate archive from the Developer ID one above: App Store needs a signed, entitled + # archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign + # DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified + # profile (as this step used to do): the in-app license screens added a SwiftPM resource + # bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot + # carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an + # sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support + # provisioning profiles"). Automatic signing assigns a profile only to the app and leaves + # the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox + # entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and + # regenerate the managed *development* profile — needed because the App Groups capability + # (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached + # one. This is DEVELOPMENT signing against the Apple Development cert already in the + # keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export + # step below + # (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store + # distribution profile that export needs. + osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true + pkill -x Xcode 2>/dev/null || true + PROFILE="Punktfunk macOS App Store Distribution" + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ + -project "$PROJECT" -scheme Punktfunk \ + -destination 'generic/platform=macOS' \ + -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + cat > "$RUNNER_TEMP/export-macos-appstore.plist" < + + + + methodapp-store-connect + destinationupload + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + installerSigningCertificate3rd Party Mac Developer Installer + provisioningProfiles + io.unom.punktfunk$PROFILE + + + EOF + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \ + -exportPath "$RUNNER_TEMP/export-macos-appstore" \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" + + - name: iOS — archive + upload to TestFlight + if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + # Best-effort until the App Store Connect app record for io.unom.punktfunk exists. + continue-on-error: true + run: | + # Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full + # rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app + # license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER + # this step used to set matched it and failed the archive ("does not support provisioning + # profiles"). Automatic signing profiles only the app and leaves the resource bundle (and + # the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID + # capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk + # AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups + # capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension) + # invalidated the cached managed dev profile, which had no widgets profile at all. This is + # DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert + # creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles). + # DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected. + # A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed + # App Store distribution profile survives for export. + osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true + pkill -x Xcode 2>/dev/null || true + PROFILE="Punktfunk iOS App Store Distribution" + # The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second + # distribution artifact in the .ipa, so manual signing must map its App ID to its own + # App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets"). + WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution" + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ + -project "$PROJECT" -scheme Punktfunk-iOS \ + -destination 'generic/platform=iOS' \ + -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + cat > "$RUNNER_TEMP/export-appstore.plist" < + + + + methodapp-store-connect + destinationupload + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + + io.unom.punktfunk$PROFILE + io.unom.punktfunk.widgets$WIDGET_PROFILE + + + + EOF + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \ + -exportPath "$RUNNER_TEMP/export-appstore" \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" + + - name: iOS — export .ipa (Gitea release + run artifact) + # The TestFlight step above uploads straight to App Store Connect (destination=upload) and + # leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an + # App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as + # that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent, + # e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs + # only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact. + if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + id: ios_ipa + run: | + ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive" + if [ ! -d "$ARCHIVE" ]; then + echo "::warning::iOS archive not found — skipping .ipa export" + exit 0 + fi + PROFILE="Punktfunk iOS App Store Distribution" + WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution" + # destination=export writes the .ipa to -exportPath; otherwise identical manual signing to + # the upload plist (both profiles, Apple Distribution). No ASC key needed — no network. + cat > "$RUNNER_TEMP/export-appstore-ipa.plist" < + + + + methodapp-store-connect + destinationexport + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + + io.unom.punktfunk$PROFILE + io.unom.punktfunk.widgets$WIDGET_PROFILE + + + + EOF + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ + -archivePath "$ARCHIVE" \ + -exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \ + -exportPath "$RUNNER_TEMP/export-ipa" + SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1) + [ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; } + mkdir -p "$GITHUB_WORKSPACE/dist" + IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa" + mv "$SRC" "$IPA" + echo "IPA=$IPA" >> "$GITHUB_ENV" + echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT" + echo "exported $IPA" + + - name: Attach .ipa to the workflow run + if: steps.ios_ipa.outputs.ipa != '' + # v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses + # (same reason as android.yml / the screenshots job below). Download is a zip of the .ipa. + uses: actions/upload-artifact@v3 + with: + name: punktfunk-ios-ipa + path: ${{ steps.ios_ipa.outputs.ipa }} + if-no-files-found: warn + retention-days: 30 + + - name: Attach .ipa to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != '' + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.sh + RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) + upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa" + + - name: tvOS — archive + upload to TestFlight + # Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built + # on every apple push (above), so this matches the iOS step's gate exactly. + if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + # Needs tvOS added to the App Store Connect app record + the tvOS platform installed + # on the runner (xcodebuild -downloadPlatform tvOS). + continue-on-error: true + run: | + # Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM + # resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the + # sdk-scoped profile this step used to set; Automatic signing profiles only the app and + # leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/ + # AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities + # and regenerate the managed *development* profile — the tvOS app carries the App Groups key + # (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against + # the Apple Development cert already in the keychain, so the App-Manager ASC key suffices. + # DISTRIBUTION signing is the export step below (manual, plist). + osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true + pkill -x Xcode 2>/dev/null || true + PROFILE="Punktfunk tvOS App Store Distribution" + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ + -project "$PROJECT" -scheme Punktfunk-tvOS \ + -destination 'generic/platform=tvOS' \ + -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ + -derivedDataPath "$DERIVED_DATA" \ + -skipMacroValidation -skipPackagePluginValidation \ + -allowProvisioningUpdates \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Automatic \ + DEVELOPMENT_TEAM="$TEAM_ID" + cat > "$RUNNER_TEMP/export-tvos.plist" < + + + + methodapp-store-connect + destinationupload + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + provisioningProfiles + io.unom.punktfunk$PROFILE + + + EOF + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \ + -exportPath "$RUNNER_TEMP/export-tvos" \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" + + - name: sccache stats (visibility only) + if: always() + run: sccache --show-stats + # App Store screenshots of the real UI, zipped and attached to the run as a build artifact. # Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green # first, and is a separate job so a capture hiccup can never red the core signal. @@ -169,7 +721,7 @@ jobs: # inherits this from the env during the xcframework build). echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" - - name: Pin + prune DerivedData (same disease release.yml already cures) + - name: Pin + prune DerivedData (same disease the distribute job already cures) # screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two # fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA, # honored by the script) so repeat runs are incremental, and GC anything a week old diff --git a/.gitea/workflows/flatpak.yml b/.gitea/workflows/flatpak.yml index d7c8a3f8..94b02f01 100644 --- a/.gitea/workflows/flatpak.yml +++ b/.gitea/workflows/flatpak.yml @@ -7,7 +7,7 @@ # Gitea has NO flatpak/ostree registry, so the bundle lives in the generic registry: # PUT https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak// # GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak// -# On tags the bundle is ALSO attached to the Gitea release (mirrors release.yml's DMG). +# On tags the bundle is ALSO attached to the Gitea release (mirrors apple.yml's DMG). # # PRIVILEGED-BUILD CONSTRAINT: flatpak-builder runs bubblewrap, which needs user namespaces. # In a Gitea/act_runner Docker executor that means the job container must be --privileged @@ -37,7 +37,7 @@ on: # binary's dependency closure must be listed here — including the native decode rungs, or a # commit that only touches the decoder never rebuilds the bundle and the Deck canary quietly # stops tracking it. pf-dxvadec is absent on purpose: it is `cfg(windows)` in pf-client-core - # and never enters the Linux closure (windows.yml / windows-msix.yml carry it instead). + # and never enters the Linux closure (windows-client.yml carries it instead). paths: - 'clients/linux/**' - 'clients/session/**' diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index c99a2090..00000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,555 +0,0 @@ -# Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1). -# -# Tag v* (or workflow_dispatch): -# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a -# Gitea release on tag pushes -# macOS (App Store) -> archive + upload to TestFlight (App Store Connect) -# iOS -> archive + upload straight to TestFlight (App Store Connect) -# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets, -# nightly -Zbuild-std, in build-xcframework.sh) -# -# One App Store listing for all platforms (universal purchase): every target shares the -# bundle ID io.unom.punktfunk. -# -# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements — -# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the -# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid). -# The Developer ID DMG is codesigned with the SAME macOS entitlements as the App Store build, -# BUT it must ALSO embed a Developer ID provisioning profile: keychain-access-groups is a -# MANAGED entitlement that AMFI only honors when an embedded profile authorizes it. A DMG -# without one is SIGKILLed at spawn ("Launchd job spawn failed", POSIX errno 163) even though -# it is validly signed AND notarized. ⌘R hides this (Xcode embeds a development profile); the -# raw Developer ID codesign path does NOT, so ⌘R is NOT equivalent to the shipped DMG here. -# -# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the -# step is continue-on-error until they exist): -# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record -# (universal purchase). -# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the -# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/). -# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in -# the runner's login keychain, in addition to "Apple Distribution" — the App Store -# .pkg is installer-signed with it. -# -# macOS Developer ID (DMG) prerequisite (one-time, Apple portal — the DMG step embeds it): -# * A "Punktfunk macOS Developer ID" provisioning profile (Distribution -> Developer ID, -# App ID io.unom.punktfunk, with the Keychain Sharing capability) installed on the runner -# under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/. It authorizes the -# managed keychain-access-groups entitlement; without it the DMG is SIGKILLed at launch -# (errno 163). If it is missing the DMG step warns and strips that entitlement (the app -# then uses ClientIdentityStore's legacy file-keychain fallback) so the build still ships -# a launchable app. -# -# Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's -# logged-in Aqua session, so it uses the **login keychain** directly. Install the signing -# identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer -# ID Application + Apple Distribution, with the WWDR intermediate present (so they show as -# *valid*). xcodebuild/codesign then sign exactly like a local build — no throwaway keychain. -# One-time, to avoid headless "codesign wants to use the key" prompts, grant codesign access: -# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \ -# ~/Library/Keychains/login.keychain-db -# -# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API -# key — notarization, TestFlight upload, automatic-signing profile fetch). -# -# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow -# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a -# loud warning. -name: release -# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels -# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never -# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the -# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - -on: - push: - # Canary: a relevant main push uploads the iOS + macOS + tvOS builds to TestFlight (Apple's - # own canary channel) — no notarized DMG (that's stable-only; see the per-step gates). - # Heavy on the shared mac-mini runner, so paths-filtered; the TestFlight steps are - # continue-on-error until the App Store Connect record exists, so this no-ops until then. - branches: [main] - paths: - - 'clients/apple/**' - - 'crates/punktfunk-core/**' - - 'scripts/build-xcframework.sh' - - 'Cargo.lock' - - '.gitea/workflows/release.yml' - # Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release - # + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. - tags: ['v*'] - workflow_dispatch: - inputs: - testflight: - description: "Upload the iOS build to TestFlight (true/false)" - required: false - default: "true" - -# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via -# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc -# invocation build-xcframework.sh makes, incl. the tvOS -Zbuild-std std builds; the Swift -# side stays on DerivedData (sccache doesn't cache swiftc). -env: - RUSTC_WRAPPER: sccache - SCCACHE_BUCKET: unom-ci-sccache - SCCACHE_ENDPOINT: https://storage.unom.io - SCCACHE_REGION: home-central - AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} - # sccache and incremental compilation are mutually exclusive; the shared cache makes the - # runner's persistent target/ disposable instead of precious. - CARGO_INCREMENTAL: "0" - -jobs: - apple: - runs-on: macos-arm64 - timeout-minutes: 120 - env: - TEAM_ID: F4H37KF6WC - PROJECT: clients/apple/Punktfunk.xcodeproj - steps: - - uses: actions/checkout@v4 - - - name: Select release Xcode - run: | - DEV_DIR="" - for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do - case "$app" in *beta*|*Beta*) continue;; esac - [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break - done - if [ -z "$DEV_DIR" ]; then - for app in /Applications/Xcode*.app; do - [ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break - done - echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds." - fi - [ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; } - # Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must - # keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the - # running dyld rejects, killing proc-macro loads (see build-xcframework.sh). - echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV" - DEVELOPER_DIR="$DEV_DIR" xcodebuild -version - - - name: Version from tag - run: | - eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth) - case "$GITHUB_REF" in - refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc) - *) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates - esac - echo "VERSION=$V" >> "$GITHUB_ENV" - echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV" - echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})" - - - name: Rust toolchain (mac + iOS + tvOS slices) - run: | - RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" - dirname "$RUSTUP" >> "$GITHUB_PATH" - "$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \ - aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios - # tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with - # nightly + -Zbuild-std, so ensure nightly + rust-src are present. - "$RUSTUP" toolchain install nightly --profile minimal - "$RUSTUP" component add rust-src --toolchain nightly - - # The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus - # via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices). - - name: CMake (for the vendored libopus audiopus_sys builds) - run: | - # Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH — - # locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so - # the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`. - for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done - if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi - BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH" - command -v cmake >/dev/null || "$BREW" install cmake - echo "$BREW_BIN" >> "$GITHUB_PATH" - # Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5 - # `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake - # inherits this from the env during the xcframework build). - echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV" - - # Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is - # belt-and-braces. bsdtar (macOS) globs by default — no --wildcards. - - name: sccache (self-healing install) - run: | - if ! command -v sccache >/dev/null; then - mkdir -p "$HOME/.local/bin" - curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \ - | tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache' - fi - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - sccache --version - - - name: Pin + prune Xcode DerivedData - # Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the - # PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace - # (~/.cache/act//hostexecutor), so each rotation minted a brand new ~760 MB tree - # under ~/Library that nothing ever collected. 31 of them piled up in three days - # (~32 GB with the shared ModuleCache), filled the runner's boot volume, and failed - # v0.16.0's xcframework build with "No space left on device". Pinning one path makes the - # tree REUSED instead of multiplied — it also keeps the module cache warm between runs. - run: | - DD="$HOME/ci/derived-data/release" - mkdir -p "$DD" - echo "DERIVED_DATA=$DD" >> "$GITHUB_ENV" - # Safety net for trees the pin does not own: the legacy per-path ones from before this - # change, and anything another job leaves in the default root. Untouched for a week ⇒ gone. - if [ -d "$HOME/Library/Developer/Xcode/DerivedData" ]; then - find "$HOME/Library/Developer/Xcode/DerivedData" -mindepth 1 -maxdepth 1 \ - -mtime +7 -exec rm -rf {} + 2>/dev/null || true - fi - echo "disk after prune:"; df -h /System/Volumes/Data | tail -1 - - - name: Build PunktfunkCore.xcframework (mac + iOS + tvOS) - # tvOS is a tier-3 target (nightly -Zbuild-std): slow on the first build, then cached on - # the self-hosted runner. Built on canary too so the tvOS archive/upload below runs on the - # same track as iOS/macOS (the nightly toolchain is installed unconditionally above). - run: BUILD_IOS=1 BUILD_TVOS=1 bash scripts/build-xcframework.sh - - - name: Stage App Store Connect API key - env: - ASC_P8: ${{ secrets.ASC_API_KEY_P8 }} - run: | - printf '%s' "$ASC_P8" > "$RUNNER_TEMP/asc.p8" - chmod 600 "$RUNNER_TEMP/asc.p8" - - - name: macOS — archive, codesign Developer ID, notarize, DMG - # Stable releases only — the notarized DMG is a Gatekeeper/direct-download artifact, not - # relevant to TestFlight testers (the canary channel). Skipped on canary main pushes. - if: startsWith(gitea.ref, 'refs/tags/v') - run: | - # Archive UNSIGNED, then codesign with the Developer ID Application identity from the - # login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups - # provisioning-profile gate at archive time; we re-assert that authorization below by - # EMBEDDING a Developer ID profile before codesign (see the keychain note further down). - # Bundle is a single static binary. - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ - -project "$PROJECT" -scheme Punktfunk \ - -destination 'generic/platform=macOS' \ - -archivePath "$RUNNER_TEMP/Punktfunk-macos.xcarchive" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGNING_ALLOWED=NO - APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app" - # Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build - # uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix. - RESOLVED="$RUNNER_TEMP/macos.entitlements" - sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \ - clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED" - - # keychain-access-groups is a MANAGED (restricted) entitlement: App Sandbox and the - # network/device keys are self-asserted for Developer ID, but a keychain access group - # must be AUTHORIZED by an embedded provisioning profile. Without one, AMFI refuses to - # spawn the sandboxed process at launch — "Launchd job spawn failed" (POSIX errno 163), - # SIGKILL before main() — even though the bundle is validly signed and notarized. Embed - # a "Developer ID" distribution profile for io.unom.punktfunk (Keychain Sharing) so its - # entitlements authorize the access group, exactly like the App Store build's profile - # does. Located by profile Name among the profiles installed on the runner (see header). - DEVID_PROFILE_NAME="Punktfunk macOS Developer ID" - PROFILE_SRC="" - for p in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/"*.provisionprofile \ - "$HOME/Library/MobileDevice/Provisioning Profiles/"*.provisionprofile; do - [ -e "$p" ] || continue - NAME=$(security cms -D -i "$p" 2>/dev/null | plutil -extract Name raw - 2>/dev/null || true) - [ "$NAME" = "$DEVID_PROFILE_NAME" ] && PROFILE_SRC="$p" && break - done - if [ -n "$PROFILE_SRC" ]; then - # Must land BEFORE codesign so it's sealed into the bundle. - cp "$PROFILE_SRC" "$APP/Contents/embedded.provisionprofile" - echo "embedded Developer ID profile: $PROFILE_SRC" - else - # Fallback so a missing/expired profile NEVER reships the errno-163 brick: drop the - # managed entitlement and let ClientIdentityStore fall back to the legacy file keychain - # (its errSecMissingEntitlement path). Degraded (one Keychain prompt) but launchable. - echo "::warning::Developer ID profile '$DEVID_PROFILE_NAME' not installed on the runner — stripping keychain-access-groups so the DMG still launches (legacy file keychain). Create it in the Apple portal + install it on the runner to restore the no-prompt data-protection keychain." - /usr/libexec/PlistBuddy -c "Delete :keychain-access-groups" "$RESOLVED" 2>/dev/null || true - fi - - codesign --force --options runtime --timestamp \ - --entitlements "$RESOLVED" \ - --sign "Developer ID Application" "$APP" - codesign --verify --strict --verbose=2 "$APP" - # Notarized DMG. - STAGE="$RUNNER_TEMP/dmg-stage" - mkdir -p "$STAGE" - cp -R "$APP" "$STAGE/" - ln -s /Applications "$STAGE/Applications" - DMG="$RUNNER_TEMP/Punktfunk-$VERSION.dmg" - hdiutil create -volname "Punktfunk" -srcfolder "$STAGE" -ov -format UDZO "$DMG" - DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun notarytool submit "$DMG" --wait \ - --key "$RUNNER_TEMP/asc.p8" \ - --key-id "${{ secrets.ASC_API_KEY_ID }}" \ - --issuer "${{ secrets.ASC_API_ISSUER_ID }}" - DEVELOPER_DIR="$XCODE_DEV_DIR" xcrun stapler staple "$DMG" - echo "DMG=$DMG" >> "$GITHUB_ENV" - - - name: Attach DMG to the Gitea release (stable tags only) - if: startsWith(gitea.ref, 'refs/tags/v') - env: - GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - . scripts/ci/gitea-release.sh - RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) - upsert_asset "$RID" "$DMG" "Punktfunk-$VERSION.dmg" - - - name: macOS App Store — archive + upload to TestFlight - if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' - # Best-effort until the App Store Connect record has the macOS platform + the - # "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer - # Installer" cert are on the runner (see the header). The macOS app is sandboxed - # (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store. - continue-on-error: true - run: | - # Separate archive from the Developer ID one above: App Store needs a signed, entitled - # archive that -exportArchive can re-sign for distribution, not the unsigned-then-codesign - # DMG path. Archive with AUTOMATIC signing (development). Why not a manually-specified - # profile (as this step used to do): the in-app license screens added a SwiftPM resource - # bundle (PunktfunkKit_PunktfunkKit), and a resource bundle is a product type that cannot - # carry a provisioning profile — a global PROVISIONING_PROFILE_SPECIFIER (here) or an - # sdk-scoped one (iOS/tvOS) lands on it and fails the archive ("does not support - # provisioning profiles"). Automatic signing assigns a profile only to the app and leaves - # the resource bundle (and the macOS-host macro plugins) alone, and bakes the sandbox - # entitlements in. -allowProvisioningUpdates lets Xcode sync the App ID capabilities and - # regenerate the managed *development* profile — needed because the App Groups capability - # (group.io.unom.punktfunk, in Config/Punktfunk-macOS.entitlements) invalidated the cached - # one. This is DEVELOPMENT signing against the Apple Development cert already in the - # keychain, so the App-Manager ASC key suffices. DISTRIBUTION signing happens in the export - # step below - # (manual, via the plist). Quit Xcode so it can't prune the manually-installed App Store - # distribution profile that export needs. - osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true - pkill -x Xcode 2>/dev/null || true - PROFILE="Punktfunk macOS App Store Distribution" - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ - -project "$PROJECT" -scheme Punktfunk \ - -destination 'generic/platform=macOS' \ - -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - cat > "$RUNNER_TEMP/export-macos-appstore.plist" < - - - - methodapp-store-connect - destinationupload - teamID$TEAM_ID - signingStylemanual - signingCertificateApple Distribution - installerSigningCertificate3rd Party Mac Developer Installer - provisioningProfiles - io.unom.punktfunk$PROFILE - - - EOF - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ - -exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \ - -exportPath "$RUNNER_TEMP/export-macos-appstore" \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" - - - name: iOS — archive + upload to TestFlight - if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' - # Best-effort until the App Store Connect app record for io.unom.punktfunk exists. - continue-on-error: true - run: | - # Archive with AUTOMATIC signing (development) — see the macOS App Store step for the full - # rationale. The SwiftPM resource bundle (PunktfunkKit_PunktfunkKit, added with the in-app - # license screens) builds for iphoneos, so even the sdk-scoped PROVISIONING_PROFILE_SPECIFIER - # this step used to set matched it and failed the archive ("does not support provisioning - # profiles"). Automatic signing profiles only the app and leaves the resource bundle (and - # the macOS-host macro plugins) alone. -allowProvisioningUpdates lets Xcode sync the App ID - # capabilities and regenerate the managed *development* profiles for both io.unom.punktfunk - # AND the embedded io.unom.punktfunk.widgets — needed because adding the App Groups - # capability (group.io.unom.punktfunk, shared with the Widget/Live-Activity extension) - # invalidated the cached managed dev profile, which had no widgets profile at all. This is - # DEVELOPMENT signing against the Apple Development cert already in the keychain — no cert - # creation, so the App-Manager ASC key is sufficient (it only manages App IDs/dev profiles). - # DISTRIBUTION signing is the export step below (manual, via the plist) and is unaffected. - # A running Xcode.app prunes unrecognized profiles — quit it so the manually-installed - # App Store distribution profile survives for export. - osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true - pkill -x Xcode 2>/dev/null || true - PROFILE="Punktfunk iOS App Store Distribution" - # The embedded PunktfunkWidgetsExtension (bundle io.unom.punktfunk.widgets) is a second - # distribution artifact in the .ipa, so manual signing must map its App ID to its own - # App Store profile too — else exportArchive fails ("no profile for io.unom.punktfunk.widgets"). - WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution" - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ - -project "$PROJECT" -scheme Punktfunk-iOS \ - -destination 'generic/platform=iOS' \ - -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - cat > "$RUNNER_TEMP/export-appstore.plist" < - - - - methodapp-store-connect - destinationupload - teamID$TEAM_ID - signingStylemanual - signingCertificateApple Distribution - provisioningProfiles - - io.unom.punktfunk$PROFILE - io.unom.punktfunk.widgets$WIDGET_PROFILE - - - - EOF - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/Punktfunk-ios.xcarchive" \ - -exportOptionsPlist "$RUNNER_TEMP/export-appstore.plist" \ - -exportPath "$RUNNER_TEMP/export-appstore" \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" - - - name: iOS — export .ipa (Gitea release + run artifact) - # The TestFlight step above uploads straight to App Store Connect (destination=upload) and - # leaves NO .ipa on disk. Re-export the SAME archive with destination=export to get an - # App Store distribution-signed .ipa for the Gitea release + the run artifacts. Same gate as - # that archive; a warn+skip (never fails the best-effort iOS leg) if the archive is absent, - # e.g. a workflow_dispatch with testflight=false. NOTE: an App Store-signed .ipa installs - # only via TestFlight/App Store, not by direct sideload — it's a release/archival artifact. - if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' - id: ios_ipa - run: | - ARCHIVE="$RUNNER_TEMP/Punktfunk-ios.xcarchive" - if [ ! -d "$ARCHIVE" ]; then - echo "::warning::iOS archive not found — skipping .ipa export" - exit 0 - fi - PROFILE="Punktfunk iOS App Store Distribution" - WIDGET_PROFILE="Punktfunk iOS Widgets App Store Distribution" - # destination=export writes the .ipa to -exportPath; otherwise identical manual signing to - # the upload plist (both profiles, Apple Distribution). No ASC key needed — no network. - cat > "$RUNNER_TEMP/export-appstore-ipa.plist" < - - - - methodapp-store-connect - destinationexport - teamID$TEAM_ID - signingStylemanual - signingCertificateApple Distribution - provisioningProfiles - - io.unom.punktfunk$PROFILE - io.unom.punktfunk.widgets$WIDGET_PROFILE - - - - EOF - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ - -archivePath "$ARCHIVE" \ - -exportOptionsPlist "$RUNNER_TEMP/export-appstore-ipa.plist" \ - -exportPath "$RUNNER_TEMP/export-ipa" - SRC=$(ls "$RUNNER_TEMP/export-ipa/"*.ipa 2>/dev/null | head -1) - [ -n "$SRC" ] || { echo "::warning::no .ipa was produced by export"; exit 0; } - mkdir -p "$GITHUB_WORKSPACE/dist" - IPA="$GITHUB_WORKSPACE/dist/Punktfunk-$VERSION.ipa" - mv "$SRC" "$IPA" - echo "IPA=$IPA" >> "$GITHUB_ENV" - echo "ipa=dist/Punktfunk-$VERSION.ipa" >> "$GITHUB_OUTPUT" - echo "exported $IPA" - - - name: Attach .ipa to the workflow run - if: steps.ios_ipa.outputs.ipa != '' - # v3, not v4: Gitea's artifact backend identifies as GHES, which upload-artifact@v4 refuses - # (same reason as android.yml / apple.yml). Download is a zip of the .ipa. - uses: actions/upload-artifact@v3 - with: - name: punktfunk-ios-ipa - path: ${{ steps.ios_ipa.outputs.ipa }} - if-no-files-found: warn - retention-days: 30 - - - name: Attach .ipa to the Gitea release (stable tags only) - if: startsWith(gitea.ref, 'refs/tags/v') && steps.ios_ipa.outputs.ipa != '' - env: - GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - . scripts/ci/gitea-release.sh - RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) - upsert_asset "$RID" "$IPA" "Punktfunk-$VERSION.ipa" - - - name: tvOS — archive + upload to TestFlight - # Canary + stable, the same track as iOS/macOS — the tvOS xcframework slice is now built - # on every apple push (above), so this matches the iOS step's gate exactly. - if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' - # Needs tvOS added to the App Store Connect app record + the tvOS platform installed - # on the runner (xcodebuild -downloadPlatform tvOS). - continue-on-error: true - run: | - # Archive with AUTOMATIC signing (development) — see the macOS App Store step. The SwiftPM - # resource bundle (PunktfunkKit_PunktfunkKit) builds for appletvos and rejected the - # sdk-scoped profile this step used to set; Automatic signing profiles only the app and - # leaves the resource bundle + the macOS-host macro plugins (OnceMacro/SwizzlingMacro/ - # AssociationMacro) alone. -allowProvisioningUpdates lets Xcode sync the App ID capabilities - # and regenerate the managed *development* profile — the tvOS app carries the App Groups key - # (group.io.unom.punktfunk) too, which invalidated the cached one. DEVELOPMENT signing against - # the Apple Development cert already in the keychain, so the App-Manager ASC key suffices. - # DISTRIBUTION signing is the export step below (manual, plist). - osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true - pkill -x Xcode 2>/dev/null || true - PROFILE="Punktfunk tvOS App Store Distribution" - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ - -project "$PROJECT" -scheme Punktfunk-tvOS \ - -destination 'generic/platform=tvOS' \ - -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation -skipPackagePluginValidation \ - -allowProvisioningUpdates \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" \ - MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ - CODE_SIGN_STYLE=Automatic \ - DEVELOPMENT_TEAM="$TEAM_ID" - cat > "$RUNNER_TEMP/export-tvos.plist" < - - - - methodapp-store-connect - destinationupload - teamID$TEAM_ID - signingStylemanual - signingCertificateApple Distribution - provisioningProfiles - io.unom.punktfunk$PROFILE - - - EOF - DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ - -archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \ - -exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \ - -exportPath "$RUNNER_TEMP/export-tvos" \ - -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ - -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ - -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" diff --git a/.gitea/workflows/sbom.yml b/.gitea/workflows/sbom.yml index 8cfc4f34..5a58c804 100644 --- a/.gitea/workflows/sbom.yml +++ b/.gitea/workflows/sbom.yml @@ -67,7 +67,7 @@ jobs: . scripts/ci/gitea-release.sh RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto) upsert_asset "$RID" "$SBOM_FILE" - # v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see release.yml). + # v3, not v4: Gitea's artifact backend rejects upload-artifact@v4 (see apple.yml). - name: Upload artifact (non-tag runs) if: "!startsWith(github.ref, 'refs/tags/')" uses: actions/upload-artifact@v3 diff --git a/.gitea/workflows/windows-client.yml b/.gitea/workflows/windows-client.yml new file mode 100644 index 00000000..684a0891 --- /dev/null +++ b/.gitea/workflows/windows-client.yml @@ -0,0 +1,318 @@ +# Windows CLIENT — build, lint, test and package, on a self-hosted windows-amd64 runner (host mode; +# the generic runner + MSVC/WinUI toolchain come from unom/infra's windows-runner/, punktfunk's own +# extras — WDK, Inno Setup, the ARM64 rustup target — self-provision via the "Ensure Windows +# toolchain" step, a fast no-op once present, so any runner with that label works). +# +# Covers BOTH client binaries: the WinUI 3 shell (windows-reactor + WASAPI + SDL3) and the +# punktfunk-session Vulkan client (pf-presenter/pf-client-core/pf-console-ui — every stream runs in +# it, spawned by the shell), plus punktfunk-cli, whose `punktfunk.exe` alias the MSIX manifest +# references. +# +# ⚠ WHY THIS IS ONE FILE. This was `windows.yml` (build+lint+test, DEBUG, x64 + arm64) and +# `windows-msix.yml` (build+package, RELEASE, x64 + arm64) — four full compiles of the same crates +# per client push, on ONE runner, from three copies of the same `paths:` list that had already +# started to drift. windows-host.yml learned the hard way that debug trees on this machine are pure +# liability: a second dep tree tips it into `cabac_decoder.cpp: fatal error C1069` building +# openh264-sys2's vendored C++, which is disk/temp exhaustion, not a source error. So there is now +# ONE release build per arch, and clippy/fmt/test run against it. Do not reintroduce a debug leg. +# +# Renamed from windows-msix.yml deliberately, and safely: `github.run_number` is REPO-WIDE in Gitea +# (consecutive runs of DIFFERENT workflows get consecutive numbers), so the canary MSIX version +# `..0` keeps climbing across the rename — on GitHub, where run_number is per-workflow, +# this same rename would have reset it to 1 and made every canary sort below the published ones. +# +# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and aarch64-pc-windows-msvc +# by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler +# (VC\Tools\MSVC\\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust target +# with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 compiler from +# the target triple (SDL3 + libopus build-from-source cross-compile fine). The one thing the aarch64 +# build can't do is *run* on the x64 host, so fmt + test run only for x64. +# +# ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the session builds +# --no-default-features there (no Skia console UI; streaming is unaffected) — flip when +# skia-binaries adds the target. +# +# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with +# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets +# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning +# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's +# `amf-qsv` leg link-imports them. +# +# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME, +# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout / per-arch +# vars are set in a step: +# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under +# C:\Windows\System32\config\systemprofile\.cache\act\\hostexecutor\, +# so the default target\ path blows past Windows' MAX_PATH (260) inside the +# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then +# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short +# root keeps every nested path well under the limit (per-arch so the two +# matrix legs don't share a target dir). +# +# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's +# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that +# var silently never gets set). pwsh writes no BOM. +# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh. +# +# ── Packaging (the `Pack + sign MSIX` step onward; skipped on pull requests) ────────────────────── +# +# Publishes signed MSIX packages (x64 + ARM64) to Gitea's generic package registry, so Windows boxes +# can install a real package (Start tile, clean install/uninstall) instead of a loose exe. +# +# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) +# Packaging internals: clients/windows/packaging/README.md. +# +# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so: +# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX). +# Published to the generic registry + the stable `latest/` alias + attached to the +# unified Gitea Release alongside every other platform's artifact. +# main push / dispatch -> ..0 (canary; base is one minor ahead of the +# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically). +# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). +# +# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD +# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the +# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public +# .cer is published next to the .msix (users import it to Trusted People before install). +# +# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret +# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that +# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself. +name: windows-client +# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels +# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never +# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the +# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + +on: + push: + branches: [main] + # ONE list now, not three. The old windows.yml + windows-msix.yml pair carried this same set + # three times (push, pull_request, and the second file), which is exactly how a crate goes + # missing from one copy — windows-host.yml documents the "Cargo.lock luck" gap that produced. + paths: + - 'clients/windows/**' + - 'clients/session/**' + - 'crates/punktfunk-core/**' + - 'crates/pf-client-core/**' + - 'crates/pf-presenter/**' + - 'crates/pf-console-ui/**' + - 'crates/pf-bitstream/**' + - 'crates/pf-vkdecode/**' + - 'crates/pf-dxvadec/**' + - 'Cargo.lock' + - 'Cargo.toml' + - '.gitea/workflows/windows-client.yml' + tags: ['v*'] + pull_request: + paths: + - 'clients/windows/**' + - 'clients/session/**' + - 'crates/punktfunk-core/**' + - 'crates/pf-client-core/**' + - 'crates/pf-presenter/**' + - 'crates/pf-console-ui/**' + - 'crates/pf-bitstream/**' + - 'crates/pf-vkdecode/**' + - 'crates/pf-dxvadec/**' + - 'Cargo.lock' + - 'Cargo.toml' + - '.gitea/workflows/windows-client.yml' + workflow_dispatch: + +# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's +# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can +# never collide; every Rust job on every host feeds and reads one warm cache. +# +# NOTE the C/C++ launcher wiring the Linux workflows carry (CMAKE_*_COMPILER_LAUNCHER, CC_*) is +# deliberately NOT set here. This runner's failure mode under extra compiler processes is the +# C1069 disk/temp exhaustion documented in windows-host.yml, so sccache-for-MSVC is its own +# change, to be made with a measurement rather than folded into a reorganisation. +env: + REGISTRY: git.unom.io + OWNER: unom + PKG: punktfunk-client-windows + RUSTC_WRAPPER: sccache + SCCACHE_BUCKET: unom-ci-sccache + SCCACHE_ENDPOINT: https://storage.unom.io + SCCACHE_REGION: home-central + AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} + # sccache and incremental compilation are mutually exclusive; CI wants the shared + # cache, dev boxes keep incremental. + CARGO_INCREMENTAL: "0" + +jobs: + # SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the + # host-mode, persistent `windows-amd64` runner that the release-SIGNING steps below and + # windows-host.yml (which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted + # PR code could therefore persist on that machine or harvest signing material a later job exposes. + # The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to + # run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral + # runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag, + # and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it + # never blocks internal PR CI. + client: + runs-on: windows-amd64 + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.fork != true + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + target: x86_64-pc-windows-msvc + td: C:\t + session_flags: '' + - arch: arm64 + target: aarch64-pc-windows-msvc + td: C:\t-a64 + # No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships + # without the Skia console UI on ARM64 (streaming unaffected) — flip when + # rust-skia adds the target. + session_flags: '--no-default-features' + steps: + - uses: actions/checkout@v4 + + - name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target) + shell: pwsh + run: ./scripts/ci/ensure-windows-toolchain.ps1 + + - name: Configure + version + shell: pwsh + run: | + # CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source + # crates (see this file's header). No FFMPEG_DIR: nothing in this package links libav* + # (M10), and pack-msix.ps1 no longer copies runtime DLLs from one. + "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + rustup target add ${{ matrix.target }} + rustc --version + cargo --version + $pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag + $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') { + # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix. + (($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.') + } else { + # Canary: ...0 — major.minor track one minor ahead of stable, run climbs monotonically. + @($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER) + } + while ($parts.Count -lt 4) { $parts += '0' } + $v = ($parts[0..3] -join '.') + "MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }} target-dir ${{ matrix.td }}" + + # All three client binaries, ONCE, in release. The shell spawns punktfunk-session.exe (a + # package sibling) for every stream, and punktfunk-cli builds the `punktfunk.exe` the manifest + # aliases and pack-msix.ps1 requires (bf981027 added the requirement without the build — the + # same gap 90c84ef4 closed for deb). --no-default-features on ARM64 is a no-op for the shell. + # + # Release, not debug, even for the lint/test legs below: a debug build here would compile the + # whole dep tree into a SECOND target dir and re-run openh264-sys2's vendored C++ through + # cc-rs's cl.exe fan-out, which is what tips this runner into C1069 (see the header). + - name: Build (release) + shell: pwsh + run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }} + + - name: Clippy (-D warnings) + shell: pwsh + run: | + # Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a + # dependency but only LINTS the packages it is given, so a decode crate that starts the + # run but is missing from this list would be gated by nothing. + $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec') + $sf = @() + if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') } + cargo clippy --release @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings + if ($LASTEXITCODE) { throw "clippy" } + + - name: Rustfmt check + if: matrix.arch == 'x64' + shell: pwsh + run: | + cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check + if ($LASTEXITCODE) { throw "rustfmt" } + + - name: Test + # x64 only: the aarch64 binaries cross-compile here but cannot RUN on this host. + if: matrix.arch == 'x64' + shell: pwsh + run: | + cargo test --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }} + if ($LASTEXITCODE) { throw "tests" } + + - name: sccache stats (visibility only) + if: always() + shell: pwsh + run: sccache --show-stats + + # ── Packaging: pushes, tags and dispatch only. A PR gets the build/lint/test signal above and + # stops there — packing would sign with a throwaway cert and publish nothing. + - name: Pack + sign MSIX + if: github.event_name != 'pull_request' + shell: pwsh + env: + MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} + MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} + run: | + & clients/windows/packaging/pack-msix.ps1 ` + -Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} ` + -TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix + + - name: Publish to Gitea generic registry + if: github.event_name != 'pull_request' + shell: pwsh + env: + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + $PSNativeCommandUseErrorActionPreference = $false + $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" + # stable release -> `latest/` alias; canary main build -> `canary/` alias. + $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' } + # version-less, arch-suffixed alias names so each channel keeps one predictable URL. + $aliasNames = @{ + "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" + "$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer" + } + $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } + if (-not $files) { throw "pack produced no artifacts to publish" } + function Put($f, $url) { + # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag + # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel + # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the + # status code instead of aborting on it. + $code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url") + if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" } + if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return } + if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" } + Write-Output "published ($code): $url" + } + foreach ($f in $files) { + $name = Split-Path $f -Leaf + # 1) immutable, versioned path + Put $f "$base/$($env:MSIX_VERSION)/$name" + # 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file) + $an = $aliasNames["$f"] + curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null + Put $f "$base/$alias/$an" + } + + # On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both + # arch legs attach to the same release concurrently — the helper's create-or-fetch handles + # the race, and x64/arm64 filenames differ so the assets don't collide. + - name: Attach MSIX to the Gitea release (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + shell: pwsh + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + . scripts/ci/gitea-release.ps1 + $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' + foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) { + if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } + } diff --git a/.gitea/workflows/windows-drivers.yml b/.gitea/workflows/windows-drivers.yml index e77da010..51cf88cd 100644 --- a/.gitea/workflows/windows-drivers.yml +++ b/.gitea/workflows/windows-drivers.yml @@ -9,7 +9,7 @@ # only live NVENC encode does, which defers to the RTX box. # # shell: pwsh deliberately (PowerShell 5.1's Out-File -Encoding utf8 prepends a BOM that corrupts the -# first GITHUB_ENV line — see windows.yml). +# first GITHUB_ENV line — see windows-client.yml). name: windows-drivers # One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels # it (a canary only needs the latest commit; each release tag is its own ref so tag runs never @@ -39,7 +39,7 @@ on: jobs: # SECURITY: builds PULL-REQUEST code on the host-mode, persistent `windows-amd64` runner shared with - # the release-signing jobs (windows-host.yml / windows-msix.yml). See windows.yml for the full + # the release-signing jobs (windows-host.yml / windows-client.yml). See windows-client.yml for the full # rationale. Definitive fix is server-side (Gitea outside-collaborator approval + isolated PR # runners); the `if:` is a fail-open backstop that never blocks internal PR CI. probe-and-proto: @@ -111,7 +111,7 @@ jobs: - name: Build + test pf-driver-proto (MSVC) run: | - # Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows.yml). + # Short target dir to dodge MAX_PATH inside the deep act host workdir (see windows-client.yml). $env:CARGO_TARGET_DIR = "C:\t\drv" cargo build -p pf-driver-proto cargo test -p pf-driver-proto @@ -144,7 +144,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Ensure Windows toolchain (WDK, FFmpeg, Inno Setup, ARM64 target) - # Shared self-provision step (also used by windows.yml/windows-msix.yml/windows-host.yml) so + # Shared self-provision step (also used by windows-client.yml/windows-host.yml) so # driver-build is self-sufficient on any windows-amd64 runner and never races a manually # dispatched provisioning workflow landing on a different one. Path is relative to the job # working-directory (packaging/windows/drivers). Near-noop once the toolchain is present. diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index e380bf11..8e30c1d1 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -3,7 +3,7 @@ # pf-vdisplay virtual-display driver + the web management console + the opt-in plugin/script runner, # run by scheduled tasks on a bundled bun) from one signed setup.exe. Runs on a self-hosted # windows-amd64 runner -# (host mode; same MSVC/Windows-SDK/LLVM env as windows.yml — generic from unom/infra's +# (host mode; same MSVC/Windows-SDK/LLVM env as windows-client.yml — generic from unom/infra's # windows-runner/, FFmpeg/Inno Setup self-provision via the "Ensure Windows toolchain" step below). # # Why an installer and not MSIX (like the client): the host installs a LocalSystem SCM service that @@ -143,7 +143,7 @@ jobs: "CMAKE_POLICY_VERSION_MINIMUM=3.5" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 # FFMPEG_DIR: the BtbN lgpl-shared x64 tree, provisioned by # scripts/ci/provision-windows-punktfunk-extras.ps1. The CLIENT used to link it too; since M10 - # it links no libav* at all (windows.yml sets no FFMPEG_DIR), so this tree is the HOST's alone + # it links no libav* at all (windows-client.yml sets no FFMPEG_DIR), so this tree is the HOST's alone # and the provisioning step keeps fetching it for that reason. The host's AMD/Intel AMF/QSV encode backend # (--features amf-qsv) link-imports avcodec/avutil/swscale from it; pack-host-installer.ps1 # then bundles its bin\*.dll into the installer. LIBCLANG_PATH is in the runner daemon env. diff --git a/.gitea/workflows/windows-msix.yml b/.gitea/workflows/windows-msix.yml deleted file mode 100644 index 59b1d9d2..00000000 --- a/.gitea/workflows/windows-msix.yml +++ /dev/null @@ -1,196 +0,0 @@ -# Build the punktfunk Windows client as signed MSIX packages (x64 + ARM64) and publish them to -# Gitea's generic package registry, so Windows boxes can download + install a real package (Start -# tile, clean install/uninstall) instead of a loose exe. Runs on a self-hosted windows-amd64 -# runner (host mode; the MSVC/WinUI toolchain comes from unom/infra's windows-runner/, the rest -# self-provisions via the "Ensure Windows toolchain" step below, same as windows.yml) — the -# Windows SDK's makeappx/signtool are baked into the runner's daemon env. -# -# Both arches come off the ONE x64 runner: x86_64 natively, aarch64 cross-compiled (the x64 MSVC -# toolset has the ARM64 cross compiler). See windows.yml for the cross-build rationale + the -# BOM/MAX_PATH runner gotchas. -# -# NO FFmpeg since M10 (design/client-native-decode.md §6): the client decodes natively, so the -# package carries no libav* DLLs and this workflow sets no FFMPEG_DIR. The host installer -# (windows-host.yml) is unchanged. -# -# Registry (public, unom org): https://git.unom.io/unom/-/packages (generic group) -# Packaging internals: clients/windows/packaging/README.md. -# -# Versioning — single project version; MSIX requires a strictly 4-part numeric version, so: -# vX.Y.Z tag -> X.Y.Z.0 (THE release; any -rc/+meta pre-release suffix is dropped for MSIX). -# Published to the generic registry + the stable `latest/` alias + attached to the -# unified Gitea Release alongside every other platform's artifact. -# main push / dispatch -> ..0 (canary; base is one minor ahead of the -# latest stable tag via scripts/ci/pf-version.ps1, run number climbs monotonically). -# Published to the generic registry + the `canary/` alias. -# Both arches share the version; artifacts are arch-suffixed (..._x64.msix / ..._arm64.msix). -# -# Signing (clients/windows/packaging/pack-msix.ps1): if the MSIX_CERT_PFX_B64 / MSIX_CERT_PASSWORD -# Actions secrets are set (a real or shared code-signing .pfx whose subject DN == Publisher), the -# package is signed with them. Otherwise an ephemeral self-signed cert is generated and its public -# .cer is published next to the .msix (users import it to Trusted People before install). -# -# That fallback is for canary/CI ONLY. On a v* tag the pack script FAILS CLOSED — a missing secret -# aborts the build instead of quietly shipping a release signed by a per-build throwaway cert that -# no one can pin. Nothing to opt into here: the script reads GITHUB_REF itself. -name: windows-msix -# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels -# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never -# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the -# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - -on: - push: - branches: [main] - paths: - - 'clients/windows/**' - - 'clients/session/**' - - 'crates/punktfunk-core/**' - - 'crates/pf-client-core/**' - - 'crates/pf-presenter/**' - - 'crates/pf-console-ui/**' - - 'crates/pf-bitstream/**' - - 'crates/pf-vkdecode/**' - - 'crates/pf-dxvadec/**' - - 'Cargo.lock' - - 'Cargo.toml' - - '.gitea/workflows/windows-msix.yml' - tags: ['v*'] - workflow_dispatch: - -env: - REGISTRY: git.unom.io - OWNER: unom - PKG: punktfunk-client-windows - RUSTC_WRAPPER: sccache - SCCACHE_BUCKET: unom-ci-sccache - SCCACHE_ENDPOINT: https://storage.unom.io - SCCACHE_REGION: home-central - AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} - # sccache and incremental compilation are mutually exclusive; CI wants the shared - # cache, dev boxes keep incremental. - CARGO_INCREMENTAL: "0" - -jobs: - package: - runs-on: windows-amd64 - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - arch: x64 - target: x86_64-pc-windows-msvc - td: C:\t - session_flags: '' - - arch: arm64 - target: aarch64-pc-windows-msvc - td: C:\t-a64 - # No skia-binaries prebuilt for aarch64-pc-windows-msvc: the session ships - # without the Skia console UI on ARM64 (streaming unaffected) — flip when - # rust-skia adds the target. - session_flags: '--no-default-features' - steps: - - uses: actions/checkout@v4 - - - name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target) - shell: pwsh - run: ./scripts/ci/ensure-windows-toolchain.ps1 - - - name: Configure + version - shell: pwsh - run: | - # CARGO_TARGET_DIR (per-arch, short) dodges the MAX_PATH wall in the CMake-from-source - # crates (see windows.yml). No FFMPEG_DIR: nothing in this package links libav* (M10), - # and pack-msix.ps1 no longer copies runtime DLLs from one. - "CARGO_TARGET_DIR=${{ matrix.td }}" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - rustup target add ${{ matrix.target }} - $pf = & "$env:GITHUB_WORKSPACE/scripts/ci/pf-version.ps1" # single source of truth: base is one minor ahead of the latest stable tag - $parts = if ($env:GITHUB_REF -like 'refs/tags/v*') { - # MSIX needs a purely-numeric 4-part version: drop any -rc/+meta pre-release suffix. - (($env:GITHUB_REF_NAME -replace '^v', '') -replace '[-+].*$', '').Split('.') - } else { - # Canary: ...0 — major.minor track one minor ahead of stable, run climbs monotonically. - @($pf.PF_MAJOR, $pf.PF_MINOR, $env:GITHUB_RUN_NUMBER) - } - while ($parts.Count -lt 4) { $parts += '0' } - $v = ($parts[0..3] -join '.') - "MSIX_VERSION=$v" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Write-Output "MSIX version $v arch ${{ matrix.arch }} target ${{ matrix.target }}" - - # All three client binaries — the shell spawns punktfunk-session.exe (a package - # sibling) for every stream, and punktfunk-console.exe is the couch Start-menu tile's - # hand-off shim. --no-default-features on ARM64 is a no-op for the shell. - - name: Build (release) - shell: pwsh - # punktfunk-cli builds the `punktfunk.exe` the manifest aliases and pack-msix.ps1 - # requires (bf981027 added the requirement without the build — same gap 90c84ef4 - # closed for deb). - run: cargo build --release -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli ${{ matrix.session_flags }} --target ${{ matrix.target }} - - - name: Pack + sign MSIX - shell: pwsh - env: - MSIX_CERT_PFX_B64: ${{ secrets.MSIX_CERT_PFX_B64 }} - MSIX_CERT_PASSWORD: ${{ secrets.MSIX_CERT_PASSWORD }} - run: | - & clients/windows/packaging/pack-msix.ps1 ` - -Version $env:MSIX_VERSION -Arch ${{ matrix.arch }} ` - -TargetDir ${{ matrix.td }}\${{ matrix.target }}\release -OutDir ${{ matrix.td }}\msix - - - name: Publish to Gitea generic registry - shell: pwsh - env: - REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - $PSNativeCommandUseErrorActionPreference = $false - $base = "https://$($env:REGISTRY)/api/packages/$($env:OWNER)/generic/$($env:PKG)" - # stable release -> `latest/` alias; canary main build -> `canary/` alias. - $alias = if ($env:GITHUB_REF -like 'refs/tags/v*') { 'latest' } else { 'canary' } - # version-less, arch-suffixed alias names so each channel keeps one predictable URL. - $aliasNames = @{ - "$($env:MSIX_PATH)" = "$($env:PKG)_${{ matrix.arch }}.msix" - "$($env:MSIX_CER_PATH)" = "$($env:PKG)_${{ matrix.arch }}.cer" - } - $files = @($env:MSIX_PATH, $env:MSIX_CER_PATH) | Where-Object { $_ -and (Test-Path $_) } - if (-not $files) { throw "pack produced no artifacts to publish" } - function Put($f, $url) { - # The generic registry makes a versioned path immutable and 409s a re-upload, so a tag - # re-run re-publishing the identical artifact must be tolerated as a no-op. (The channel - # alias below is delete-then-reuploaded and never 409s.) No curl -f, so we can read the - # status code instead of aborting on it. - $code = [int](curl.exe -sS -o NUL -w "%{http_code}" --user "enricobuehler:$($env:REGISTRY_TOKEN)" --upload-file "$f" "$url") - if ($LASTEXITCODE -ne 0) { throw "upload failed (curl exit $LASTEXITCODE): $url" } - if ($code -eq 409) { Write-Output "already published (409, immutable): $url"; return } - if ($code -lt 200 -or $code -ge 300) { throw "upload failed (HTTP $code): $url" } - Write-Output "published ($code): $url" - } - foreach ($f in $files) { - $name = Split-Path $f -Leaf - # 1) immutable, versioned path - Put $f "$base/$($env:MSIX_VERSION)/$name" - # 2) channel alias (delete-then-reupload; the generic registry 409s on an existing file) - $an = $aliasNames["$f"] - curl.exe -fsS -o NUL --user "enricobuehler:$($env:REGISTRY_TOKEN)" -X DELETE "$base/$alias/$an" 2>$null - Put $f "$base/$alias/$an" - } - - # On a real release, also attach the MSIX (+ its .cer) to the unified Gitea Release. Both - # arch legs attach to the same release concurrently — the helper's create-or-fetch handles - # the race, and x64/arm64 filenames differ so the assets don't collide. - - name: Attach MSIX to the Gitea release (stable tags only) - if: startsWith(gitea.ref, 'refs/tags/v') - shell: pwsh - env: - GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - . scripts/ci/gitea-release.ps1 - $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' - foreach ($f in @($env:MSIX_PATH, $env:MSIX_CER_PATH)) { - if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } - } diff --git a/.gitea/workflows/windows.yml b/.gitea/workflows/windows.yml deleted file mode 100644 index d6d4d7e3..00000000 --- a/.gitea/workflows/windows.yml +++ /dev/null @@ -1,169 +0,0 @@ -# Windows client CI — runs on a self-hosted windows-amd64 runner (host mode; the generic runner + -# toolchain come from unom/infra's windows-runner/; punktfunk's own extras - WDK, Inno Setup, -# the ARM64 rustup target - self-provision via the "Ensure Windows toolchain" step below, a fast -# no-op once already present, so any runner with that label works with no manual dispatch step -# first). Build + clippy + fmt + test BOTH client binaries: the WinUI 3 shell -# (windows-reactor + WASAPI + SDL3) and the punktfunk-session Vulkan client -# (pf-presenter/pf-client-core/pf-console-ui — every stream runs in it, spawned by the -# shell). ARM64 note: rust-skia publishes no aarch64-pc-windows-msvc prebuilt binaries, so the -# session builds --no-default-features there (no Skia console UI; streaming is unaffected) — -# flip when skia-binaries adds the target. -# -# NO FFmpeg here since M10 (design/client-native-decode.md §6): the client decodes with -# pf-vkdecode / pf-dxvadec / openh264+rav1d and links no libav* at all, so this workflow sets -# no FFMPEG_DIR, no PF_FFVK_VULKAN_INCLUDE and prepends nothing to PATH. The provisioning -# script still fetches the FFmpeg trees because the HOST keeps FFmpeg — windows-host.yml's -# `amf-qsv` leg link-imports them. -# -# Two architectures from ONE x64 runner: x86_64-pc-windows-msvc natively and -# aarch64-pc-windows-msvc by cross-compiling. The x64 MSVC toolset ships an ARM64 cross compiler -# (VC\Tools\MSVC\\bin\Hostx64\arm64\cl.exe) and aarch64-pc-windows-msvc is a tier-2 Rust -# target with host tools, so no ARM64 runner is needed — the cc/cmake crates pick the ARM64 -# compiler from the target triple (SDL3 + libopus build-from-source cross-compile fine). The one -# thing the aarch64 build can't do is *run* on the x64 host, so fmt + test run only for x64. -# -# The MSVC/WinUI toolchain (cargo/rustup on ASCII paths, NASM, CMake, LLVM, CARGO_HOME, -# CMAKE_POLICY_VERSION_MINIMUM, …) is baked into the runner's daemon env. Per-checkout -# / per-arch vars are set in a step: -# - CARGO_TARGET_DIR=C:\t… the runner's host workdir is buried deep under -# C:\Windows\System32\config\systemprofile\.cache\act\\hostexecutor\, -# so the default target\ path blows past Windows' MAX_PATH (260) inside the -# CMake-from-source builds (audiopus_sys / SDL3) — MSBuild's tracker then -# can't create its .tlog (DirectoryNotFoundException -> MSB6003). A short -# root keeps every nested path well under the limit (per-arch so the two -# matrix legs don't share a target dir). -# -# Steps use `shell: pwsh` (PowerShell 7) deliberately: Windows PowerShell 5.1's -# `Out-File -Encoding utf8` prepends a UTF-8 BOM that corrupts the first GITHUB_ENV line (that -# var silently never gets set). pwsh writes no BOM. -# The runner's daemon wrapper puts C:\Program Files\PowerShell\7 on PATH so the job finds pwsh. -name: windows -# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels -# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never -# cancel each other). Keeps a busy push cadence from piling ~10 queued runs per commit onto the -# runner fleet. Gitea honors this for push triggers (PR triggers: see gitea#35933). -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - - -on: - push: - branches: [main] - paths: - - 'clients/windows/**' - - 'clients/session/**' - - 'crates/punktfunk-core/**' - - 'crates/pf-client-core/**' - - 'crates/pf-presenter/**' - - 'crates/pf-console-ui/**' - - 'crates/pf-bitstream/**' - - 'crates/pf-vkdecode/**' - - 'crates/pf-dxvadec/**' - - 'Cargo.lock' - - 'Cargo.toml' - - '.gitea/workflows/windows.yml' - pull_request: - paths: - - 'clients/windows/**' - - 'clients/session/**' - - 'crates/punktfunk-core/**' - - 'crates/pf-client-core/**' - - 'crates/pf-presenter/**' - - 'crates/pf-console-ui/**' - - 'crates/pf-bitstream/**' - - 'crates/pf-vkdecode/**' - - 'crates/pf-dxvadec/**' - - 'Cargo.lock' - - 'Cargo.toml' - - '.gitea/workflows/windows.yml' - workflow_dispatch: - -# Shared compile cache: sccache -> RustFS S3 (storage.unom.io, LAN-pinned via ci-core's -# unbound). Keys include compiler hash + target + flags, so cross-OS/arch entries can -# never collide; every Rust job on every host feeds and reads one warm cache. -env: - RUSTC_WRAPPER: sccache - SCCACHE_BUCKET: unom-ci-sccache - SCCACHE_ENDPOINT: https://storage.unom.io - SCCACHE_REGION: home-central - AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }} - # sccache and incremental compilation are mutually exclusive; CI wants the shared - # cache, dev boxes keep incremental. - CARGO_INCREMENTAL: "0" - -jobs: - # SECURITY: this job builds PULL-REQUEST code (attacker-controllable build.rs / cargo build) on the - # host-mode, persistent `windows-amd64` runner that the release-SIGNING jobs (windows-host.yml / - # windows-msix.yml, which decrypt MSIX_CERT_PFX_B64 + REGISTRY_TOKEN to disk) also run on. Untrusted - # PR code could therefore persist on that machine or harvest signing material a later job exposes. - # The DEFINITIVE fix is operational and lives outside this file: enable Gitea's "require approval to - # run workflows for PRs from outside collaborators/forks", and/or route PR CI to isolated ephemeral - # runners. The `if:` below is only a backstop — it skips fork PRs where Gitea reports the fork flag, - # and FAILS OPEN (still runs) for same-repo PRs and on Gitea versions that don't populate it, so it - # never blocks internal PR CI. - build: - runs-on: windows-amd64 - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.fork != true - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - target: [x86_64-pc-windows-msvc, aarch64-pc-windows-msvc] - steps: - - uses: actions/checkout@v4 - - - name: Ensure Windows toolchain (WDK, Inno Setup, ARM64 target) - shell: pwsh - run: ./scripts/ci/ensure-windows-toolchain.ps1 - - - name: Configure + toolchain versions - shell: pwsh - run: | - # Per-arch short target root (dodges MAX_PATH; keeps the two legs from sharing target\). - $td = if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { 'C:\t-a64' } else { 'C:\t' } - "CARGO_TARGET_DIR=$td" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - # No FFMPEG_DIR / PF_FFVK_VULKAN_INCLUDE / PATH prepend: the client links no libav* - # since M10 (see this file's header), so nothing here needs import libs or runtime DLLs. - # The HOST still does — windows-host.yml sets them for its amf-qsv leg. - rustup target add ${{ matrix.target }} - rustc --version - cargo --version - Write-Output "target ${{ matrix.target }} target-dir $td" - - # Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session - # drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op - # for the shell, which has no features). - # punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so - # a CLI that only the release workflow compiles is a release-day surprise. Its tests - # RUN the binary (help contract), as the session's contract_smoke runs the session — - # the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber). - - name: Build - shell: pwsh - run: | - $sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } - cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }} - - - name: Clippy (-D warnings) - shell: pwsh - run: | - # Every crate in the `paths:` trigger above is named here: `cargo clippy -p X` BUILDS a - # dependency but only LINTS the packages it is given, so a decode crate that starts the - # run but is missing from this list would be gated by nothing. - $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-bitstream','-p','pf-vkdecode','-p','pf-dxvadec') - $sf = @() - if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') } - cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings - - - name: Rustfmt check - if: matrix.target == 'x86_64-pc-windows-msvc' - shell: pwsh - run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec -- --check - - - name: Test - if: matrix.target == 'x86_64-pc-windows-msvc' - shell: pwsh - run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-dxvadec --target ${{ matrix.target }} diff --git a/clients/windows/packaging/README.md b/clients/windows/packaging/README.md index e91d3138..666ef7d6 100644 --- a/clients/windows/packaging/README.md +++ b/clients/windows/packaging/README.md @@ -2,7 +2,7 @@ The Windows client ships as **signed MSIX** packages so Windows boxes get a real package (Start tile, clean install/uninstall) instead of a loose exe. CI builds + publishes them from -[`.gitea/workflows/windows-msix.yml`](../../../.gitea/workflows/windows-msix.yml) to Gitea's +[`.gitea/workflows/windows-client.yml`](../../../.gitea/workflows/windows-client.yml) to Gitea's **generic** package registry (`https://git.unom.io/unom/-/packages`), on every `main` push that touches the client (canary) and on `vX.Y.Z` release tags (stable) — see [Release Channels](https://punktfunk.unom.io/docs/channels). @@ -14,7 +14,7 @@ package links FFmpeg, so neither arch needs a per-arch `FFMPEG_DIR` tree staged one less thing the ARM64 leg can be missing). Artifacts are arch-suffixed (`..._x64.msix` / `..._arm64.msix`, each with its matching `.cer`); `pack-msix.ps1 -Arch x64|arm64` stamps the manifest `ProcessorArchitecture` and names the output. See -[`windows.yml`](../../../.gitea/workflows/windows.yml) for the cross-build rationale. +[`windows-client.yml`](../../../.gitea/workflows/windows-client.yml) for the cross-build rationale. ## What's in the package diff --git a/scripts/ci/gitea-release.sh b/scripts/ci/gitea-release.sh index 6c890b72..469f9598 100644 --- a/scripts/ci/gitea-release.sh +++ b/scripts/ci/gitea-release.sh @@ -2,7 +2,7 @@ # Shared Gitea Release helpers for the punktfunk CI workflows (Linux + macOS runners). # # Source this file, then call ensure_release / upsert_asset. It replaces the three -# copy-pasted inline blocks that used to live in release.yml / flatpak.yml / decky.yml, +# copy-pasted inline blocks that used to live in apple.yml / flatpak.yml / decky.yml, # and fixes a latent bug those had: the bare asset POST returns 409 if an asset with the # same name already exists, so re-running a workflow — or reusing the rolling `canary` # release with stable filenames — would fail. upsert_asset deletes the old asset first. diff --git a/scripts/ci/provision-windows-punktfunk-extras.ps1 b/scripts/ci/provision-windows-punktfunk-extras.ps1 index e7a34b13..2a79c611 100644 --- a/scripts/ci/provision-windows-punktfunk-extras.ps1 +++ b/scripts/ci/provision-windows-punktfunk-extras.ps1 @@ -1,6 +1,6 @@ # Layers punktfunk-specific tooling onto the shared unom Windows CI runner: FFmpeg (the HOST's # amf-qsv encode leg, x64 only), Inno Setup (the host installer), and the aarch64-pc-windows-msvc -# rustup target (windows-msix.yml's ARM64 leg). The runner itself - act_runner, Node, rustup, +# rustup target (windows-client.yml's ARM64 leg). The runner itself - act_runner, Node, rustup, # VS Build Tools/NASM/CMake/LLVM - is provisioned generically by unom/infra # (windows-runner/windows-runner.pkr.hcl + proxmox/windows-runner's Terraform clone); this script # is what punktfunk adds on top, since FFmpeg/Inno Setup/the ARM64 target aren't every project's @@ -15,7 +15,7 @@ function info($m) { Write-Host "[provision-punktfunk-extras] $m" } $env:RUSTUP_HOME = "C:\Users\Public\.rustup" $env:CARGO_HOME = "C:\Users\Public\.cargo" -# --- ARM64 cross-compile target (windows.yml / windows-msix.yml build aarch64-pc-windows-msvc off +# --- ARM64 cross-compile target (windows-client.yml builds aarch64-pc-windows-msvc off # this x64 box; the ARM64 MSVC cross compiler itself comes from unom/infra's generic VS Build # Tools provisioning, which already includes the ARM64 component). --- $rustup = "C:\Users\Public\.cargo\bin\rustup.exe" @@ -33,7 +33,7 @@ if (Test-Path $rustup) { # bundled DLLs LGPL-2.1+ (dynamic linking satisfies the relink duty) rather than GPL, so the # shipped installer/MSIX stay consistent with punktfunk's MIT OR Apache-2.0 posture. # ⚠ The CLIENT no longer links FFmpeg at all (M10, design/client-native-decode.md §6): it decodes -# with pf-vkdecode / pf-dxvadec / openh264 + rav1d. windows.yml and windows-msix.yml set no +# with pf-vkdecode / pf-dxvadec / openh264 + rav1d. windows-client.yml sets no # FFMPEG_DIR and the MSIX bundles no libav* DLLs, so only the x64 tree is fetched now - the ARM64 # one existed solely for the ARM64 client leg. Delete a stale C:\Users\Public\ffmpeg-arm64 by # hand; this script does not remove what it no longer installs. diff --git a/scripts/ci/provision-windows-wdk.ps1 b/scripts/ci/provision-windows-wdk.ps1 index a06d9175..e2faee52 100644 --- a/scripts/ci/provision-windows-wdk.ps1 +++ b/scripts/ci/provision-windows-wdk.ps1 @@ -8,7 +8,7 @@ # installed. Safe to run repeatedly. Runs non-interactively (/q /norestart) — never auto-reboots. # # Invoked by scripts/ci/ensure-windows-toolchain.ps1, the shared self-provision step every Windows -# CI workflow runs at job start (windows-drivers.yml, windows.yml, windows-msix.yml, +# CI workflow runs at job start (windows-drivers.yml, windows-client.yml, # windows-host.yml), on top of the generic runner unom/infra provisions (windows-runner/) and # provision-windows-punktfunk-extras.ps1's FFmpeg/Inno Setup/ARM64-target layer. Run as the # runner's account (SYSTEM) with admin rights. diff --git a/scripts/ci/setup-macos-runner.sh b/scripts/ci/setup-macos-runner.sh index 1b1355a2..81fff083 100644 --- a/scripts/ci/setup-macos-runner.sh +++ b/scripts/ci/setup-macos-runner.sh @@ -94,7 +94,7 @@ fi # Deliberately NO DEVELOPER_DIR here: cargo (rust ld) must stay on the system default — # a newer-than-OS Xcode's ld emits dylibs the running dyld rejects ("mis-aligned # LINKEDIT string pool"), breaking every proc-macro build. Steps that need a full Xcode -# (xcodebuild) resolve it themselves (build-xcframework.sh, release.yml). +# (xcodebuild) resolve it themselves (build-xcframework.sh, apple.yml's `distribute` job). PLIST_STAGE="$RUNNER_HOME/io.gitea.act_runner.plist" PLIST_SYSTEM="/Library/LaunchDaemons/io.gitea.act_runner.plist"