Skip to content

fix: retain faint caption strokes in framebuffer OCR #534

fix: retain faint caption strokes in framebuffer OCR

fix: retain faint caption strokes in framebuffer OCR #534

name: build artifacts
on:
push:
branches:
- "**"
- "!feature/tvos"
pull_request:
workflow_dispatch:
inputs:
build_linux:
description: "Build the Linux AppImage, portable tarball, and Arch package"
required: true
default: true
type: boolean
build_macos:
description: "Build macOS DMG"
required: true
default: false
type: boolean
build_windows:
description: "Build Windows portable and installer packages"
required: true
default: true
type: boolean
build_android:
description: "Build Android and Android TV APKs"
required: true
default: true
type: boolean
build_webos:
description: "Build webOS IPK"
required: true
default: false
type: boolean
build_tvos:
description: "Build unsigned Apple TV device IPA and run simulator smoke"
required: true
default: true
type: boolean
release:
description: "Use release signing and identity gates where applicable; tvOS remains unsigned. Publishes nothing."
required: false
default: false
type: boolean
webos_toolchain_archive_url:
description: "Optional tar archive containing build/webos-sdk and Qt webOS prefixes"
required: false
type: string
workflow_call:
inputs:
release:
required: false
default: false
type: boolean
enable_macos_signing:
required: false
default: false
type: boolean
secrets:
MACOS_CERTIFICATE_P12:
required: false
MACOS_CERTIFICATE_PASSWORD:
required: false
MACOS_SIGNING_IDENTITY:
required: false
APPLE_NOTARY_KEY_P8:
required: false
APPLE_NOTARY_KEY_ID:
required: false
APPLE_NOTARY_ISSUER_ID:
required: false
CACHIX_AUTH_TOKEN:
required: false
ANDROID_RELEASE_KEYSTORE_BASE64:
required: false
ANDROID_RELEASE_KEYSTORE_PASSWORD:
required: false
ANDROID_RELEASE_KEY_ALIAS:
required: false
ANDROID_RELEASE_KEY_PASSWORD:
required: false
permissions:
contents: read
env:
# PRs consume public caches but never receive publishing credentials.
SPOOL_CACHE_WRITE: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
concurrency:
group: build-artifacts-${{ github.ref }}
cancel-in-progress: true
jobs:
build-policy:
name: Select artifact build
runs-on: ubuntu-24.04
permissions:
pull-requests: read
outputs:
build: ${{ steps.policy.outputs.build }}
steps:
# No checkout or repository code: this token only reads PR metadata.
- name: Avoid duplicate branch and PR builds
id: policy
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
HEAD_OWNER: ${{ github.repository_owner }}
HEAD_BRANCH: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
RELEASE_BUILD: ${{ inputs.release }}
run: |
set -euo pipefail
build=true
if [[ "$EVENT_NAME" == push && "$REF_TYPE" == branch &&
"$HEAD_BRANCH" != master && "$RELEASE_BUILD" != true ]]; then
if prs="$(gh api --method GET --paginate --slurp \
"repos/$GH_REPO/pulls" \
-f state=open -f head="$HEAD_OWNER:$HEAD_BRANCH" -f per_page=100)"; then
# Match both repositories and the full branch name, not just
# the owner's label. Do not require a SHA match: PR metadata
# can lag the push while its synchronize run is being queued.
if jq -e --arg repo "$GH_REPO" --arg head "$HEAD_BRANCH" \
'any(.[][]; .state == "open" and .base.repo.full_name == $repo
and .head.repo.full_name == $repo and .head.ref == $head)' \
<<<"$prs" >/dev/null; then
build=false
echo "::notice::An open PR owns this branch's artifact build."
fi
else
echo "::warning::Could not query open PRs; running the branch build rather than skipping coverage."
fi
fi
echo "build=$build" >>"$GITHUB_OUTPUT"
toolchain:
name: Toolchain versions
runs-on: ubuntu-24.04
needs: build-policy
if: ${{ needs.build-policy.outputs.build == 'true' }}
outputs:
qt_version: ${{ steps.versions.outputs.qt_version }}
qt_series: ${{ steps.versions.outputs.qt_series }}
qt_windows_kit: ${{ steps.versions.outputs.qt_windows_kit }}
ffmpeg_version: ${{ steps.versions.outputs.ffmpeg_version }}
qcoro_revision: ${{ steps.versions.outputs.qcoro_revision }}
qcoro_windows_prefix: ${{ steps.versions.outputs.qcoro_windows_prefix }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
- name: Read the pinned Qt and FFmpeg versions
id: versions
run: |
bash tools/toolchain-versions.sh --check
bash tools/toolchain-versions.sh >> "$GITHUB_OUTPUT"
lint:
name: Lint scripts
runs-on: ubuntu-24.04
needs: build-policy
if: ${{ needs.build-policy.outputs.build == 'true' }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
fetch-depth: 2
submodules: recursive
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.1
with:
primary-key: nix-lint-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock') }}
restore-prefixes-first-match: nix-lint-${{ runner.os }}-
gc-max-store-size-linux: 2G
# Stale entries under the same prefix are dead weight against the
# shared repository quota, and nothing restores them once the key moves.
# Release tags and PR merge refs only restore; trusted branch builds
# can save and prune their own GitHub cache scope.
save: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge-prefixes: nix-lint-${{ runner.os }}-
purge-created: P7D
purge-primary-key: never
- name: Check commit whitespace
run: git show --check --format= HEAD
- name: Syntax-check shell scripts
run: |
mapfile -t scripts < <(git ls-files '*.sh' | sort -u)
printf 'checking %d scripts\n' "${#scripts[@]}"
for s in "${scripts[@]}"; do
bash -n "$s"
done
- name: Syntax-check Python scripts
run: |
mapfile -t scripts < <(git ls-files '*.py' | sort -u)
printf 'checking %d scripts\n' "${#scripts[@]}"
nix develop .#lint -c python3 -m py_compile "${scripts[@]}"
- name: Check release artifacts stay separable from build inputs
run: bash tools/check-release-artifacts.sh
- name: Check the toolchain pin is the only Qt and FFmpeg version
run: bash tools/toolchain-versions.sh --check
- name: Validate FFmpeg capability manifest
run: |
nix develop .#lint -c python3 tests/tools/FfmpegCapabilitiesTest.py \
tools/ffmpeg-capabilities.py tools/manifests/ffmpeg-capabilities.json
- name: Evaluate Nix flake
run: nix flake check --no-build --no-write-lock-file
- name: Scan QML imports
run: nix develop .#lint -c bash tools/check-qml-imports.sh
- name: Verify shared build library sources and exports its helpers
run: |
source tools/lib/build-common.sh
for fn in mpv_meson_build cmake_build_app resolve_qmlimportscanner stage_elf_shared_library; do
declare -F "$fn" >/dev/null || { echo "missing helper: $fn" >&2; exit 1; }
done
echo "build-common.sh OK"
linux-sanitizers:
name: Linux real decoder and package consumer ASan/UBSan
runs-on: ubuntu-24.04
timeout-minutes: 120
needs: lint
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_linux }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: spool
skipPush: true
- name: Build instrumented production core and unified native tests
id: sanitizer-build
env:
BUILD_ROOT: ${{ github.workspace }}/build/linux-sanitizers
SPOOL_CMAKE_EXTRA_ARGS: -DSPOOL_SANITIZERS=ON
JOBS: 2
run: nix develop .#native -c bash tools/build-linux-release.sh
- name: Exercise actual bounded decoder and provider-package consumer
run: |
nix develop .#native -c env \
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
build/linux-sanitizers/app/spool-tests --run-all \
--filter bounded-zstd,provider-package-unpack --workers 1 \
--results "$PWD/build/linux-sanitizers/app/sanitizer-results.json"
- name: Prove foreign-owner admission at the actual server boundary
if: ${{ !cancelled() && steps.sanitizer-build.outcome == 'success' }}
run: |
sudo env \
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \
"$PWD/build/linux-sanitizers/app/spool-tests" \
--child spoolet-admission --require-foreign-owner
- name: Preserve sanitizer selector evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: internal-linux-sanitizer-results
path: |
build/linux-sanitizers/app/sanitizer-results.json
build/linux-sanitizers/app/sanitizer-results.json.*.log
if-no-files-found: warn
retention-days: 7
linux-packages:
name: Linux packages
runs-on: ubuntu-24.04
needs: lint
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_linux }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
- name: Resolve tailored Qt cache key
id: qt-cache-inputs
run: |
qt_derivation="$(nix eval --raw .#native-qt-cache.drvPath)"
echo "key=${qt_derivation##*/}" >>"$GITHUB_OUTPUT"
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.1
with:
primary-key: nix-native-${{ runner.os }}-${{ steps.qt-cache-inputs.outputs.key }}
restore-prefixes-first-match: nix-native-${{ runner.os }}-
gc-max-store-size-linux: 4G
# Cache hygiene: see the lint job above.
save: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge-prefixes: nix-native-${{ runner.os }}-
purge-created: P7D
purge-primary-key: never
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: spool
skipPush: true
- name: Build tailored Qt
run: |
nix build --accept-flake-config .#native-qt-cache \
--out-link "$RUNNER_TEMP/spool-native-qt" --print-build-logs
- name: Publish tailored Qt
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
env:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: |
if [[ -n "$CACHIX_AUTH_TOKEN" ]]; then
cachix push spool "$RUNNER_TEMP/spool-native-qt"
fi
- name: Build cached nix run package
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
run: |
nix build --accept-flake-config .#native-cache \
--out-link "$RUNNER_TEMP/spool-native" --print-build-logs
- name: Publish native cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
env:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: |
if [[ -n "$CACHIX_AUTH_TOKEN" ]]; then
cachix push spool "$RUNNER_TEMP/spool-native"
fi
- uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
id: linux-build-cache
with:
path: .ccache
key: linux5-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock', 'mpv/meson.build', 'mpv/meson.options', 'tools/manifests/mpv-native.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/manifests/toolchain.json', 'tools/manifests/linuxdeploy.json', 'tools/ffmpeg-capabilities.py', 'tools/package-audit.py', 'tools/build-linux-release.sh', 'tools/package-appimage.sh', 'tools/package-linux-bundle.sh', 'tools/lib/build-common.sh', 'tools/lib/qt-deploy.sh', 'CMakeLists.txt') }}
restore-keys: |
linux5-${{ runner.os }}-
# The default dev shell has no Qt on purpose (it is the hermetic Qt-source
# build shell). The native app build needs nixpkgs Qt, so use .#native.
- name: Build host app
run: nix develop .#native -c bash tools/build-linux-release.sh
- name: Run traditional and real GUI e2e tests
run: |
nix develop .#native -c python tools/run-tests.py \
--build-dir build/linux-release/app --workers "$(nproc)"
- name: Retain internal native phase results after failures
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: internal-linux-unified-tests
path: |
build/linux-release/app/*-results.json*
build/linux-release/app/test-phases.json
build/linux-release/app/test-artifacts/app-journey-*/*.png
if-no-files-found: ignore
retention-days: 1
- name: Lint compiled QML
run: nix develop .#native -c cmake --build build/linux-release/app --target spool_qmllint_strict
# A page switch is meant to cost one frame. That is a number, so it is
# measured every commit rather than noticed once it has got bad.
- name: Restore render benchmark baseline
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: .render-benchmark
key: render-benchmark-${{ runner.os }}-${{ github.sha }}
restore-keys: |
render-benchmark-${{ runner.os }}-
- name: Measure render latency
run: |
mkdir -p dist/render-benchmark
nix develop .#native -c env \
SPOOL_BENCH=routes \
SPOOL_BENCH_OUT="$PWD/dist/render-benchmark/warm.json" \
SPOOL_BENCH_ITERATIONS=4 \
bash tools/launch-native-app.sh build/linux-release/install/bin/spool
# The same walk with every page dropped between switches: what a
# memory-starved device pays, tracked but not gated.
nix develop .#native -c env \
SPOOL_BENCH=routes \
SPOOL_BENCH_COLD=1 \
SPOOL_BENCH_OUT="$PWD/dist/render-benchmark/cold.json" \
SPOOL_BENCH_ITERATIONS=4 \
bash tools/launch-native-app.sh build/linux-release/install/bin/spool
- name: Report render latency
run: |
# Hosted runners vary too much for performance failures. Warn only on
# large overall slowdowns; move gating to dedicated hardware first.
nix develop .#native -c python3 tools/compare-render-benchmark.py \
dist/render-benchmark/warm.json \
--baseline .render-benchmark/warm.json \
--warn-only \
--markdown dist/render-benchmark/warm.md
nix develop .#native -c python3 tools/compare-render-benchmark.py \
dist/render-benchmark/cold.json \
--baseline .render-benchmark/cold.json \
--warn-only \
--markdown dist/render-benchmark/cold.md
cat dist/render-benchmark/warm.md dist/render-benchmark/cold.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload render benchmark
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-render-benchmark
path: dist/render-benchmark
- name: Promote this run to the baseline
if: ${{ success() && github.ref == 'refs/heads/master' }}
run: |
mkdir -p .render-benchmark
cp dist/render-benchmark/warm.json dist/render-benchmark/cold.json .render-benchmark/
- name: Save render benchmark baseline
if: ${{ success() && github.ref == 'refs/heads/master' }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: .render-benchmark
key: render-benchmark-${{ runner.os }}-${{ github.sha }}
- name: Package AppImage
run: nix develop .#native -c bash tools/package-appimage.sh
- name: Audit extracted AppImage
shell: nix develop .#native -c bash -euo pipefail {0}
run: |
appimage="$(realpath dist/*.AppImage)"
expected="$(realpath dist/*.inventory.tsv)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
(
cd "$work"
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --appimage-extract >/dev/null
)
extracted="$work/squashfs-root"
test ! -d "$work/AppDir" || extracted="$work/AppDir"
python3 tools/package-audit.py inventory "$extracted" --output "$work/extracted.tsv"
cmp "$expected" "$work/extracted.tsv"
if find "$extracted" -name '*.qmltypes' -print -quit | grep -q .; then
echo 'error: extracted AppImage contains qmltypes metadata' >&2
exit 1
fi
elf_args=(elf "$extracted" --root usr/bin/spool)
test ! -f "$extracted/usr/libexec/secret-tool" || elf_args+=(--root usr/libexec/secret-tool)
while IFS= read -r elf; do elf_args+=(--root "${elf#"$extracted/"}"); done \
< <(find "$extracted/usr/lib" -type f -name 'libmpv.so.*' | sort)
for root in usr/plugins usr/qml; do
test ! -d "$extracted/$root" || while IFS= read -r elf; do
elf_args+=(--root "${elf#"$extracted/"}")
done < <(find "$extracted/$root" -type f -name '*.so*' | sort)
done
# glibc is bundled; only these come from the host.
for soname in libGLESv2.so.2 libgbm.so.1 libglapi.so.0
do
elf_args+=(--allow-system "$soname")
done
python3 tools/package-audit.py "${elf_args[@]}"
# The same audited AppDir, extracted rather than mounted: a distro
# package unpacks it once instead of starting the AppImage runtime and
# reading a compressed image on every launch.
- name: Package portable bundle
run: nix develop .#native -c bash tools/package-linux-bundle.sh
- name: Package Arch binary package
run: bash tools/package-arch.sh
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-linux
path: |
dist/*.AppImage
dist/*-linux-x86_64.tar.zst
dist/*.pkg.tar.zst
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
- name: Save Linux build cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release && steps.linux-build-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: .ccache
key: ${{ steps.linux-build-cache.outputs.cache-primary-key }}
macos-dmg:
name: macOS DMG (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
needs: lint
# Public repositories receive unlimited standard GitHub-hosted runner
# minutes, so pushes, pull requests, and releases exercise macOS too.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_macos }}
env:
# MoltenVK's documented discrete-binding path avoids an unsupported
# argument-encoder selector in Apple's hosted paravirtualized Metal GPU.
# This still executes real Vulkan/Metal and retains every Vulkan selector.
MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS: "0"
# Select the documented public Metal buffer allocator without placement
# heaps; real Vulkan initialization, rendering and readback checks remain.
MVK_CONFIG_USE_MTLHEAP: "0"
strategy:
# Apple Silicon and Intel Macs each get a native build; one failing
# architecture should still let the other produce its DMG.
fail-fast: false
matrix:
include:
- arch: arm64
runner: macos-14
- arch: x86_64
runner: macos-15-intel
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
keep-env-derivations = true
keep-outputs = true
# Pinned nixpkgs archives still need a reasonable connect timeout
# when a runner has no local copy.
connect-timeout = 30
download-attempts = 5
- name: Resolve tailored Qt cache key
id: qt-cache-inputs
run: |
qt_derivation="$(nix eval --raw .#native-qt-cache.drvPath)"
echo "key=${qt_derivation##*/}" >>"$GITHUB_OUTPUT"
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.1
with:
primary-key: nix-native-${{ runner.os }}-${{ matrix.arch }}-${{ steps.qt-cache-inputs.outputs.key }}
restore-prefixes-first-match: nix-native-${{ runner.os }}-${{ matrix.arch }}-
gc-max-store-size-macos: 8G
# Cache hygiene: see the lint job above.
save: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge-prefixes: nix-native-${{ runner.os }}-${{ matrix.arch }}-
purge-created: P7D
purge-primary-key: never
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: spool
skipPush: true
- name: Build tailored Qt
run: |
nix build --accept-flake-config .#native-qt-cache \
--out-link "$RUNNER_TEMP/spool-native-qt" --print-build-logs
- name: Publish tailored Qt
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
env:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: |
if [[ -n "$CACHIX_AUTH_TOKEN" ]]; then
cachix push spool "$RUNNER_TEMP/spool-native-qt"
fi
- name: Build cached nix run package
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
run: |
nix build --accept-flake-config .#native-cache \
--out-link "$RUNNER_TEMP/spool-native" --print-build-logs
- name: Publish native cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
env:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: |
if [[ -n "$CACHIX_AUTH_TOKEN" ]]; then
cachix push spool "$RUNNER_TEMP/spool-native"
fi
- uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
id: macos-build-cache
with:
path: |
build/macos/mpv
build/macos/mpv-prefix
build/macos/app
~/Library/Caches/ccache
key: macos-${{ matrix.arch }}-${{ hashFiles('flake.nix', 'flake.lock', 'mpv/meson.build', 'tools/manifests/mpv-native.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/manifests/toolchain.json', 'tools/ffmpeg-capabilities.py', 'tools/package-audit.py', 'tools/build-macos.sh', 'tools/package-macos-dmg.sh', 'tools/sign-macos.sh', 'tools/lib/build-common.sh', 'tools/lib/qt-deploy.sh', 'CMakeLists.txt') }}
restore-keys: |
macos-${{ matrix.arch }}-
# Older Nix releases reject the relative path input recorded by newer
# lock files when the root flake comes from a shallow Git checkout.
# Override only the evaluator input with the exact checked-out submodule
# revision; explicitly retain its shallow status rather than asking Nix
# to treat the CI checkout as a complete repository. The build scripts
# still compile the local mpv/ checkout.
- name: Resolve checked-out mpv input
env:
ARCH: ${{ matrix.arch }}
run: |
revision="$(git -C mpv rev-parse HEAD)"
overrides=()
# `nix develop` builds bashInteractive from the flake's own `nixpkgs`
# input. On Intel that input evaluates to a release which dropped
# x86_64-darwin, and nix answers the failure by silently falling back
# to the system bash 3.2, which cannot parse the environment script
# it generates. Point the input at the same pin this system's shell
# already uses, read from the lock so the two cannot drift.
if [[ "$ARCH" == "x86_64" ]]; then
url="$(jq -r '.nodes["nixpkgs-x86-darwin"].locked
| "github:\(.owner)/\(.repo)/\(.rev)"' flake.lock)"
printf 'overriding nixpkgs with %s\n' "$url"
overrides=(--override-input nixpkgs "$url")
fi
# Preserve all unrelated pins: removing the lock refetched Qt's
# dependencies after the cache build and produced a different Qt.
for attempt in 1 2 3; do
if nix flake lock --override-input mpv-src "git+file:$PWD/mpv?rev=$revision&shallow=1" \
${overrides[@]+"${overrides[@]}"}; then
exit 0
fi
printf 'flake lock attempt %d failed; retrying\n' "$attempt" >&2
sleep $((attempt * 20))
done
exit 1
- name: Build macOS app
env:
SPOOL_MACOS_CREDENTIAL_SERVICE: ${{ inputs.enable_macos_signing && 'com.sachk.spool' || '' }}
run: nix develop .#native --no-write-lock-file -c bash tools/build-macos.sh
- name: Run traditional and real GUI e2e tests
run: |
nix develop .#native --no-write-lock-file -c python tools/run-tests.py \
--build-dir build/macos/app --workers "$(sysctl -n hw.ncpu)"
- name: Retain internal native phase results after failures
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: internal-macos-${{ matrix.arch }}-unified-tests
path: |
build/macos/app/*-results.json*
build/macos/app/test-phases.json
build/macos/app/test-artifacts/app-journey-*/*.png
if-no-files-found: ignore
retention-days: 1
- name: Lint compiled QML
run: nix develop .#native --no-write-lock-file -c cmake --build build/macos/app --target spool_qmllint_strict
- name: Load bundled Vulkan runtime
run: |
python3 - <<'PY'
import ctypes
import os
from pathlib import Path
contents = Path("build/macos/install/Spool.app/Contents").resolve()
os.environ["VK_DRIVER_FILES"] = str(contents / "Resources/vulkan/icd.d/MoltenVK_icd.json")
# Exercise dyld, not merely the symbolic dependency audit. Neither
# load may fall back to a Nix/Homebrew library outside the bundle.
ctypes.CDLL(str(contents / "Frameworks/libMoltenVK.dylib"))
loader = ctypes.CDLL(str(contents / "Frameworks/libvulkan.1.dylib"))
class Extension(ctypes.Structure):
_fields_ = [("name", ctypes.c_char * 256), ("version", ctypes.c_uint32)]
enumerate_extensions = loader.vkEnumerateInstanceExtensionProperties
enumerate_extensions.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(Extension)]
enumerate_extensions.restype = ctypes.c_int32
count = ctypes.c_uint32()
result = enumerate_extensions(None, ctypes.byref(count), None)
if result != 0:
raise SystemExit(f"Vulkan extension enumeration failed: {result}")
extensions = (Extension * count.value)()
result = enumerate_extensions(None, ctypes.byref(count), extensions)
if result != 0:
raise SystemExit(f"Vulkan extension enumeration failed: {result}")
names = {entry.name.decode() for entry in extensions}
if "VK_EXT_metal_surface" not in names:
raise SystemExit(f"MoltenVK Metal surface extension missing: {sorted(names)}")
print("Bundled Vulkan loader and MoltenVK load successfully; Metal surface extension available")
PY
- name: Sign macOS application
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.enable_macos_signing }}
env:
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: tools/sign-macos.sh build/macos/install/Spool.app
- name: Package DMG
run: nix develop .#native --no-write-lock-file -c bash tools/package-macos-dmg.sh
- name: Sign macOS disk image
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.enable_macos_signing }}
env:
MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: tools/sign-macos.sh dist/*.dmg
- name: Audit mounted DMG
run: |
set -euo pipefail
mountpoint="$RUNNER_TEMP/spool-dmg"
mkdir -p "$mountpoint"
hdiutil attach -readonly -nobrowse -mountpoint "$mountpoint" dist/*.dmg
trap 'bash tools/detach-dmg.sh "$mountpoint"' EXIT
app="$(find "$mountpoint" -maxdepth 1 -name '*.app' -print -quit)"
test -n "$app"
python3 tools/package-audit.py inventory "$app" --output "$RUNNER_TEMP/macos-dmg.inventory.tsv"
python3 tools/package-audit.py macho "$app" \
--root Contents/Frameworks/libvulkan.1.dylib \
--root Contents/Frameworks/libMoltenVK.dylib
test -z "$(find "$app" -name '*.qmltypes' -print -quit)"
if [[ '${{ inputs.enable_macos_signing }}' == true ]]; then
codesign --verify --strict --deep --verbose=2 "$app"
fi
SPOOL_LAUNCH_TEST_QPA_PLATFORM=cocoa \
bash tools/launch-native-app.sh "$app"
bash tools/detach-dmg.sh "$mountpoint"
trap - EXIT
- name: Package macOS analysis input
run: |
dmg="$(printf '%s\n' dist/*.dmg)"
tar -czf "${dmg}.app.tar.gz" -C build/macos/install Spool.app
- name: Notarize and staple DMG
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.enable_macos_signing }}
timeout-minutes: 20
continue-on-error: true
env:
APPLE_NOTARY_KEY_P8: ${{ secrets.APPLE_NOTARY_KEY_P8 }}
APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }}
APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }}
run: tools/notarize-macos.sh dist/*.dmg
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-macos-dmg-${{ matrix.arch }}
path: |
dist/*.dmg
dist/*.dmg.app.tar.gz
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
- name: Save macOS build cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release && steps.macos-build-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: |
build/macos/mpv
build/macos/mpv-prefix
build/macos/app
~/Library/Caches/ccache
key: ${{ steps.macos-build-cache.outputs.cache-primary-key }}
tvos:
name: tvOS (${{ matrix.sdk }} arm64)
runs-on: macos-15
timeout-minutes: 180
needs: [lint, toolchain]
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_tvos }}
strategy:
fail-fast: false
matrix:
sdk: [appletvos, appletvsimulator]
env:
APPLE_SDK: ${{ matrix.sdk }}
APPLE_ARCH: arm64
APPLE_DEPLOYMENT_TARGET: "16.0"
CODE_SIGNING_ALLOWED: "NO"
JOBS: "3"
BUILD_JOBS: "3"
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- name: Install native source-build tools
run: brew install cmake ninja meson pkg-config bash python imagemagick gpatch
- name: Identify the exact Apple source toolchain
id: apple-toolchain
run: |
set -euo pipefail
# No prefix restore: patched source trees and configured cross-builds
# must never survive a source, SDK, compiler, or toolchain change.
identity="$(
{
xcodebuild -version
xcrun --sdk "$APPLE_SDK" --show-sdk-build-version
xcrun --sdk "$APPLE_SDK" --show-sdk-path
xcrun --sdk "$APPLE_SDK" clang --version
brew list --versions
git -C mpv rev-parse HEAD
} | shasum -a 256
)"
echo "key=${identity%% *}" >>"$GITHUB_OUTPUT"
echo "QT_HOST_PATH=$GITHUB_WORKSPACE/build/apple/host-qt/${{ needs.toolchain.outputs.qt_version }}/macos" >>"$GITHUB_ENV"
- uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
id: tvos-source-cache
with:
path: |
build/apple/host-qt
build/apple/qmlimportscanner
build/apple/downloads
build/apple/qt-sources
build/apple/sources
build/apple/qt-${{ matrix.sdk }}-arm64
build/apple/${{ matrix.sdk }}-arm64
build/downloads
key: tvos-source1-${{ runner.os }}-${{ runner.arch }}-${{ matrix.sdk }}-16.0-${{ steps.apple-toolchain.outputs.key }}-${{ hashFiles('tools/manifests/toolchain.json', 'tools/manifests/android-third-party.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/apple/**', 'tools/build-tvos.sh', 'tools/ffmpeg-capabilities.py', 'tools/lib/manifest-sources.sh', 'tools/webos-native/patches/qtdeclarative-*-qmlimportscanner-exclude-subtrees.patch', 'third_party/**', 'mpv/meson.build', 'mpv/meson.options') }}
- name: Install matching macOS Qt host tools
if: ${{ steps.tvos-source-cache.outputs.cache-hit != 'true' }}
env:
QT_VERSION: ${{ needs.toolchain.outputs.qt_version }}
run: |
python3 -m venv "$RUNNER_TEMP/aqt"
"$RUNNER_TEMP/aqt/bin/pip" install aqtinstall
"$RUNNER_TEMP/aqt/bin/aqt" install-qt mac desktop "$QT_VERSION" clang_64 \
-O "$GITHUB_WORKSPACE/build/apple/host-qt" \
-m qtshadertools qttasktree qtwebsockets qtimageformats
- name: Build actual source Qt, media closure, and tvOS app
run: bash tools/build-tvos.sh
- uses: ./.github/actions/install-nix
if: ${{ matrix.sdk == 'appletvsimulator' }}
with:
nix_conf: |
experimental-features = nix-command flakes
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
if: ${{ matrix.sdk == 'appletvsimulator' }}
with:
name: spool
skipPush: true
- name: Build same-binary native controller for the actual mobile app journey
if: ${{ matrix.sdk == 'appletvsimulator' }}
run: nix develop .#native -c env DEPLOY_APP=0 bash tools/build-macos.sh
- name: Run native traditional then GPU GUI tests in isolated Apple TV simulator
if: ${{ matrix.sdk == 'appletvsimulator' }}
env:
SPOOL_TEST_DEVICE_CLEANUP_REGRESSIONS: "1"
run: bash tools/apple/smoke-tvos.sh build/tvos/appletvsimulator-arm64/install/Spool.app
- name: Package artifact-audited unsigned device IPA (no device runtime claim)
if: ${{ matrix.sdk == 'appletvos' }}
run: bash tools/package-tvos.sh build/tvos/appletvos-arm64/install/Spool.app dist/tvos
- name: Upload only the device release package
if: ${{ matrix.sdk == 'appletvos' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-tvos-device-arm64
path: dist/tvos/Spool-*-tvOS-arm64.ipa
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
- name: Archive simulator proof without losing executable permissions
if: ${{ matrix.sdk == 'appletvsimulator' }}
run: |
tar -czf "$RUNNER_TEMP/tvos-simulator-arm64.tar.gz" \
-C build/tvos/appletvsimulator-arm64 install
- name: Keep simulator artifacts internal
if: ${{ always() && matrix.sdk == 'appletvsimulator' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: internal-tvos-simulator-arm64
path: |
${{ runner.temp }}/tvos-simulator-arm64.tar.gz
build/tvos/smoke/**/safe-export/**
retention-days: ${{ inputs.release && 1 || 7 }}
- name: Save coherent Apple dependency cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release && steps.tvos-source-cache.outputs.cache-hit != 'true' }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: |
build/apple/host-qt
build/apple/qmlimportscanner
build/apple/downloads
build/apple/qt-sources
build/apple/sources
build/apple/qt-${{ matrix.sdk }}-arm64
build/apple/${{ matrix.sdk }}-arm64
build/downloads
key: ${{ steps.tvos-source-cache.outputs.cache-primary-key }}
webos-ipk:
name: webOS IPK
runs-on: ubuntu-24.04
needs: lint
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_webos }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
- uses: nix-community/cache-nix-action@7df957e333c1e5da7721f60227dbba6d06080569 # v7.0.1
with:
primary-key: nix-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-
gc-max-store-size-linux: 4G
# Cache hygiene: see the lint job above.
save: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge: ${{ env.SPOOL_CACHE_WRITE == 'true' && !inputs.release }}
purge-prefixes: nix-${{ runner.os }}-
purge-created: P7D
purge-primary-key: never
- uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
id: webos-cache
with:
path: |
build/webos-sdk
build/qt6-611-host-install
build/qt6-611-target-static-install
build/qt6-611-target-install
build/webos-thirdparty-build
build/webos-qcoro
build/third_party
.ccache
mpv/build/webos-libmpv
key: webos3-${{ runner.os }}-${{ hashFiles('flake.nix', 'flake.lock', 'mpv/meson.build', 'build-ipk.sh', 'tools/package-audit.py', 'tools/lib/*.sh', 'tools/manifests/ffmpeg-capabilities.json', 'tools/manifests/toolchain.json', 'tools/manifests/mpv-native.json', 'tools/manifests/qt-webos-6.11.json', 'tools/manifests/webos-third-party.json', 'tools/webos-native/**') }}
restore-keys: |
webos3-${{ runner.os }}-
webos2-${{ runner.os }}-
- name: Restore webOS toolchain archive
env:
INPUT_URL: ${{ inputs.webos_toolchain_archive_url || '' }}
VAR_URL: ${{ vars.WEBOS_TOOLCHAIN_ARCHIVE_URL }}
run: |
if [ -n "${INPUT_URL:-$VAR_URL}" ]; then
export WEBOS_TOOLCHAIN_ARCHIVE_URL="${INPUT_URL:-$VAR_URL}"
fi
nix develop -c bash tools/webos-native/restore-toolchain.sh
test -x build/webos-sdk/arm-webos-linux-gnueabi_sdk-buildroot/bin/arm-webos-linux-gnueabi-gcc
# build-webos.sh uses the default dev shell on purpose: the webOS target
# links the cross-built static Qt, and that build must not see nixpkgs Qt.
- name: Build webOS dependencies
if: ${{ steps.webos-cache.outputs.cache-hit != 'true' }}
run: |
for phase in fetch qt-host qt-target dependencies; do
nix develop -c bash tools/webos-native/build-webos.sh "$phase"
done
# Tags and PR merge refs restore without creating duplicate cache copies.
- name: Save webOS dependency cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && steps.webos-cache.outputs.cache-hit != 'true' && !inputs.release }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: |
build/webos-sdk
build/qt6-611-host-install
build/qt6-611-target-static-install
build/qt6-611-target-install
build/webos-thirdparty-build
build/webos-qcoro
build/third_party
.ccache
mpv/build/webos-libmpv
key: ${{ steps.webos-cache.outputs.cache-primary-key }}
- name: Build IPK
run: ./build-ipk.sh
- name: Validate IPK
shell: nix develop -c bash -euo pipefail {0}
run: |
set -euo pipefail
shopt -s nullglob
ipks=(build/*.ipk)
if [ "${#ipks[@]}" -ne 1 ]; then
printf 'expected exactly one IPK, found %d\n' "${#ipks[@]}" >&2
exit 1
fi
ipk="${ipks[0]}"
test -s "$ipk"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
ar t "$ipk" | tee "$work/members.txt"
grep -qx debian-binary "$work/members.txt"
grep -Eq '^control\.tar\.' "$work/members.txt"
grep -Eq '^data\.tar\.' "$work/members.txt"
repo="$PWD"
(cd "$work" && ar x "$repo/$ipk")
data_tar="$(find "$work" -maxdepth 1 -name 'data.tar.*' -print -quit)"
test -n "$data_tar"
tar -tf "$data_tar" | tee "$work/data-files.txt" >/dev/null
grep -Eq '(^|/)appinfo\.json$' "$work/data-files.txt"
grep -Eq '(^|/)bin/spool$' "$work/data-files.txt"
mkdir "$work/data"
tar -xf "$data_tar" -C "$work/data"
appinfo="$(find "$work/data" -name appinfo.json -print -quit)"
test -n "$appinfo"
app_root="$(dirname "$appinfo")"
python3 tools/package-audit.py inventory "$app_root" --output "$work/extracted-webos.inventory.tsv"
cmp build/webos-stage.inventory.tsv "$work/extracted-webos.inventory.tsv"
test ! -e "$app_root/qt-qml"
test ! -e "$app_root/bin/qt.conf"
test "$(find "$app_root" -name splash.png | wc -l)" -eq 1
for font in AtkinsonHyperlegible-Bold.otf AtkinsonHyperlegible-Regular.otf \
IBMPlexSans-Variable.ttf PTRootUI-Variable.ttf MaterialIcons-Regular.ttf
do
test -f "$app_root/fonts/$font"
done
python3 - "$appinfo" <<'PY'
import json
import re
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
appinfo = json.load(handle)
assert appinfo["id"] == "com.sachk.spool", appinfo.get("id")
assert appinfo["main"] == "bin/spool", appinfo.get("main")
assert re.fullmatch(r"\d+\.\d+\.\d+", appinfo["version"]), appinfo.get("version")
print(f"Validated {appinfo['id']} {appinfo['version']} IPK")
PY
launcher="$(find "$work/data" -path '*/bin/spool' -type f -print -quit)"
test -n "$launcher"
test -x "$launcher"
python3 - "$launcher" <<'PY'
import os
from pathlib import Path
import struct
import sys
binary = Path(sys.argv[1])
with binary.open("rb") as handle:
header = handle.read(20)
if header[:4] != b"\x7fELF":
binary = binary.with_name("spool.real")
assert binary.is_file(), "launcher is not ELF and spool.real is missing"
assert os.access(binary, os.X_OK), "spool.real is not executable"
with binary.open("rb") as handle:
header = handle.read(20)
assert header[:4] == b"\x7fELF", "app binary is not ELF"
assert header[4] == 1, "app binary is not 32-bit ELF"
assert header[5] == 1, "app binary is not little-endian ELF"
elf_type, machine = struct.unpack_from("<HH", header, 16)
assert elf_type in (2, 3), f"unexpected ELF type: {elf_type}"
assert machine == 40, f"app binary is not ARM ELF: machine={machine}"
print(f"Validated 32-bit little-endian ARM app binary: {binary.name}")
PY
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-webos-ipk
path: build/*.ipk
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
android-apks:
name: Android ${{ matrix.abi }} APK
runs-on: ubuntu-24.04
timeout-minutes: 150
strategy:
fail-fast: false
matrix:
abi: [arm64-v8a, armeabi-v7a, x86_64]
needs: lint
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_android }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
# Keep the SDK/NDK and Qt host-tools closure in Cachix rather than
# consuming Actions cache quota needed by the source-built target
# toolchains below. The shell excludes the emulator (launch tests use
# #android-emulator) and native Qt Quick/desktop-only modules, but still
# needs matching host code generators to cross-compile Qt and the app.
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: spool
skipPush: true
# Realising the shell into a profile is what makes its closure a root
# that can be pushed; without one there is nothing to name.
- name: Materialise the Android dev shell
run: |
nix develop --accept-flake-config .#android \
--profile "$RUNNER_TEMP/android-shell" -c true
- name: Publish the Android dev shell
if: ${{ env.SPOOL_CACHE_WRITE == 'true' }}
env:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
run: |
if [[ -n "$CACHIX_AUTH_TOKEN" ]]; then
cachix push spool "$RUNNER_TEMP/android-shell"
fi
# The build scripts skip work whose install marker is already present, so a
# near-miss restore would be reused as if it were current. The key covers
# every input that changes the cached tree and there are no restore-keys:
# a miss rebuilds from source rather than resuming someone else's tree.
#
# The NDK and the host Qt tools are what the dev shell contributes to that
# tree, so the key names their store paths rather than hashing flake.nix.
# A store path moves exactly when the toolchain does: adding an unrelated
# package to the shell no longer throws away an hour and a half of Qt and
# mpv builds, and a real toolchain bump can no longer be papered over with
# a tree built against the previous one.
- name: Resolve toolchain cache inputs
id: cache-inputs
run: |
set -euo pipefail
echo "mpv=$(git rev-parse HEAD:mpv)" >>"$GITHUB_OUTPUT"
# shellcheck disable=SC2016
toolchain="$(nix develop .#android -c sh -c \
'printf "%s\n%s\n" "$ANDROID_NDK_ROOT" "$SPOOL_ANDROID_QT_HOST"')"
printf 'toolchain inputs:\n%s\n' "$toolchain"
echo "toolchain=$(printf '%s' "$toolchain" | sha256sum | cut -c1-16)" \
>>"$GITHUB_OUTPUT"
- uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
id: android-cache
with:
path: |
build/android/qt
build/android/deps
build/android/qcoro
key: android6-${{ matrix.abi }}-${{ runner.os }}-${{ steps.cache-inputs.outputs.mpv }}-${{ steps.cache-inputs.outputs.toolchain }}-${{ hashFiles('tools/android/build-dependencies.sh', 'tools/android/build-qt6.sh', 'tools/android/patches/*.patch', 'tools/ffmpeg-capabilities.py', 'tools/lib/build-common.sh', 'tools/lib/manifest-sources.sh', 'tools/manifests/android-third-party.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/manifests/toolchain.json', 'tools/manifests/qt-android-6.11.json') }}
- name: Build cached Android toolchains
if: ${{ steps.android-cache.outputs.cache-hit != 'true' }}
run: |
nix develop .#android -c env \
ANDROID_ABI="${{ matrix.abi }}" \
QT_ANDROID_MEMORY_PER_JOB_MIB=2500 \
ANDROID_DEPS_MEMORY_PER_JOB_MIB=2500 \
bash -c '
bash tools/android/build-dependencies.sh all
bash tools/android/build-qt6.sh all
'
- name: Prepare Android release keystore
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release }}
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
test -n "$KEYSTORE_BASE64"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode >"$RUNNER_TEMP/spool-release.p12"
chmod 600 "$RUNNER_TEMP/spool-release.p12"
- name: Build the Android APK
env:
SPOOL_ANDROID_KEYSTORE_PATH: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && format('{0}/spool-release.p12', runner.temp) || '' }}
SPOOL_ANDROID_KEYSTORE_ALIAS: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && secrets.ANDROID_RELEASE_KEY_ALIAS || '' }}
SPOOL_ANDROID_KEYSTORE_STORE_PASS: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD || '' }}
SPOOL_ANDROID_KEYSTORE_KEY_PASS: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && secrets.ANDROID_RELEASE_KEY_PASSWORD || '' }}
ANDROID_ABI: ${{ matrix.abi }}
SPOOL_ANDROID_BUILD_TESTS: ${{ matrix.abi == 'x86_64' && 'ON' || 'OFF' }}
run: nix develop .#android -c bash tools/android/build-apks.sh
- name: Verify native libraries stay compressed
env:
ANDROID_ABI: ${{ matrix.abi }}
run: |
python3 - <<'PY'
import os
import zipfile
path = f"dist/android/spool-{os.environ['ANDROID_ABI']}.apk"
with zipfile.ZipFile(path) as apk:
libraries = [entry for entry in apk.infolist()
if entry.filename.startswith("lib/") and entry.filename.endswith(".so")]
if not libraries:
raise SystemExit(f"{path}: no native libraries")
uncompressed = [entry.filename for entry in libraries
if entry.compress_type != zipfile.ZIP_DEFLATED]
if uncompressed:
raise SystemExit(f"{path}: native libraries are not compressed: {uncompressed}")
print(f"{path}: native libraries are compressed ({os.path.getsize(path)} bytes)")
PY
- name: Verify Android release signing identity
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release }}
env:
EXPECTED_CERT_SHA256: 0c9deff0513eed6d43521df31ad6d2d8e928c029b146d902f4bcc25a883c1fb8
run: |
set -euo pipefail
nix develop .#android -c bash -c "
for apk in dist/android/*.apk; do
output=\"\$(\"\$ANDROID_HOME/build-tools/36.0.0/apksigner\" verify --verbose --print-certs \"\$apk\")\"
printf '%s\n' \"\$output\"
grep -Fq \"Signer #1 certificate SHA-256 digest: \$EXPECTED_CERT_SHA256\" <<<\"\$output\"
done
"
- name: Save Android toolchain cache
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && steps.android-cache.outputs.cache-hit != 'true' && !inputs.release }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: |
build/android/qt
build/android/deps
build/android/qcoro
key: ${{ steps.android-cache.outputs.cache-primary-key }}
- name: Build same-binary native controller for the actual mobile app journey
if: ${{ matrix.abi == 'x86_64' }}
run: nix develop .#native -c bash tools/build-linux-release.sh
- name: Run native and real app GUI phases on phone and actual Android TV images
if: ${{ matrix.abi == 'x86_64' }}
env:
SPOOL_TEST_DEVICE_CLEANUP_REGRESSIONS: "1"
run: |
status=0
for form_factor in phone tv; do
nix develop .#android -c env SPOOL_ANDROID_FORM_FACTOR="$form_factor" \
bash tools/android/emulator-launch-test.sh \
dist/android/spool-e2e-app-x86_64.apk || status=1
done
exit "$status"
- name: Upload internal emulator test receipts and diagnostics
if: ${{ always() && matrix.abi == 'x86_64' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: internal-spool-android-tests
path: |
build/android/launch-test/**/safe-export/**
dist/android/spool-tests-x86_64.apk
dist/android/spool-e2e-tests-x86_64.apk
dist/android/spool-e2e-app-x86_64.apk
retention-days: 1
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-android-apks-${{ matrix.abi }}
path: dist/android/spool-${{ matrix.abi }}.apk
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
# The Gradle project this ABI was packaged from, without its native
# libraries. The universal job puts the union of every ABI's libs.xml
# back into one of these and packages it; nothing else can produce a
# resources.arsc that names both architectures.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
# Named outside the spool-* family on purpose: the release job
# collects every spool-* artifact, flattens them together and
# publishes whatever lands at the top. These are build inputs for one
# later job, not something anybody should be offered a download of.
name: internal-android-universal-inputs-${{ matrix.abi }}
path: dist/android/universal-inputs/${{ matrix.abi }}
retention-days: 1
android-universal-apk:
name: Android universal APK
runs-on: ubuntu-24.04
timeout-minutes: 60
# Every ABI leg reports under this one job name, so this waits for all of
# them and then packages the two that a real device can run.
needs: android-apks
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_android }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
- uses: ./.github/actions/install-nix
with:
nix_conf: |
experimental-features = nix-command flakes
- uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16
with:
name: spool
skipPush: true
# No x86_64: nothing that reaches a living room or a pocket runs it, and
# leaving it out is most of the difference between a download somebody
# will wait for and one they will not.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: spool-android-apks-arm64-v8a
path: dist/android
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: spool-android-apks-armeabi-v7a
path: dist/android
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: internal-android-universal-inputs-arm64-v8a
path: dist/android/universal-inputs/arm64-v8a
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: internal-android-universal-inputs-armeabi-v7a
path: dist/android/universal-inputs/armeabi-v7a
- name: Prepare Android release keystore
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release }}
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_RELEASE_KEYSTORE_BASE64 }}
run: |
set -euo pipefail
test -n "$KEYSTORE_BASE64"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode >"$RUNNER_TEMP/spool-release.p12"
chmod 600 "$RUNNER_TEMP/spool-release.p12"
- name: Package the universal APK
env:
SPOOL_ANDROID_KEYSTORE_PATH: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && format('{0}/spool-release.p12', runner.temp) || '' }}
SPOOL_ANDROID_KEYSTORE_ALIAS: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && secrets.ANDROID_RELEASE_KEY_ALIAS || '' }}
SPOOL_ANDROID_KEYSTORE_STORE_PASS: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && secrets.ANDROID_RELEASE_KEYSTORE_PASSWORD || '' }}
SPOOL_ANDROID_KEYSTORE_KEY_PASS: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release && secrets.ANDROID_RELEASE_KEY_PASSWORD || '' }}
run: |
nix develop .#android -c bash tools/android/build-universal-apk.sh \
dist/android/spool-universal.apk \
dist/android/universal-inputs \
dist/android/spool-arm64-v8a.apk \
dist/android/spool-armeabi-v7a.apk
- name: Verify Android release signing identity
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && inputs.release }}
env:
EXPECTED_CERT_SHA256: 0c9deff0513eed6d43521df31ad6d2d8e928c029b146d902f4bcc25a883c1fb8
run: |
set -euo pipefail
nix develop .#android -c bash -c "
output=\"\$(\"\$ANDROID_HOME/build-tools/36.0.0/apksigner\" verify --verbose --print-certs dist/android/spool-universal.apk)\"
printf '%s\n' \"\$output\"
grep -Fq \"Signer #1 certificate SHA-256 digest: \$EXPECTED_CERT_SHA256\" <<<\"\$output\"
"
# Nothing else gates Android artefact size, and this is the one file
# whose whole point is that a stranger is willing to download it.
- name: Check the universal APK stays downloadable
env:
LIMIT_MIB: 150
run: |
set -euo pipefail
size="$(stat -c %s dist/android/spool-universal.apk)"
printf 'universal APK: %s MiB\n' "$((size / 1048576))"
if (( size > LIMIT_MIB * 1048576 )); then
echo "error: universal APK is larger than ${LIMIT_MIB} MiB" >&2
exit 1
fi
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-android-apk-universal
path: dist/android/spool-universal.apk
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
windows-x64:
name: Windows x64 portable and installer
runs-on: windows-2025
timeout-minutes: 75
needs: [toolchain, lint]
if: ${{ github.event_name != 'workflow_dispatch' || inputs.build_windows }}
env:
QT_VERSION: ${{ needs.toolchain.outputs.qt_version }}
QT_ARCH: win64_${{ needs.toolchain.outputs.qt_windows_kit }}
SPOOL_QT_ROOT: C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }}
SPOOL_QCORO_ROOT: C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }}
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.0.2
with:
persist-credentials: false
submodules: recursive
- name: Install upstream FFmpeg build environment
id: msys2
uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2
with:
msystem: MSYS
path-type: inherit
install: make diffutils pkgconf
- name: Install build and packaging tools
shell: pwsh
env:
MSYS2_LOCATION: ${{ steps.msys2.outputs.msys2-location }}
run: tools\windows\install-build-tools.ps1
- name: Restore Qt and QCoro
id: qt-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: |
C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }}
C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }}
key: windows-qt-${{ needs.toolchain.outputs.qt_version }}-qcoro-${{ needs.toolchain.outputs.qcoro_revision }}-${{ hashFiles('tools/manifests/qt-windows-6.11.json') }}
- name: Install Qt from pinned official archives
if: steps.qt-cache.outputs.cache-hit != 'true'
shell: pwsh
run: tools\windows\install-qt.ps1 -Force
- name: Build QCoro
if: steps.qt-cache.outputs.cache-hit != 'true'
shell: pwsh
run: tools\windows\install-qcoro.ps1 -Force
- name: Save Qt and QCoro
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && steps.qt-cache.outputs.cache-hit != 'true' && !inputs.release }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: |
C:\Qt\${{ needs.toolchain.outputs.qt_version }}\${{ needs.toolchain.outputs.qt_windows_kit }}
C:\Qt\${{ needs.toolchain.outputs.qcoro_windows_prefix }}
key: windows-qt-${{ needs.toolchain.outputs.qt_version }}-qcoro-${{ needs.toolchain.outputs.qcoro_revision }}-${{ hashFiles('tools/manifests/qt-windows-6.11.json') }}
- name: Read mpv revision
id: mpv-revision
shell: pwsh
run: |
"revision=$(git -C mpv rev-parse HEAD)" >> $env:GITHUB_OUTPUT
- name: Restore source-built libmpv
id: mpv-cache
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: build/windows-deps/mpv
key: windows-mpv-${{ runner.os }}-${{ steps.mpv-revision.outputs.revision }}-${{ hashFiles('tools/windows/build-mpv.ps1', 'tools/windows/build-ffmpeg.ps1', 'tools/windows/build-ffmpeg.sh', 'tools/windows/check-ffmpeg.py', 'tools/windows/build-shader-tools.ps1', 'tools/windows/common.ps1', 'tools/manifests/mpv-native.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/manifests/toolchain.json', 'tools/ffmpeg-capabilities.py') }}
- name: Build libmpv from the fork
if: steps.mpv-cache.outputs.cache-hit != 'true'
timeout-minutes: 45
shell: pwsh
env:
MSYS2_LOCATION: ${{ steps.msys2.outputs.msys2-location }}
run: tools\windows\build-mpv.ps1 -Clean
- name: Save source-built libmpv
if: ${{ env.SPOOL_CACHE_WRITE == 'true' && steps.mpv-cache.outputs.cache-hit != 'true' && !inputs.release }}
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.0.3
with:
path: build/windows-deps/mpv
key: windows-mpv-${{ runner.os }}-${{ steps.mpv-revision.outputs.revision }}-${{ hashFiles('tools/windows/build-mpv.ps1', 'tools/windows/build-ffmpeg.ps1', 'tools/windows/build-ffmpeg.sh', 'tools/windows/check-ffmpeg.py', 'tools/windows/build-shader-tools.ps1', 'tools/windows/common.ps1', 'tools/manifests/mpv-native.json', 'tools/manifests/ffmpeg-capabilities.json', 'tools/manifests/toolchain.json', 'tools/ffmpeg-capabilities.py') }}
- name: Build application
shell: pwsh
run: tools\windows\build.ps1 -Clean -CMakeArguments '-DSPOOL_TEST_RENDER_BACKEND=opengl'
- name: Install pinned real OpenGL and fixture test dependencies
shell: pwsh
run: tools\windows\install-test-runtime.ps1
- name: Run traditional and real OpenGL GUI e2e tests
shell: pwsh
run: tools\windows\test.ps1
- name: Retain internal native phase results after failures
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: internal-windows-unified-tests
path: |
build/windows-release/app/*-results.json*
build/windows-release/app/test-phases.json
build/windows-release/app/test-artifacts/app-journey-*/*.png
if-no-files-found: ignore
retention-days: 1
- name: Stage and launch-test release payload
shell: pwsh
run: tools\windows\stage.ps1
- name: Build portable and installer packages
shell: pwsh
run: tools\windows\package.ps1
- name: Upload portable executable
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-windows-x64-portable
path: |
dist/*-Portable.exe
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}
- name: Upload installer executable
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0
with:
name: spool-windows-x64-installer
path: |
dist/*-Setup.exe
if-no-files-found: error
retention-days: ${{ inputs.release && 1 || 7 }}