Files
CI/.github/workflows/build-macos.yml
T
2026-07-03 20:58:21 +08:00

449 lines
19 KiB
YAML

name: Build Citron (macOS)
concurrency:
group: build-macos-nightly-${{ github.ref }}
cancel-in-progress: true
on:
workflow_dispatch: {}
jobs:
check-version:
name: Check if new version available
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.compare.outputs.should_build }}
new_hash: ${{ steps.upstream.outputs.hash_short }}
new_hash_full: ${{ steps.upstream.outputs.hash_full }}
steps:
- name: Checkout this repo
uses: actions/checkout@v4
- name: Get upstream commit hash
id: upstream
run: |
REPO_URL="https://github.com/citron-neo/emulator.git"
FULL_HASH=$(git ls-remote "$REPO_URL" HEAD | cut -f1)
SHORT_HASH=$(echo "$FULL_HASH" | cut -c1-7)
echo "hash_short=$SHORT_HASH" >> $GITHUB_OUTPUT
echo "hash_full=$FULL_HASH" >> $GITHUB_OUTPUT
- name: Get last built version
id: last_built
run: |
LAST_HASH=$(cat LATEST_VERSION_MACOS 2>/dev/null | tr -d '\n' || echo "none")
echo "last_hash=$LAST_HASH" >> $GITHUB_OUTPUT
- name: Check if release exists
id: release_check
run: |
if gh release view nightly-macos --repo "${{ github.repository }}" > /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- name: Compare versions
id: compare
run: |
if [ "${{ steps.upstream.outputs.hash_short }}" != "${{ steps.last_built.outputs.last_hash }}" ]; then
echo "should_build=true" >> $GITHUB_OUTPUT
elif [ "${{ steps.release_check.outputs.exists }}" != "true" ]; then
echo "should_build=true" >> $GITHUB_OUTPUT
else
echo "should_build=false" >> $GITHUB_OUTPUT
fi
build:
name: "Citron Build (macOS)"
needs: [check-version]
if: needs.check-version.outputs.should_build == 'true'
runs-on: macos-latest
timeout-minutes: 180
steps:
- name: Checkout this repo
uses: actions/checkout@v4
- name: Install System Dependencies via Homebrew
run: |
brew install ninja boost catch2 cmake enet fmt ffmpeg glslang hidapi libvpx lld llvm nasm nlohmann-json openal-soft sdl2 sdl3 molten-vk vulkan-headers vulkan-loader webp lz4 zstd openssl@3 pkg-config libusb opus python@3.12
- name: Clone Citron Source
shell: bash
run: |
git config --global http.version HTTP/1.1
git config --global http.userAgent "git/2.39.3 (AppleGit-146)"
ATTEMPT=0
MAX_ATTEMPTS=10
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
ATTEMPT=$((ATTEMPT + 1))
echo "Attempt $ATTEMPT of $MAX_ATTEMPTS: Cloning repository..."
rm -rf citron
if git clone --recursive "https://github.com/citron-neo/emulator.git" citron; then
echo "āœ… Clone successful on attempt $ATTEMPT."
break
fi
echo "āš ļø Clone failed. Waiting 30 seconds before retry..."
sleep 30
done
if [ ! -d citron ]; then
echo "āŒ Failed to clone after $MAX_ATTEMPTS attempts."
exit 1
fi
- name: Initialize Submodules
working-directory: ./citron
run: |
git submodule sync --recursive
git submodule update --init --recursive --force
- name: Patch AGL in Qt FindWrapOpenGL.cmake
working-directory: ./citron
run: |
set -e
QT_OPENGL_CMAKE=$(find externals/qt -type f -path "*/macos/lib/cmake/Qt6/FindWrapOpenGL.cmake" | sort -V | tail -n 1)
if [ -n "$QT_OPENGL_CMAKE" ] && [ -f "$QT_OPENGL_CMAKE" ]; then
echo "Patching: $QT_OPENGL_CMAKE"
awk 'NR>=40 && NR<=46 {if(NR==40) print " set(__opengl_agl_fw_path \"\")"; next}1' "$QT_OPENGL_CMAKE" > "$QT_OPENGL_CMAKE.tmp" && mv "$QT_OPENGL_CMAKE.tmp" "$QT_OPENGL_CMAKE"
else
echo "Warning: FindWrapOpenGL.cmake not found under externals/qt, skipping AGL patch"
fi
- name: Get Nightly Version
id: version
working-directory: ./citron
run: |
git fetch --tags
VERSION=$(git rev-parse --short HEAD)
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
- name: Set up Python 3.12 venv
working-directory: ./citron
run: |
python3.12 -m venv venv
source venv/bin/activate
pip install jsonschema jinja2
echo "VIRTUAL_ENV=$PWD/venv" >> $GITHUB_ENV
echo "$PWD/venv/bin" >> $GITHUB_PATH
- name: Configure CMake
working-directory: ./citron
run: |
OPENAL_DIR="$(brew --prefix openal-soft)"
SDL2_DIR="$(brew --prefix sdl2)"
cmake -B build -S . -G "Ninja" \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_PREFIX_PATH="$OPENAL_DIR;$SDL2_DIR" \
-DCITRON_BUILD_TYPE=Release \
-DCITRON_USE_FASTER_LD=OFF \
-DCITRON_USE_BUNDLED_VCPKG=OFF \
-DENABLE_QT=ON \
-DCITRON_ENABLE_LTO=ON \
-DCITRON_TESTS=OFF \
-DUSE_DISCORD_PRESENCE=ON \
-DENABLE_WEB_SERVICE=ON \
-DENABLE_OPENSSL=ON \
-DENABLE_SDL2=ON \
-DCITRON_USE_EXTERNAL_SDL2=OFF
- name: Build Citron
working-directory: ./citron
run: cmake --build build --config Release
- name: Package macOS Build
run: |
set -e
mkdir -p dist
APP_BUNDLE_PATH=$(find citron/build -name "citron.app" | head -n 1)
if [ -z "$APP_BUNDLE_PATH" ]; then
echo "::error::Could not find citron.app bundle after build!"
exit 1
fi
echo "Found .app bundle at: $APP_BUNDLE_PATH"
QT_MACOS_ROOT=$(find citron/build/externals/qt -maxdepth 3 -type d -path "*/macos" | sort -V | tail -n 1)
if [ -z "$QT_MACOS_ROOT" ]; then
echo "::error::Could not find Qt macOS root under citron/build/externals/qt"
exit 1
fi
echo "Using Qt root: $QT_MACOS_ROOT"
MACDEPLOYQT_PATH="$QT_MACOS_ROOT/bin/macdeployqt"
if [ ! -f "$MACDEPLOYQT_PATH" ]; then
MACDEPLOYQT_PATH="$QT_MACOS_ROOT/bin/macdeployqt6"
fi
QT_LIB_PATH="$QT_MACOS_ROOT/lib"
if [ ! -f "$MACDEPLOYQT_PATH" ]; then
echo "::error::Could not find macdeployqt or macdeployqt6 in $QT_MACOS_ROOT/bin"
exit 1
fi
"$MACDEPLOYQT_PATH" "$APP_BUNDLE_PATH" -libpath="$QT_LIB_PATH" -verbose=1 -always-overwrite
FRAMEWORKS_PATH="$APP_BUNDLE_PATH/Contents/Frameworks"
EXECUTABLE_PATH="$APP_BUNDLE_PATH/Contents/MacOS/citron"
mkdir -p "$FRAMEWORKS_PATH"
# Create Vulkan ICD json to ensure bundled MoltenVK is used (not homebrew's)
echo "Setting up Vulkan ICD for bundled MoltenVK..."
VULKAN_ICD_DIR="$APP_BUNDLE_PATH/Contents/Resources/vulkan/icd.d"
mkdir -p "$VULKAN_ICD_DIR"
cat > "$VULKAN_ICD_DIR/MoltenVK_icd.json" << 'ICDEOF'
{
"file_format_version": "1.0.0",
"ICD": {
"library_path": "../../../Frameworks/libMoltenVK.dylib",
"api_version": "1.4.0",
"is_portability_driver": true
}
}
ICDEOF
echo "Created MoltenVK ICD at $VULKAN_ICD_DIR/MoltenVK_icd.json"
# Copy missing transitive dependencies (like libsharpyuv from webp, liblcms2 from jxl).
# Scan the main executable too; macdeployqt can miss non-Qt Homebrew libraries linked by citron.
echo "Checking for missing transitive dependencies..."
find_bundled_binaries() {
printf '%s\n' "$EXECUTABLE_PATH"
find "$FRAMEWORKS_PATH" -name "*.dylib" -type f | while read -r dylib; do
printf '%s\n' "$dylib"
done
find "$FRAMEWORKS_PATH" -name "*.framework" -type d | while read -r framework_dir; do
framework_name=$(basename "$framework_dir" .framework)
framework_binary="$framework_dir/Versions/A/$framework_name"
[ ! -f "$framework_binary" ] && framework_binary="$framework_dir/$framework_name"
[ -f "$framework_binary" ] && printf '%s\n' "$framework_binary"
done
}
copy_deps_for_binary() {
local binary="$1"
[ -f "$binary" ] || return 0
# Handle @rpath/ references
otool -L "$binary" 2>/dev/null | grep -E "^\s+@rpath/" | awk '{print $1}' | sed 's|@rpath/||' | while read -r dep; do
if [ ! -f "$FRAMEWORKS_PATH/$dep" ]; then
HOMEBREW_LIB=$(find /opt/homebrew/lib /opt/homebrew/Cellar /usr/local/lib -name "$dep" 2>/dev/null | head -1)
if [ -n "$HOMEBREW_LIB" ] && [ -f "$HOMEBREW_LIB" ]; then
echo "Copying missing @rpath dependency for $(basename "$binary"): $dep"
cp "$HOMEBREW_LIB" "$FRAMEWORKS_PATH/"
chmod u+w "$FRAMEWORKS_PATH/$dep"
install_name_tool -id "@rpath/$dep" "$FRAMEWORKS_PATH/$dep" 2>/dev/null || true
fi
fi
done
# Handle absolute homebrew path references (critical for portability!)
otool -L "$binary" 2>/dev/null | grep -E "^\s+(/opt/homebrew|/usr/local)" | awk '{print $1}' | while read -r abs_dep; do
dep_name=$(basename "$abs_dep")
if [ ! -f "$FRAMEWORKS_PATH/$dep_name" ] && [ -f "$abs_dep" ]; then
echo "Copying missing absolute-path dependency for $(basename "$binary"): $dep_name (from $abs_dep)"
cp "$abs_dep" "$FRAMEWORKS_PATH/"
chmod u+w "$FRAMEWORKS_PATH/$dep_name"
install_name_tool -id "@rpath/$dep_name" "$FRAMEWORKS_PATH/$dep_name" 2>/dev/null || true
fi
done
}
copy_missing_deps() {
find_bundled_binaries | while read -r binary; do
copy_deps_for_binary "$binary"
done
}
# Run multiple passes to catch nested dependencies
for i in 1 2 3 4 5; do
echo "Pass $i: checking dependencies..."
copy_missing_deps
done
install_name_tool -add_rpath "@executable_path/../Frameworks" "$EXECUTABLE_PATH" 2>/dev/null || true
# Remove all hardcoded homebrew/system rpaths from executable - CRITICAL for portability
echo "Removing hardcoded rpaths from executable..."
otool -l "$EXECUTABLE_PATH" | grep -A2 LC_RPATH | grep "path " | awk '{print $2}' | while read -r rpath; do
case "$rpath" in
@executable_path*|@loader_path*|@rpath*)
echo "Keeping relative rpath: $rpath"
;;
*)
echo "Removing absolute rpath: $rpath"
install_name_tool -delete_rpath "$rpath" "$EXECUTABLE_PATH" 2>/dev/null || true
;;
esac
done
find "$FRAMEWORKS_PATH" -name "*.dylib" -type f | while read -r dylib; do
dylib_name=$(basename "$dylib")
install_name_tool -id "@rpath/$dylib_name" "$dylib" 2>/dev/null || true
# Rewrite absolute paths to @rpath
otool -L "$dylib" 2>/dev/null | grep -E "^\s+/" | awk '{print $1}' | while read -r dep; do
dep_name=$(basename "$dep")
if [ -f "$FRAMEWORKS_PATH/$dep_name" ]; then
install_name_tool -change "$dep" "@rpath/$dep_name" "$dylib" 2>/dev/null || true
fi
done
# Remove hardcoded rpaths from dylibs - CRITICAL for portability
otool -l "$dylib" 2>/dev/null | grep -A2 LC_RPATH | grep "path " | awk '{print $2}' | while read -r rpath; do
case "$rpath" in
@executable_path*|@loader_path*|@rpath*)
;;
*)
echo "Removing absolute rpath from $dylib_name: $rpath"
install_name_tool -delete_rpath "$rpath" "$dylib" 2>/dev/null || true
;;
esac
done
# Add @loader_path rpath so dylibs can find their sibling dylibs
install_name_tool -add_rpath "@loader_path" "$dylib" 2>/dev/null || true
done
otool -L "$EXECUTABLE_PATH" 2>/dev/null | grep -E "^\s+/" | awk '{print $1}' | while read -r dep; do
dep_name=$(basename "$dep")
if [ -f "$FRAMEWORKS_PATH/$dep_name" ]; then
install_name_tool -change "$dep" "@rpath/$dep_name" "$EXECUTABLE_PATH" 2>/dev/null || true
fi
done
find "$FRAMEWORKS_PATH" -name "*.framework" -type d | while read -r framework_dir; do
framework_name=$(basename "$framework_dir" .framework)
framework_binary="$framework_dir/Versions/A/$framework_name"
[ ! -f "$framework_binary" ] && framework_binary="$framework_dir/$framework_name"
[ -f "$framework_binary" ] && install_name_tool -id "@rpath/$framework_name.framework/Versions/A/$framework_name" "$framework_binary" 2>/dev/null || true
done
find_bundled_binaries | while read -r binary; do
otool -L "$binary" 2>/dev/null | grep -E "^\s+/" | awk '{print $1}' | while read -r dep; do
dep_name=$(basename "$dep")
if [ -f "$FRAMEWORKS_PATH/$dep_name" ]; then
install_name_tool -change "$dep" "@rpath/$dep_name" "$binary" 2>/dev/null || true
fi
done
done
# Some SDL3 consumers load "libSDL3.dylib" dynamically at runtime, so it will not
# necessarily appear in otool output. Homebrew/macdeployqt may only leave
# "libSDL3.0.dylib" in Frameworks, which makes that dlopen path fail.
SDL3_BREW_PREFIX="$(brew --prefix sdl3 2>/dev/null || true)"
if [ ! -f "$FRAMEWORKS_PATH/libSDL3.dylib" ]; then
if [ -n "$SDL3_BREW_PREFIX" ] && [ -f "$SDL3_BREW_PREFIX/lib/libSDL3.dylib" ]; then
echo "Copying libSDL3.dylib from Homebrew for runtime dlopen compatibility"
cp "$SDL3_BREW_PREFIX/lib/libSDL3.dylib" "$FRAMEWORKS_PATH/libSDL3.dylib"
elif [ -f "$FRAMEWORKS_PATH/libSDL3.0.dylib" ]; then
echo "Creating libSDL3.dylib from bundled libSDL3.0.dylib for runtime dlopen compatibility"
cp "$FRAMEWORKS_PATH/libSDL3.0.dylib" "$FRAMEWORKS_PATH/libSDL3.dylib"
fi
fi
if [ -f "$FRAMEWORKS_PATH/libSDL3.dylib" ]; then
chmod u+w "$FRAMEWORKS_PATH/libSDL3.dylib"
install_name_tool -id "@rpath/libSDL3.dylib" "$FRAMEWORKS_PATH/libSDL3.dylib" 2>/dev/null || true
fi
chmod -R u+w "$APP_BUNDLE_PATH"
codesign --force --deep -s - "$APP_BUNDLE_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE_PATH"
echo "Final executable SDL dependencies:"
otool -L "$EXECUTABLE_PATH" | grep -i SDL || true
echo "Final executable rpaths:"
otool -l "$EXECUTABLE_PATH" | grep -A2 LC_RPATH || true
echo "Checking for remaining Homebrew library references..."
if find_bundled_binaries | while read -r binary; do
otool -L "$binary" 2>/dev/null | grep -E "^\s+(/opt/homebrew|/usr/local)" && echo " in $binary"
done | tee /tmp/citron-homebrew-refs.txt; [ -s /tmp/citron-homebrew-refs.txt ]; then
echo "::error::Packaged app still contains Homebrew library references."
exit 1
fi
# Step 8: Create the final DMG
echo "Creating final DMG..."
DMG_FILENAME="Citron-macOS-nightly-${{ steps.version.outputs.VERSION }}.dmg"
hdiutil create -fs HFS+ -srcfolder "$APP_BUNDLE_PATH" -volname "Citron Nightly" "./$DMG_FILENAME"
mv "$DMG_FILENAME" ./dist/
- name: Upload macOS Build
uses: actions/upload-artifact@v4
with:
name: Citron-macOS-Nightly
path: "dist/*.dmg"
release:
if: ${{ github.ref_name == 'main' }}
name: "Release (macOS)"
needs: [check-version, build]
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout this repository
uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: Citron-macOS-Nightly
path: .
- name: Get Version from Filename
run: |
FILENAME=$(ls *.dmg)
VERSION=${FILENAME#Citron-macOS-nightly-}
VERSION=${VERSION%.dmg}
echo "APP_VERSION=${VERSION}" >> $GITHUB_ENV
- name: Delete Previous Release
run: gh release delete "nightly-macos" --repo "${{ github.repository }}" --cleanup-tag -y || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Set Build Date
run: echo "BUILD_DATE=$(date +'%Y-%m-%d')" >> $GITHUB_ENV
- name: Release Artifacts
uses: softprops/action-gh-release@v2
with:
name: "Continuous Build (macOS Nightly: ${{ env.APP_VERSION }})"
tag_name: "nightly-macos"
prerelease: false
files: "*.dmg"
body: |
**Citron Upstream Commit:** [`${{ needs.check-version.outputs.new_hash }}`](https://github.com/citron-neo/emulator/commit/${{ needs.check-version.outputs.new_hash_full }})
This build is based on the latest Citron emulator source code from the upstream repository.
**Build Details:**
- Platform: macOS (Universal)
- Build Type: Release with Qt6 and LTO
- Date: ${{ env.BUILD_DATE }}
- name: Update LATEST_VERSION_MACOS
if: success()
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
echo "${{ env.APP_VERSION }}" > LATEST_VERSION_MACOS
git add LATEST_VERSION_MACOS
git commit -m "Update LATEST_VERSION_MACOS to ${{ env.APP_VERSION }} [skip ci]" || exit 0
for i in {1..5}; do
git pull --rebase origin main && git push && break || sleep 2
done
- name: Post to Discord
if: success()
run: |
DOWNLOAD_URL="https://github.com/${{ github.repository }}/releases/tag/nightly-macos"
DATE=$(date +'%Y-%m-%d')
WEBHOOK_URL="${{ secrets.DISCORD_WEBHOOK }}"
if [ -z "$WEBHOOK_URL" ]; then
echo "DISCORD_WEBHOOK is not set; skipping Discord notification."
exit 0
fi
curl -H "Content-Type: application/json" \
-d "{\"content\":\"šŸŽ **New macOS Build Available!**\n\nšŸ“¦ **Version:** \`${{ env.APP_VERSION }}\`\nšŸ“… **Date:** ${DATE}\nšŸ”— **Download:** $DOWNLOAD_URL\"}" \
"$WEBHOOK_URL"