Skip to content

Fix: bundle Qt image format plugins in Linux AppImage #191

Fix: bundle Qt image format plugins in Linux AppImage

Fix: bundle Qt image format plugins in Linux AppImage #191

Workflow file for this run

name: Build PollyMC-Continued
on:
push:
branches: [main, develop]
tags: ["v*"]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
skip-check:
runs-on: ubuntu-latest
outputs:
skip: ${{ steps.check.outputs.skip }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- id: check
shell: bash
run: |
set -euo pipefail
COMMITS=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
COMMITS=$(git log --format="%s %b" "${{ github.event.pull_request.base.sha }}".."${{ github.event.pull_request.head.sha }}")
elif [ "${{ github.event_name }}" = "push" ]; then
COMMITS=$(git log --format="%s %b" "${{ github.event.before }}".."${{ github.event.after }}" 2>/dev/null || git log --format="%s %b" -1)
else
COMMITS=$(git log --format="%s %b" -1)
fi
echo "DEBUG: event_name=${{ github.event_name }}"
echo "DEBUG: commits=${COMMITS:0:500}"
if echo "$COMMITS" | grep -qF '[skip-all]'; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "DEBUG: [skip-all] found, skipping entire workflow."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "DEBUG: No [skip-all] found."
fi
# --- Single source of truth for version number ---
# Every downstream job (build-windows, build-linux, build-macos, release)
# consumes this output instead of recomputing its own bump, so the
# version baked into artifact filenames always matches the release tag.
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
echo "DEBUG: Tag push detected, using tag version: ${VERSION}"
else
LATEST_TAG=$(git describe --tags --match "v*" --abbrev=0 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
# No tags exist yet in this repo - diff against full history instead
# of a synthetic "v0.0.0" ref, which would make `git log` fail.
BUMP_COMMITS=$(git log --format="%s %b" 2>/dev/null || echo "")
LATEST_TAG="v0.0.0"
else
BUMP_COMMITS=$(git log "${LATEST_TAG}..HEAD" --format="%s %b" 2>/dev/null || echo "")
fi
VERSION=${LATEST_TAG#v}
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
if echo "$BUMP_COMMITS" | grep -q "BREAKING CHANGE"; then
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0
BUMP="major"
elif echo "$BUMP_COMMITS" | grep -qE "^(feat|feature|Add|Build|Enhance)(\(|:|\ )"; then
MINOR=$((MINOR + 1)); PATCH=0
BUMP="minor"
else
PATCH=$((PATCH + 1))
BUMP="patch"
fi
VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "DEBUG: Latest tag: ${LATEST_TAG}"
echo "DEBUG: Next version: ${VERSION} (${BUMP} bump)"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
changelog-check:
name: Verify changelog entry
needs: [skip-check]
if: needs.skip-check.outputs.skip != 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check for matching CHANGELOG.md section
run: |
set -euo pipefail
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
EXPECTED_TAG="${{ github.ref_name }}"
else
EXPECTED_TAG="v${{ needs.skip-check.outputs.version }}"
fi
HEAD_MSG=$(git log --format="%s %b" HEAD -1)
if echo "$HEAD_MSG" | grep -qF '[skip changelog]'; then
echo "Skipping changelog check: [skip changelog] found in the latest commit."
exit 0
fi
echo "Looking for '## ${EXPECTED_TAG}' in CHANGELOG.md ..."
# Use a counted grep, not `grep -q`: -q exits on the first match and
# closes the pipe, sed then dies with SIGPIPE and pipefail turns the
# whole pipeline into a failure even though the section exists.
if [ "$(sed 's/\r$//' CHANGELOG.md | grep -Fxc "## ${EXPECTED_TAG}" || true)" -eq 0 ]; then
echo "::error::CHANGELOG.md has no '## ${EXPECTED_TAG}' section, but the computed release version is ${EXPECTED_TAG}."
echo "::error::Add a '## ${EXPECTED_TAG}' section (Added/Changed/Fixed/Removed) to CHANGELOG.md, or add '[skip changelog]' to the commit message if this change doesn't need a release note."
exit 1
fi
echo "Found matching CHANGELOG.md section for ${EXPECTED_TAG}."
build-windows:
name: Windows (MinGW)
needs: [skip-check]
if: needs.skip-check.outputs.skip != 'true'
runs-on: windows-latest
permissions:
contents: read
env:
# Set once at job level so it's visible in every step (including the
# Build step, where ccache actually does its work) - a per-step
# `export` doesn't survive to the next step in Actions.
CCACHE_DIR: ${{ github.workspace }}/.ccache
defaults:
run:
shell: msys2 {0}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Cache MSYS2 packages
uses: actions/cache@v5
with:
path: C:\msys64\var\cache\pacman\pkg
# NOTE: hashFiles assumes this workflow file is saved as build.yml.
# If it's saved under a different filename, hashFiles returns "" and
# this key never changes when the workflow is edited. Verify the
# actual path, or hash something more stable like CMakeLists.txt.
key: msys2-v1
restore-keys: msys2-
- name: Setup MSYS2
uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
update: true
install: >-
mingw-w64-x86_64-gcc
mingw-w64-x86_64-cmake
mingw-w64-x86_64-ninja
mingw-w64-x86_64-qt6-base
mingw-w64-x86_64-qt6-svg
mingw-w64-x86_64-qt6-5compat
mingw-w64-x86_64-qt6-networkauth
mingw-w64-x86_64-qt6-websockets
mingw-w64-x86_64-extra-cmake-modules
mingw-w64-x86_64-tomlplusplus
mingw-w64-x86_64-cmark
mingw-w64-x86_64-qrencode
mingw-w64-x86_64-libarchive
mingw-w64-x86_64-zlib
mingw-w64-x86_64-curl
mingw-w64-x86_64-openssl
mingw-w64-x86_64-libpng
mingw-w64-x86_64-lz4
mingw-w64-x86_64-xz
mingw-w64-x86_64-bzip2
mingw-w64-x86_64-zstd
mingw-w64-x86_64-brotli
mingw-w64-x86_64-nghttp2
mingw-w64-x86_64-libssh2
mingw-w64-x86_64-expat
mingw-w64-x86_64-libiconv
mingw-w64-x86_64-libb2
mingw-w64-x86_64-libidn2
mingw-w64-x86_64-sqlite3
mingw-w64-x86_64-nsis
mingw-w64-x86_64-ccache
- name: Cache ccache
uses: actions/cache@v5
with:
path: .ccache
# Unique per run so a fresh save always succeeds (actions/cache
# entries are immutable per key - reusing a static key means every
# run after the first silently fails to save, so ccache never
# learns about new commits and hit rate degrades over time).
# restore-keys still picks up the most recent prior cache to warm
# from.
key: ccache-windows-${{ github.run_id }}
restore-keys: |
ccache-windows-
- name: Setup Node.js
shell: bash
run: |
# Node.js is available via MSYS2; just install deps
cd bot-server
npm install --omit=dev
- name: Configure
run: |
set -euo pipefail
cmake -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=install \
-DCMAKE_SYSTEM_LIBRARY_PATH=/mingw64/bin \
-DCMAKE_CXX_FLAGS="-Wno-error" \
-DCMAKE_C_FLAGS="-Wno-error" \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DLauncher_VERSION="${{ needs.skip-check.outputs.version }}" \
-DLauncher_BUILD_NUMBER="${{ github.run_number }}" \
-S . -B build
- name: Build
run: |
set -euo pipefail
ccache -z
cmake --build build -j$(nproc)
cmake --install build
echo "=== ccache stats ==="
ccache -s
ccache --print-stats
- name: Bundle bot-server
run: |
set -euo pipefail
rm -rf install/bot-server
cp -r bot-server install/bot-server
rm -f install/bot-server/.gitignore
# Remove any dev-only files to keep the bundle lean
rm -rf install/bot-server/node_modules/.cache 2>/dev/null || true
- name: Deploy Qt
run: |
set -euo pipefail
cd install
/mingw64/bin/windeployqt6.exe --release pollymc.exe
SYSTEM_DLLS="^(KERNEL32|KERNELBASE|USER32|GDI32|gdi32full|ADVAPI32|SHELL32|ole32|OLEAUT32|msvcrt|msvcp_win|ntdll|WS2_32|SHLWAPI|WINMM|VERSION|d3d11|d3d12|dxgi|dwmapi|DWrite|UxTheme|USP10|COMCTL32|COMDLG32|IMAPI2|MAPI32|netapi32|PSAPI|Secur32|WINTRUST|CRYPT32|IMAGEHLP|DBGHELP|PDHINST|WTSAPI32|WININET|MSWSOCK|IPHLPAPI|WS2HELP|bcrypt|RPCRT4|AUTHZ|MPR|USERENV|DNSAPI|WINHTTP)\.dll$"
NEED_FIX=0
for pass in $(seq 1 15); do
ADDED=0
for f in *.exe *.dll; do
[ -f "$f" ] || continue
while IFS= read -r dll; do
[ -z "$dll" ] && continue
if echo "$dll" | grep -qiE "^(api-ms-win-|ext-ms-win-)"; then continue; fi
if [ -f "$dll" ]; then continue; fi
if echo "$dll" | grep -iqE "$SYSTEM_DLLS"; then continue; fi
if [ -f "/c/Windows/System32/$dll" ]; then continue; fi
if [ -f "/mingw64/bin/$dll" ]; then
cp -f "/mingw64/bin/$dll" .
echo "Pass $pass: deployed $dll (needed by $f)"
ADDED=1
else
echo "::error file=$f::$f is missing dependency: $dll"
NEED_FIX=1
fi
done < <(objdump -p "$f" 2>/dev/null | awk -F': ' '/^\s*DLL Name:/ {print $2}' | tr -d '\r')
done
[ "$ADDED" -eq 0 ] && break
done
echo "=== DLLs in install/ ==="
ls -la *.dll
[ "$NEED_FIX" -eq 0 ] || exit 1
- name: Build NSIS installer
run: |
set -euo pipefail
BUILD_DIR_WIN="$(cygpath -w "$(pwd)/install")"
VERSION="${{ needs.skip-check.outputs.version }}"
# Fail loudly instead of silently shipping an installer with the
# wrong name if the placeholder pattern doesn't match the .nsi file.
if ! grep -q 'PollyMC-Continued-[^ ]*-Setup' pollymc_installer.nsi; then
echo "::error::Expected placeholder 'PollyMC-Continued-*-Setup' not found in pollymc_installer.nsi"
exit 1
fi
sed "s/PollyMC-Continued-[^ ]*-Setup/PollyMC-Continued-${VERSION}-Windows-Setup/" \
pollymc_installer.nsi > pollymc_installer_versioned.nsi
makensis \
"-DBUILD_DIR=${BUILD_DIR_WIN}" \
"-DVERSION=${VERSION}" \
"pollymc_installer_versioned.nsi"
ls -la PollyMC-Continued-*-Setup.exe || (echo "ERROR: Installer not found!" && exit 1)
- name: Test installed binary for missing DLLs
shell: powershell
run: |
$installer = Get-ChildItem -Path "$env:GITHUB_WORKSPACE" -Filter "PollyMC-Continued-*-Setup.exe" | Select-Object -First 1
if (-not $installer) { throw "No installer found at $env:GITHUB_WORKSPACE" }
$installDir = Join-Path $env:RUNNER_TEMP "PollyMC-Test"
Write-Host "Silently installing $($installer.Name) to $installDir ..."
$proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S", "/D=$installDir" -Wait -PassThru -NoNewWindow
Write-Host "Installer exit code: $($proc.ExitCode)"
$exePath = Join-Path $installDir "pollymc.exe"
if (-not (Test-Path $exePath)) {
$exePath = Join-Path $installDir "PollyMC-Continued" "pollymc.exe"
}
if (-not (Test-Path $exePath)) { throw "pollymc.exe not found after installation" }
$stderrFile = Join-Path $env:RUNNER_TEMP "pollymc_stderr.txt"
if (Test-Path $stderrFile) { Remove-Item $stderrFile -Force }
$startTime = Get-Date
$process = Start-Process -FilePath $exePath -PassThru -RedirectStandardError $stderrFile
$exited = $process.WaitForExit(10000)
if (-not $exited) {
$process.Kill()
$process.WaitForExit()
$events = Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=$startTime} -MaxEvents 10 -ErrorAction SilentlyContinue
$hasError = $false
foreach ($event in $events) {
if ($event.Message -match "pollymc") {
Write-Host "::error::Application Error detected"
Write-Host "Event: $($event.TimeCreated) -- $($event.Message)"
if ($event.Message -match "([a-zA-Z0-9_-]+\.dll)") {
Write-Host "::error::Missing or faulty module: $($matches[1])"
}
$hasError = $true
}
}
if (-not $hasError) {
Write-Host "pollymc.exe ran for 10s with no DLL errors"
} else {
throw "Missing DLLs detected in pollymc.exe"
}
} else {
$exitCode = $process.ExitCode
if ($exitCode -eq -1073741515) {
Write-Host "::error::STATUS_DLL_NOT_FOUND (0xC0000135) -- a required DLL is missing"
$events = Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=$startTime} -MaxEvents 10 -ErrorAction SilentlyContinue
foreach ($event in $events) {
if ($event.Message -match "pollymc|$([regex]::Escape($exePath))") {
Write-Host $event.Message
if ($event.Message -match "(?i)([a-zA-Z0-9_-]+\.dll)") {
Write-Host "::error::Missing DLL: $($matches[1])"
}
}
}
throw "pollymc.exe failed -- missing DLL (STATUS_DLL_NOT_FOUND)"
} else {
Write-Host "pollymc.exe exited with code $exitCode (DLLs loaded OK)"
}
}
if (Test-Path $stderrFile) {
$stderr = Get-Content $stderrFile
if ($stderr) { Write-Host "=== stderr ===`n$stderr" }
}
- name: Zip portable build
run: |
set -euo pipefail
VERSION="${{ needs.skip-check.outputs.version }}"
cd install
ls -la pollymc.exe || (echo "ERROR: pollymc.exe not found in install!" && exit 1)
# portable.txt marks a portable install (data dir + updater asset matching depend on it),
# so the portable zip must contain it next to pollymc.exe
cp "$GITHUB_WORKSPACE/program_info/portable.txt" portable.txt
# manifest.txt lists every top-level entry of the install; the updater installs exactly
# this list, so files that are new to a release (like portable.txt) actually get deployed
find . -mindepth 1 -maxdepth 1 -printf '%P\n' | sort > manifest.txt
"/c/Program Files/7-Zip/7z.exe" a \
"../PollyMC-Continued-${VERSION}-Windows-portable.zip" .
cd ..
ls -la PollyMC-Continued-*-Windows-portable.zip || (echo "ERROR: Zip not found!" && exit 1)
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: PollyMC-Continued-Windows
path: |
PollyMC-Continued-*-Setup.exe
PollyMC-Continued-*-Windows-portable.zip
build-linux:
name: Linux
needs: [skip-check]
if: needs.skip-check.outputs.skip != 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
env:
APPIMAGE_EXTRACT_AND_RUN: "1"
# Set once at job level so it's visible in every step (including the
# Build step, where ccache actually does its work) - a per-step
# `export` doesn't survive to the next step in Actions. Explicit path
# also avoids relying on ccache's default cache dir, which changed
# location as of ccache 4.0 (now ~/.cache/ccache on Linux).
CCACHE_DIR: ${{ github.workspace }}/.ccache
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install Dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y \
build-essential cmake ninja-build pkg-config \
qt6-base-dev qt6-svg-dev qt6-5compat-dev qt6-networkauth-dev \
qt6-websockets-dev qt6-base-dev-tools qt6-tools-dev libgl-dev \
qt6-image-formats-plugins \
extra-cmake-modules \
libtomlplusplus-dev libcmark-dev libqrencode-dev cmark \
libquazip1-qt6-dev libarchive-dev zlib1g-dev \
libcurl4-openssl-dev libssl-dev libpng-dev \
liblz4-dev liblzma-dev libbz2-dev libzstd-dev \
gamemode-dev \
patchelf \
ccache \
breeze
- name: Cache ccache
uses: actions/cache@v5
with:
path: ${{ env.CCACHE_DIR }}
# Unique per run so a fresh save always succeeds (actions/cache
# entries are immutable per key - reusing a static key means every
# run after the first silently fails to save, so ccache never
# learns about new commits and hit rate degrades over time).
# restore-keys still picks up the most recent prior cache to warm
# from.
key: ccache-linux-${{ github.run_id }}
restore-keys: |
ccache-linux-
- name: Setup Node.js
run: |
# Node.js ships preinstalled on GitHub-hosted runners; just install deps
cd bot-server
npm install --omit=dev
- name: Configure
run: |
set -euo pipefail
cmake -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_CXX_FLAGS="-Wno-error" \
-DCMAKE_C_FLAGS="-Wno-error" \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DLauncher_VERSION="${{ needs.skip-check.outputs.version }}" \
-DLauncher_BUILD_NUMBER="${{ github.run_number }}" \
-S . -B build
- name: Build
run: |
set -euo pipefail
ccache -z
cmake --build build -j$(nproc)
echo "=== ccache stats ==="
ccache -s
ccache --print-stats
- name: Package
run: |
set -euo pipefail
cmake --install build --prefix /tmp/pollymc
mkdir -p appdir/usr/bin appdir/usr/lib appdir/usr/share/applications appdir/usr/share/icons/hicolor/256x256/apps
cp /tmp/pollymc/bin/pollymc appdir/usr/bin/
cp /tmp/pollymc/bin/pollymc_filelink appdir/usr/bin/ 2>/dev/null || true
cp -r /tmp/pollymc/share/* appdir/usr/share/ 2>/dev/null || true
cp build/program_info/org.pollymc.PollyMC.desktop appdir/usr/share/applications/org.pollymc.PollyMC.desktop
cp program_info/org.pollymc.PollyMC_256.png appdir/usr/share/icons/hicolor/256x256/apps/org.pollymc.PollyMC.png
# Bundle critical libraries explicitly
cp -L /usr/lib/x86_64-linux-gnu/libssl.so.* appdir/usr/lib/ 2>/dev/null || true
cp -L /usr/lib/x86_64-linux-gnu/libcrypto.so.* appdir/usr/lib/ 2>/dev/null || true
cp -L /usr/lib/x86_64-linux-gnu/libgamemode.so.* appdir/usr/lib/ 2>/dev/null || true
cp -L /usr/lib/x86_64-linux-gnu/libgamemodeauto.so.* appdir/usr/lib/ 2>/dev/null || true
# Bundle all Qt image format plugins
mkdir -p appdir/usr/plugins/imageformats
cp -L /usr/lib/x86_64-linux-gnu/qt6/plugins/imageformats/*.so appdir/usr/plugins/imageformats/ 2>/dev/null || true
# Bundle the Qt icon engine plugin (libqsvgicon.so) so SVG icons
# render in menus/toolbars. Without it, QIcon::fromTheme() SVG
# icons silently fail to load. imageformats/libqsvg.so alone is not
# enough - it only handles SVG as a raster image.
mkdir -p appdir/usr/plugins/iconengines
for p in /usr/lib/x86_64-linux-gnu/qt6/plugins/iconengines/*.so /usr/lib/qt6/plugins/iconengines/*.so; do
if [ -f "$p" ]; then
cp -L "$p" appdir/usr/plugins/iconengines/
echo "DEBUG: bundled icon engine plugin: $p"
fi
done
- name: Bundle bot-server
run: |
set -euo pipefail
# applicationDirPath() = appdir/usr/bin at runtime inside the AppImage
cp -r bot-server appdir/usr/bin/bot-server
rm -f appdir/usr/bin/bot-server/.gitignore
# Remove any dev-only files to keep the bundle lean
rm -rf appdir/usr/bin/bot-server/node_modules/.cache 2>/dev/null || true
- name: Cache linuxdeploy
uses: actions/cache@v5
with:
path: |
linuxdeploy-x86_64.AppImage
linuxdeploy-plugin-qt-x86_64.AppImage
key: linuxdeploy-continuous-${{ runner.os }}
restore-keys: |
linuxdeploy-continuous-
- name: Create AppImage
run: |
set -ex
if [ ! -f linuxdeploy-x86_64.AppImage ]; then
wget -v "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage" -O linuxdeploy-x86_64.AppImage
fi
if [ ! -f linuxdeploy-plugin-qt-x86_64.AppImage ]; then
wget -v "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage" -O linuxdeploy-plugin-qt-x86_64.AppImage
fi
chmod +x linuxdeploy-x86_64.AppImage linuxdeploy-plugin-qt-x86_64.AppImage
cp program_info/org.pollymc.PollyMC_256.png appdir/org.pollymc.PollyMC.png
cp build/program_info/org.pollymc.PollyMC.desktop appdir/org.pollymc.PollyMC.desktop
# Bundle the KDE Breeze widget style (a kstyle plugin, not shipped by
# Qt itself). Placing it in appdir/usr/plugins/qt/styles BEFORE
# linuxdeploy runs lets linuxdeploy-plugin-qt pick it up and bundle
# its dependencies. Ubuntu 24.04 package: "breeze". The Qt6 plugin is
# usually named breeze6.so (or breeze.so on older releases).
BREEZE_STYLE=""
for p in /usr/lib/x86_64-linux-gnu/qt6/plugins/styles/breeze*.so /usr/lib/qt6/plugins/styles/breeze*.so; do
if [ -f "$p" ]; then BREEZE_STYLE="$p"; break; fi
done
if [ -n "$BREEZE_STYLE" ]; then
mkdir -p appdir/usr/plugins/qt/styles
cp -L "$BREEZE_STYLE" appdir/usr/plugins/qt/styles/
echo "DEBUG: bundled Breeze style plugin: $BREEZE_STYLE"
else
echo "::warning::Breeze Qt6 style plugin not found on runner; only stock Qt styles will be available"
fi
QMAKE=$(command -v qmake6 || command -v qmake)
if [ -z "$QMAKE" ]; then
echo "::error::qmake not found in PATH"
exit 1
fi
echo "DEBUG: QMAKE=$QMAKE"
QMAKE="$QMAKE" ./linuxdeploy-x86_64.AppImage \
--appdir appdir \
--plugin qt \
--output appimage
- name: Rename AppImage
run: |
set -euo pipefail
FILE=$(ls PollyMC*.AppImage 2>/dev/null | head -n1)
if [ -z "$FILE" ]; then
echo "ERROR: No AppImage found. Listing directory:"; ls -la; exit 1
fi
mv "$FILE" "PollyMC-Continued-${{ needs.skip-check.outputs.version }}-Linux-x86_64.AppImage"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: PollyMC-Continued-Linux-AppImage
path: PollyMC-Continued-${{ needs.skip-check.outputs.version }}-Linux-x86_64.AppImage
build-macos:
name: macOS (Universal)
needs: [skip-check]
if: needs.skip-check.outputs.skip != 'true'
runs-on: macos-14
permissions:
contents: read
env:
BUILD_TYPE: Release
MACOSX_DEPLOYMENT_TARGET: "13.0"
# Set once at job level so it's visible in every step (including the
# Build step, where ccache actually does its work) - a per-step
# `export` doesn't survive to the next step in Actions. Explicit path
# also avoids relying on ccache's default cache dir, which changed
# location as of ccache 4.0 (now ~/Library/Caches/ccache on macOS).
CCACHE_DIR: ${{ github.workspace }}/.ccache
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Install build tools
run: |
set -euo pipefail
brew update
brew install ninja extra-cmake-modules cmark qrencode libarchive tomlplusplus lz4 xz zstd libpng ccache
- name: Install Qt
uses: jurplel/install-qt-action@v4
with:
aqtsource: git+https://github.com/miurahr/aqtinstall.git
version: 6.11.*
modules: qtimageformats qtnetworkauth qtwebsockets
cache: true
- name: Cache ccache
uses: actions/cache@v5
with:
path: ${{ env.CCACHE_DIR }}
# Unique per run so a fresh save always succeeds (actions/cache
# entries are immutable per key - reusing a static key means every
# run after the first silently fails to save, so ccache never
# learns about new commits and hit rate degrades over time).
# restore-keys still picks up the most recent prior cache to warm
# from.
key: ccache-macos-${{ github.run_id }}
restore-keys: |
ccache-macos-
- name: Setup Node.js
run: |
# Node.js ships preinstalled on GitHub-hosted runners; just install deps
cd bot-server
npm install --omit=dev
- name: Configure
run: |
set -euo pipefail
export PKG_CONFIG_PATH="/opt/homebrew/opt/libarchive/lib/pkgconfig:$PKG_CONFIG_PATH"
cmake -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=install \
-DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" \
-DCMAKE_OSX_ARCHITECTURES="arm64" \
-DLauncher_BUILD_RELEASE=ON \
-DLauncher_BUILD_PLATFORM="${{ runner.os }}" \
-DLauncher_ENABLE_JAVA_DOWNLOADER=ON \
-DENABLE_LTO=ON \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DLauncher_VERSION="${{ needs.skip-check.outputs.version }}" \
-DLauncher_BUILD_NUMBER="${{ github.run_number }}" \
-S . -B build
- name: Read and validate version
id: version
run: |
set -euo pipefail
VERSION=$(cmake -N -LA build | sed -n 's/^Launcher_VERSION:STRING=//p')
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid Launcher_VERSION: $VERSION" >&2
exit 1
fi
if [[ "$GITHUB_REF" == refs/tags/v* && "v$VERSION" != "$GITHUB_REF_NAME" ]]; then
echo "Tag $GITHUB_REF_NAME does not match launcher version $VERSION" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build
run: |
ccache -z
cmake --build --preset macos_universal --config "$BUILD_TYPE"
echo "=== ccache stats ==="
ccache -s
ccache --print-stats
- name: Import signing certificate
env:
APPLE_CODESIGN_CERT: ${{ secrets.APPLE_CODESIGN_CERT }}
APPLE_CODESIGN_ID: ${{ secrets.APPLE_CODESIGN_ID }}
APPLE_CODESIGN_PASSWORD: ${{ secrets.APPLE_CODESIGN_PASSWORD }}
run: |
set -euo pipefail
if [[ -z "$APPLE_CODESIGN_CERT" && -z "$APPLE_CODESIGN_ID" ]]; then
echo "No Apple signing certificate configured; the artifact will be ad-hoc signed."
exit 0
fi
if [[ -z "$APPLE_CODESIGN_CERT" || -z "$APPLE_CODESIGN_ID" || -z "$APPLE_CODESIGN_PASSWORD" ]]; then
echo "Apple signing secrets are only partially configured." >&2
exit 1
fi
KEYCHAIN_PATH="$RUNNER_TEMP/pollymc-build.keychain-db"
CERTIFICATE_PATH="$RUNNER_TEMP/pollymc-codesign.p12"
printf '%s' "$APPLE_CODESIGN_CERT" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "$APPLE_CODESIGN_PASSWORD" "$KEYCHAIN_PATH"
security default-keychain -s "$KEYCHAIN_PATH"
security unlock-keychain -p "$APPLE_CODESIGN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CODESIGN_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$APPLE_CODESIGN_PASSWORD" "$KEYCHAIN_PATH"
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
- name: Package app, ZIP, and DMG
env:
APPLE_CODESIGN_ID: ${{ secrets.APPLE_CODESIGN_ID }}
APPLE_NOTARIZE_APPLE_ID: ${{ secrets.APPLE_NOTARIZE_APPLE_ID }}
APPLE_NOTARIZE_TEAM_ID: ${{ secrets.APPLE_NOTARIZE_TEAM_ID }}
APPLE_NOTARIZE_PASSWORD: ${{ secrets.APPLE_NOTARIZE_PASSWORD }}
run: |
set -euo pipefail
chmod +x packaging/macos/package.sh
packaging/macos/package.sh \
build \
install-macos \
dist-macos \
"${{ steps.version.outputs.version }}" \
"$BUILD_TYPE"
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: PollyMC-Continued-macOS
path: |
dist-macos/PollyMC-Continued-${{ steps.version.outputs.version }}-macOS-arm64.zip
dist-macos/PollyMC-Continued-${{ steps.version.outputs.version }}-macOS-arm64.dmg
dist-macos/PollyMC-Continued-${{ steps.version.outputs.version }}-macOS-arm64.sha256
if-no-files-found: error
- name: Remove temporary signing keychain
if: ${{ always() }}
run: |
set -euo pipefail
if [[ -n "${KEYCHAIN_PATH:-}" && -f "$KEYCHAIN_PATH" ]]; then
security delete-keychain "$KEYCHAIN_PATH"
fi
release:
name: Publish Release
needs: [skip-check, changelog-check, build-windows, build-linux, build-macos]
if: always() && needs.skip-check.outputs.skip != 'true' && needs.changelog-check.result == 'success' && (needs.build-windows.result == 'success' || needs.build-linux.result == 'success' || needs.build-macos.result == 'success') && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check for skip-release marker
id: skipcheck
run: |
set -euo pipefail
HEAD_MSG=$(git log --format="%s %b" HEAD -1)
echo "DEBUG: HEAD commit message: $HEAD_MSG"
if echo "$HEAD_MSG" | grep -qF '[skip release]'; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Skipping release: [skip release] found in the latest commit."
else
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "DEBUG: No [skip release] found, proceeding with release."
fi
- name: Download all artifacts
if: steps.skipcheck.outputs.skip != 'true'
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: List release files
if: steps.skipcheck.outputs.skip != 'true'
run: |
set -euo pipefail
ls -la PollyMC-Continued-* || true
- name: Prepare release metadata
if: steps.skipcheck.outputs.skip != 'true'
id: prep
run: |
set -euo pipefail
# Version is computed once in skip-check and reused everywhere,
# so this no longer re-derives its own (potentially divergent)
# bump. On a tag push, use the tag itself as-is.
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
NEW_TAG="${{ github.ref_name }}"
else
NEW_TAG="v${{ needs.skip-check.outputs.version }}"
fi
echo "tag_name=${NEW_TAG}" >> "$GITHUB_OUTPUT"
echo "release_name=PollyMC Continued ${NEW_TAG}" >> "$GITHUB_OUTPUT"
- name: Extract changelog section
if: steps.skipcheck.outputs.skip != 'true'
id: changelog
run: |
set -euo pipefail
TAG="${{ steps.prep.outputs.tag_name }}"
# CHANGELOG.md uses CRLF; strip \r so awk pattern matching is reliable
sed 's/\r$//' CHANGELOG.md > /tmp/changelog_lf.md
awk -v ver="## ${TAG}" '
$0 == ver { found=1; next }
found && /^## / { exit }
found { print }
' /tmp/changelog_lf.md > /tmp/section.md
if [[ -s /tmp/section.md ]]; then
# trim leading/trailing blank lines
sed -e '/./,$!d' -e ':a' -e '/^\n*$/{$d;N;ba' -e '}' /tmp/section.md > release_notes.md
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "_No changelog entry found for ${TAG}._" > release_notes.md
echo "found=false" >> "$GITHUB_OUTPUT"
echo "::warning::No CHANGELOG.md section found for ${TAG} - check that the version header matches exactly."
fi
echo "--- release_notes.md ---"
cat release_notes.md
- name: Publish release
if: steps.skipcheck.outputs.skip != 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.prep.outputs.tag_name }}
name: "${{ steps.prep.outputs.release_name }}"
body_path: release_notes.md
generate_release_notes: false
prerelease: ${{ github.ref == 'refs/heads/develop' || github.event_name == 'workflow_dispatch' || contains(github.ref_name, '-rc') }}
files: |
PollyMC-Continued-*-Setup.exe
PollyMC-Continued-*-Windows-portable.zip
PollyMC-Continued-*-Linux-x86_64.AppImage
PollyMC-Continued-*-macOS-arm64.zip
PollyMC-Continued-*-macOS-arm64.dmg
PollyMC-Continued-*-macOS-arm64.sha256