diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml index f82f33ea2..3be3d4305 100644 --- a/.github/workflows/build_osx.yml +++ b/.github/workflows/build_osx.yml @@ -89,7 +89,7 @@ jobs: -B "${{ github.workspace }}/cmake-build-cxx-api" \ -DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}" \ -DCMAKE_PREFIX_PATH="${{ github.workspace }}/install" - + cmake --build "${{ github.workspace }}/cmake-build-cxx-api" --config "Debug" cmake --build "${{ github.workspace }}/cmake-build-cxx-api" --config "Release" @@ -98,3 +98,73 @@ jobs: with: name: projectm-osx-${{ matrix.libs }}-${{ matrix.fslib }}-${{ matrix.arch }}-${{ matrix.runs-on }} path: install/* + + build-framework: + name: "Framework: ${{ matrix.cxx_interface && 'C + C++' || 'C only' }}, Arch: ${{ matrix.arch }}, Build OS: ${{ matrix.runs-on }}" + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + arch: ['arm64', 'x86_64'] + cxx_interface: [true, false] + runs-on: ['macos-15', 'macos-15-intel'] + exclude: + - arch: arm64 + runs-on: macos-15-intel + - arch: x86_64 + runs-on: macos-15 + + steps: + - name: Install Packages + run: brew install ninja + + - uses: actions/checkout@v4 + with: + submodules: 'recursive' + + - name: Configure Framework Build + run: | + if [ "${{ matrix.cxx_interface }}" == "true" ]; then + cxx_iface=ON + else + cxx_iface=OFF + fi + cmake -G "Ninja Multi-Config" \ + -S "${{ github.workspace }}" \ + -B "${{ github.workspace }}/cmake-build" \ + -DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install" \ + -DCMAKE_VERBOSE_MAKEFILE=YES \ + -DBUILD_SHARED_LIBS=ON \ + -DENABLE_MACOS_FRAMEWORK=ON \ + -DENABLE_CXX_INTERFACE="${cxx_iface}" \ + -DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}" + + - name: Build Release + run: cmake --build "${{ github.workspace }}/cmake-build" --config "Release" --parallel + + - name: Validate Frameworks + run: | + STRICT=1 bash "${{ github.workspace }}/scripts/test-macos-framework.sh" \ + "${{ github.workspace }}/cmake-build" + + - name: Install + run: cmake --build "${{ github.workspace }}/cmake-build" --config "Release" --target install + + - name: Verify Installed Frameworks + run: | + echo "--- Checking installed framework structure ---" + ls -la "${{ github.workspace }}/install/lib/" + # Verify frameworks were installed (not bare dylibs) + test -d "${{ github.workspace }}/install/lib/projectM-4.framework" \ + || { echo "FAIL: projectM-4.framework not installed"; exit 1; } + test -d "${{ github.workspace }}/install/lib/projectM-4-playlist.framework" \ + || { echo "FAIL: projectM-4-playlist.framework not installed"; exit 1; } + # Run the same validation on installed frameworks + STRICT=1 bash "${{ github.workspace }}/scripts/test-macos-framework.sh" \ + "${{ github.workspace }}/install" + + - name: Upload Framework Artifact + uses: actions/upload-artifact@v4 + with: + name: projectm-osx-framework-${{ matrix.cxx_interface && 'cxx' || 'c-only' }}-${{ matrix.arch }}-${{ matrix.runs-on }} + path: install/* diff --git a/scripts/test-macos-framework.sh b/scripts/test-macos-framework.sh index f665c0fab..73884b105 100755 --- a/scripts/test-macos-framework.sh +++ b/scripts/test-macos-framework.sh @@ -2,14 +2,25 @@ # test-macos-framework.sh # CI test script for validating macOS framework builds # -# Usage: ./scripts/test-macos-framework.sh +# Usage: ./scripts/test-macos-framework.sh [...] +# +# Searches the given directories for projectM-4.framework and +# projectM-4-playlist.framework. Works with both build trees +# (where frameworks are in src/libprojectM/ and src/playlist/) +# and install prefixes (where frameworks are in lib/). +# +# Environment: +# STRICT=1 (default) SKIPs become FAILs - use for CI +# STRICT=0 SKIPs are warnings - use for local testing # # This script validates: -# 1. Framework directory structure -# 2. Header completeness -# 3. Linkability (compile and link a test program) +# 1. Framework directory structure (symlinks, directories, Info.plist) +# 2. Header completeness (every expected header must be present) +# 3. No unexpected headers (catch stale or misplaced files) +# 4. Linkability (compile and link a test program against framework) +# 5. dylib identity (install name matches framework convention) -set -e +set -euo pipefail # Cleanup temp files on exit TEMP_FILES=() @@ -20,19 +31,46 @@ cleanup() { } trap cleanup EXIT -BUILD_DIR="${1:-.}" +SEARCH_DIRS=("${@:-.}") +STRICT="${STRICT:-1}" # In strict mode, SKIPs become FAILs + +# Find a framework by name in the search directories +find_framework() { + local name="$1" + for dir in "${SEARCH_DIRS[@]}"; do + # Check common locations + for candidate in \ + "$dir/$name.framework" \ + "$dir/src/libprojectM/$name.framework" \ + "$dir/src/playlist/$name.framework" \ + "$dir/lib/$name.framework"; do + if [ -d "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + done + return 1 +} echo "=== macOS Framework CI Tests ===" -echo "Build directory: $BUILD_DIR" +echo "Search directories: ${SEARCH_DIRS[*]}" +echo "Strict mode: $STRICT" echo "" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' +YELLOW='\033[0;33m' NC='\033[0m' # No Color +TESTS_RUN=0 +TESTS_PASSED=0 + pass() { echo -e "${GREEN}PASS${NC}: $1" + TESTS_RUN=$((TESTS_RUN + 1)) + TESTS_PASSED=$((TESTS_PASSED + 1)) } fail() { @@ -40,6 +78,14 @@ fail() { exit 1 } +skip() { + if [ "$STRICT" = "1" ]; then + fail "SKIP not allowed in strict mode: $1" + else + echo -e "${YELLOW}SKIP${NC}: $1" + fi +} + # Test 1: Framework Structure Validation test_structure() { local framework_path="$1" @@ -55,20 +101,41 @@ test_structure() { [ -d "$framework_path/Versions/A" ] || fail "Missing Versions/A directory" [ -L "$framework_path/Versions/Current" ] || fail "Missing Versions/Current symlink" + # Verify Current symlink target + local current_target + current_target=$(readlink "$framework_path/Versions/Current") + [ "$current_target" = "A" ] || fail "Versions/Current symlink points to '$current_target', expected 'A'" + # Check Version A contents [ -f "$framework_path/Versions/A/$framework_name" ] || fail "Missing binary: Versions/A/$framework_name" [ -d "$framework_path/Versions/A/Headers" ] || fail "Missing Headers directory" [ -d "$framework_path/Versions/A/Resources" ] || fail "Missing Resources directory" [ -f "$framework_path/Versions/A/Resources/Info.plist" ] || fail "Missing Info.plist" - # Check top-level symlinks + # Validate Info.plist has correct bundle executable name + local bundle_exec + bundle_exec=$(/usr/libexec/PlistBuddy -c "Print :CFBundleExecutable" "$framework_path/Versions/A/Resources/Info.plist" 2>/dev/null || true) + [ "$bundle_exec" = "$framework_name" ] || fail "Info.plist CFBundleExecutable is '$bundle_exec', expected '$framework_name'" + + # Check top-level symlinks exist and are symlinks (not copies) [ -L "$framework_path/$framework_name" ] || fail "Missing top-level binary symlink" [ -L "$framework_path/Headers" ] || fail "Missing top-level Headers symlink" [ -L "$framework_path/Resources" ] || fail "Missing top-level Resources symlink" # Verify symlinks point to correct locations - local binary_target=$(readlink "$framework_path/$framework_name") - [ "$binary_target" = "Versions/Current/$framework_name" ] || fail "Binary symlink points to wrong location: $binary_target" + local binary_target headers_target resources_target + binary_target=$(readlink "$framework_path/$framework_name") + headers_target=$(readlink "$framework_path/Headers") + resources_target=$(readlink "$framework_path/Resources") + [ "$binary_target" = "Versions/Current/$framework_name" ] || fail "Binary symlink points to '$binary_target', expected 'Versions/Current/$framework_name'" + [ "$headers_target" = "Versions/Current/Headers" ] || fail "Headers symlink points to '$headers_target', expected 'Versions/Current/Headers'" + [ "$resources_target" = "Versions/Current/Resources" ] || fail "Resources symlink points to '$resources_target', expected 'Versions/Current/Resources'" + + # Verify the binary is a Mach-O dylib + local file_type + file_type=$(file -b "$framework_path/Versions/A/$framework_name") + echo "$file_type" | grep -q "Mach-O" || fail "Binary is not Mach-O: $file_type" + echo "$file_type" | grep -qi "dynamically linked\|dynamic library" || fail "Binary is not a dynamic library: $file_type" pass "Structure validation for $framework_name.framework" } @@ -88,15 +155,25 @@ test_headers() { local headers_dir="$framework_path/Headers/$header_subdir" [ -d "$headers_dir" ] || fail "Headers directory does not exist: $headers_dir" + # Check each expected header is present for header in "${expected_headers[@]}"; do - [ -f "$headers_dir/$header" ] || fail "Missing header: $header" + [ -f "$headers_dir/$header" ] || fail "Missing expected header: $header_subdir/$header" done - # Count total headers found - local header_count=$(find "$headers_dir" -name "*.h" -o -name "*.hpp" | wc -l | tr -d ' ') - echo " Found $header_count header files" + # Check for unexpected headers (catches stale files or wrong headers being copied) + local actual_count expected_count + actual_count=$(find "$headers_dir" -name "*.h" -o -name "*.hpp" | wc -l | tr -d ' ') + expected_count=${#expected_headers[@]} + echo " Found $actual_count header files (expected $expected_count)" + if [ "$actual_count" -ne "$expected_count" ]; then + echo " Expected headers:" + for h in "${expected_headers[@]}"; do echo " $h"; done + echo " Actual headers:" + find "$headers_dir" \( -name "*.h" -o -name "*.hpp" \) -exec basename {} \; | sort | while read -r h; do echo " $h"; done + fail "Header count mismatch: found $actual_count, expected $expected_count" + fi - pass "Header validation for $framework_name.framework" + pass "Header validation for $framework_name.framework ($actual_count headers)" } # Test 3: Linkability Test @@ -107,12 +184,14 @@ test_linkability() { echo "--- Testing linkability: $framework_name.framework ---" - local framework_parent=$(dirname "$framework_path") + local framework_parent + framework_parent=$(dirname "$framework_path") # Headers are in Headers// to support include patterns like # #include , so we add -I Framework.framework/Headers local include_path="$framework_path/Headers" - local test_file=$(mktemp -t framework_test).c - local test_output=$(mktemp -t framework_test) + local test_file test_output + test_file=$(mktemp -t framework_test).c + test_output=$(mktemp -t framework_test) TEMP_FILES+=("$test_file" "$test_output") echo "$test_code" > "$test_file" @@ -126,26 +205,74 @@ test_linkability() { fi } +# Test 4: dylib install name check +test_install_name() { + local framework_path="$1" + local framework_name="$2" + + echo "--- Testing install name: $framework_name.framework ---" + + local binary="$framework_path/Versions/A/$framework_name" + local install_name + install_name=$(otool -D "$binary" 2>/dev/null | tail -1) + + # For a framework, the install name should contain the framework name + if echo "$install_name" | grep -q "$framework_name"; then + echo " Install name: $install_name" + pass "Install name check for $framework_name.framework" + else + echo " Install name: $install_name" + fail "Install name does not reference framework name '$framework_name'" + fi +} + +# ============================================================================ # Main tests +# ============================================================================ # projectM-4.framework -PROJECTM_FRAMEWORK="$BUILD_DIR/src/libprojectM/projectM-4.framework" -if [ -d "$PROJECTM_FRAMEWORK" ]; then +PROJECTM_FRAMEWORK=$(find_framework "projectM-4" || true) +if [ -n "$PROJECTM_FRAMEWORK" ] && [ -d "$PROJECTM_FRAMEWORK" ]; then + echo "Found: $PROJECTM_FRAMEWORK" test_structure "$PROJECTM_FRAMEWORK" "projectM-4" - # Expected C API headers + # All expected C API headers (must be exhaustive) PROJECTM_HEADERS=( "projectM.h" "audio.h" + "callbacks.h" "core.h" - "types.h" + "debug.h" + "logging.h" + "memory.h" "parameters.h" + "render_opengl.h" + "touch.h" + "types.h" + "user_sprites.h" "projectM_export.h" "version.h" ) + # C++ headers are present when built with ENABLE_CXX_INTERFACE + # Check if any C++ headers exist to determine if we should validate them + if [ -d "$PROJECTM_FRAMEWORK/Headers/projectM-4/Audio" ]; then + PROJECTM_HEADERS+=( + "projectM_cxx_export.h" + "Logging.hpp" + "ProjectM.hpp" + "Audio/AudioConstants.hpp" + "Audio/FrameAudioData.hpp" + "Audio/Loudness.hpp" + "Audio/MilkdropFFT.hpp" + "Audio/PCM.hpp" + "Audio/WaveformAligner.hpp" + "Renderer/RenderContext.hpp" + "Renderer/TextureTypes.hpp" + ) + fi test_headers "$PROJECTM_FRAMEWORK" "projectM-4" "projectM-4" "${PROJECTM_HEADERS[@]}" - # Linkability test - use the full include path to match existing header patterns + # Linkability test with the C API PROJECTM_TEST_CODE=' #include int main() { @@ -155,40 +282,44 @@ int main() { } ' test_linkability "$PROJECTM_FRAMEWORK" "projectM-4" "$PROJECTM_TEST_CODE" + test_install_name "$PROJECTM_FRAMEWORK" "projectM-4" else - echo "SKIP: projectM-4.framework not found at $PROJECTM_FRAMEWORK" + skip "projectM-4.framework not found in search directories" fi echo "" # projectM-4-playlist.framework -PLAYLIST_FRAMEWORK="$BUILD_DIR/src/playlist/projectM-4-playlist.framework" -if [ -d "$PLAYLIST_FRAMEWORK" ]; then +PLAYLIST_FRAMEWORK=$(find_framework "projectM-4-playlist" || true) +if [ -n "$PLAYLIST_FRAMEWORK" ] && [ -d "$PLAYLIST_FRAMEWORK" ]; then + echo "Found: $PLAYLIST_FRAMEWORK" test_structure "$PLAYLIST_FRAMEWORK" "projectM-4-playlist" - # Expected headers (in projectM-4/ subdir to match include pattern) + # All expected playlist headers (must be exhaustive) PLAYLIST_HEADERS=( "playlist.h" + "playlist_callbacks.h" "playlist_core.h" + "playlist_filter.h" + "playlist_items.h" + "playlist_memory.h" + "playlist_playback.h" "playlist_types.h" "projectM_playlist_export.h" ) test_headers "$PLAYLIST_FRAMEWORK" "projectM-4-playlist" "projectM-4" "${PLAYLIST_HEADERS[@]}" # Linkability test (needs projectM framework too) - # Playlist headers use projectM-4/ prefix to match the main library PLAYLIST_TEST_CODE=' #include int main() { return 0; } ' - # For playlist, we need both frameworks if [ -d "$PROJECTM_FRAMEWORK" ]; then echo "--- Testing linkability: projectM-4-playlist.framework ---" framework_parent=$(dirname "$PLAYLIST_FRAMEWORK") projectm_parent=$(dirname "$PROJECTM_FRAMEWORK") - # Include paths for both frameworks include_path="$PLAYLIST_FRAMEWORK/Headers" projectm_include_path="$PROJECTM_FRAMEWORK/Headers" test_file=$(mktemp -t framework_test).c @@ -206,10 +337,14 @@ int main() { echo "$compile_errors" fail "Failed to compile and link against projectM-4-playlist.framework" fi + else + skip "Cannot test playlist linkability without projectM-4.framework" fi + + test_install_name "$PLAYLIST_FRAMEWORK" "projectM-4-playlist" else - echo "SKIP: projectM-4-playlist.framework not found at $PLAYLIST_FRAMEWORK" + skip "projectM-4-playlist.framework not found in search directories" fi echo "" -echo "=== All framework tests passed ===" +echo "=== All framework tests passed ($TESTS_PASSED/$TESTS_RUN) ==="