Merge pull request 'ci: cache the C/C++ half, link with mold, fix the debug/release cache collision, consolidate the Apple and Windows-client workflows' (#191) from worktree-ci-optimization into main
android / android (push) Canceled after 34s
apple / swift (push) Canceled after 1m49s
apple / distribute (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 0s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 55s
ci / web (push) Canceled after 49s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (push) Canceled after 42s
deb / build-publish (push) Canceled after 15s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-gamescope (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / smoke-install (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Canceled after 0s
windows-drivers / driver-build (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
decky / build-publish (push) Successful in 1m23s

Reviewed-on: #191
This commit was merged in pull request #191.
This commit is contained in:
2026-08-13 10:41:54 +00:00
30 changed files with 1454 additions and 1057 deletions
+10 -1
View File
@@ -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_<android-triple> vars, which this does not touch; CC_x86_64_unknown_linux_gnu
# covers only the HOST build scripts and proc macros.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -265,7 +274,7 @@ jobs:
retention-days: 30
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
# Generic registry is public for reads — matches windows-msix.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# Generic registry is public for reads — matches windows-client.yml / deb.yml (REGISTRY_TOKEN, user enricobuehler).
# main = canary store + `canary/` sideload alias; a `vX.Y.Z` tag = `latest/` alias + attached
# to the unified Gitea Release.
- name: Publish to generic registry + attach to Gitea release
+579 -41
View File
@@ -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 <login-pw> \
# ~/Library/Keychains/login.keychain-db
#
# Secrets: only ASC_API_KEY_P8 / ASC_API_KEY_ID / ASC_API_ISSUER_ID (App Store Connect API
# key — notarization, TestFlight upload, automatic-signing profile fetch).
#
# Needs a RELEASE Xcode on the runner (App Store rejects beta-SDK builds); the workflow
# picks the first non-beta /Applications/Xcode*.app and only falls back to a beta with a
# loud warning.
name: apple
# One pending run per workflow+ref: a newer push supersedes the queued/running one and cancels
# it (a canary only needs the latest commit; each release tag is its own ref so tag runs never
@@ -19,30 +93,39 @@ concurrency:
on:
push:
# Canary: a relevant main push builds + tests, then uploads the iOS + macOS + tvOS builds to
# TestFlight (Apple's own canary channel) — no notarized DMG (that's stable-only; see the
# per-step gates). Heavy on the shared mac-mini runner, hence the tight paths filter.
branches: [main]
# Scope canary builds to what this artifact is built FROM — a docs-only or
# web-only push should not light up the whole fleet. Applies to branch pushes;
# tag runs are matched by `tags:` (proven by flatpak/windows-msix releases).
paths:
- 'crates/**'
- 'crates/punktfunk-core/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'scripts/ci/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
# Stable: a `vX.Y.Z` tag is THE release — notarized DMG attached to the unified Gitea Release
# + macOS/iOS/tvOS to TestFlight for manual promotion to the App Store. Tag runs are matched by
# `tags:` and are NOT subject to the paths filter above.
tags: ['v*']
pull_request:
paths:
- 'crates/**'
- 'crates/punktfunk-core/**'
- 'clients/apple/**'
- 'scripts/build-xcframework.sh'
- 'scripts/ci/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'rust-toolchain.toml'
- 'scripts/ci/**'
- '.gitea/workflows/apple.yml'
workflow_dispatch:
inputs:
testflight:
description: "Upload the iOS/macOS/tvOS builds to TestFlight (true/false)"
required: false
default: "true"
# Shared compile cache: sccache -> RustFS S3 (storage.unom.io — the mini resolves it via
# the router, i.e. the hairpin path whose TLS always validated). Covers every cargo/rustc
@@ -61,11 +144,11 @@ env:
jobs:
# SECURITY: builds/tests PULL-REQUEST code on the host-mode, persistent `macos-arm64` runner shared
# with the release-signing job (release.yml, which loads the App Store Connect key). Untrusted PR
# code could persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's
# "require approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral
# runners. The `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and
# still runs same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
# with the release-signing job below (which loads the App Store Connect key). Untrusted PR code could
# persist on it or harvest signing material. Definitive fix is server-side: enable Gitea's "require
# approval for PRs from outside collaborators/forks", and/or isolate PR CI on ephemeral runners. The
# `if:` is a fail-open backstop — it skips fork PRs where Gitea reports the fork flag and still runs
# same-repo PRs (and where the flag is absent), so it never blocks internal PR CI.
swift:
runs-on: macos-arm64
if: >-
@@ -85,17 +168,10 @@ jobs:
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# `punktfunk-core` now decodes Opus in-core for the Apple client (surround), pulling
# `audiopus_sys`, which builds a vendored static libopus via CMake when pkg-config can't find a
@@ -127,6 +203,475 @@ jobs:
working-directory: clients/apple
run: swift test
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# ── Distribution ────────────────────────────────────────────────────────────────────────────────
# `needs: swift` is the entire reason this lives here rather than in its own file: it is what makes
# a failed `swift test` stop a TestFlight upload. Never demote it to a parallel job.
distribute:
needs: swift
# Pushes to main (canary), v* tags (stable) and manual dispatch — never pull requests.
if: gitea.event_name != 'pull_request'
runs-on: macos-arm64
timeout-minutes: 120
env:
TEAM_ID: F4H37KF6WC
PROJECT: clients/apple/Punktfunk.xcodeproj
steps:
- uses: actions/checkout@v4
- name: Select release Xcode
run: |
DEV_DIR=""
for app in /Applications/Xcode.app /Applications/Xcode_*.app /Applications/Xcode-*.app; do
case "$app" in *beta*|*Beta*) continue;; esac
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
if [ -z "$DEV_DIR" ]; then
for app in /Applications/Xcode*.app; do
[ -x "$app/Contents/Developer/usr/bin/xcodebuild" ] && DEV_DIR="$app/Contents/Developer" && break
done
echo "::warning::No release Xcode found — using $DEV_DIR. TestFlight/App Store REJECTS beta-SDK builds."
fi
[ -n "$DEV_DIR" ] || { echo "no usable Xcode found" >&2; exit 1; }
# Scoped to xcodebuild steps only (XCODE_DEV_DIR, not DEVELOPER_DIR): cargo must
# keep the system-default linker — a newer-than-OS Xcode's ld produces dylibs the
# running dyld rejects, killing proc-macro loads (see build-xcframework.sh).
echo "XCODE_DEV_DIR=$DEV_DIR" >> "$GITHUB_ENV"
DEVELOPER_DIR="$DEV_DIR" xcodebuild -version
- name: Version from tag
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE, PF_CHANNEL, PF_STABLE_TAG (single source of truth)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; V="${V%%-*}" ;; # App Store marketing version is numeric X.Y.Z (drop -rc)
*) V="$PF_BASE" ;; # canary marketing version = one minor ahead of the latest stable tag; the build number disambiguates
esac
echo "VERSION=$V" >> "$GITHUB_ENV"
# GITHUB_RUN_NUMBER is REPO-WIDE in Gitea (not per-workflow as on GitHub): consecutive runs
# of different workflows get consecutive numbers. That is why folding the old release.yml
# into this file could not reset the build number and strand TestFlight, which rejects a
# non-increasing CFBundleVersion. It also means this climbs by ~8 per push rather than by 1
# — monotonic either way, which is all App Store Connect asks.
echo "BUILD_NUM=$GITHUB_RUN_NUMBER" >> "$GITHUB_ENV"
echo "version $V build $GITHUB_RUN_NUMBER (channel $PF_CHANNEL, latest stable ${PF_STABLE_TAG})"
- name: Rust toolchain (mac + iOS + tvOS slices)
run: |
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# tvOS targets are tier-3 (no prebuilt std) — build-xcframework.sh compiles them with
# nightly + -Zbuild-std, so ensure nightly + rust-src are present.
"$RUSTUP" toolchain install nightly --profile minimal
"$RUSTUP" component add rust-src --toolchain nightly
# The in-core Opus decode (surround) pulls audiopus_sys, which builds a vendored static libopus
# via CMake — keep the xcframework self-contained (no runtime libopus.dylib on end-user devices).
- name: CMake (for the vendored libopus audiopus_sys builds)
run: |
# Runner steps run with `bash --noprofile --norc`, so Homebrew's bin dir isn't on PATH —
# locate brew explicitly, install cmake if missing, and export its bin dir to GITHUB_PATH so
# the xcframework build step (audiopus_sys → vendored libopus) finds `cmake`.
for B in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$B" ] && BREW="$B" && break; done
if [ -z "$BREW" ]; then echo "::error::Homebrew not found on the runner"; exit 1; fi
BREW_BIN="$(dirname "$BREW")"; export PATH="$BREW_BIN:$PATH"
command -v cmake >/dev/null || "$BREW" install cmake
echo "$BREW_BIN" >> "$GITHUB_PATH"
# Homebrew's CMake 4 dropped compatibility with the vendored libopus's pre-3.5
# `cmake_minimum_required`; treat 3.5 as the policy minimum (the cmake crate's child cmake
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
# Shared compile cache. The script handles the macOS side — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: sh scripts/ci/ensure-sccache.sh
- name: Pin + prune Xcode DerivedData
# Without -derivedDataPath, xcodebuild derives its DerivedData directory name from the
# PROJECT'S ABSOLUTE PATH — and act_runner rotates its workspace
# (~/.cache/act/<hash>/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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
EOF
DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \
-archivePath "$RUNNER_TEMP/Punktfunk-tvos.xcarchive" \
-exportOptionsPlist "$RUNNER_TEMP/export-tvos.plist" \
-exportPath "$RUNNER_TEMP/export-tvos" \
-authenticationKeyPath "$RUNNER_TEMP/asc.p8" \
-authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \
-authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}"
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# App Store screenshots of the real UI, zipped and attached to the run as a build artifact.
# Skipped on PRs (cost); runs on main pushes + manual dispatch. Needs the build/test job green
# first, and is a separate job so a capture hiccup can never red the core signal.
@@ -155,17 +700,10 @@ jobs:
"$RUSTUP" target add aarch64-apple-darwin x86_64-apple-darwin \
aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# Shared compile cache. ~/.local/bin is on the runner daemon's PATH; GITHUB_PATH is
# belt-and-braces. bsdtar (macOS) globs by default — no --wildcards.
# Shared compile cache. The script handles the macOS side (user-prefix install +
# GITHUB_PATH, bsdtar globbing) — see scripts/ci/ensure-sccache.sh.
- name: sccache (self-healing install)
run: |
if ! command -v sccache >/dev/null; then
mkdir -p "$HOME/.local/bin"
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-aarch64-apple-darwin.tar.gz \
| tar -xz --strip-components=1 -C "$HOME/.local/bin" '*/sccache'
fi
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# See the swift job: audiopus_sys (via the in-core Opus decode) builds vendored libopus with CMake.
- name: CMake (for the vendored libopus audiopus_sys builds)
@@ -183,7 +721,7 @@ jobs:
# inherits this from the env during the xcframework build).
echo "CMAKE_POLICY_VERSION_MINIMUM=3.5" >> "$GITHUB_ENV"
- name: Pin + prune DerivedData (same disease release.yml already cures)
- name: Pin + prune DerivedData (same disease the distribute job already cures)
# screenshots.sh builds into a throwaway mktemp DerivedData per invocation — two
# fresh ~1 GB trees per run, zero reuse. Pin one stable root (PF_SHOT_DERIVED_DATA,
# honored by the script) so repeat runs are incremental, and GC anything a week old
+52
View File
@@ -81,6 +81,14 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache (aws-lc-sys, vendored libopus, openh264's C++). Safe at workflow
# level — no cross-compiling job here (see ci.yml's `rust` job for that trap). NOTE these ALSO
# have to be named in the makepkg step's `sudo -u builder env …` list: sudo's env_reset drops
# everything not listed, which is why the sccache vars are already spelled out there.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
CARGO_INCREMENTAL: "0"
jobs:
@@ -220,6 +228,39 @@ jobs:
echo "REPO=$REPO" >> "$GITHUB_ENV"
echo "pacman $V-$R -> repo '$REPO'"
# ── The web console, built once per (web+sdk content, bun) instead of once per job ─────────
# Shares deb.yml's key family — see the fuller note there. Unlike the RPM leg this needs no
# hand-off macro: makepkg builds with PF_SRCDIR pointing at this workspace, so a restored
# web/.output is already exactly where PKGBUILD's build-if-missing guard looks for it.
#
# Built here as root, BEFORE the makepkg step's `chown -R builder:` sweeps the tree, so the
# bundle ends up owned like everything else the builder user is handed.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build the web console (cache miss only)
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
bun run build
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build packages (makepkg)
run: |
git config --global --add safe.directory "$PWD"
@@ -252,8 +293,19 @@ jobs:
SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
CMAKE_C_COMPILER_LAUNCHER="$CMAKE_C_COMPILER_LAUNCHER" \
CMAKE_CXX_COMPILER_LAUNCHER="$CMAKE_CXX_COMPILER_LAUNCHER" \
CC_x86_64_unknown_linux_gnu="$CC_x86_64_unknown_linux_gnu" \
CXX_x86_64_unknown_linux_gnu="$CXX_x86_64_unknown_linux_gnu" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
# Visibility only. The stats have to be read as the SAME user that ran the compiles —
# sccache keeps its stats in a per-user server process, so a root `--show-stats` here
# would report an idle server and zero everything.
sudo -u builder env SCCACHE_BUCKET="$SCCACHE_BUCKET" SCCACHE_ENDPOINT="$SCCACHE_ENDPOINT" \
SCCACHE_REGION="$SCCACHE_REGION" \
AWS_ACCESS_KEY_ID="$AWS_ACCESS_KEY_ID" AWS_SECRET_ACCESS_KEY="$AWS_SECRET_ACCESS_KEY" \
sccache --show-stats || true
# The host must ship a VERSIONED libav soname dep, and nothing else in this pipeline proves
# it. packaging/arch/PKGBUILD lists bare `libavcodec.so` etc. and relies on makepkg rewriting
+2 -7
View File
@@ -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: |
+56 -18
View File
@@ -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:
+118 -32
View File
@@ -79,6 +79,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"
@@ -89,18 +94,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).
@@ -138,7 +143,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
@@ -151,10 +158,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-<rustc>-<lock>` — 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:
@@ -172,7 +189,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.
@@ -185,7 +226,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
@@ -197,6 +243,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"
@@ -207,6 +269,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 }}
@@ -251,18 +320,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: |
@@ -298,9 +366,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:
@@ -335,6 +406,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
@@ -493,14 +570,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.
@@ -537,8 +614,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:
@@ -551,6 +633,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 }}
+2 -2
View File
@@ -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/<version>/<file>
# GET https://git.unom.io/api/packages/unom/generic/punktfunk-client-flatpak/<version>/<file>
# 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/**'
+24 -10
View File
@@ -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
-555
View File
@@ -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 <login-pw> \
# ~/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/<hash>/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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>installerSigningCertificate</key><string>3rd Party Mac Developer Installer</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>export</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict>
<key>io.unom.punktfunk</key><string>$PROFILE</string>
<key>io.unom.punktfunk.widgets</key><string>$WIDGET_PROFILE</string>
</dict>
</dict>
</plist>
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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key><string>app-store-connect</string>
<key>destination</key><string>upload</string>
<key>teamID</key><string>$TEAM_ID</string>
<key>signingStyle</key><string>manual</string>
<key>signingCertificate</key><string>Apple Distribution</string>
<key>provisioningProfiles</key>
<dict><key>io.unom.punktfunk</key><string>$PROFILE</string></dict>
</dict>
</plist>
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 }}"
+63 -8
View File
@@ -51,6 +51,14 @@ env:
SCCACHE_REGION: home-central
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_SECRET_ACCESS_KEY }}
# The C/C++ half of the cache (aws-lc-sys, the vendored libopus, openh264's C++). Safe at
# workflow level here: unlike ci.yml/deb.yml this workflow has no cross-compiling job whose
# image sets its own CC_x86_64_unknown_linux_gnu. See ci.yml's `rust` job for that trap.
# This matters twice per push — the f43 and f44 legs are the two longest jobs in the fleet.
CMAKE_C_COMPILER_LAUNCHER: sccache
CMAKE_CXX_COMPILER_LAUNCHER: sccache
CC_x86_64_unknown_linux_gnu: sccache cc
CXX_x86_64_unknown_linux_gnu: sccache c++
# sccache and incremental compilation are mutually exclusive; CI wants the shared
# cache, dev boxes keep incremental.
CARGO_INCREMENTAL: "0"
@@ -80,14 +88,9 @@ jobs:
- uses: actions/checkout@v4
# Shared compile cache (sccache -> RustFS S3 over the LAN). Baked into the builder
# images; this fetch keeps the job green while the running :latest predates the bake.
# images; this heals the job while the running :latest predates the bake.
- name: sccache (no-op once the image bakes it)
run: |
command -v sccache >/dev/null 2>&1 || {
curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz \
| tar -xz --wildcards --strip-components=1 -C /usr/local/bin '*/sccache'
}
sccache --version
run: sh scripts/ci/ensure-sccache.sh
# rpmbuild + git archive need the checkout trusted; cache the crates download.
# The client link deps are also baked into the fedora-rpm image, but this job runs
@@ -145,11 +148,63 @@ jobs:
echo "GROUP=$GROUP" >> "$GITHUB_ENV"
echo "rpm $V-$R -> group '$GROUP'"
# ── The web console, built once per (web+sdk content, bun) instead of once per leg ─────────
# Two legs run here (f43 + f44) and each built its own identical copy of a bundle that is a
# pure function of web/ and sdk/ — see the fuller note in deb.yml, whose key family this
# shares, so whichever job builds it first warms the rest of the fleet.
#
# ⚠ The build has to happen HERE, in the workspace, rather than being left to the spec. Two
# reasons, and both are load-bearing:
# * build-rpm.sh packages a `git archive` tarball and web/.output is gitignored, so a
# bundle sitting in the workspace is invisible to rpmbuild — it must be handed over by
# absolute path (PF_PREBUILT_WEB_OUTPUT -> the spec's pf_prebuilt_web macro).
# * the reverse direction is worse: the spec builds into rpmbuild's %{_topdir}, which
# build-rpm.sh creates with mktemp and removes on EXIT. A console built in there is gone
# before actions/cache's post step runs, so the cache would never populate and every run
# would be a miss that quietly rebuilt — the cache would look present and do nothing.
- name: Web console cache key
run: echo "bunver=$(bun --version 2>/dev/null || echo none)" >> "$GITHUB_ENV"
- name: Cache the built web console
id: webconsole
uses: actions/cache@v4
with:
path: web/.output
key: web-console-linux-bun${{ env.bunver }}-${{ hashFiles('web/**', 'sdk/**') }}
- name: Build the web console (cache miss only)
if: steps.webconsole.outputs.cache-hit != 'true'
run: |
cd web
bun install --frozen-lockfile --ignore-scripts
bun run build
# Same mandatory assertion as deb.yml — a missing or wrong-preset bundle must fail here, not
# become a quietly console-less RPM. The spec re-checks the marker on whatever it packages.
- name: The console must exist (cache hit or fresh build)
run: |
if [ ! -f web/.output/server/index.mjs ]; then
echo "::error::web/.output is missing — neither the cache restore nor the build produced it"
exit 1
fi
grep -q 'Bun\.serve' web/.output/server/index.mjs || {
echo "::error::web/.output is not a bun bundle (wrong nitro preset)"; exit 1; }
echo "web console present: $(du -sh web/.output | cut -f1)"
- name: Build RPM
# PF_WITH_WEB=1 / PF_WITH_SCRIPTING=1 → also build the punktfunk-web console + the
# punktfunk-scripting runner subpackages (the publish loop globs them in; the host RPM
# Recommends both). Both need bun (ensured in Prep).
run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 bash packaging/rpm/build-rpm.sh
run: |
PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" \
PF_WITH_WEB=1 PF_WITH_SCRIPTING=1 \
PF_PREBUILT_WEB_OUTPUT="$GITHUB_WORKSPACE/web/.output" \
bash packaging/rpm/build-rpm.sh
# Visibility only — the two RPM legs are the longest jobs in the fleet, so a cache
# regression here is the most expensive one to leave undetected.
- name: sccache stats (visibility only)
if: always()
run: sccache --show-stats
# Signs with packages@unom.io (org secret) and self-verifies before publish. On a v* tag a
# missing key FAILS the build rather than publishing unsigned RPMs into a gpgcheck=1 repo.
+1 -1
View File
@@ -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
+318
View File
@@ -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
# `<minor>.<run>.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\<ver>\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\<hash>\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 -> <next-minor>.<run_number>.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: <major>.<minor>.<run>.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 }
}
+4 -4
View File
@@ -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.
+2 -2
View File
@@ -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.
-196
View File
@@ -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 -> <next-minor>.<run_number>.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: <major>.<minor>.<run>.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 }
}
-169
View File
@@ -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\<ver>\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\<hash>\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 }}
+18
View File
@@ -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
+42
View File
@@ -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"]
+11
View File
@@ -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
+10
View File
@@ -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
+12
View File
@@ -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
+2 -2
View File
@@ -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
+16 -1
View File
@@ -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.
+18
View File
@@ -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}" \
+23 -2
View File
@@ -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
+65
View File
@@ -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
+1 -1
View File
@@ -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.
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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"