Files
PR/.github/workflows/build-macos.yml
T
2026-05-02 15:51:14 +12:00

395 lines
18 KiB
YAML

name: Build Citron (macOS)
concurrency:
group: build-macos-${{ inputs.target_branch || github.event.pull_request.head.ref || github.ref }}
cancel-in-progress: true
on:
workflow_dispatch:
inputs:
target_branch:
description: "Upstream branch to build"
required: false
type: string
pr_number:
description: "PR number to comment on"
required: false
type: string
head_repo:
description: "Head repo to clone from (e.g. user/emulator)"
required: false
type: string
env:
UPSTREAM_REPO: "citron-neo/emulator" # <--- Change this to switch forks
jobs:
check-version:
name: Check if build needed
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 }}
branch_name: ${{ steps.upstream.outputs.branch_name }}
head_repo: ${{ steps.upstream.outputs.head_repo }}
pr_number: ${{ steps.upstream.outputs.pr_number }}
steps:
- name: Checkout this repo
uses: actions/checkout@v4
- name: Get upstream commit hash
id: upstream
run: |
PR_NUMBER="${{ inputs.pr_number }}"
TARGET_BRANCH="${{ inputs.target_branch }}"
HEAD_REPO="${{ inputs.head_repo }}"
if [ -z "$HEAD_REPO" ]; then HEAD_REPO="${{ env.UPSTREAM_REPO }}"; fi
if [ -z "$TARGET_BRANCH" ]; then TARGET_BRANCH="main"; fi
REPO_URL="https://github.com/${HEAD_REPO}.git"
FULL_HASH=$(git ls-remote "$REPO_URL" "refs/heads/$TARGET_BRANCH" | cut -f1 | head -n1)
if [ -z "$FULL_HASH" ]; then
REPO_URL="https://github.com/${{ env.UPSTREAM_REPO }}.git"
TARGET_BRANCH="main"
FULL_HASH=$(git ls-remote "$REPO_URL" HEAD | cut -f1)
fi
SHORT_HASH=$(echo "$FULL_HASH" | cut -c1-7)
echo "branch_name=$TARGET_BRANCH" >> $GITHUB_OUTPUT
echo "hash_short=$SHORT_HASH" >> $GITHUB_OUTPUT
echo "hash_full=$FULL_HASH" >> $GITHUB_OUTPUT
echo "head_repo=$HEAD_REPO" >> $GITHUB_OUTPUT
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
- name: Check if already built (via PR comment)
id: built_check
env:
GH_TOKEN: ${{ secrets.EMULATOR_PR_TOKEN }}
run: |
HASH_SHORT="${{ steps.upstream.outputs.hash_short }}"
PR_NUMBER="${{ steps.upstream.outputs.pr_number }}"
UPSTREAM="${{ env.UPSTREAM_REPO }}"
PLATFORM="macOS"
if [ -z "$GH_TOKEN" ] || [ -z "$PR_NUMBER" ]; then
echo "No token or PR number, will build."
echo "already_built=false" >> $GITHUB_OUTPUT
exit 0
fi
MARKER="<!-- citron-pr-builds:${PR_NUMBER} -->"
COMMENT_BODY=$(gh api "repos/${UPSTREAM}/issues/${PR_NUMBER}/comments?per_page=100" \
--jq "[.[] | select(.body | contains(\"$MARKER\") or contains(\"Build Artifacts for PR #${PR_NUMBER}\"))] | last | .body // empty" \
2>/dev/null || echo "")
if [ -z "$COMMENT_BODY" ]; then
echo "No master comment found, will build."
echo "already_built=false" >> $GITHUB_OUTPUT
exit 0
fi
PLATFORM_ROW=$(echo "$COMMENT_BODY" | grep "\*\*${PLATFORM}\*\*" || true)
echo "Platform row: $PLATFORM_ROW"
if echo "$PLATFORM_ROW" | grep -qF "$HASH_SHORT"; then
if echo "$PLATFORM_ROW" | grep -qE "(\[DMG\]|Failed)"; then
echo "Commit ${HASH_SHORT} already built or failed for ${PLATFORM}. Skipping."
echo "already_built=true" >> $GITHUB_OUTPUT
exit 0
fi
fi
echo "No conclusive result for commit ${HASH_SHORT} on ${PLATFORM}. Will build."
echo "already_built=false" >> $GITHUB_OUTPUT
continue-on-error: true
- name: Compare versions
id: compare
run: |
if [ "${{ steps.built_check.outputs.already_built }}" = "true" ]; then
echo "should_build=false" >> $GITHUB_OUTPUT
else
echo "should_build=true" >> $GITHUB_OUTPUT
fi
comment-start:
name: Mark Progress
needs: [check-version]
if: needs.check-version.outputs.should_build == 'true' && needs.check-version.outputs.pr_number != ''
runs-on: ubuntu-latest
steps:
- name: Update Master Comment (In Progress)
env:
GH_TOKEN: ${{ secrets.EMULATOR_PR_TOKEN }}
PR_NUMBER: ${{ needs.check-version.outputs.pr_number }}
UPSTREAM: ${{ env.UPSTREAM_REPO }}
HASH_SHORT: ${{ needs.check-version.outputs.new_hash }}
HASH_FULL: ${{ needs.check-version.outputs.new_hash_full }}
HEAD_REPO: ${{ needs.check-version.outputs.head_repo }}
run: |
if [ -z "$GH_TOKEN" ]; then echo "EMULATOR_PR_TOKEN not set"; exit 0; fi
MARKER="<!-- citron-pr-builds:${PR_NUMBER} -->"
TITLE="**Build Artifacts for PR #${PR_NUMBER}**"
COMMENT_IDS=$(gh api "repos/${UPSTREAM}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.body | contains(\"$MARKER\") or contains(\"Build Artifacts for PR #${PR_NUMBER}\")) | .id" 2>/dev/null || true)
COMMENT_ID=$(printf '%s\n' "$COMMENT_IDS" | sed '/^$/d' | tail -n 1)
if [ -z "$COMMENT_ID" ]; then
INITIAL_BODY="${MARKER}
${TITLE}
| Platform | Commit | Artifacts (Direct Download) | Logs |
| :--- | :--- | :--- | :--- |
| **Windows** | | Pending | |
| **Linux** | | Pending | |
| **Android** | | Pending | |
| **macOS** | | Pending | |
*Note: Direct downloads powered by nightly.link*"
jq -n --arg body "$INITIAL_BODY" '{body: $body}' | gh api -X POST "repos/${UPSTREAM}/issues/${PR_NUMBER}/comments" --input - >/dev/null || echo "Failed creation"
sleep 2
COMMENT_IDS=$(gh api "repos/${UPSTREAM}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.body | contains(\"$MARKER\") or contains(\"Build Artifacts for PR #${PR_NUMBER}\")) | .id" 2>/dev/null || true)
COMMENT_ID=$(printf '%s\n' "$COMMENT_IDS" | sed '/^$/d' | tail -n 1)
fi
for DUPLICATE_ID in $COMMENT_IDS; do
if [ -n "$COMMENT_ID" ] && [ "$DUPLICATE_ID" != "$COMMENT_ID" ]; then
gh api -X DELETE "repos/${UPSTREAM}/issues/comments/${DUPLICATE_ID}" >/dev/null 2>&1 || true
fi
done
if [ -n "$COMMENT_ID" ]; then
COMMIT_LINK="[\`${HASH_SHORT}\`](https://github.com/${HEAD_REPO}/commit/${HASH_FULL})"
export NEW_ROW="| **macOS** | ${COMMIT_LINK} | Building... | |"
gh api "repos/${UPSTREAM}/issues/comments/${COMMENT_ID}" --jq ".body" > body.txt
grep -Fq "$MARKER" body.txt || { BODY=$(cat body.txt); printf '%s\n%s\n' "$MARKER" "$BODY" > body.txt; }
perl -pi -e 's|.*\*\*macOS\*\*.*|$ENV{NEW_ROW}|' body.txt
jq -n --arg body "$(cat body.txt)" '{body: $body}' | gh api -X PATCH "repos/${UPSTREAM}/issues/comments/${COMMENT_ID}" --input - || echo "Failed update"
fi
continue-on-error: true
build:
name: "Citron Build (macOS)"
needs: [check-version, comment-start]
if: always() && 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 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)"
CLONE_REPO="${{ needs.check-version.outputs.head_repo }}"
if [[ "$CLONE_REPO" != *"/"* ]]; then CLONE_REPO="${{ env.UPSTREAM_REPO }}"; fi
git clone --recursive -b "${{ needs.check-version.outputs.branch_name }}" "https://github.com/${CLONE_REPO}.git" citron
- 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: |
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
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"
fi
- 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=2 -always-overwrite
FRAMEWORKS_PATH="$APP_BUNDLE_PATH/Contents/Frameworks"
# Create Vulkan ICD json to ensure bundled MoltenVK is used
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 "Checking for missing transitive dependencies..."
copy_missing_deps() {
find "$FRAMEWORKS_PATH" -name "*.dylib" -type f | while read -r dylib; do
# Handle @rpath/ references
otool -L "$dylib" 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
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
otool -L "$dylib" 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
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
done
}
for i in {1..5}; do copy_missing_deps; done
EXECUTABLE_PATH="$APP_BUNDLE_PATH/Contents/MacOS/citron"
install_name_tool -add_rpath "@executable_path/../Frameworks" "$EXECUTABLE_PATH" 2>/dev/null || true
# Remove absolute 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*) ;;
*) 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
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
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
chmod -R u+w "$APP_BUNDLE_PATH"
codesign --force --deep -s - "$APP_BUNDLE_PATH"
hdiutil create -fs HFS+ -srcfolder "$APP_BUNDLE_PATH" -volname "Citron PR Build" "./Citron-macOS.dmg"
mv *.dmg ./dist/
- name: Upload
uses: actions/upload-artifact@v4
with:
name: Citron-macOS-DMG
path: "dist/*.dmg"
comment-end:
name: Comment End
needs: [check-version, build]
if: always() && needs.check-version.outputs.should_build == 'true' && needs.check-version.outputs.pr_number != ''
runs-on: ubuntu-latest
steps:
- name: Update Master Comment (Results)
env:
GH_TOKEN: ${{ secrets.EMULATOR_PR_TOKEN }}
PR_NUMBER: ${{ needs.check-version.outputs.pr_number }}
RUN_ID: ${{ github.run_id }}
REPO: ${{ github.repository }}
UPSTREAM: ${{ env.UPSTREAM_REPO }}
HASH_SHORT: ${{ needs.check-version.outputs.new_hash }}
HASH_FULL: ${{ needs.check-version.outputs.new_hash_full }}
HEAD_REPO: ${{ needs.check-version.outputs.head_repo }}
BUILD_STATUS: ${{ needs.build.result }}
run: |
if [ -z "$GH_TOKEN" ]; then echo "EMULATOR_PR_TOKEN not set"; exit 1; fi
MARKER="<!-- citron-pr-builds:${PR_NUMBER} -->"
COMMENT_IDS=$(gh api "repos/${UPSTREAM}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.body | contains(\"$MARKER\") or contains(\"Build Artifacts for PR #${PR_NUMBER}\")) | .id" 2>/dev/null || true)
COMMENT_ID=$(printf '%s\n' "$COMMENT_IDS" | sed '/^$/d' | tail -n 1)
for DUPLICATE_ID in $COMMENT_IDS; do
if [ -n "$COMMENT_ID" ] && [ "$DUPLICATE_ID" != "$COMMENT_ID" ]; then
gh api -X DELETE "repos/${UPSTREAM}/issues/comments/${DUPLICATE_ID}" >/dev/null 2>&1 || true
fi
done
if [ -n "$COMMENT_ID" ]; then
LOG_URL="https://github.com/${REPO}/actions/runs/${RUN_ID}"
COMMIT_LINK="[\`${HASH_SHORT}\`](https://github.com/${HEAD_REPO}/commit/${HASH_FULL})"
if [ "$BUILD_STATUS" = "success" ]; then
MAC_URL="https://nightly.link/${REPO}/actions/runs/${RUN_ID}/Citron-macOS-DMG.zip"
ARTIFACTS="[DMG](${MAC_URL})"
else
ARTIFACTS="Failed"
fi
export NEW_ROW="| **macOS** | ${COMMIT_LINK} | ${ARTIFACTS} | [Run](${LOG_URL}) |"
gh api "repos/${UPSTREAM}/issues/comments/${COMMENT_ID}" --jq ".body" > body.txt
grep -Fq "$MARKER" body.txt || { BODY=$(cat body.txt); printf '%s\n%s\n' "$MARKER" "$BODY" > body.txt; }
perl -pi -e 's|.*\*\*macOS\*\*.*|$ENV{NEW_ROW}|' body.txt
jq -n --arg body "$(cat body.txt)" '{body: $body}' | gh api -X PATCH "repos/${UPSTREAM}/issues/comments/${COMMENT_ID}" --input - || echo "Failed update"
else
echo "Master comment not found, skipping final update."
fi