name: release-binaries on: workflow_dispatch: inputs: publish_release: description: Publish the binaries as a GitHub release type: boolean default: true push: branches: - nx2 - nx2-experimental # A binary only changes when build inputs change; docs-only pushes skip the build. paths: - "src/**" - "CMakeLists.txt" - "CMakeModules/**" - "externals/**" - "dist/**" - "tools/**" - ".patch/**" - "cpmfile.json" - ".github/workflows/release-binaries.yml" permissions: contents: read # Clang major the Linux PGO build uses, from apt.llvm.org. Eden's profile is written by a current # clang and an older reader refuses it, so this has to stay at or above whatever produced # eden.profdata; bump it when a refreshed profile needs a newer LLVM. env: PGO_LLVM_VERSION: "23" # A running build is never cancelled: it finishes and publishes. A push meanwhile queues one build; # a later push replaces it, so the newest commit builds as soon as the running one is done. concurrency: group: release-binaries-${{ github.ref }} cancel-in-progress: false jobs: build: name: ${{ matrix.platform }} binary runs-on: ${{ matrix.runner }} env: CCACHE_DIR: ${{ github.workspace }}/.ccache CCACHE_MAXSIZE: 3000M SCCACHE_DIR: ${{ github.workspace }}/.sccache SCCACHE_CACHE_SIZE: 3G strategy: fail-fast: false matrix: include: - platform: linux-x86_64 runner: ubuntu-24.04 # The two -pgo builds are the same programs compiled by clang against Eden's periodically # updated profile (https://github.com/Eden-CI/PGO). Windows uses MSYS2's MinGW clang like # Eden does; MSVC cannot read an LLVM .profdata file. - platform: linux-x86_64-pgo runner: ubuntu-24.04 pgo: true - platform: windows-x86_64 runner: windows-2022 - platform: windows-x86_64-pgo runner: windows-2022 pgo: true mingw: true - platform: linux-aarch64 runner: ubuntu-24.04-arm - platform: linux-aarch64-pgo runner: ubuntu-24.04-arm pgo: true - platform: macos-arm64 runner: macos-15 steps: - name: Check out source uses: actions/checkout@v5 - name: Install Linux dependencies if: startsWith(matrix.platform, 'linux') shell: bash run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ autoconf cmake g++ gcc git glslang-tools libglu1-mesa-dev \ libpulse-dev libtool libudev-dev libxkbcommon-x11-0 \ mesa-common-dev nasm ninja-build qt6-base-private-dev \ qt6-charts-dev qt6-tools-dev libavfilter-dev libavcodec-dev \ libswscale-dev pkg-config zlib1g-dev libva-dev libvdpau-dev \ spirv-tools spirv-headers libusb-1.0-0-dev \ libboost-dev libboost-fiber-dev libboost-context-dev \ libopus-dev libasound2-dev ccache \ libxcb-cursor0 libxcb-icccm4 libxcb-image0 \ libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xinerama0 libxcb-xkb1 # The packages docs/Deps.md lists for macOS, plus ccache. - name: Install macOS dependencies if: startsWith(matrix.platform, 'macos') shell: bash env: HOMEBREW_NO_INSTALL_CLEANUP: 1 run: | brew install autoconf automake boost ffmpeg fmt glslang hidapi libtool libusb lz4 ninja \ nlohmann-json openssl pkg-config qt@6 sdl3 speexdsp zlib zstd cmake catch2 molten-vk \ vulkan-loader spirv-tools vulkan-utility-libraries ccache - name: Install Qt on Linux x86_64 if: startsWith(matrix.platform, 'linux') && runner.arch != 'ARM64' uses: jurplel/install-qt-action@v4 with: version: "6.8.3" host: linux target: desktop arch: linux_gcc_64 modules: qtcharts cache: true - name: Install Qt on Linux ARM64 if: startsWith(matrix.platform, 'linux') && runner.arch == 'ARM64' uses: jurplel/install-qt-action@v4 with: version: "6.8.3" host: linux_arm64 target: desktop arch: linux_gcc_arm64 modules: qtcharts cache: true # Ubuntu's own clang 18 cannot read a profile from a current LLVM, so take clang from the LLVM # apt repo. The versioned packages are named explicitly: that repo's unversioned clang/lld # metapackages can lag the binaries they depend on and then resolve to nothing. lld-23 only # ships ld.lld under /usr/lib/llvm-23/bin, so -B points the linker search there. - name: Install clang for PGO if: startsWith(matrix.platform, 'linux') && matrix.pgo shell: bash run: | set -euo pipefail curl -fsSL https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc > /dev/null echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-$PGO_LLVM_VERSION main" | sudo tee /etc/apt/sources.list.d/llvm.list > /dev/null sudo apt-get update sudo apt-get install -y --no-install-recommends \ "clang-$PGO_LLVM_VERSION" "lld-$PGO_LLVM_VERSION" "clang-$PGO_LLVM_VERSION" --version # Fetched into the source tree so both the Linux and MSYS2 configure steps can read it from a # path their clang understands. - name: Download PGO profile if: matrix.pgo shell: bash run: | set -euo pipefail curl -fsSL -o eden.profdata \ https://github.com/Eden-CI/PGO/releases/latest/download/eden.profdata test -s eden.profdata - name: Set up MSVC if: matrix.platform == 'windows-x86_64' uses: ilammy/msvc-dev-cmd@v1 - name: Install Qt on Windows if: matrix.platform == 'windows-x86_64' uses: jurplel/install-qt-action@v4 with: version: "6.8.3" host: windows target: desktop arch: win64_msvc2022_64 modules: qtcharts cache: true # MSVC is not something ccache handles well, so that job uses sccache. The other jobs use # ccache, which CMake enables through USE_CCACHE (externals/cmake-modules/UseCcache.cmake). - name: Install sccache on Windows if: matrix.platform == 'windows-x86_64' shell: pwsh run: | $zip = Join-Path $env:RUNNER_TEMP 'sccache.zip' Invoke-WebRequest -OutFile $zip https://github.com/mozilla/sccache/releases/download/v0.19.0/sccache-v0.19.0-x86_64-pc-windows-msvc.zip Expand-Archive $zip -DestinationPath (Join-Path $env:RUNNER_TEMP 'sccache') $bin = (Get-ChildItem -Recurse -Filter sccache.exe (Join-Path $env:RUNNER_TEMP 'sccache')).DirectoryName $bin | Out-File -Append -Encoding utf8 $env:GITHUB_PATH - name: Install Vulkan SDK on Windows if: matrix.platform == 'windows-x86_64' shell: pwsh run: ./tools/windows/install-vulkan-sdk.ps1 - name: Verify Vulkan tools on Windows if: matrix.platform == 'windows-x86_64' shell: pwsh run: glslangValidator --version # Eden builds its Windows PGO binaries with MSYS2's clang toolchain, so we do the same. The # package list is docs/Deps.md (MSYS2) plus what the bundled FFmpeg needs to compile. - name: Set up MSYS2 if: matrix.mingw uses: msys2/setup-msys2@v2 with: msystem: UCRT64 update: true cache: true install: >- git make autoconf libtool automake-wrapper patch jq pactoys ninja pacboy: >- toolchain:p clang:p lld:p cmake:p python-pip:p qt6-base:p qt6-charts:p qt6-tools:p qt6-translations:p qt6-svg:p openssl:p boost:p fmt:p lz4:p nlohmann-json:p zlib:p zstd:p libiconv:p vulkan-memory-allocator:p vulkan-devel:p glslang:p enet:p opus:p libusb:p unordered_dense:p jbigkit:p xz:p libdeflate:p bzip2:p lerc:p graphite2:p libwebp:p libtiff:p brotli:p ccache:p - name: Cache CPM downloads uses: actions/cache@v5 with: path: .cache/cpm key: cpm-${{ runner.os }}-${{ hashFiles('cpmfile.json', 'CMakeLists.txt', 'src/**/CMakeLists.txt') }} restore-keys: | cpm-${{ runner.os }}- # The sha in the key makes every run save a fresh cache (actions/cache never saves over an # exact hit), and restore-keys seeds it from the newest one of the same platform. - name: Cache compiler objects uses: actions/cache@v5 with: path: | .ccache .sccache key: objects-${{ matrix.platform }}-${{ github.sha }} restore-keys: | objects-${{ matrix.platform }}- - name: Configure Linux if: startsWith(matrix.platform, 'linux') shell: bash run: | set -euo pipefail flags=( -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DYUZU_TESTS=OFF -DYUZU_CMD=ON -DYUZU_ROOM=OFF -DYUZU_ROOM_STANDALONE=OFF -DYUZU_USE_BUNDLED_QT=OFF "-DCMAKE_PREFIX_PATH=$QT_ROOT_DIR" -DYUZU_USE_BUNDLED_SDL3=ON -DVulkanHeaders_FORCE_BUNDLED=ON -DVulkanUtilityLibraries_FORCE_BUNDLED=ON -DENABLE_UPDATE_CHECKER=OFF -DUSE_DISCORD_PRESENCE=OFF -DUSE_CCACHE=ON ) if [ '${{ matrix.pgo }}' = 'true' ]; then # Same flags Eden's CI passes for its clang PGO build. profile="-fprofile-use=$PWD/eden.profdata -Wno-backend-plugin \ -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date" flags+=( "-DCMAKE_C_COMPILER=clang-$PGO_LLVM_VERSION" "-DCMAKE_CXX_COMPILER=clang++-$PGO_LLVM_VERSION" "-DCMAKE_EXE_LINKER_FLAGS=-B/usr/lib/llvm-$PGO_LLVM_VERSION/bin -fuse-ld=lld" "-DCMAKE_C_FLAGS=$profile" "-DCMAKE_CXX_FLAGS=$profile" ) fi cmake -S . -B build/release -G Ninja "${flags[@]}" - name: Configure Windows if: matrix.platform == 'windows-x86_64' shell: bash run: >- cmake -S . -B build/release -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DYUZU_TESTS=OFF -DYUZU_CMD=ON -DYUZU_ROOM=OFF -DYUZU_ROOM_STANDALONE=OFF -DYUZU_USE_BUNDLED_QT=OFF -DYUZU_USE_BUNDLED_SDL3=ON -DENABLE_UPDATE_CHECKER=OFF -DUSE_DISCORD_PRESENCE=OFF -DCMAKE_C_COMPILER_LAUNCHER=sccache -DCMAKE_CXX_COMPILER_LAUNCHER=sccache - name: Configure macOS if: startsWith(matrix.platform, 'macos') shell: bash run: | set -euo pipefail cmake -S . -B build/release -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_TESTING=OFF \ -DYUZU_TESTS=OFF \ -DYUZU_CMD=ON \ -DYUZU_ROOM=OFF \ -DYUZU_ROOM_STANDALONE=OFF \ -DYUZU_USE_BUNDLED_QT=OFF \ "-DCMAKE_PREFIX_PATH=$(brew --prefix qt@6);$(brew --prefix zlib)" \ "-DOPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" \ -DVulkanHeaders_FORCE_BUNDLED=ON \ -DVulkanUtilityLibraries_FORCE_BUNDLED=ON \ -DENABLE_UPDATE_CHECKER=OFF \ -DUSE_DISCORD_PRESENCE=OFF \ -DUSE_CCACHE=ON # MinGW build: system Qt and libraries from MSYS2, clang plus Eden's PGO profile. The profile # path is converted to its Windows form because clang runs with the MSYS mount underneath. - name: Configure MinGW PGO if: matrix.mingw shell: msys2 {0} run: | set -euo pipefail profile="-fprofile-use=$(cygpath -m "$PWD/eden.profdata") -Wno-backend-plugin \ -Wno-profile-instr-unprofiled -Wno-profile-instr-out-of-date" cmake -S . -B build/release -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_TESTING=OFF \ -DYUZU_TESTS=OFF \ -DYUZU_CMD=ON \ -DYUZU_ROOM=OFF \ -DYUZU_ROOM_STANDALONE=OFF \ -DYUZU_USE_BUNDLED_QT=OFF \ -DYUZU_USE_BUNDLED_SDL3=ON \ -DYUZU_USE_BUNDLED_FFMPEG=ON \ -DVulkanHeaders_FORCE_BUNDLED=ON \ -DVulkanUtilityLibraries_FORCE_BUNDLED=ON \ -DENABLE_UPDATE_CHECKER=OFF \ -DUSE_DISCORD_PRESENCE=OFF \ -DUSE_CCACHE=ON \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ -DCMAKE_EXE_LINKER_FLAGS=-fuse-ld=lld \ "-DCMAKE_C_FLAGS=$profile" \ "-DCMAKE_CXX_FLAGS=$profile" - name: Build if: ${{ ! matrix.mingw }} shell: bash run: | cmake --build build/release --target yuzu yuzu-cmd --parallel if command -v ccache > /dev/null; then ccache --show-stats; else sccache --show-stats; fi - name: Build MinGW PGO if: matrix.mingw shell: msys2 {0} run: | cmake --build build/release --target yuzu yuzu-cmd --parallel ccache --show-stats # Plain tar.gz with the two programs. GitHub artifacts drop the executable bit, so a tar is # what keeps it. This one is not self-contained: it needs Qt 6 and the usual libraries # installed on the system. - name: Package Linux tar.gz if: startsWith(matrix.platform, 'linux') shell: bash run: | set -euo pipefail test -x build/release/bin/eglantine test -x build/release/bin/eden-cli strip build/release/bin/eglantine build/release/bin/eden-cli tar -czf "eglantine-${GITHUB_SHA}-${{ matrix.platform }}.tar.gz" \ -C build/release/bin eglantine eden-cli ls -la "eglantine-${GITHUB_SHA}-${{ matrix.platform }}.tar.gz" # A self-contained AppImage: linuxdeploy bundles every library the two programs need except # the base system ones (glibc, X11/Wayland client libraries, the GPU driver and Vulkan loader), # and its Qt plugin adds the Qt libraries and platform plugins. Runs on glibc 2.39+ systems. - name: Package Linux AppImage if: startsWith(matrix.platform, 'linux') shell: bash run: | set -euo pipefail test -x build/release/bin/eglantine test -x build/release/bin/eden-cli tools="$RUNNER_TEMP/linuxdeploy" mkdir -p "$tools" arch=$(uname -m) # aarch64 or x86_64 curl -fsSL -o "$tools/linuxdeploy-$arch.AppImage" \ https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$arch.AppImage curl -fsSL -o "$tools/linuxdeploy-plugin-qt-$arch.AppImage" \ https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-$arch.AppImage chmod +x "$tools"/*.AppImage # The desktop file names its icon dev.eglantine.eglantine. icon="$RUNNER_TEMP/dev.eglantine.eglantine.png" cp dist/Eglantine.png "$icon" export APPIMAGE_EXTRACT_AND_RUN=1 export QMAKE="$QT_ROOT_DIR/bin/qmake" export LDAI_OUTPUT="eglantine-${GITHUB_SHA}-${{ matrix.platform }}.AppImage" export OUTPUT="$LDAI_OUTPUT" export ARCH="$arch" "$tools/linuxdeploy-$arch.AppImage" --appdir AppDir \ --executable build/release/bin/eglantine \ --executable build/release/bin/eden-cli \ --desktop-file dist/dev.eden_emu.eden.desktop \ --icon-file "$icon" \ --plugin qt \ --output appimage test -s "$LDAI_OUTPUT" ls -la "$LDAI_OUTPUT" - name: Package Windows binary if: matrix.platform == 'windows-x86_64' shell: pwsh run: | foreach ($exe in 'eglantine.exe', 'eden-cli.exe') { if (-not (Test-Path "build/release/bin/$exe")) { throw "build/release/bin/$exe was not produced" } } windeployqt --release --no-translations build/release/bin/eglantine.exe # A dynamic MinGW build is not portable on its own: windeployqt6 brings the Qt libraries and # plugins, then every non-system DLL the binaries and those plugins pull in gets copied next to # them (docs/Caveats.md#msys2). - name: Package MinGW binary if: matrix.mingw shell: msys2 {0} run: | set -euo pipefail export PATH="/ucrt64/bin:$PATH" cd build/release/bin test -f eglantine.exe test -f eden-cli.exe windeployqt6 --no-translations --no-compiler-runtime --no-opengl-sw \ --no-system-dxc-compiler --no-system-d3d-compiler eglantine.exe deps() { objdump -p "$1" | awk '/DLL Name:/ {print $3}' | while read -r dll; do [ -z "$dll" ] && continue source=$(command -v "$dll" 2>/dev/null || true) [ -z "$source" ] && continue # The system DLLs ship with Windows, so they never need bundling. case "$source" in *System32* | *SysWOW64*) continue ;; esac if [ ! -f "$dll" ]; then cp "$source" "$dll" deps "$source" fi done } for exe in eglantine.exe eden-cli.exe; do deps "$exe" done find . -name '*.dll' | while read -r dll; do deps "$dll" done # A self-contained app bundle: macdeployqt copies Qt and every other Homebrew library both # programs load into the bundle, then the leftover Homebrew search paths are removed so a # machine with Homebrew still uses the bundled copies. Ad-hoc signed, not notarized. - name: Package macOS app if: startsWith(matrix.platform, 'macos') shell: bash run: | set -euo pipefail app=build/release/bin/eglantine.app test -x "$app/Contents/MacOS/eglantine" test -x build/release/bin/eden-cli cp build/release/bin/eden-cli "$app/Contents/MacOS/eden-cli" "$(brew --prefix qt@6)/bin/macdeployqt" "$app" \ -executable="$app/Contents/MacOS/eden-cli" -libpath="$(brew --prefix)/lib" \ -always-overwrite -verbose=1 # Qt's virtual keyboard input plugin pulls in the QML stack, which the emulator doesn't use. rm -f "$app/Contents/PlugIns/platforminputcontexts/libqtvirtualkeyboardplugin.dylib" binaries=$(find "$app/Contents" -type f \( -perm -u+x -o -name '*.dylib' \) \ -exec sh -c 'file -b "$1" | grep -q Mach-O' _ {} \; -print) for binary in $binaries; do otool -l "$binary" | awk '/cmd LC_RPATH/ {getline; getline; print $2}' | { grep -E '^/(opt/homebrew|usr/local)/' || true; } | while read -r rpath; do install_name_tool -delete_rpath "$rpath" "$binary" 2> /dev/null done done # Libraries that load their dependencies through @rpath find them in the bundle. for program in eglantine eden-cli; do install_name_tool -add_rpath @executable_path/../Frameworks \ "$app/Contents/MacOS/$program" 2> /dev/null done leaks=$(for binary in $binaries; do otool -L "$binary" | grep -E '^\s+/(opt/homebrew|usr/local)/' otool -l "$binary" | awk '/cmd LC_RPATH/ {getline; getline; print $2}' | grep -E '^/(opt/homebrew|usr/local)/' done || true) if [ -n "$leaks" ]; then echo "References still pointing outside the bundle:" echo "$leaks" | sort -u exit 1 fi # macdeployqt invalidates the signatures. The programs get the entitlements when the # tree has them (the Hypervisor CPU backend needs com.apple.security.hypervisor). codesign --force --deep --sign - "$app" if [ -f dist/eglantine.entitlements ]; then codesign --force --sign - --entitlements dist/eglantine.entitlements \ "$app/Contents/MacOS/eden-cli" codesign --force --sign - --entitlements dist/eglantine.entitlements "$app" fi codesign --verify --deep --strict "$app" ditto -c -k --keepParent "$app" "eglantine-${GITHUB_SHA}-${{ matrix.platform }}.zip" ls -la "eglantine-${GITHUB_SHA}-${{ matrix.platform }}.zip" # Also ship a .dmg (drag eglantine.app onto Applications). dmgdir=$(mktemp -d) ditto "$app" "$dmgdir/eglantine.app" ln -s /Applications "$dmgdir/Applications" hdiutil create -volname "Eglantine" -srcfolder "$dmgdir" -ov -format UDZO \ "eglantine-${GITHUB_SHA}-${{ matrix.platform }}.dmg" ls -la "eglantine-${GITHUB_SHA}-${{ matrix.platform }}.dmg" # The artifact is the bin folder itself: GitHub zips artifacts on download, and uploading a # zip gave a zip inside a zip. The release job zips it once for the release. - name: Upload Windows binary if: matrix.platform == 'windows-x86_64' uses: actions/upload-artifact@v7 with: name: eglantine-${{ matrix.platform }}-${{ github.sha }} path: build/release/bin/ if-no-files-found: error retention-days: 7 - name: Upload MinGW binary if: matrix.mingw uses: actions/upload-artifact@v7 with: name: eglantine-${{ matrix.platform }}-${{ github.sha }} path: build/release/bin/ if-no-files-found: error retention-days: 7 # Both Linux files go in one artifact: the tar.gz and the AppImage. - name: Upload Linux binary if: startsWith(matrix.platform, 'linux') uses: actions/upload-artifact@v7 with: name: eglantine-${{ matrix.platform }}-${{ github.sha }} path: | eglantine-${{ github.sha }}-${{ matrix.platform }}.tar.gz eglantine-${{ github.sha }}-${{ matrix.platform }}.AppImage if-no-files-found: error compression-level: 0 retention-days: 7 # Zipped with ditto beforehand: the bundle's framework symlinks don't survive artifact zipping. - name: Upload macOS app if: startsWith(matrix.platform, 'macos') uses: actions/upload-artifact@v7 with: name: eglantine-${{ matrix.platform }}-${{ github.sha }} path: | eglantine-${{ github.sha }}-${{ matrix.platform }}.zip eglantine-${{ github.sha }}-${{ matrix.platform }}.dmg if-no-files-found: error compression-level: 0 retention-days: 7 # Publishes every binary as a GitHub pre-release once every platform built: on pushes to nx2 # and nx2-experimental, and on manual runs unless "Publish the binaries as a GitHub release" is # unticked. release: name: Publish release needs: build if: github.event_name == 'push' || inputs.publish_release runs-on: ubuntu-24.04 permissions: contents: write steps: - name: Download binaries uses: actions/download-artifact@v7 with: pattern: eglantine-*-${{ github.sha }} path: artifacts - name: Package release files shell: bash run: | set -euo pipefail mkdir dist # Asset names drop the commit sha: the release tag already identifies the build, and # stable names let the notes table link to them. strip_sha() { sed "s/-${GITHUB_SHA}//" <<< "$1"; } for artifact in artifacts/*/; do name=$(basename "$artifact") if compgen -G "$artifact*.AppImage" > /dev/null; then # Linux: ship the AppImage and the tar.gz as they are. for file in "$artifact"*.AppImage "$artifact"*.tar.gz; do cp "$file" "dist/$(strip_sha "$(basename "$file")")" done elif compgen -G "$artifact*.zip" > /dev/null; then for file in "$artifact"*.zip "$artifact"*.dmg; do [[ -e "$file" ]] && cp "$file" "dist/$(strip_sha "$(basename "$file")")" done else (cd "$artifact" && zip -qr "${GITHUB_WORKSPACE}/dist/$(strip_sha "$name").zip" .) fi done ls -la dist - name: Create release shell: bash env: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail tag="build-${GITHUB_RUN_NUMBER}" base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${tag}" cat > notes.md < Eglantine %s build %s (%s)\n%s' \ "$GITHUB_REF_NAME" "$GITHUB_RUN_NUMBER" "${GITHUB_SHA::7}" "$URL") jq -n --arg content "$MSG" \ '{content: $content, allowed_mentions: {roles: ["1566184315178713190"]}}' \ | curl -fsS -H "Content-Type: application/json" -d @- "$WEBHOOK"