Add framework CI jobs and harden test script

New CI job matrix (build-framework):
- Tests framework builds on both arm64 and x86_64
- Tests with and without C++ interface
- Runs strict validation after build AND after install
- Verifies installed frameworks match build output

Test script improvements:
- Exhaustive header lists (all C API + all C++ headers)
- Exact header count validation (catches stale/unexpected files)
- Strict mode (STRICT=1) where SKIPs become FAILs
- Info.plist CFBundleExecutable validation
- Symlink target verification (Current, Headers, Resources)
- Mach-O dylib binary type check
- dylib install name validation
- Flexible framework search across build tree and install prefix
- Test pass counter in summary
This commit is contained in:
Mischa
2026-02-02 21:47:29 -08:00
parent c515b7ecea
commit ec8cdbda40
2 changed files with 238 additions and 33 deletions

View File

@ -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/*

View File

@ -2,14 +2,25 @@
# test-macos-framework.sh
# CI test script for validating macOS framework builds
#
# Usage: ./scripts/test-macos-framework.sh <build-dir>
# Usage: ./scripts/test-macos-framework.sh <dir> [<dir>...]
#
# 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/<framework-name>/ to support include patterns like
# #include <projectM-4/projectM.h>, 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 <projectM-4/projectM.h>
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 <projectM-4/playlist.h>
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) ==="