Repository navigation
build artifacts #524
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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-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 native tests | |
| run: | | |
| nix develop .#native -c ctest --test-dir build/linux-release/app \ | |
| -E '^mpv-video-item' --parallel "$(nproc)" --output-on-failure | |
| - name: Lint compiled QML | |
| run: nix develop .#native -c cmake --build build/linux-release/app --target spool_qmllint_strict | |
| - name: Launch-test host app UI | |
| run: nix develop .#native -c bash tools/launch-native-app.sh build/linux-release/install/bin/spool | |
| # 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 }} | |
| 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 native tests | |
| run: | | |
| nix develop .#native --no-write-lock-file -c ctest --test-dir build/macos/app \ | |
| --parallel "$(sysctl -n hw.ncpu)" --output-on-failure | |
| - 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: Launch-test macOS app UI | |
| env: | |
| SPOOL_LAUNCH_TEST_QPA_PLATFORM: cocoa | |
| run: nix develop .#native --no-write-lock-file -c bash tools/launch-native-app.sh build/macos/install/Spool.app | |
| - 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 | |
| - name: Exercise native Apple TV simulator UI and playback | |
| if: ${{ matrix.sdk == 'appletvsimulator' }} | |
| run: bash tools/apple/smoke-tvos.sh build/tvos/appletvsimulator-arm64/install/Spool.app | |
| - name: Package validated unsigned device IPA | |
| 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/*.png | |
| build/tvos/smoke/result.json | |
| build/tvos/smoke/credentials-result.json | |
| 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 }} | |
| 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: Launch-test both launcher categories in headless emulator | |
| if: ${{ matrix.abi == 'x86_64' }} | |
| run: | | |
| nix develop .#android -c bash tools/android/emulator-launch-test.sh \ | |
| dist/android/spool-x86_64.apk | |
| - name: Upload launch-test diagnostics | |
| if: ${{ failure() && matrix.abi == 'x86_64' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 | |
| with: | |
| name: spool-android-launch-test | |
| path: | | |
| build/android/launch-test | |
| build/android/emulator.log | |
| retention-days: 1 | |
| - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.0 | |
| with: | |
| name: spool-android-apks-${{ matrix.abi }} | |
| path: dist/android/*.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 | |
| - name: Run tests | |
| shell: pwsh | |
| run: tools\windows\test.ps1 | |
| - 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 }} |