fix(cpn): tie macOS bundle to its own SDL copies, and verify it (#7653)

Co-authored-by: Peter Feerick <5500713+pfeerick@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Peter Feerick
2026-08-16 13:46:47 +10:00
committed by GitHub
co-authored by Peter Feerick Claude Opus 5
parent 2025ac7274
commit c562f486d0
8 changed files with 357 additions and 55 deletions
+24 -3
View File
@@ -10,8 +10,17 @@ inputs:
description: 'Qt architecture'
required: runner.os != 'Linux'
sdl2-version:
description: 'SDL2 version'
description: 'Upstream SDL2 version (Windows only)'
default: '2.32.8'
sdl3-version:
description: 'SDL3 version built from source for macOS'
default: '3.4.14'
sdl2-compat-version:
description: 'sdl2-compat version built from source for macOS'
default: '2.32.70'
macos-deployment-target:
description: 'Minimum macOS version the SDL libraries are built for'
default: '11.0'
runs:
using: "composite"
@@ -29,14 +38,26 @@ runs:
cache: true
cache-key-prefix: 'install-qt-action-${{ inputs.qt-arch }}'
- name: Setup SDL2
# macOS builds SDL from source (see setup-sdl.sh) - cache it so only the first
# run per version pays for it
- name: Cache SDL build
if: runner.os == 'macOS'
uses: actions/cache@v6
with:
path: ${{ runner.temp }}/sdl
key: sdl-${{ runner.os }}-${{ inputs.sdl3-version }}-${{ inputs.sdl2-compat-version }}-${{ inputs.macos-deployment-target }}
- name: Setup SDL
if: runner.os != 'Linux'
shell: bash
env:
ACTION_PATH: ${{ github.action_path }}
SDL2_VERSION: ${{ inputs.sdl2-version }}
SDL3_VERSION: ${{ inputs.sdl3-version }}
SDL2_COMPAT_VERSION: ${{ inputs.sdl2-compat-version }}
MACOS_DEPLOYMENT_TARGET: ${{ inputs.macos-deployment-target }}
run: |
"${ACTION_PATH}/setup-sdl2.sh" ${SDL2_VERSION}
"${ACTION_PATH}/setup-sdl.sh"
- name: Setup MSVC
if: runner.os == 'Windows'
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# setup-sdl.sh
# Provisions SDL for Companion builds.
#
# Windows: upstream SDL2 MSVC development binaries.
# macOS: sdl2-compat + SDL3 built from source.
# Linux: provided by the Docker container.
#
# macOS builds against sdl2-compat rather than classic SDL2 because that is what users get
# from Homebrew, and it is the configuration that exposed the SDL3 bundling bug - CI needs to
# exercise the same code path Companion ships through.
#
# Homebrew itself cannot be used here: its bottles are single-architecture and target the
# runner's macOS version, while Companion ships a universal binary for macOS 11.0+. Building
# from source is what keeps both properties.
set -euo pipefail
SDL2_VERSION="${SDL2_VERSION:?SDL2_VERSION must be set}"
build_cmake_project() {
local name="$1" url="$2" srcdir="$3"
shift 3
echo "Building ${name} (universal, macOS ${MACOS_DEPLOYMENT_TARGET}+)..."
curl -fsSL -o "${RUNNER_TEMP}/${name}.tar.gz" "${url}"
mkdir -p "${RUNNER_TEMP}/src"
tar -xzf "${RUNNER_TEMP}/${name}.tar.gz" -C "${RUNNER_TEMP}/src"
cmake -S "${RUNNER_TEMP}/src/${srcdir}" -B "${RUNNER_TEMP}/build-${name}" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_OSX_ARCHITECTURES="x86_64;arm64" \
-DCMAKE_OSX_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET}" \
-DCMAKE_INSTALL_PREFIX="${SDL_ROOT}" \
-DCMAKE_PREFIX_PATH="${SDL_ROOT}" \
"$@"
cmake --build "${RUNNER_TEMP}/build-${name}" --parallel
cmake --install "${RUNNER_TEMP}/build-${name}"
}
case "$RUNNER_OS" in
Windows)
ARCHIVE="SDL2-devel-${SDL2_VERSION}-VC.zip"
URL="https://github.com/libsdl-org/SDL/releases/download/release-${SDL2_VERSION}/${ARCHIVE}"
DEST="${RUNNER_TEMP}/SDL2"
echo "Downloading SDL2 ${SDL2_VERSION} (Windows/MSVC)..."
curl -fsSL -o "${RUNNER_TEMP}/${ARCHIVE}" "${URL}"
unzip -q "${RUNNER_TEMP}/${ARCHIVE}" -d "${DEST}"
SDL2_ROOT="${DEST}/SDL2-${SDL2_VERSION}"
echo "SDL2_ROOT=${SDL2_ROOT}" >> "${GITHUB_ENV}"
;;
macOS)
SDL3_VERSION="${SDL3_VERSION:?SDL3_VERSION must be set}"
SDL2_COMPAT_VERSION="${SDL2_COMPAT_VERSION:?SDL2_COMPAT_VERSION must be set}"
MACOS_DEPLOYMENT_TARGET="${MACOS_DEPLOYMENT_TARGET:-11.0}"
SDL_ROOT="${RUNNER_TEMP}/sdl"
STAMP="${SDL_ROOT}/.stamp-${SDL3_VERSION}-${SDL2_COMPAT_VERSION}-${MACOS_DEPLOYMENT_TARGET}"
if [[ -f "${STAMP}" ]]; then
echo "Reusing cached SDL build in ${SDL_ROOT}"
else
rm -rf "${SDL_ROOT}"
# SDL3 first - sdl2-compat needs its headers and CMake package to configure
build_cmake_project "SDL3" \
"https://github.com/libsdl-org/SDL/releases/download/release-${SDL3_VERSION}/SDL3-${SDL3_VERSION}.tar.gz" \
"SDL3-${SDL3_VERSION}" \
-DSDL_SHARED=ON -DSDL_STATIC=OFF -DSDL_TESTS=OFF -DSDL_EXAMPLES=OFF
build_cmake_project "sdl2-compat" \
"https://github.com/libsdl-org/sdl2-compat/releases/download/release-${SDL2_COMPAT_VERSION}/sdl2-compat-${SDL2_COMPAT_VERSION}.tar.gz" \
"sdl2-compat-${SDL2_COMPAT_VERSION}" \
-DSDL2COMPAT_STATIC=OFF -DSDL2COMPAT_TESTS=OFF
touch "${STAMP}"
fi
# Both live under one prefix, so a single entry on CMAKE_PREFIX_PATH finds
# SDL2 (sdl2-compat) and SDL3 alike.
echo "SDL2_ROOT=${SDL_ROOT}" >> "${GITHUB_ENV}"
echo "Verifying universal slices and deployment target..."
for lib in "${SDL_ROOT}"/lib/libSDL2-2.0.0.dylib "${SDL_ROOT}"/lib/libSDL3.0.dylib; do
[[ -f "${lib}" ]] || { echo "::error::Expected ${lib} to exist"; exit 1; }
archs="$(lipo -archs "${lib}")"
case "${archs}" in
*x86_64*arm64*|*arm64*x86_64*) ;;
*) echo "::error::${lib} is not universal (got: ${archs})"; exit 1 ;;
esac
echo " $(basename "${lib}"): ${archs}"
vtool -show-build "${lib}" | grep -E 'minos|platform' || true
done
;;
Linux)
echo "Skipping — SDL is provided by the Docker container"
;;
*)
echo "::error::Unsupported RUNNER_OS: ${RUNNER_OS}"
exit 1
;;
esac
echo "SDL setup complete for ${RUNNER_OS}"
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# setup-sdl2.sh
# Downloads/installs SDL2 for Windows and macOS.
# Linux is handled separately via a Docker container.
set -euo pipefail
VERSION="${1:?Usage: setup-sdl2.sh <version>}"
case "$RUNNER_OS" in
Windows)
ARCHIVE="SDL2-devel-${VERSION}-VC.zip"
URL="https://github.com/libsdl-org/SDL/releases/download/release-${VERSION}/${ARCHIVE}"
DEST="${RUNNER_TEMP}/SDL2"
echo "Downloading SDL2 ${VERSION} (Windows/MSVC)..."
curl -fsSL -o "${RUNNER_TEMP}/${ARCHIVE}" "${URL}"
unzip -q "${RUNNER_TEMP}/${ARCHIVE}" -d "${DEST}"
SDL2_ROOT="${DEST}/SDL2-${VERSION}"
echo "SDL2_ROOT=${SDL2_ROOT}" >> "${GITHUB_ENV}"
;;
macOS)
ARCHIVE="SDL2-${VERSION}.dmg"
URL="https://github.com/libsdl-org/SDL/releases/download/release-${VERSION}/${ARCHIVE}"
echo "Downloading SDL2 ${VERSION} (macOS framework)..."
curl -fsSL -o "${RUNNER_TEMP}/${ARCHIVE}" "${URL}"
hdiutil attach "${RUNNER_TEMP}/${ARCHIVE}" -quiet -mountpoint "${RUNNER_TEMP}/sdl2-mnt"
sudo cp -R "${RUNNER_TEMP}/sdl2-mnt/SDL2.framework" /Library/Frameworks/
hdiutil detach "${RUNNER_TEMP}/sdl2-mnt" -quiet
;;
Linux)
echo "Skipping — SDL2 is provided by the Docker container"
;;
*)
echo "::error::Unsupported RUNNER_OS: ${RUNNER_OS}"
exit 1
;;
esac
echo "SDL2 ${VERSION} setup complete for ${RUNNER_OS}"
+48 -5
View File
@@ -570,26 +570,35 @@ elseif(APPLE)
# Run macdeployqt
install(SCRIPT ${qt_deploy_companion} COMPONENT Runtime)
option(COMPANION_VERIFY_BUNDLE
"Fail the install if the macOS bundle references libraries outside itself" ON)
set(COMPANION_VERIFY_BUNDLE_ALLOW "" CACHE STRING
"Regex of absolute library paths the bundle verification may ignore")
# Bundle SDL2 (copy the real dylib; IMPORTED_RUNTIME_ARTIFACTS keeps
# Homebrew symlinks and breaks the app inside a .dmg). Homebrew's
# sdl2-compat also loads SDL3 from @loader_path/libSDL3.dylib at runtime.
set(_bundle_expects_sdl3 FALSE)
set(_bundled_sdl2 FALSE)
if(TARGET SDL2::SDL2)
get_target_property(_sdl2_location SDL2::SDL2 IMPORTED_LOCATION)
if(NOT _sdl2_location)
get_target_property(_sdl2_location SDL2::SDL2 IMPORTED_LOCATION_RELEASE)
endif()
if(_sdl2_location AND _sdl2_location MATCHES "\\.framework(/|$)")
# Genuine upstream SDL2.framework (CI's setup-sdl2.sh, or a manual framework
# install): no SDL3 dlopen shim to worry about, and IMPORTED_RUNTIME_ARTIFACTS
# copies frameworks correctly — it's only the Homebrew dylib case below whose
# symlinks it mangles. This is what CI has shipped all along.
# Genuine upstream SDL2.framework (a manual framework install): no SDL3 dlopen
# shim to worry about, and IMPORTED_RUNTIME_ARTIFACTS copies frameworks
# correctly — it's only the dylib case below whose symlinks it mangles.
# CI no longer takes this path; setup-sdl.sh builds sdl2-compat + SDL3 instead.
message(STATUS "Bundling SDL2 framework: ${_sdl2_location}")
install(IMPORTED_RUNTIME_ARTIFACTS SDL2::SDL2
FRAMEWORK DESTINATION "${companion_app_dir}/Contents/Frameworks"
LIBRARY DESTINATION "${companion_app_dir}/Contents/Frameworks"
COMPONENT Runtime)
set(_bundled_sdl2 TRUE)
elseif(_sdl2_location)
message(STATUS "Bundling SDL2 library: ${_sdl2_location}")
set(_bundled_sdl2 TRUE)
get_filename_component(_sdl2_real "${_sdl2_location}" REALPATH)
get_filename_component(_sdl2_name "${_sdl2_location}" NAME)
install(FILES "${_sdl2_real}"
@@ -610,6 +619,10 @@ elseif(APPLE)
endif()
if(NOT _sdl3_real)
set(_sdl3_hints "")
# SDL3 usually lives right next to SDL2 (setup-sdl.sh installs both to one prefix) -
# check there before falling back to Homebrew heuristics.
get_filename_component(_sdl2_libdir "${_sdl2_location}" DIRECTORY)
list(APPEND _sdl3_hints "${_sdl2_libdir}")
if(DEFINED ENV{HOMEBREW_PREFIX})
list(APPEND _sdl3_hints
"$ENV{HOMEBREW_PREFIX}/opt/sdl3/lib"
@@ -645,8 +658,12 @@ elseif(APPLE)
DESTINATION "${companion_app_dir}/Contents/Frameworks"
RENAME "libSDL3.dylib"
COMPONENT Runtime)
set(_bundle_expects_sdl3 TRUE)
else()
message(WARNING "SDL3 not found; packaged Companion may crash if linked against sdl2-compat")
message(FATAL_ERROR
"SDL3 not found, but the bundled SDL2 (${_sdl2_location}) is sdl2-compat, which "
"dlopens libSDL3.dylib at runtime - Companion would not start without it. Install "
"SDL3, or point CMAKE_PREFIX_PATH/HOMEBREW_PREFIX at a prefix that has it.")
endif()
install(CODE "
@@ -673,6 +690,21 @@ elseif(APPLE)
else()
message(WARNING "SDL2::SDL2 has no IMPORTED_LOCATION; SDL2 will not be bundled")
endif()
# Point the executable at the SDL copies bundled above. qt_deploy_runtime_dependencies
# ran before they existed, and install_name_tool -id only fixes each dylib's own id, so
# nothing else guarantees this. Runs for both the framework and dylib branches, and must
# stay ahead of the re-sign below so the signature covers the rewritten load commands.
# Skipped when nothing was bundled - rewriting to Frameworks would then point the
# executable at files that are not there.
if(_bundled_sdl2)
configure_file(
"${COMPANION_TARGETS_DIR}/bundle_sdl_fixup.cmake.in"
"${CMAKE_CURRENT_BINARY_DIR}/bundle_sdl_fixup.cmake"
@ONLY
)
install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/bundle_sdl_fixup.cmake" COMPONENT Runtime)
endif()
endif()
# Re-sign bundle after all contents are installed
@@ -685,6 +717,17 @@ elseif(APPLE)
)
" COMPONENT Runtime)
# Last gate: a bundle that still points at /opt/homebrew or a build-machine Qt prefix
# starts on the machine that built it and nowhere else. Fail here rather than ship it.
if(COMPANION_VERIFY_BUNDLE)
configure_file(
"${COMPANION_TARGETS_DIR}/bundle_verify.cmake.in"
"${CMAKE_CURRENT_BINARY_DIR}/bundle_verify.cmake"
@ONLY
)
install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/bundle_verify.cmake" COMPONENT Runtime)
endif()
else()
# Detect architecture for linuxdeploy AppImage
if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
@@ -0,0 +1,65 @@
# Rewrite absolute SDL load commands in the Companion executable to the bundled copies.
#
# Configured from companion/targets/mac/bundle_sdl_fixup.cmake.in - do not edit the copy in
# the build tree.
#
# Companion used to run fixup_bundle, which walked every Mach-O in the bundle and rewrote all
# of their load commands in one pass. That was replaced by qt_deploy_runtime_dependencies,
# which runs *before* the SDL install(FILES) steps, and by an install_name_tool -id call that
# only fixes each dylib's own id. Nothing in that chain guarantees the executable actually
# references the SDL copies sitting in Contents/Frameworks, so make it explicit here.
set(_app "${CMAKE_INSTALL_PREFIX}/@companion_app_dir@")
set(_bin "${_app}/Contents/MacOS/@COMPANION_OSX_APP_BUNDLE_NAME@")
if(NOT EXISTS "${_bin}")
message(FATAL_ERROR "Companion executable not found for SDL fixup: ${_bin}")
endif()
execute_process(
COMMAND otool -L "${_bin}"
OUTPUT_VARIABLE _otool_out
ERROR_VARIABLE _otool_err
RESULT_VARIABLE _otool_res
)
if(NOT _otool_res EQUAL 0)
message(FATAL_ERROR "otool -L failed for ${_bin}: ${_otool_err}")
endif()
string(REPLACE "\n" ";" _lines "${_otool_out}")
foreach(_line IN LISTS _lines)
# dependency lines are tab-indented: "\t<path> (compatibility version ..., current ...)"
string(REGEX MATCH "^\t([^ ]+)" _matched "${_line}")
if(NOT _matched)
continue()
endif()
set(_ref "${CMAKE_MATCH_1}")
# @rpath / @executable_path / @loader_path already resolve inside the bundle
if(NOT _ref MATCHES "^/")
continue()
endif()
if(NOT _ref MATCHES "SDL2|SDL3")
continue()
endif()
# Keep the framework tail (SDL2.framework/Versions/A/SDL2) so the rewritten reference
# matches the layout install(IMPORTED_RUNTIME_ARTIFACTS) produced; plain dylibs land
# directly in Frameworks under their own name.
if(_ref MATCHES "/(SDL[23]\\.framework/.*)$")
set(_tail "${CMAKE_MATCH_1}")
else()
get_filename_component(_tail "${_ref}" NAME)
endif()
set(_new "@executable_path/../Frameworks/${_tail}")
message(STATUS "Rewriting SDL reference: ${_ref} -> ${_new}")
execute_process(
COMMAND install_name_tool -change "${_ref}" "${_new}" "${_bin}"
ERROR_VARIABLE _change_err
RESULT_VARIABLE _change_res
)
if(NOT _change_res EQUAL 0)
message(FATAL_ERROR "install_name_tool -change failed for ${_ref}: ${_change_err}")
endif()
endforeach()
@@ -0,0 +1,92 @@
# Verify the installed Companion .app carries no references to libraries outside itself.
#
# Configured from companion/targets/mac/bundle_verify.cmake.in - do not edit the copy in the
# build tree.
#
# A .dmg that links against /opt/homebrew, /usr/local/Cellar or a build-machine Qt prefix
# starts fine on the machine that built it and fails everywhere else, which is exactly how the
# sdl2-compat breakage reached users. Catch it at install time instead.
#
# Disable with -DCOMPANION_VERIFY_BUNDLE=OFF; widen with -DCOMPANION_VERIFY_BUNDLE_ALLOW=<regex>.
set(_app "${CMAKE_INSTALL_PREFIX}/@companion_app_dir@")
set(_allow "@COMPANION_VERIFY_BUNDLE_ALLOW@")
set(_expect_sdl3 "@_bundle_expects_sdl3@")
if(NOT IS_DIRECTORY "${_app}")
message(FATAL_ERROR "Bundle not found for verification: ${_app}")
endif()
# sdl2-compat dlopens "@loader_path/libSDL3.dylib" - invisible to otool, so check by hand
if(_expect_sdl3 AND NOT EXISTS "${_app}/Contents/Frameworks/libSDL3.dylib")
message(FATAL_ERROR
"Contents/Frameworks/libSDL3.dylib is missing. Companion was linked against "
"sdl2-compat, which loads SDL3 at runtime, so the bundle will not start.")
endif()
file(GLOB_RECURSE _candidates "${_app}/Contents/*")
set(_offenders "")
set(_checked 0)
foreach(_file IN LISTS _candidates)
if(IS_SYMLINK "${_file}" OR IS_DIRECTORY "${_file}")
continue()
endif()
execute_process(
COMMAND otool -L "${_file}"
OUTPUT_VARIABLE _otool_out
ERROR_VARIABLE _otool_err
RESULT_VARIABLE _otool_res
)
# non-Mach-O files (plists, icons, translations) simply fail the scan - skip them
if(NOT _otool_res EQUAL 0 OR _otool_out MATCHES "is not an object file")
continue()
endif()
math(EXPR _checked "${_checked} + 1")
string(REPLACE "\n" ";" _lines "${_otool_out}")
foreach(_line IN LISTS _lines)
string(REGEX MATCH "^\t([^ ]+)" _matched "${_line}")
if(NOT _matched)
continue()
endif()
set(_ref "${CMAKE_MATCH_1}")
if(NOT _ref MATCHES "^/")
continue() # @rpath / @executable_path / @loader_path resolve inside the bundle
endif()
if(_ref MATCHES "^/usr/lib/" OR _ref MATCHES "^/System/")
continue() # shipped with macOS
endif()
if(_allow AND _ref MATCHES "${_allow}")
continue()
endif()
file(RELATIVE_PATH _rel "${_app}" "${_file}")
list(APPEND _offenders "${_rel}: ${_ref}")
endforeach()
endforeach()
if(_offenders)
list(REMOVE_DUPLICATES _offenders)
string(REPLACE ";" "\n " _report "${_offenders}")
message(FATAL_ERROR
"Bundle references libraries outside itself - this .dmg would not start on another "
"machine:\n ${_report}\n"
"Re-run with -DCOMPANION_VERIFY_BUNDLE=OFF to package anyway.")
endif()
message(STATUS "Bundle verification: ${_checked} Mach-O files, no external references")
# The bundle seal is a separate concern from the per-Mach-O signatures: dyld does not consult
# CodeResources when loading, so a stale seal does not stop the app launching. It would matter
# for Gatekeeper/notarization, so report it - but do not fail a build over it.
execute_process(
COMMAND codesign --verify --deep --strict --verbose=2 "${_app}"
ERROR_VARIABLE _codesign_err
RESULT_VARIABLE _codesign_res
)
if(NOT _codesign_res EQUAL 0)
message(WARNING "Bundle signature is not valid (not fatal): ${_codesign_err}")
endif()
+13 -1
View File
@@ -164,7 +164,19 @@ Open it and drag **EdgeTX Companion** into `/Applications`:
open native/edgetx-companion-*.dmg
```
The packaging step bundles Qt, SDL2, and SDL3 into the `.app` automatically.
The packaging step bundles Qt, SDL2, and SDL3 into the `.app` automatically, then verifies that
nothing in the bundle still points at a library outside it. If that check fails, the build stops
rather than producing a `.dmg` that only runs on your machine — most often because a dependency
was picked up from Homebrew without being copied in. Pass `-DCOMPANION_VERIFY_BUNDLE=OFF` to
package anyway.
!!! note
A locally built `.dmg` is not what CI ships. Homebrew's bottles are single-architecture and
target your macOS version, so your build is arm64-only and requires the macOS you built it
on. CI builds sdl2-compat and SDL3 from source as universal (`x86_64` + `arm64`) binaries
targeting macOS 11.0 — see `.github/actions/build_companion/setup-sdl.sh`. Both use the same
sdl2-compat + SDL3 stack, so the code path is identical; only the slices and minimum OS
differ.
# Troubleshooting
+7
View File
@@ -34,6 +34,13 @@ if [ "$(uname)" = "Darwin" ]; then
COMMON_OPTIONS="${COMMON_OPTIONS} -DCMAKE_OSX_DEPLOYMENT_TARGET='11.0'"
fi
# find_package(... CONFIG) skips the lib/cmake/<Name> search pattern for prefixes that only
# come from the CMAKE_PREFIX_PATH env var (not -D) - forward it so CI's source-built SDL3 is
# actually found instead of silently missing from the bundle.
if [[ -n "${CMAKE_PREFIX_PATH:-}" ]]; then
COMMON_OPTIONS="${COMMON_OPTIONS} -DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}"
fi
# Generate EDGETX_VERSION_SUFFIX if not already set
if [[ -z ${EDGETX_VERSION_SUFFIX} ]]; then
gh_type=$(echo "$GITHUB_REF" | awk -F / '{print $2}') #heads|tags|pull