Skip to content

feat(gen3d): Pixal3D backend across UI, CLI and MCP #3866

feat(gen3d): Pixal3D backend across UI, CLI and MCP

feat(gen3d): Pixal3D backend across UI, CLI and MCP #3866

Workflow file for this run

name: Deploy
on:
push:
branches: [ "master", "feat/quads", "feat/quads-*" ]
pull_request:
# `master` covers shipping work; `feat/quads` is the long-lived
# quad-migration integration branch (#326) that chunked PRs land
# on before the final merge to master. The `feat/quads-*` glob
# covers chunked PRs that target a previous chunk's branch
# (stacked PRs). Without these, chunked PRs would have no CI
# safety net.
branches: [ "master", "feat/quads", "feat/quads-*" ]
release:
types: [published]
env:
# Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
BUILD_TYPE: Release
AQT_VERSION: '==3.3.*'
QT_VERSION: '6.9.3'
ASSIMP_VERSION: '6.0.5'
ASSIMP_DIR_VERSION: '6.0'
OGRE_VERSION: '14.5.2'
# Bump to bust the macOS assimp/ogre caches. The cached OGRE/Assimp SDKs bake
# an absolute Xcode SDK path (e.g. .../usr/lib/libz.tbd) into their CMake
# export. The Pin-Xcode step also pins SDKROOT so CMake's ZLIB resolves under
# the selected Xcode (xcode-select alone didn't stop find_package(ZLIB) from
# picking xcrun's default 26.5 SDK). Bump this whenever the pinned Xcode/SDK
# changes so the SDK is rebuilt against it and stale libz.tbd paths are
# discarded. (sdkpin1 = first build under the SDKROOT-pinned environment;
# sdkpin2 = bust the stale assimp cache that still baked the Xcode 26.5
# libz.tbd path — "No rule to make target .../MacOSX26.5.sdk/.../libz.tbd"
# when OGRE consumed it under the pinned 26.3.)
MACOS_CACHE_VERSION: 'sdkpin2-assimp605'
jobs:
# send-slack-notification:
# runs-on: ubuntu-latest
# steps:
# - name: send slack message
# uses: docker://technosophos/slack-notify
# env:
# SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
# SLACK_MESSAGE: "Building QtMeshEditor in GitHub Actions - works! :D"
####################################################################
# Doc pins (README + website hook) must match CMakeLists project() VERSION
####################################################################
verify-doc-versions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Pinned GitHub Action refs match CMakeLists.txt
run: ./scripts/sync-doc-versions-from-cmake.sh --check
- name: File association packaging files present
run: chmod +x ./scripts/verify-file-associations.sh && ./scripts/verify-file-associations.sh
- name: TRELLIS.2 restricted-dependency gate (no nvdiffrast/nvdiffrec)
run: chmod +x ./scripts/check-trellis2-restricted-deps.sh && ./scripts/check-trellis2-restricted-deps.sh
####################################################################
# Asset Scan (runs first, before all builds)
####################################################################
scan-assets-qtmesh:
needs: verify-doc-versions
# Validate repo assets using the published Docker image (latest).
# Runs on PRs, pushes to master, and releases.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Published :latest may lag behind Dockerfile/runtime deps (e.g. libsecret).
# Build from this repo's Dockerfile + the latest release .deb to validate the image.
- name: Build qtmesh Docker image for asset scan
run: |
set -euo pipefail
VERSION="$(grep -E '^[[:space:]]*project\([[:space:]]*QtMeshEditor[[:space:]]+VERSION' CMakeLists.txt \
| sed -E 's/.*VERSION[[:space:]]+([0-9.]+).*/\1/')"
# The Dockerfile now COPYs qtmesheditor_${TARGETARCH}.deb; this scan
# job builds for the host arch only (amd64 runner), so fetch the amd64
# .deb under that exact name.
DEB_URL="https://github.com/fernandotonon/QtMeshEditor/releases/download/${VERSION}/qtmesheditor_amd64.deb"
if ! curl -fsSL -o qtmesheditor_amd64.deb "$DEB_URL"; then
echo "::notice::No release .deb for ${VERSION}; using 3.4.0 package for Docker scan build."
curl -fsSL -o qtmesheditor_amd64.deb \
"https://github.com/fernandotonon/QtMeshEditor/releases/download/3.4.0/qtmesheditor_amd64.deb"
fi
docker build --platform linux/amd64 -t ghcr.io/fernandotonon/qtmesh:ci-scan -f Dockerfile --build-arg VERSION="${VERSION}" .
- name: Scan media/ via Docker (ci-scan image)
run: |
set +e
docker run --rm \
-v "${{ github.workspace }}:/workspace" \
-w /workspace \
ghcr.io/fernandotonon/qtmesh:ci-scan \
scan --config /workspace/qtmesh.yml --json > qtmesh-scan-report.json
scan_exit=$?
set -e
if [ "$scan_exit" -ne 0 ]; then
echo "::warning::Docker asset scan reported issues (using latest published image)"
fi
if ! jq -e '.summary and .version' qtmesh-scan-report.json >/dev/null 2>&1; then
echo "::warning::Docker scan output was not valid JSON. Cloud upload will be skipped."
echo '{}' > qtmesh-scan-report.json
fi
- name: Upload scan report to QtMesh Cloud
if: always()
env:
QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }}
QTMESH_CLOUD_API_URL: https://api.qtmesh.dev
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number || '' }}
run: |
if [ -z "${QTMESH_CLOUD_TOKEN:-}" ]; then
echo "::notice::QTMESH_CLOUD_TOKEN is not set. Skipping QtMesh Cloud upload."
exit 0
fi
if ! jq -e '.summary and .version' qtmesh-scan-report.json >/dev/null 2>&1; then
echo "::warning::No valid scan JSON payload found. Skipping QtMesh Cloud upload."
exit 0
fi
if [ -n "${GITHUB_PR_NUMBER:-}" ]; then
jq --arg branch "${GITHUB_REF_NAME}" \
--arg sha "${GITHUB_SHA}" \
--arg runId "${GITHUB_RUN_ID}" \
--argjson pr "${GITHUB_PR_NUMBER}" \
'. + {meta: {branch: $branch, commitSha: $sha, runId: $runId, prNumber: $pr}}' \
qtmesh-scan-report.json > qtmesh-scan-upload.json
else
jq --arg branch "${GITHUB_REF_NAME}" \
--arg sha "${GITHUB_SHA}" \
--arg runId "${GITHUB_RUN_ID}" \
'. + {meta: {branch: $branch, commitSha: $sha, runId: $runId}}' \
qtmesh-scan-report.json > qtmesh-scan-upload.json
fi
upload_report() {
local base_url="${1%/}"
curl --silent --show-error --fail \
-X POST "${base_url}/v1/ingest/scan" \
-H "Authorization: Bearer ${QTMESH_CLOUD_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary @qtmesh-scan-upload.json >/tmp/qtmesh-cloud-upload-response.json
}
if upload_report "${QTMESH_CLOUD_API_URL}"; then
echo "QtMesh Cloud upload succeeded via ${QTMESH_CLOUD_API_URL}"
cat /tmp/qtmesh-cloud-upload-response.json
else
echo "::warning::QtMesh Cloud upload failed via ${QTMESH_CLOUD_API_URL}."
fi
- name: Scan media/ via GitHub Action (root action.yml)
uses: ./
with:
command: scan
input-file: .
options: --config /workspace/qtmesh.yml
image-tag: ci-scan
####################################################################
# Windows Deploy
####################################################################
build-n-cache-assimp-windows:
needs: scan-assets-qtmesh
runs-on: windows-latest
steps:
- name: Cache Assimp
id: cache-assimp-windows
uses: actions/cache@v3
env:
cache-name: cache-assimp-windows-mingw1310-605
with:
# It is caching the folder that also contains source and building files, maybe in the future it would be nice cache only the includes and dll
path: |
C:/PROGRA~2/Assimp
key: ${{ runner.os }}-build-${{ env.cache-name }}
- if: steps.cache-assimp-windows.outputs.cache-hit != 'true'
name: Install Qt (for MinGW compiler)
uses: jurplel/install-qt-action@v3
with:
aqtversion: ${{ env.AQT_VERSION }}
version: ${{ env.QT_VERSION }}
host: 'windows'
target: 'desktop'
arch: 'win64_mingw'
tools: 'tools_cmake tools_mingw1310'
- if: steps.cache-assimp-windows.outputs.cache-hit != 'true'
name: Check out Assimp repo
uses: actions/checkout@master
with:
repository: assimp/assimp
ref: v${{ env.ASSIMP_VERSION }}
path: ${{github.workspace}}/assimp
- if: steps.cache-assimp-windows.outputs.cache-hit != 'true'
name: Build Assimp repo
env:
CMAKE_GENERATOR: "MinGW Makefiles"
PATH: "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin;D:/a/QtMeshEditor/Qt/Tools/CMake_64/bin;${{ env.PATH }}"
run: |
Write-Host "Building Assimp with Qt MinGW 13.1.0"
Write-Host "gcc version: $(gcc.exe --version | Select-Object -First 1)"
Write-Host "cmake version: $(cmake --version | Select-Object -First 1)"
Set-Location "${{github.workspace}}/assimp"
# NB Draco encode (#506) is OFF on Windows/MinGW for now — Assimp's
# bundled Draco fails to configure under MinGW (its `draco` install
# target doesn't exist) and ENABLE_ONNX/MOCAP are likewise off here.
# Linux + macOS ship Draco via a standalone static build (see those jobs).
cmake -S . -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DCMAKE_GENERATOR="MinGW Makefiles" -DCMAKE_C_COMPILER="D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/gcc.exe" -DCMAKE_CXX_COMPILER="D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/g++.exe" -DASSIMP_WARNINGS_AS_ERRORS=OFF
D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/mingw32-make.exe install -j8
Copy-Item -Recurse -Force "C:/PROGRA~2/Assimp" "${{github.workspace}}/assimp-build"
shell: powershell
build-n-cache-ogre-windows:
needs: build-n-cache-assimp-windows
runs-on: windows-latest
steps:
- name: Cache Assimp
id: cache-assimp-windows
uses: actions/cache@v3
env:
cache-name: cache-assimp-windows-mingw1310-605
with:
path: |
C:/PROGRA~2/Assimp
key: ${{ runner.os }}-build-${{ env.cache-name }}
- name: Cache Ogre
id: cache-ogre-windows
uses: actions/cache@v3
env:
cache-name: cache-ogre-windows-assimp605
with:
path: ${{github.workspace}}/ogre-build/SDK
# Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the ogre repo.
key: ${{ runner.os }}-build-${{ env.cache-name }}
- if: steps.cache-ogre-windows.outputs.cache-hit != 'true'
name: Install Doxygen
shell: powershell
run: |
# Doxygen is optional for the Ogre build (only used for docs); the
# doxygen.nl download is flaky, so retry a few times and never fail the
# job on it — Ogre configures fine without it.
$url = "https://www.doxygen.nl/files/doxygen-1.9.7-setup.exe"
$ok = $false
for ($i = 1; $i -le 3; $i++) {
try {
Invoke-WebRequest -Uri $url -OutFile doxygen-installer.exe -TimeoutSec 120
$ok = $true; break
} catch {
Write-Host "Doxygen download attempt $i failed: $_"
Start-Sleep -Seconds 10
}
}
if ($ok) {
Start-Process -FilePath .\doxygen-installer.exe -ArgumentList '/VERYSILENT' -Wait
} else {
Write-Host "::warning::Doxygen unavailable after retries; continuing without it (docs only)."
}
- if: steps.cache-ogre-windows.outputs.cache-hit != 'true'
name: Check out ogre repo
uses: actions/checkout@master
with:
repository: OGRECave/ogre
ref: v${{ env.OGRE_VERSION }}
path: ${{github.workspace}}/ogre
- if: steps.cache-ogre-windows.outputs.cache-hit != 'true'
name: Install Qt (for MinGW)
uses: jurplel/install-qt-action@v3
with:
aqtversion: ${{ env.AQT_VERSION }}
version: ${{ env.QT_VERSION }}
host: 'windows'
target: 'desktop'
arch: 'win64_mingw'
tools: 'tools_cmake tools_mingw1310'
- if: steps.cache-ogre-windows.outputs.cache-hit != 'true'
name: Add Qt MinGW to PATH for Ogre build
run: |
echo "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "Added Qt MinGW 13.1.0 to PATH for Ogre build"
where gcc.exe
gcc --version
shell: powershell
- if: steps.cache-ogre-windows.outputs.cache-hit != 'true'
name: Build Ogre3D
shell: powershell
env:
CMAKE_GENERATOR: "MinGW Makefiles"
ASSIMP_DIR: "C:/Program Files (x86)/Assimp/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }}/"
PATH: "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin;D:/a/QtMeshEditor/Qt/Tools/CMake_64/bin;${{ env.PATH }}"
run: |
Write-Host "Verifying Ogre build tools:"
Write-Host "gcc location: $((Get-Command gcc.exe).Source)"
Write-Host "g++ location: $((Get-Command g++.exe).Source)"
Write-Host "mingw32-make location: $((Get-Command mingw32-make.exe).Source)"
Write-Host "cmake location: $((Get-Command cmake.exe).Source)"
Set-Location "${{github.workspace}}/ogre"
cmake -B "${{github.workspace}}/ogre-build" -S . -DDOXYGEN_EXECUTABLE="C:\Program Files\doxygen\bin\doxygen.exe" -DCMAKE_C_COMPILER="D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/gcc.exe" -DCMAKE_CXX_COMPILER="D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/g++.exe" -DCMAKE_CXX_STANDARD=17 -DOGRE_BUILD_PLUGIN_ASSIMP=ON -DOGRE_BUILD_PLUGIN_PCZ=ON -DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON -DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON -DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF -DOGRE_BUILD_RENDERSYSTEM_D3D9=OFF -DOGRE_BUILD_RENDERSYSTEM_D3D11=OFF -DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF -DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF -DOGRE_BUILD_COMPONENT_PYTHON=OFF -DOGRE_INSTALL_TOOLS=OFF -DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
Set-Location "${{github.workspace}}/ogre-build"
D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/mingw32-make.exe install -j8
build-windows:
# The CMake configure and build commands are platform agnostic and should work equally well on Windows or Mac.
# You can convert this to a matrix build if you need cross-platform coverage.
# See: https://docs.github.com/en/free-pro-team@latest/actions/learn-github-actions/managing-complex-workflows#using-a-build-matrix
needs: [build-n-cache-assimp-windows, build-n-cache-ogre-windows]
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- name: Cache Assimp
id: cache-assimp-windows
uses: actions/cache@v3
env:
cache-name: cache-assimp-windows-mingw1310-605
with:
# It is caching the folder that also contains source and building files, maybe in the future it would be nice cache only the includes and dll
path: |
C:/PROGRA~2/Assimp
key: ${{ runner.os }}-build-${{ env.cache-name }}
- name: Cache Ogre
id: cache-ogre-windows
uses: actions/cache@v3
env:
cache-name: cache-ogre-windows-assimp605
with:
path: ${{github.workspace}}/ogre-build/SDK
# Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the ogre repo.
key: ${{ runner.os }}-build-${{ env.cache-name }}
- name: Install Qt
uses: jurplel/install-qt-action@v3
with:
aqtversion: ${{ env.AQT_VERSION }}
version: ${{ env.QT_VERSION }}
host: 'windows'
target: 'desktop'
arch: 'win64_mingw'
tools: 'tools_cmake tools_mingw1310'
- name: Add Qt MinGW to PATH
run: |
echo "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "Added Qt MinGW 13.1.0 to PATH"
where gcc.exe
gcc --version
shell: powershell
- name: Configure CMake
env:
OGRE_DIR: ${{github.workspace}}/ogre-build/SDK/CMake/
CMAKE_GENERATOR: "MinGW Makefiles"
ASSIMP_DIR: C:/PROGRA~2/Assimp
PATH: "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin;D:/a/QtMeshEditor/Qt/Tools/CMake_64/bin;${{ env.PATH }}"
run: |
Write-Host "Verifying compiler paths:"
Write-Host "gcc location: $((Get-Command gcc.exe).Source)"
Write-Host "g++ location: $((Get-Command g++.exe).Source)"
Write-Host "mingw32-make location: $((Get-Command mingw32-make.exe).Source)"
Write-Host "cmake location: $((Get-Command cmake.exe).Source)"
gcc --version
cmake --version
cmake -S . -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DCMAKE_CXX_FLAGS="-g" -DCMAKE_C_FLAGS="-g" -DQT_QMAKE_EXECUTABLE=qmake -DCMAKE_C_COMPILER="D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/gcc.exe" -DCMAKE_CXX_COMPILER="D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/g++.exe" -DCMAKE_EXE_LINKER_FLAGS=-static -DQt6_DIR=D:/a/QtMeshEditor/Qt/${{env.QT_VERSION}}/mingw_64/lib/cmake/Qt6 -DQT_DIR=D:/a/QtMeshEditor/Qt/${{env.QT_VERSION}}/mingw_64/lib/cmake/Qt6 -DQt6GuiTools_DIR=D:/a/QtMeshEditor/Qt/${{env.QT_VERSION}}/mingw_64/lib/cmake/Qt6GuiTools -DOGRE_DIR=${{github.workspace}}/ogre-build/SDK/CMake/ -DASSIMP_DIR=C:/PROGRA~2/Assimp/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }} -DENABLE_ONNX=ON -DENABLE_TRELLIS_CPP=ON
# ENABLE_TRELLIS_CPP builds the bundled trellis-cli.exe (static MinGW,
# OpenMP off, portable AVX baseline — cmake/TrellisCpp.cmake). The
# install rules carry it + its license notices into bin/, so the zip
# and the installer ship it and the DLL verifier import-walks it. shell: powershell
- name: Build
env:
PATH: "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin;D:/a/QtMeshEditor/Qt/Tools/CMake_64/bin;${{ env.PATH }}"
run: |
Write-Host "Using make from:"
Write-Host "mingw32-make location: $((Get-Command mingw32-make.exe).Source)"
mingw32-make --version
D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/mingw32-make.exe install -j8
shell: powershell
- name: Verify bundled trellis-cli.exe
run: |
if (-not (Test-Path "${{github.workspace}}/bin/trellis-cli.exe")) {
Write-Error "ENABLE_TRELLIS_CPP=ON but bin/trellis-cli.exe is missing"
exit 1
}
$lic = "${{github.workspace}}/bin/trellis-cli.THIRD_PARTY_LICENSES.txt"
if (-not (Test-Path $lic) -or (Get-Item $lic).Length -eq 0) {
Write-Error "trellis-cli license notices missing or EMPTY in bin/ (the 3.38.0 zip shipped a 0-byte file)"
exit 1
}
Write-Host "bundled trellis-cli.exe present: $((Get-Item "${{github.workspace}}/bin/trellis-cli.exe").Length) bytes"
shell: powershell
- name: Upload debug symbols to Sentry
if: github.event_name == 'release' && github.event.action == 'published'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: |
cp "${{github.workspace}}/bin/QtMeshEditor.exe" "${{github.workspace}}/bin/QtMeshEditor.debug.exe"
"D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin/strip.exe" --strip-debug "${{github.workspace}}/bin/QtMeshEditor.exe"
curl -sL https://sentry.io/get-cli/ | bash
/usr/local/bin/sentry-cli debug-files upload --include-sources "${{github.workspace}}/bin/QtMeshEditor.debug.exe"
rm -f "${{github.workspace}}/bin/QtMeshEditor.debug.exe"
shell: bash
- name: Copy assimp dll to the binary folder
run: |
Write-Host "Checking available DLL files:"
Get-ChildItem C:/PROGRA~2/Assimp/bin
if (Test-Path "C:/PROGRA~2/Assimp/bin/libassimp-6.dll") {
Copy-Item "C:/PROGRA~2/Assimp/bin/libassimp-6.dll" "${{github.workspace}}/bin"
} elseif (Test-Path "C:/PROGRA~2/Assimp/bin/libassimp.dll") {
Copy-Item "C:/PROGRA~2/Assimp/bin/libassimp.dll" "${{github.workspace}}/bin"
} else {
Write-Host "Looking for any assimp dll files:"
Copy-Item "C:/PROGRA~2/Assimp/bin/libassimp*.dll" "${{github.workspace}}/bin"
}
shell: powershell
- name: Copy gcc dll to the binary folder
run: |
Write-Host "Using Qt MinGW 13.1.0 DLLs"
$mingwBin = "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin"
Write-Host "MinGW bin directory: $mingwBin"
Get-ChildItem "$mingwBin/*.dll" | Where-Object { $_.Name -match "(libgcc|libstdc|libwinpthread)" }
Copy-Item "$mingwBin/libgcc_s_seh-1.dll" "${{github.workspace}}/bin" -ErrorAction SilentlyContinue
Copy-Item "$mingwBin/libstdc++-6.dll" "${{github.workspace}}/bin" -ErrorAction SilentlyContinue
Copy-Item "$mingwBin/libwinpthread-1.dll" "${{github.workspace}}/bin" -ErrorAction SilentlyContinue
# ONNX Runtime is MSVC-built: app-local deployment of the VC++
# runtime it links (vcruntime140*, msvcp140*) so a clean Windows
# without the VC redistributable can still start the process.
# windows-latest carries the redist copies in System32.
$sys32 = "$env:SystemRoot/System32"
foreach ($dll in @("vcruntime140.dll","vcruntime140_1.dll","msvcp140.dll","msvcp140_1.dll","msvcp140_2.dll")) {
if (Test-Path "$sys32/$dll") {
Copy-Item "$sys32/$dll" "${{github.workspace}}/bin" -Force
Write-Host "Bundled $dll"
} else {
Write-Host "NOTE: $dll not found in System32"
}
}
Copy-Item "${{github.workspace}}/src/dependencies/zlib1.dll" "${{github.workspace}}/bin/zlib1__.dll" -ErrorAction SilentlyContinue
Copy-Item "${{github.workspace}}/src/dependencies/zlib1.dll" "${{github.workspace}}/bin/libzlib.dll" -ErrorAction SilentlyContinue
shell: powershell
- name: Copy Qt QML modules for Inspector and Material Editor
run: |
$qtDir = "D:/a/QtMeshEditor/Qt/${{ env.QT_VERSION }}/mingw_64"
$qmlDest = "${{github.workspace}}/bin/qml"
New-Item -ItemType Directory -Force -Path $qmlDest | Out-Null
Copy-Item -Recurse "$qtDir/qml/QtQuick" "$qmlDest/" -ErrorAction SilentlyContinue
Copy-Item -Recurse "$qtDir/qml/QtQml" "$qmlDest/" -ErrorAction SilentlyContinue
Write-Host "QML modules copied to $qmlDest"
Get-ChildItem $qmlDest -Directory
# Verify critical modules were copied
if (-not (Test-Path "$qmlDest/QtQuick")) {
Write-Error "Critical: QtQuick module not found after copy"
exit 1
}
if (-not (Test-Path "$qmlDest/QtQml")) {
Write-Error "Critical: QtQml module not found after copy"
exit 1
}
shell: powershell
- name: Copy Qt QML runtime libraries
run: |
$qtDir = "D:/a/QtMeshEditor/Qt/${{ env.QT_VERSION }}/mingw_64"
$dest = "${{github.workspace}}/bin"
foreach ($lib in @("Qt6QuickControls2", "Qt6QuickControls2Impl", "Qt6QuickControls2Basic",
"Qt6QuickControls2BasicStyleImpl", "Qt6QuickTemplates2", "Qt6QuickLayouts",
"Qt6QuickDialogs2", "Qt6QuickDialogs2Utils", "Qt6QuickDialogs2QuickImpl",
"Qt6OpenGL")) {
$dll = "$qtDir/bin/${lib}.dll"
if (Test-Path $dll) {
Copy-Item $dll $dest
Write-Host "Copied $lib.dll"
}
}
shell: powershell
- name: Verify Windows package DLL dependencies
run: |
$mingw = "D:/a/QtMeshEditor/Qt/Tools/mingw1310_64/bin"
$objdump = "$mingw/objdump.exe"
$ogreSdk = "${{github.workspace}}/ogre-build/SDK/bin"
if (-not (Test-Path $ogreSdk)) {
$ogreSdk = "${{github.workspace}}/ogre-build/SDK/bin/Release"
}
pwsh -File "${{github.workspace}}/scripts/verify-windows-package-dlls.ps1" `
-BinDir "${{github.workspace}}/bin" `
-Objdump $objdump `
-OgreSdkBin $ogreSdk `
-CopyMissingOgreDlls
shell: powershell
- name: Remove test-only dance models from package
run: |
Remove-Item "${{github.workspace}}/bin/media/models/Twist Dance.fbx" -ErrorAction SilentlyContinue
Remove-Item "${{github.workspace}}/bin/media/models/Rumba Dancing.fbx" -ErrorAction SilentlyContinue
Remove-Item "${{github.workspace}}/bin/media/models/Hip Hop Dancing.fbx" -ErrorAction SilentlyContinue
shell: powershell
- name: Smoke-test CLI (bin directory)
shell: powershell
run: |
$exe = "${{github.workspace}}\bin\qtmesh.exe"
Write-Host "Running: $exe --version"
$output = & $exe --version
$exitCode = $LASTEXITCODE
Write-Host "Output: $output"
Write-Host "Exit code: $exitCode"
if ($output -notmatch "qtmesh \d+\.\d+\.\d+") {
Write-Error "CLI smoke test FAILED: output '$output' does not match expected 'qtmesh X.Y.Z'"
exit 1
}
# Note: qtmesh.exe may exit with STATUS_DLL_NOT_FOUND (0xC0000135, -1073741515)
# during ExitProcess() DLL unload on Windows — a known MinGW DLL detach artifact
# when the exit happens before Ogre statics are initialised. This does not indicate
# a functional failure; the version string was produced correctly.
# Fail only on codes that mean a genuine crash (non-negative non-zero).
if ($exitCode -gt 0) {
Write-Error "CLI smoke test FAILED: exe exited with code $exitCode"
exit 1
}
Write-Host "CLI smoke test PASSED"
exit 0
- name: Upload Artifact
if: github.event_name == 'release' && github.event.action == 'published'
uses: actions/upload-artifact@v4
with:
name: QtMeshEditor-${{github.ref_name}}-bin-Windows
path: ${{github.workspace}}/bin
- name: Bundle Windows file-association helper
run: |
New-Item -ItemType Directory -Force -Path "${{github.workspace}}/bin/scripts" | Out-Null
Copy-Item "${{github.workspace}}/scripts/register-windows-file-associations.ps1" "${{github.workspace}}/bin/scripts/"
shell: powershell
- name: Compress File
if: github.event_name == 'release' && github.event.action == 'published'
run: Compress-Archive ${{github.workspace}}/bin QtMeshEditor-${{github.ref_name}}-bin-Windows.zip
shell: powershell
- name: Build Windows installer (Inno Setup)
if: github.event_name == 'release' && github.event.action == 'published'
shell: powershell
run: |
choco install innosetup -y --no-progress
$isccPath = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe"
if (-not (Test-Path $isccPath)) {
Write-Error "Inno Setup installation failed or ISCC.exe not found at $isccPath"
exit 1
}
$version = "${{ github.ref_name }}".TrimStart("v")
$iss = Get-Content "${{github.workspace}}/packaging/windows/QtMeshEditor.iss" -Raw
$iss = $iss.Replace("@PROJECT_VERSION@", $version)
Set-Content -Path "${{github.workspace}}/packaging/windows/QtMeshEditor.build.iss" -Value $iss
& $isccPath "${{github.workspace}}/packaging/windows/QtMeshEditor.build.iss"
- name: Smoke-test CLI from zip
if: github.event_name == 'release' && github.event.action == 'published'
shell: powershell
run: |
$zipFile = "QtMeshEditor-${{github.ref_name}}-bin-Windows.zip"
$testDir = "${{github.workspace}}\smoketest-zip"
New-Item -ItemType Directory -Force -Path $testDir | Out-Null
Expand-Archive -Path $zipFile -DestinationPath $testDir -Force
$version = "${{github.ref_name}}".TrimStart("v")
$exe = "$testDir\bin\qtmesh.exe"
Write-Host "Running: $exe --version"
$output = & $exe --version
$exitCode = $LASTEXITCODE
Write-Host "Output: $output"
Write-Host "Exit code: $exitCode"
if ($output -notmatch [regex]::Escape($version)) {
Write-Error "Zip smoke test FAILED: expected version '$version' in output '$output'"
exit 1
}
# STATUS_DLL_NOT_FOUND (0xC0000135, -1073741515) during DLL detach is a known MinGW artifact
if ($exitCode -gt 0) {
Write-Error "Zip smoke test FAILED: exe exited with code $exitCode"
exit 1
}
Write-Host "Zip smoke test PASSED"
exit 0
- name: Uploadfile to release
if: github.event_name == 'release' && github.event.action == 'published'
uses: xresloader/upload-to-github-release@main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
file: QtMeshEditor-${{github.ref_name}}-bin-Windows.zip
update_latest_release: true
overwrite: false
verbose: true
- name: Upload Windows installer to release
if: github.event_name == 'release' && github.event.action == 'published'
uses: xresloader/upload-to-github-release@main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# Inno Setup writes to packaging/windows/Output/ (its default OutputDir,
# next to the .iss) — the upload must point there, not the repo root,
# or the installer asset is never attached to the release.
file: packaging/windows/Output/QtMeshEditor-${{github.ref_name}}-setup-Windows.exe
update_latest_release: true
overwrite: false
verbose: true
####################################################################
# Linux Deploy
####################################################################
build-n-cache-assimp-linux:
needs: scan-assets-qtmesh
# Build Assimp + Draco for BOTH Linux arches so the Docker image can ship a
# native linux/arm64 variant (Apple Silicon Macs) alongside linux/amd64.
# arm64 runs on GitHub's native ubuntu-24.04-arm runner (no QEMU).
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: change folder permissions
run: |
sudo chmod 777 /usr/local/lib
sudo chmod 777 /usr/local/include
- name: Cache Assimp
id: cache-assimp-linux
uses: actions/cache@v3
env:
# Cache key is per-ARCH: runner.os is "Linux" for both amd64 and the
# ubuntu-*-arm runner, so without matrix.arch the two builds would
# clobber each other's cache (arm64 restoring amd64 .so's, etc.).
cache-name: cache-assimp-linux-${{ matrix.arch }}-605
with:
# Assimp cache files are stored in `/home/runner/work/QtMeshEditor/QtMeshEditor/assimp` on Linux
path: |
/usr/local/lib/cmake/
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
#key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }}
# Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo.
key: ${{ runner.os }}-build-${{ env.cache-name }}
- if: steps.cache-assimp-linux.outputs.cache-hit != 'true'
name: Check out Assimp repo
uses: actions/checkout@master
with:
repository: assimp/assimp
ref: v${{ env.ASSIMP_VERSION }}
path: /home/runner/work/QtMeshEditor/QtMeshEditor/assimp
- if: steps.cache-assimp-linux.outputs.cache-hit != 'true'
name: Build Assimp repo
# Assimp is built WITHOUT -DASSIMP_BUILD_DRACO: Assimp's own Draco
# integration is broken across platforms (macOS -Werror on draco headers,
# Linux exports an assimp::draco target referencing an uninstalled .so,
# Windows/MinGW's `draco` install target doesn't exist). We only need
# Draco's ENCODER for MeshDracoEncoder (#506), so we build the vendored
# contrib/draco as a standalone static lib below and install it to the
# same prefix — clean and identical on all three platforms.
# -DASSIMP_WARNINGS_AS_ERRORS=OFF: Assimp defaults it ON, and on the arm64
# runner's GCC a warning in Common/Assimp.cpp is fatal ("all warnings
# being treated as errors") — it doesn't fire on amd64's GCC. Disable it
# (the Windows Assimp build already does, for the same reason).
run: |
cmake -B /home/runner/work/QtMeshEditor/QtMeshEditor/assimp-build -S /home/runner/work/QtMeshEditor/QtMeshEditor/assimp -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DASSIMP_WARNINGS_AS_ERRORS=OFF
cd /home/runner/work/QtMeshEditor/QtMeshEditor/assimp-build/
sudo make install -j8
- if: steps.cache-assimp-linux.outputs.cache-hit != 'true'
name: Build & install Draco (standalone static, for #506 glTF compression)
run: |
cmake -B /home/runner/work/QtMeshEditor/QtMeshEditor/draco-build \
-S /home/runner/work/QtMeshEditor/QtMeshEditor/assimp/contrib/draco \
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DDRACO_TESTS=OFF -DBUILD_SHARED_LIBS=OFF
cmake --build /home/runner/work/QtMeshEditor/QtMeshEditor/draco-build --target draco_static -j8
sudo cmake --install /home/runner/work/QtMeshEditor/QtMeshEditor/draco-build || true
# cmake --install may miss the umbrella archive when only draco_static
# was built — copy the lib + headers explicitly so Draco.cmake finds them.
sudo cp /home/runner/work/QtMeshEditor/QtMeshEditor/draco-build/libdraco.a /usr/local/lib/ 2>/dev/null || true
sudo mkdir -p /usr/local/include/draco
sudo cp -R /home/runner/work/QtMeshEditor/QtMeshEditor/assimp/contrib/draco/src/draco/. /usr/local/include/draco/
sudo cp /home/runner/work/QtMeshEditor/QtMeshEditor/draco-build/draco/draco_features.h /usr/local/include/draco/ 2>/dev/null || true
build-n-cache-ogre-linux:
needs: build-n-cache-assimp-linux
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: change folder permissions
run: |
sudo chmod 777 /usr/local/lib
sudo chmod 777 /usr/local/include
sudo chmod 777 /usr/local/share
- name: Cache Ogre
id: cache-ogre-linux
uses: actions/cache@v3
env:
cache-name: cache-ogre-linux-${{ matrix.arch }}-assimp605
with:
path: |
/usr/local/lib/lib*
/usr/local/share/OGRE/
/usr/local/share/OGRE*
/usr/local/include/OGRE/
/usr/local/lib/OGRE/
/usr/local/lib/pkgconfig/
key: ${{ runner.os }}-build-${{ env.cache-name }}
- if: steps.cache-ogre-linux.outputs.cache-hit != 'true'
name: Cache Assimp
id: cache-assimp-linux
uses: actions/cache@v3
env:
cache-name: cache-assimp-linux-${{ matrix.arch }}-605
with:
# Assimp cache files are stored in `/home/runner/work/QtMeshEditor/assimp` on Linux/macOS
path: |
/usr/local/lib/cmake/
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
#key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }}
# Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo.
key: ${{ runner.os }}-build-${{ env.cache-name }}
- if: steps.cache-ogre-linux.outputs.cache-hit != 'true'
name: install openGL
run: |
sudo apt update
sudo apt-get install freeglut3-dev libxrandr-dev
- if: steps.cache-ogre-linux.outputs.cache-hit != 'true'
name: Check out ogre repo
uses: actions/checkout@master
with:
repository: OGRECave/ogre
ref: v${{ env.OGRE_VERSION }}
path: /home/runner/work/QtMeshEditor/QtMeshEditor/ogre
- if: steps.cache-ogre-linux.outputs.cache-hit != 'true'
name: Build Ogre3D repo
run: |
sudo cmake -B /home/runner/work/QtMeshEditor/QtMeshEditor/ogre-build \
-S /home/runner/work/QtMeshEditor/QtMeshEditor/ogre \
-DOGRE_BUILD_PLUGIN_ASSIMP=ON -Dassimp_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }} \
-DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON \
-DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON -DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF \
-DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF \
-DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF \
-DOGRE_BUILD_COMPONENT_BULLET=OFF \
-DOGRE_BUILD_COMPONENT_PYTHON=OFF -DOGRE_INSTALL_TOOLS=OFF \
-DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF \
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
cd /home/runner/work/QtMeshEditor/QtMeshEditor/ogre-build/
sudo make install -j8
build-linux:
# Explicit name: without it GitHub derives the check name from EVERY matrix
# parameter, so this job reported as
# "build-linux (amd64, ubuntu-latest, linux, linux_gcc_64, gcc_64, x86_64-linux-gnu)".
# Branch protection required a context called plain "build-linux", which a
# matrix job never produces, so that context sat pending forever and every
# PR showed BLOCKED with nothing actually failing. Pinning a short stable
# name keeps the required contexts working even if the matrix gains or
# loses parameters later.
name: build-linux (${{ matrix.arch }})
needs: [build-n-cache-assimp-linux, build-n-cache-ogre-linux]
# Matrix over both Linux arches → qtmesheditor_amd64.deb + qtmesheditor_arm64.deb.
# The arm64 .deb is what lets the Docker image ship a native linux/arm64
# variant for Apple Silicon Macs (no QEMU). Qt arch and the multiarch lib
# triplet differ per arch; everything else (packaging) is shared verbatim.
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
qt_host: linux
qt_arch: linux_gcc_64
qt_dir: gcc_64
triplet: x86_64-linux-gnu
- arch: arm64
runner: ubuntu-24.04-arm
# Qt 6.9.3 arm64 lives under the aqt host "linux_arm64" (NOT "linux"
# — that host has no arm64 packages: aqt errors "qt_base not found").
qt_host: linux_arm64
qt_arch: linux_gcc_arm64
qt_dir: gcc_arm64
triplet: aarch64-linux-gnu
runs-on: ${{ matrix.runner }}
env:
LD_LIBRARY_PATH: /usr/local/lib/:/usr/local/lib/OGRE/:/usr/local/lib/pkgconfig/
steps:
- uses: actions/checkout@v3.5.3
with:
submodules: true
- name: Install Qt
# v4 is required for the arm64 lane: v3 rejects host "linux_arm64"
# ("not one of windows|mac|linux"). v4 adds linux_arm64/windows_arm64
# hosts and is backward-compatible with the amd64 host: linux inputs.
uses: jurplel/install-qt-action@v4
with:
aqtversion: ${{ env.AQT_VERSION }}
version: ${{ env.QT_VERSION }}
host: '${{ matrix.qt_host }}'
target: 'desktop'
arch: '${{ matrix.qt_arch }}'
# qtmultimedia: performance capture (ENABLE_MOCAP, epic #869)
modules: 'qtmultimedia'
- name: change folder permissions
run: |
sudo chmod 777 /usr/local/lib
sudo chmod 777 /usr/local/include
sudo chmod 777 /usr/local/share
- name: Cache Assimp
id: cache-assimp-linux
uses: actions/cache@v3
env:
cache-name: cache-assimp-linux-${{ matrix.arch }}-605
with:
path: |
/usr/local/lib/cmake/
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
key: ${{ runner.os }}-build-${{ env.cache-name }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
- name: Cache Ogre
id: cache-ogre-linux
uses: actions/cache@v3
env:
cache-name: cache-ogre-linux-${{ matrix.arch }}-assimp605
with:
path: |
/usr/local/lib/lib*
/usr/local/share/OGRE/
/usr/local/share/OGRE*
/usr/local/include/OGRE/
/usr/local/lib/OGRE/
/usr/local/lib/pkgconfig/
key: ${{ runner.os }}-build-${{ env.cache-name }}
- name: Install Sentry dependencies
run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev libsecret-1-dev libvulkan-dev glslang-tools glslc libshaderc-dev
- name: Configure CMake
run: |
sudo cmake -S . -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \
-DCMAKE_CXX_FLAGS="-g" -DCMAKE_C_FLAGS="-g" \
-DENABLE_STABLE_DIFFUSION=ON \
-DENABLE_ONNX=ON \
-DENABLE_TRELLIS_CPP=ON \
-DQTMESH_ONNX_GPU=OFF \
-DENABLE_MOCAP=ON \
-DENABLE_DRACO=ON \
-DENABLE_AUTO_UPDATER=OFF \
-DASSIMP_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }} \
-DASSIMP_INCLUDE_DIR=/usr/local/include/assimp \
-DQt6_DIR=/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/cmake/Qt6 \
-DQT_DIR=/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/cmake/Qt6 \
-DQt6GuiTools_DIR=/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/cmake/Qt6GuiTools
- name: Build
run: sudo make install -j8
- name: Verify bundled trellis-cli is loadable
run: |
# Regression guard for 3.37.3/3.37.4: shipping trellis-cli without
# libggml*.so made every snap/deb install fail with exit 127.
if [ ! -f ./bin/trellis-cli ]; then
echo "ERROR: ENABLE_TRELLIS_CPP=ON but ./bin/trellis-cli is missing"
ls -la ./bin | head -40
exit 1
fi
echo "=== ldd trellis-cli ==="
ldd ./bin/trellis-cli
if ldd ./bin/trellis-cli | grep -E 'libggml' | grep -q 'not found'; then
echo "ERROR: unresolved libggml deps — package would exit 127 on users' machines"
exit 1
fi
# If still dynamically linked to ggml, the matching .so must sit in
# ./bin so CopyTrellisRuntime / pack-deb can ship them ($ORIGIN).
if ldd ./bin/trellis-cli | grep -qE 'libggml[^ ]*\.so'; then
while read -r line; do
so=$(echo "$line" | awk '{print $1}')
case "$so" in
libggml*.so*)
# Exact SONAME must exist in ./bin (not a broad libggml*
# fallback — that let CI pass when the linked name was missing).
if [ ! -e "./bin/$so" ]; then
echo "ERROR: linked $so but $so is missing from ./bin"
exit 1
fi
;;
esac
done < <(ldd ./bin/trellis-cli)
else
echo "trellis-cli has no dynamic libggml deps (static ggml — preferred)"
fi
# Vulkan GPU backend must be linked AND resolvable at link time
# (ldd can still print "libvulkan.so.1 => not found").
vulkan_ldd="$(ldd ./bin/trellis-cli)"
if ! grep -Eq '(^|[[:space:]])libvulkan\.so[^[:space:]]*[[:space:]]+=>[[:space:]]+/' <<<"$vulkan_ldd"; then
echo "ERROR: trellis-cli Vulkan dependency missing or unresolved:"
printf '%s\n' "$vulkan_ldd"
exit 1
fi
echo "trellis-cli links a resolved libvulkan (GPU backend enabled)"
# 3.37.7 regression: GGML_NATIVE=OFF still defaulted AVX2+FMA, so
# Ivy Bridge (AVX, no FMA) died with SIGILL / exit 4 on vfmadd*.
# Scan the CLI and any colocated libggml*.so*. Capture objdump to a
# file first — under pipefail, `objdump | grep -m1` can SIGPIPE the
# producer and make a real match look like a miss.
if [ "${{ matrix.arch }}" = "amd64" ]; then
fma_found=0
shopt -s nullglob
for f in ./bin/trellis-cli ./bin/libggml*.so*; do
objdump -d "$f" >"/tmp/trellis-disasm.$$.txt"
if grep -E '[[:space:]](vfmadd|vfmsub|vfnmadd|vfnmsub)' \
"/tmp/trellis-disasm.$$.txt" >"/tmp/trellis-fma.$$.txt"; then
echo "ERROR: $f contains FMA opcodes (not portable to pre-Haswell CPUs):"
head -5 "/tmp/trellis-fma.$$.txt"
fma_found=1
fi
rm -f "/tmp/trellis-disasm.$$.txt" "/tmp/trellis-fma.$$.txt"
done
if [ "$fma_found" -ne 0 ]; then
exit 1
fi
echo "trellis-cli (+ any libggml*.so) has no FMA opcodes (portable AVX baseline)"
fi
- name: Upload debug symbols to Sentry
if: github.event_name == 'release' && github.event.action == 'published'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: |
# Extract debug symbols, strip binary, upload to Sentry
sudo objcopy --only-keep-debug ./bin/QtMeshEditor ./bin/QtMeshEditor.debug
sudo strip --strip-debug --strip-unneeded ./bin/QtMeshEditor
sudo objcopy --add-gnu-debuglink=./bin/QtMeshEditor.debug ./bin/QtMeshEditor
curl -sL https://sentry.io/get-cli/ | bash
sentry-cli debug-files upload --include-sources ./bin/QtMeshEditor.debug
sudo rm -f ./bin/QtMeshEditor.debug
- name: Add missing libraries
run: |
# Create the bin directory since cmake builds to build/bin but we need ./bin for the tests
mkdir -p ./bin
mkdir -p ./build/bin
# Copy Qt ICU libraries to both locations
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/libicui18n.* ./bin
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/libicuuc.* ./bin
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/libicudata.* ./bin
# Also copy to build/bin where cmake actually puts the executables
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/libicui18n.* ./build/bin/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/libicuuc.* ./build/bin/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}/lib/libicudata.* ./build/bin/ || true
# Copy libraries to system locations
sudo cp -R ./bin/*.so* /lib/${{ matrix.triplet }} || true
sudo cp -R /usr/local/lib/OGRE/* /lib/${{ matrix.triplet }} || true
sudo cp -R /usr/local/lib/OGRE/* ./bin || true
sudo cp -R /usr/local/lib/OGRE/* ./build/bin/ || true
- name: Manual Pack
run: |
echo "Creating folders 'n files"
mkdir -p ./pack-deb/usr/bin
mkdir -p ./pack-deb/usr/share/qtmesheditor/
mkdir -p ./pack-deb/usr/share/qtmesheditor/cfg/
mkdir -p ./pack-deb/usr/share/qtmesheditor/media/
mkdir -p ./pack-deb/usr/share/qtmesheditor/profiles/
mkdir -p ./pack-deb/usr/share/qtmesheditor/platforms/
mkdir -p ./pack-deb/usr/lib/qtmesheditor/
mkdir -p ./pack-deb/usr/share/doc/qtmesheditor/
mkdir -p ./pack-deb/usr/share/applications/
mkdir -p ./pack-deb/usr/share/mime/packages/
mkdir ./pack-deb/DEBIAN/
cp ./bin/DEBIAN-control ./pack-deb/DEBIAN/control
cp ./packaging/linux/qtmesheditor.desktop ./pack-deb/usr/share/applications/
cp ./packaging/linux/qtmesheditor-url.desktop ./pack-deb/usr/share/applications/
cp ./packaging/linux/qtmesheditor-mimetypes.xml ./pack-deb/usr/share/mime/packages/
cp ./packaging/linux/DEBIAN-postinst ./pack-deb/DEBIAN/postinst
chmod 755 ./pack-deb/DEBIAN/postinst
cp ./bin/QtMeshEditor ./pack-deb/usr/share/qtmesheditor/qtmesheditor
# Bundled TRELLIS.2 runtime (ENABLE_TRELLIS_CPP) — must sit next
# to the editor binary, where Trellis2Predictor probes for it.
if [ -f ./bin/trellis-cli ]; then
cp ./bin/trellis-cli ./pack-deb/usr/share/qtmesheditor/trellis-cli
chmod 755 ./pack-deb/usr/share/qtmesheditor/trellis-cli
# MIT compliance: the runtime's upstream + vendored notices
# must accompany the redistributed binary.
cp ./bin/trellis-cli.THIRD_PARTY_LICENSES.txt \
./pack-deb/usr/share/doc/qtmesheditor/trellis-cli.THIRD_PARTY_LICENSES.txt
# ggml shared libs (if BUILD_SHARED_LIBS wasn't fully static):
# must sit next to trellis-cli ($ORIGIN) AND in the private lib
# dir. 3.37.3/3.37.4 shipped only the CLI → exit 127 on snap/deb.
for ggml_lib in ./bin/libggml*; do
[ -e "$ggml_lib" ] || continue
cp -a "$ggml_lib" ./pack-deb/usr/share/qtmesheditor/
cp -a "$ggml_lib" ./pack-deb/usr/lib/qtmesheditor/
done
fi
# Create proper launcher script
printf '#!/bin/sh\nexport LD_LIBRARY_PATH="/usr/lib/qtmesheditor:/usr/share/qtmesheditor:${LD_LIBRARY_PATH}"\nexport QT_PLUGIN_PATH="/usr/lib/qtmesheditor/plugins:/usr/share/qtmesheditor:${QT_PLUGIN_PATH}"\nexport QML2_IMPORT_PATH="/usr/share/qtmesheditor/qml:${QML2_IMPORT_PATH}"\n# Ogre requires X11 window handles; force XCB under Wayland\nexport QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-xcb}"\nexec /usr/share/qtmesheditor/qtmesheditor "$@"\n' > ./pack-deb/usr/bin/qtmesheditor
chmod 755 ./pack-deb/usr/bin/qtmesheditor
# Create qtmesh symlink for CLI mode (name starts with "qtmesh" but not "qtmesheditor")
ln -sf qtmesheditor ./pack-deb/usr/bin/qtmesh
# Copy copyright file
cp ./DEBIAN-copyright ./pack-deb/usr/share/doc/qtmesheditor/copyright
cp -R ./bin/cfg/ ./pack-deb/usr/share/qtmesheditor/
# Fix OGRE plugin path: CI build hardcodes /usr/local/lib/OGRE but
# the .deb bundles plugins in /usr/lib/qtmesheditor/
sed -i 's|^PluginFolder=.*|PluginFolder=/usr/lib/qtmesheditor|' ./pack-deb/usr/share/qtmesheditor/cfg/plugins.cfg
sudo chmod -R 755 ./pack-deb/usr/share/qtmesheditor/cfg
cp -R ./bin/media/ ./pack-deb/usr/share/qtmesheditor/
# Remove large dance model files used only for testing
rm -f ./pack-deb/usr/share/qtmesheditor/media/models/"Twist Dance.fbx"
rm -f ./pack-deb/usr/share/qtmesheditor/media/models/"Rumba Dancing.fbx"
rm -f ./pack-deb/usr/share/qtmesheditor/media/models/"Hip Hop Dancing.fbx"
cp -R ./bin/profiles ./pack-deb/usr/share/qtmesheditor/
cp -R ./bin/platforms/ ./pack-deb/usr/share/qtmesheditor/
if [ -d ./bin/tls ]; then
cp -R ./bin/tls/ ./pack-deb/usr/share/qtmesheditor/tls/
fi
# Copy bundled libraries to private lib directory
cp -R ./bin/*.so* ./pack-deb/usr/lib/qtmesheditor/
# Bundle Assimp (built from source at /usr/local/lib, not in ./bin/)
cp -R /usr/local/lib/libassimp.so* ./pack-deb/usr/lib/qtmesheditor/
# Bundle Ogre component libs from /usr/local/lib/ that are not in the
# OGRE plugin dir (e.g. libOgreMeshLodGenerator, libOgreOverlay, etc.)
cp /usr/local/lib/libOgre*.so* ./pack-deb/usr/lib/qtmesheditor/ 2>/dev/null || true
# Copy Qt QML modules for Material Editor
QT_DIR="/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/${{ matrix.qt_dir }}"
mkdir -p ./pack-deb/usr/share/qtmesheditor/qml
cp -R "$QT_DIR/qml/QtQuick" ./pack-deb/usr/share/qtmesheditor/qml/
cp -R "$QT_DIR/qml/QtQml" ./pack-deb/usr/share/qtmesheditor/qml/
# Copy Qt QML runtime libraries
# Multimedia / MultimediaQuick: mocap (ENABLE_MOCAP, epic #869)
# links Qt6::Multimedia, so the .deb MUST ship its .so or the
# binary aborts at load with "libQt6Multimedia.so.6: cannot open
# shared object file" (broke the scan-assets Docker job on 3.23.0).
for lib in QuickControls2 QuickControls2Impl QuickControls2Basic QuickControls2BasicStyleImpl \
QuickDialogs2 QuickDialogs2Utils QuickDialogs2QuickImpl \
QuickTemplates2 QuickLayouts Quick QmlModels QmlMeta QmlWorkerScript Qml QmlCore \
Multimedia MultimediaQuick \
OpenGL; do
cp -R "$QT_DIR/lib/libQt6${lib}.so"* ./pack-deb/usr/lib/qtmesheditor/ 2>/dev/null || true
done
# Qt Multimedia FFmpeg backend plugin (video/webcam decode for mocap).
# Without it Multimedia loads but can't open any media source.
if [ -d "$QT_DIR/plugins/multimedia" ]; then
mkdir -p ./pack-deb/usr/lib/qtmesheditor/plugins/multimedia
cp -R "$QT_DIR/plugins/multimedia/"*.so \
./pack-deb/usr/lib/qtmesheditor/plugins/multimedia/ 2>/dev/null || true
fi
# MJPEG webcams deliver Format_Jpeg frames; decoding them via
# QImage::fromData("JPEG") requires the qjpeg imageformat plugin.
if [ -d "$QT_DIR/plugins/imageformats" ]; then
mkdir -p ./pack-deb/usr/lib/qtmesheditor/plugins/imageformats
cp -R "$QT_DIR/plugins/imageformats/"*.so \
./pack-deb/usr/lib/qtmesheditor/plugins/imageformats/ 2>/dev/null || true
fi
# Qt FFmpeg stub shims — libffmpegmediaplugin.so depends on these; when
# they are absent the FFmpeg backend fails to load and
# QMediaDevices::videoInputs() returns empty ("no camera available").
cp -L "$QT_DIR/lib/libQt6FFmpegStub-"*.so* ./pack-deb/usr/lib/qtmesheditor/ 2>/dev/null || true
# Bundle FFmpeg libs required by libffmpegmediaplugin.so so confined
# Snap / minimal .deb installs can open webcams and video files.
FFMPEG_PLUGIN="./pack-deb/usr/lib/qtmesheditor/plugins/multimedia/libffmpegmediaplugin.so"
if [ -f "$FFMPEG_PLUGIN" ]; then
for lib in $(ldd "$FFMPEG_PLUGIN" | awk '/=> \// {print $3}'); do
case "$lib" in
*/libav*|*/libsw*)
cp -L "$lib" ./pack-deb/usr/lib/qtmesheditor/
;;
esac
done
if ldd "$FFMPEG_PLUGIN" | grep -q 'not found'; then
echo "ERROR: libffmpegmediaplugin.so has unresolved deps:" >&2
ldd "$FFMPEG_PLUGIN" | grep 'not found' >&2
exit 1
fi
fi
# PipeWire client — Qt 6 Multimedia dlopen()s libpipewire-0.3 for camera
# enumeration on modern Linux (not via the FFmpeg plugin).
sudo apt-get install -y libpipewire-0.3-0t64
for lib in libpipewire-0.3.so.0; do
src="/usr/lib/${{ matrix.triplet }}/${lib}"
if [ ! -e "$src" ]; then
echo "ERROR: missing ${src} (needed for webcam enumeration)" >&2
exit 1
fi
cp -L "$src" ./pack-deb/usr/lib/qtmesheditor/
done
# Set the .deb Architecture field to match this build (control file
# ships amd64 by default; the arm64 build must advertise arm64 or
# dpkg refuses to install it on an arm64 system / in the arm64 image).
sed -i 's/^Architecture:.*/Architecture: ${{ matrix.arch }}/' ./pack-deb/DEBIAN/control
dpkg-deb --build --root-owner-group pack-deb
mv pack-deb.deb qtmesheditor_${{ matrix.arch }}.deb
- uses: actions/upload-artifact@v4
if: github.event_name == 'release' && github.event.action == 'published'
with:
# Per-arch artifact name — upload-artifact@v4 errors on duplicate names,
# and the matrix runs this job once per arch.
name: linux-binaries-${{ matrix.arch }}
path: ${{github.workspace}}/*.deb
- if: github.event_name == 'release' && github.event.action == 'published'
uses: xresloader/upload-to-github-release@main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
file: ${{github.workspace}}/*.deb
update_latest_release: true
overwrite: false
verbose: true
####################################################################
# Unit Tests - on Linux
####################################################################
unit-tests-linux:
# Skip unit tests on release — they already ran on the PR and master push
if: github.event_name != 'release'
needs: [build-n-cache-assimp-linux, build-n-cache-ogre-linux]
runs-on: ubuntu-latest
# Hard cap on the whole job. The full sweep takes ~12 min normally
# (build + xvfb + ~25 suites + coverage + sonar). Cap at 90 so a
# cold ccache build on a large PR still finishes under the limit.
timeout-minutes: 90
permissions: read-all
env:
LD_LIBRARY_PATH: gcc_64/lib/:/usr/local/lib/:/usr/local/lib/OGRE/:/usr/local/lib/pkgconfig/:/lib/x86_64-linux-gnu/
QT_QPA_PLATFORM: xcb
QT_DEBUG_PLUGINS: 0
DISPLAY: :99
steps:
- uses: actions/checkout@v3.5.3
with:
submodules: true
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Fix broken apt repositories
run: |
# Remove Microsoft repos that frequently return 403/signature errors on GitHub runners
sudo rm -f /etc/apt/sources.list.d/microsoft-prod.list /etc/apt/sources.list.d/azure-cli.list || true
sudo apt-get update || true
- name: Install Qt
uses: jurplel/install-qt-action@v3
with:
aqtversion: ${{ env.AQT_VERSION }}
version: ${{ env.QT_VERSION }}
host: 'linux'
target: 'desktop'
arch: 'linux_gcc_64'
# qtmultimedia: performance capture (ENABLE_MOCAP, epic #869)
modules: 'qtmultimedia'
- name: change folder permissions
run: |
sudo chmod 777 /usr/local/lib
sudo chmod 777 /usr/local/include
sudo chmod 777 /usr/local/share
- name: Cache Assimp
id: cache-assimp-linux
uses: actions/cache@v3
env:
# This job runs on amd64 — restore the amd64 producer's cache.
cache-name: cache-assimp-linux-amd64-605
with:
path: |
/usr/local/lib/cmake/
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
key: ${{ runner.os }}-build-${{ env.cache-name }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-
- name: Cache Ogre
id: cache-ogre-linux
uses: actions/cache@v3
env:
cache-name: cache-ogre-linux-amd64-assimp605
with:
path: |
/usr/local/lib/lib*
/usr/local/share/OGRE/
/usr/local/share/OGRE*
/usr/local/include/OGRE/
/usr/local/lib/OGRE/
/usr/local/lib/pkgconfig/
key: ${{ runner.os }}-build-${{ env.cache-name }}
- name: Install Build Wrapper
uses: SonarSource/sonarqube-scan-action/install-build-wrapper@v6
env:
SONAR_HOST_URL: https://sonarcloud.io
- name: Setup ccache
# Cache compiler outputs across CI runs so the test build (the
# slowest step in this job, dominated by ~500 .cpp compiles)
# gets near-instant replays for files that haven't changed.
#
# MEASURED 2026-09-18: hit rate was 0/1442 on every run, i.e. the
# cache never worked, because of two compounding problems:
#
# 1. SCOPING. GitHub Actions caches are BRANCH-SCOPED: a run can read
# caches written by its own ref or by the DEFAULT branch, never by
# another PR's ref. A per-run key saved from a PR lands under
# `refs/pull/N/merge`, so PR #1054 could never read PR #1052's
# entry — every PR build started cold and then wrote a ~840 MB
# entry only it could ever read.
# 2. QUOTA. Those write-only entries (12 x ~840 MB) filled the repo's
# 10 GB cache allowance, so GitHub evicted by age — including the
# single master-ref entry that every PR needs as its base.
#
# Fix: only the DEFAULT BRANCH writes a cache (`save` is true just on
# master), and every run restores from the master lineage. PR builds
# become read-only consumers of a warm master cache: they get the
# hits without competing for the quota, and one shared entry replaces
# a dozen private ones. `key` still carries run_id so master's own
# save is a fresh immutable entry rather than a no-op write.
uses: hendrikmuhs/ccache-action@v1.2
with:
key: sonar-tests-${{ runner.os }}-${{ github.run_id }}
# NB NO TRAILING DASH, and do NOT add the `ccache-` prefix here.
# From the action's own source (dist/restore/index.js):
# const keyPrefix = ccacheVariant + "-"; // "ccache-"
# const restoreKeys = inputs.restoreKeys
# .map(k => keyPrefix + k + "-");
# It prepends "ccache-" AND appends "-" to whatever we pass, so what
# is actually looked up is `ccache-<ours>-`. Entries are saved as
# `ccache-sonar-tests-Linux-<run_id>-<timestamp>`, therefore:
# "sonar-tests-Linux-" → "ccache-sonar-tests-Linux--" ✗ (double dash)
# "ccache-sonar-tests-Linux-" → "ccache-ccache-sonar-tests-…" ✗ (doubled prefix)
# "sonar-tests-Linux" → "ccache-sonar-tests-Linux-" ✓
# Both earlier values matched nothing, which is why every run logged
# "No cache found." and build-wrapper stayed ~27 min cold.
restore-keys: |
sonar-tests-${{ runner.os }}
# Only master writes; PRs read master's entry (see above).
save: ${{ github.ref == 'refs/heads/master' }}
max-size: 2G
# ccache wraps gcc/g++ so build-wrapper still sees every
# invocation it needs to record for SonarCloud.
create-symlink: true
- name: Setup headless environment for Qt tests
env:
DEBIAN_FRONTEND: noninteractive
run: |
sudo apt-get update
# libretro PSX core is installed later via install-ps1-libretro-core.sh
# (buildbot download). Dropping libretro-beetle-psx here avoids apt
# stalls that have consumed the whole job timeout on GitHub runners.
timeout 600 sudo apt-get install -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold \
libxcb-cursor0 libxcb-xinerama0 libx11-dev xvfb \
mesa-utils libgl1-mesa-dri libegl-mesa0 libgbm1 libglx-mesa0 \
libsecret-1-dev libsodium-dev
# Start Xvfb with GLX support for Ogre GL initialization
Xvfb :99 -screen 0 1024x768x24 +extension GLX > /dev/null 2>&1 &
sleep 3
echo "DISPLAY=:99" >> $GITHUB_ENV
echo "QT_QPA_PLATFORM=xcb" >> $GITHUB_ENV
# Force Mesa software rendering (llvmpipe) - avoids DRI3 hardware errors in CI
echo "LIBGL_ALWAYS_SOFTWARE=1" >> $GITHUB_ENV
echo "MESA_GL_VERSION_OVERRIDE=3.3" >> $GITHUB_ENV
- name: Configure CMake for Tests
env:
# ccache + coverage flags interact awkwardly: the default
# hash includes the absolute build directory and source mtimes,
# which guarantees a cache miss on every CI run since the runner
# is ephemeral. Loosening these makes the cache effective without
# affecting correctness — gcov still emits .gcda at runtime, so
# coverage data is regenerated per test run regardless.
CCACHE_SLOPPINESS: pch_defines,time_macros,include_file_mtime,include_file_ctime
CCACHE_NOHASHDIR: "true"
run: |
mkdir build
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_COMPILER_LAUNCHER=ccache \
-DCMAKE_C_COMPILER_LAUNCHER=ccache \
-DASSIMP_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }} \
-DASSIMP_INCLUDE_DIR=/usr/local/include/assimp \
-DQt6_DIR=/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/cmake/Qt6 \
-DQT_DIR=/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/cmake/Qt6 \
-DQt6GuiTools_DIR=/home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/cmake/Qt6GuiTools \
-DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS="--coverage -fprofile-arcs -ftest-coverage -O0 -DCOVERAGE_BUILD" \
-DCMAKE_C_FLAGS="--coverage -fprofile-arcs -ftest-coverage -O0 -DCOVERAGE_BUILD" \
-DCMAKE_EXE_LINKER_FLAGS="--coverage" \
-DBUILD_QT_MESH_EDITOR=OFF \
-DENABLE_SENTRY=OFF \
-DENABLE_PS1_RIP=ON \
-DENABLE_ONNX=ON \
-DQTMESH_ONNX_GPU=OFF \
-DENABLE_MOCAP=ON \
-DENABLE_DRACO=ON
- name: Run build-wrapper
env:
CCACHE_SLOPPINESS: pch_defines,time_macros,include_file_mtime,include_file_ctime
CCACHE_NOHASHDIR: "true"
run: |
echo "=== Running build-wrapper for SonarCloud analysis ==="
# Zero the ccache hit/miss counters so the post-build `ccache -s`
# reflects ONLY this run's compiles (hit rate = how effective the
# restored cache was for this build). The cached objects themselves
# are untouched — this clears statistics, not the cache.
ccache -z || true
# NB: no `make clean` — ccache handles correctness via its hash,
# and incremental builds let unchanged TUs replay as cached hits.
# build-wrapper still records every compile invocation it sees
# (ccache wraps the compiler, so build-wrapper's intercept is
# transparent to the cache layer).
# Build only test targets (BUILD_QT_MESH_EDITOR=OFF) so the compile
# phase leaves room for the ~25-suite test sweep within the job cap.
build-wrapper-linux-x86-64 --out-dir build-wrapper-output \
make -C build -j$(nproc) \
UnitTests \
qtmesh_test_common \
qtmesh_updater \
qtmesh_ps1core_stub \
qtmesh_ps1core_libretro \
MaterialEditorQML_test \
MaterialEditorQML_qml_test \
MaterialEditorQML_perf_test \
CloudAccountMenuButton_test \
ProjectPackager_test \
MaterialEditorQML_qml_test_runner
echo "=== ccache statistics ==="
ccache -s || true
echo "=== Build wrapper completed ==="
ls -la build-wrapper-output/ || echo "Build wrapper output directory not created"
ls -la build-wrapper-output/build-wrapper-dump.json 2>/dev/null || echo "build-wrapper-dump.json not found"
- name: Add missing libraries
run: |
# Debug build puts executables in build/debug/, Release in build/bin/
mkdir -p ./bin
mkdir -p ./build/bin
mkdir -p ./build/debug
# Copy Qt ICU libraries
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicui18n.* ./bin
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicuuc.* ./bin
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicudata.* ./bin
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicui18n.* ./build/debug/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicuuc.* ./build/debug/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicudata.* ./build/debug/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicui18n.* ./build/bin/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicuuc.* ./build/bin/ || true
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/libicudata.* ./build/bin/ || true
# Copy libraries to system locations
sudo cp -R ./bin/*.so* /lib/x86_64-linux-gnu || true
sudo cp -R /usr/local/lib/OGRE/* /lib/x86_64-linux-gnu || true
sudo cp -R /usr/local/lib/OGRE/* ./bin || true
sudo cp -R /usr/local/lib/OGRE/* ./build/bin/ || true
sudo cp -R /usr/local/lib/OGRE/* ./build/debug/ || true
- name: Install libretro PSX core for PS1 rip tests
run: ./scripts/install-ps1-libretro-core.sh build/debug
- name: Run Comprehensive Test Suite
env:
QT_QPA_PLATFORM: xcb
QT_DEBUG_PLUGINS: 0
DISPLAY: :99
LIBGL_ALWAYS_SOFTWARE: 1
MESA_GL_VERSION_OVERRIDE: "3.3"
QTMESH_PS1_FORCE_STUB: "1"
run: |
# Disable errexit so test crashes (SIGSEGV=139) don't abort the step.
# We track failures/crashes manually and check at the end.
set +e
export QT_QPA_PLATFORM="xcb"
export QT_DEBUG_PLUGINS=0
export LIBGL_ALWAYS_SOFTWARE=1
export MESA_GL_VERSION_OVERRIDE=3.3
sudo cp -R /home/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/gcc_64/lib/ /lib/x86_64-linux-gnu/
export DISPLAY=:99
export QTMESH_PS1_FORCE_STUB=1
# Function to find test executable path
find_test() {
local test_name=$1
if [ -f "./build/debug/$test_name" ]; then
echo "./build/debug/$test_name"
elif [ -f "./build/bin/$test_name" ]; then
echo "./build/bin/$test_name"
elif [ -f "./bin/$test_name" ]; then
echo "./bin/$test_name"
else
echo ""
fi
}
# Function to run a test executable (optionally with gtest_filter)
run_test() {
local test_name=$1
local output_file=$2
local filter=${3:-}
local test_path
test_path=$(find_test "$test_name")
if [ -z "$test_path" ]; then
echo "ERROR: $test_name not found in ./build/debug/, ./build/bin/, or ./bin/"
find build -name "$test_name" -type f 2>/dev/null || true
return 1
fi
echo "Found $test_name at $test_path"
local exit_code=0
local expected_tests
if [ -n "$filter" ]; then
expected_tests=$(count_listed_tests "$test_path" "$filter")
else
expected_tests=$(count_listed_tests "$test_path")
fi
if [ -n "$filter" ]; then
$test_path --gtest_output=xml:$output_file --gtest_filter="$filter"
else
$test_path --gtest_output=xml:$output_file
fi
exit_code=$?
if [ $exit_code -ne 0 ]; then
return $exit_code
fi
local actual_tests
actual_tests=$(count_testcases_in_xml "$output_file")
if [ "$actual_tests" -ne "$expected_tests" ]; then
echo "ERROR: $test_name ran $actual_tests/$expected_tests discovered test(s)."
return 1
fi
local skipped_count
skipped_count=$(count_skipped_in_xml "$output_file")
if [ "$skipped_count" -gt 0 ]; then
echo "ERROR: $test_name produced $skipped_count skipped test(s)."
SKIPPED_TESTS=$((SKIPPED_TESTS + skipped_count))
return 1
fi
return 0
}
TOTAL_SUITES=0
PASSED_SUITES=0
CRASHED_SUITES=0
FAILED_SUITES=0
SKIPPED_TESTS=0
# Suites that create a real OgreWidget + GL context are known to SIGSEGV
# under Mesa/Xvfb on CI (same root cause as the skipped MCP GL tests).
# Crashes in these suites are treated as warnings, not build failures.
# Suites that segfault (signal 11) during Ogre GL3Plus initialisation
# under headless Mesa/Xvfb — the crash happens at suite start-up,
# BEFORE any test body runs (verified across multiple CI runs: no
# [ RUN ] line precedes the crash, and a render-loop null-guard did
# not change it). This is the same environmental GL-init failure the
# list already exempts for the *Widget/*Ogre suites; it's runner-
# dependent (master's runner often gets past it, this PR's did not).
# A whitelisted crash still rolls the suite's discovered test count
# into the executed total so the aggregate ACTUAL==EXPECTED check
# doesn't misread it as missing tests. MCPServerTest is already
# documented below as GL-constrained headless.
GL_CRASH_ALLOWLIST="SpaceCameraWidgetIntegrationTest OgreWidgetTest ViewCubeControllerOgreTest MCPServerTest MCPServerMeshToolsDeepCoverageTest MainWindowTest"
is_gl_crash_allowed() {
local suite="$1"
case "$suite" in
CLIPipeline*) return 0 ;;
esac
for allowed in $GL_CRASH_ALLOWLIST; do
[ "$suite" = "$allowed" ] && return 0
done
return 1
}
count_skipped_in_xml() {
local xml_file=$1
if [ ! -f "$xml_file" ]; then
echo "0"
return 0
fi
# Treat any non-executed gtest result as a CI failure.
# gtest may encode these as result="skipped", status="notrun",
# or result="suppressed" (e.g., disabled tests).
grep -E '<testcase[^>]*(result="skipped"|status="notrun"|result="suppressed")' "$xml_file" | wc -l
}
count_testcases_in_xml() {
local xml_file=$1
if [ ! -f "$xml_file" ]; then
echo "0"
return 0
fi
grep '<testcase ' "$xml_file" | wc -l
}
count_listed_tests() {
local test_path=$1
local filter=${2:-}
if [ -n "$filter" ]; then
$test_path --gtest_filter="$filter" --gtest_list_tests 2>/dev/null | grep -E '^ [^[:space:]]' | wc -l
else
$test_path --gtest_list_tests 2>/dev/null | grep -E '^ [^[:space:]]' | wc -l
fi
}
# === Run UnitTests: each test suite in its own process ===
# This prevents a crash in one suite (e.g. Ogre GL init segfault)
# from killing all subsequent suites and losing their coverage data.
# Fix ownership so tests can run without sudo (sudo masks signal exit codes)
sudo chown -R $(whoami) build/ 2>/dev/null || true
UNIT_TEST_PATH=$(find_test "UnitTests")
if [ -n "$UNIT_TEST_PATH" ]; then
echo "=== Running UnitTests suites in isolation ==="
# Get list of test suites
SUITES=$($UNIT_TEST_PATH --gtest_list_tests 2>/dev/null | grep -E '^\w' | sed 's/\.$//' | sort -u)
EXPECTED_UNIT_TESTS=$(count_listed_tests "$UNIT_TEST_PATH")
ACTUAL_UNIT_TESTS=0
# Per-suite wall-clock cap. Most suites finish in <10s;
# but the CLIPipelineCmdTest suite re-imports a small
# FBX (Twist Dance) inside every test case (~50 tests
# × ~20s/import on the CI runner ≈ 4–6 min total),
# which intermittently exceeded the prior 5-min cap.
# Bumped to 20 min: MainWindowTest (78 cases) reconstructs the
# full MainWindow per case (~7–8s each on the software-GL CI
# runner ≈ ~10 min), and CLIPipelineCmdTest re-imports an FBX
# per case — both legitimately exceed the old 10-min cap on a
# slow runner. The job timeout is 90 min, so 20 min/suite still
# leaves ample headroom while catching a real deadlock (a hung
# Qt event loop never finishes anyway). NOTE: MainWindowTest's
# per-case full-window rebuild is a known hot spot flagged for
# the test-suite redundancy audit — if it shrinks, drop this.
#
# `timeout --signal=KILL` jumps straight to SIGKILL because
# a deadlocked Qt event loop or GL driver doesn't reliably
# honour SIGTERM. `--foreground` keeps it usable inside a
# for-loop where the shell isn't a session leader.
PER_SUITE_TIMEOUT=1200
for suite in $SUITES; do
TOTAL_SUITES=$((TOTAL_SUITES + 1))
echo "--- Running suite: $suite ---"
# Run without sudo so signal exit codes (e.g. 139 for SIGSEGV) propagate correctly.
# sudo can mask crash exit codes, reporting 0 for segfaulted children.
suite_xml="test-results-${suite}.xml"
expected_suite_tests=$(count_listed_tests "$UNIT_TEST_PATH" "${suite}.*")
timeout --foreground --signal=KILL "$PER_SUITE_TIMEOUT" \
$UNIT_TEST_PATH --gtest_filter="${suite}.*" --gtest_output=xml:${suite_xml} 2>&1
exit_code=$?
# timeout(1) reports 124 when the wall-clock cap expired.
# SIGKILL via --signal=KILL also raises 137 on the child;
# `timeout` translates that back to 124 so the caller
# sees a single timeout signature. Treat as a non-fatal
# warning (same shape as the GL-crash allowlist) so the
# remaining suites still run and report.
if [ $exit_code -eq 124 ]; then
echo "WARNING: Suite $suite TIMED OUT after ${PER_SUITE_TIMEOUT}s — killed by wall-clock cap."
CRASHED_SUITES=$((CRASHED_SUITES + 1))
continue
fi
if [ $exit_code -eq 0 ]; then
actual_suite_tests=$(count_testcases_in_xml "$suite_xml")
ACTUAL_UNIT_TESTS=$((ACTUAL_UNIT_TESTS + actual_suite_tests))
if [ "$actual_suite_tests" -ne "$expected_suite_tests" ]; then
echo "ERROR: Suite $suite ran $actual_suite_tests/$expected_suite_tests discovered test(s)."
FAILED_SUITES=$((FAILED_SUITES + 1))
continue
fi
skipped_count=$(count_skipped_in_xml "$suite_xml")
if [ "$skipped_count" -gt 0 ]; then
echo "ERROR: Suite $suite produced $skipped_count skipped test(s)."
SKIPPED_TESTS=$((SKIPPED_TESTS + skipped_count))
FAILED_SUITES=$((FAILED_SUITES + 1))
else
PASSED_SUITES=$((PASSED_SUITES + 1))
fi
elif [ $exit_code -ge 128 ]; then
signal=$(($exit_code - 128))
if is_gl_crash_allowed "$suite"; then
echo "WARNING: Suite $suite CRASHED (signal $signal) — known GL/Xvfb issue on CI, not counted as failure."
PASSED_SUITES=$((PASSED_SUITES + 1))
# Roll the suite's expected count into the running total
# so the overall ACTUAL == EXPECTED post-check doesn't
# mistake a whitelisted crash for missing tests.
ACTUAL_UNIT_TESTS=$((ACTUAL_UNIT_TESTS + expected_suite_tests))
else
echo "WARNING: Suite $suite CRASHED (signal $signal). Coverage data saved by signal handler."
CRASHED_SUITES=$((CRASHED_SUITES + 1))
fi
else
echo "ERROR: Suite $suite failed (exit code $exit_code)"
FAILED_SUITES=$((FAILED_SUITES + 1))
fi
done
if [ "$ACTUAL_UNIT_TESTS" -ne "$EXPECTED_UNIT_TESTS" ]; then
echo "ERROR: UnitTests executed $ACTUAL_UNIT_TESTS/$EXPECTED_UNIT_TESTS discovered test(s)."
FAILED_SUITES=$((FAILED_SUITES + 1))
fi
echo "=== UnitTests summary: $PASSED_SUITES/$TOTAL_SUITES suites passed, $FAILED_SUITES failed, $CRASHED_SUITES crashed, $SKIPPED_TESTS skipped tests ==="
echo "=== UnitTests cases: $ACTUAL_UNIT_TESTS/$EXPECTED_UNIT_TESTS executed ==="
else
echo "ERROR: UnitTests not found!"
FAILED_SUITES=$((FAILED_SUITES + 1))
fi
# === Run other test executables ===
echo "Running MaterialEditorQML Unit Tests..."
run_test "MaterialEditorQML_test" "test-results-materialeditor.xml" || FAILED_SUITES=$((FAILED_SUITES + 1))
echo "Running MaterialEditorQML QML Integration Tests..."
run_test "MaterialEditorQML_qml_test" "test-results-qml.xml" || FAILED_SUITES=$((FAILED_SUITES + 1))
echo "Running MaterialEditorQML Performance Tests..."
run_test "MaterialEditorQML_perf_test" "test-results-perf.xml" || FAILED_SUITES=$((FAILED_SUITES + 1))
echo "Running QML Component Tests..."
run_test "MaterialEditorQML_qml_test_runner" "test-results-qml-component.xml" || FAILED_SUITES=$((FAILED_SUITES + 1))
if [ "$CRASHED_SUITES" -gt 0 ]; then
echo "WARNING: $CRASHED_SUITES test suite(s) crashed, but coverage data was preserved"
fi
if [ "$SKIPPED_TESTS" -gt 0 ]; then
echo "ERROR: $SKIPPED_TESTS test(s) were skipped"
fi
# Save failure count for post-coverage check
echo "FAILED_SUITES=$FAILED_SUITES" >> $GITHUB_ENV
echo "CRASHED_SUITES=$CRASHED_SUITES" >> $GITHUB_ENV
echo "SKIPPED_TESTS=$SKIPPED_TESTS" >> $GITHUB_ENV
# Note: MCP GL-heavy integration coverage is currently represented by
# UnitTests (MCPServerTest suite). Dedicated GL integration execution remains
# constrained in headless Mesa/Xvfb environments due Ogre GL3Plus crashes.
- name: Upload Test Results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
test-results-*.xml
- name: Set up Python 3.8 for gcovr
uses: actions/setup-python@v4
with:
python-version: 3.8
- name: install gcovr
run: |
pip install gcovr==7.2
- name: Generate coverage data
run: |
echo "=== Coverage Generation Debug Info ==="
echo "Current directory: $(pwd)"
GCDA_COUNT=$(find build -name "*.gcda" -type f 2>/dev/null | wc -l)
echo "Found $GCDA_COUNT .gcda files"
if [ "$GCDA_COUNT" -eq 0 ]; then
echo "ERROR: No .gcda files found! Tests may not have run with coverage."
exit 1
fi
echo "=== Generating SonarQube coverage XML via gcovr ==="
gcovr --root . \
--filter 'src/' \
--exclude 'src/OgreXML/' \
--exclude 'src/dependencies/' \
--exclude '.*/(LLMManager|LLMWorker|ModelDownloader|SDManager|AIChatManager)\.cpp' \
--exclude '.*/(AIChatManager|SDManager|SDWorker)\.h' \
--exclude '.*_test\.cpp' \
--exclude '.*TestHelpers\.h' \
--exclude '.*QtAppEnvironment_test\.cpp' \
--exclude '.*_autogen' \
--exclude '.*/ui_files/' \
--exclude '.*/moc_' \
--exclude '.*qrc_.*\.cpp' \
--exclude-throw-branches \
--exclude-unreachable-branches \
--gcov-ignore-errors=no_working_dir_found \
--sonarqube=coverage.xml \
--gcov-executable gcov \
build \
2>&1 | tail -20
echo "=== Validating coverage files ==="
if [ -f coverage.xml ] && [ -s coverage.xml ]; then
echo "coverage.xml exists and has content ($(wc -c < coverage.xml) bytes)"
echo "Files with coverage data:"
grep -c '<file' coverage.xml || echo "0 files"
echo "=== Coverage summary ==="
COVERED=$(grep -o 'covered="true"' coverage.xml | wc -l)
UNCOVERED=$(grep -o 'covered="false"' coverage.xml | wc -l)
TOTAL=$((COVERED + UNCOVERED))
if [ "$TOTAL" -gt 0 ]; then
PCT=$((COVERED * 100 / TOTAL))
echo "Lines: $COVERED/$TOTAL covered (~${PCT}%)"
fi
else
echo "ERROR: coverage.xml is missing or empty!"
exit 1
fi
# Prep the inputs the scanner reads (compile DB + sanity checks). The scan
# itself runs via the official action below, which bundles its own JDK —
# so it no longer depends on the runner's default Java (SonarCloud dropped
# Java 17, which broke the old npm `sonar-scanner` + runner-JDK approach).
- name: Prepare SonarCloud inputs
run: |
echo "=== SonarCloud Analysis Setup ==="
# Ensure compile_commands.json is available for C++ analysis
if [ -f build/compile_commands.json ]; then
cp build/compile_commands.json .
echo "Copied compile_commands.json from build/"
fi
# Check build-wrapper output
BW_DIR="build-wrapper-output"
if [ -d "$BW_DIR" ]; then
echo "Build-wrapper output found at: $BW_DIR"
ls -la "$BW_DIR"/ | head -5
else
echo "WARNING: Build-wrapper output not found at $BW_DIR"
find . -name "build-wrapper-dump.json" -type f 2>/dev/null | head -5
fi
echo "=== Coverage files ==="
ls -la coverage.xml 2>/dev/null || echo "coverage.xml not found"
- name: Run sonar-scanner
# Dependabot PRs run without access to repository secrets, so SONAR_TOKEN
# is empty there and the scan fails with a 401 that blocks the PR through
# no fault of its own. Skip the scan for Dependabot; the analysis still
# runs on branch pushes and same-repo PRs, which is where the quality gate
# matters.
if: ${{ github.actor != 'dependabot[bot]' }}
uses: SonarSource/sonarqube-scan-action@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: https://sonarcloud.io
with:
# Keep coverage exclusions in sonar-project.properties as the single
# source of truth. Token/host come from env above.
args: >
-Dsonar.projectKey=fernandotonon_QtMeshEditor
-Dsonar.organization=fernandotonon
-Dsonar.sources=src/
-Dsonar.tests=src/,tests/
-Dsonar.test.inclusions=**/*_test.cpp,**/test_*.cpp,tests/**/*.cpp
-Dsonar.exclusions=**/OgreXML/**,**/dependencies/**,**/*_autogen/**,**/CMakeFiles/**,**/ui_files/**,**/moc_*,**/_deps/**
-Dsonar.coverageReportPaths=coverage.xml
-Dsonar.c.file.suffixes=.c
-Dsonar.cpp.file.suffixes=.cpp,.cc,.cxx,.c++,.hpp,.hh,.hxx,.h++,.h
-Dsonar.objc.file.suffixes=.m,.mm
-Dsonar.cfamily.build-wrapper-output=build-wrapper-output
-Dsonar.cfamily.cache.enabled=true
-Dsonar.cfamily.threads=4
-Dsonar.verbose=true
- name: Upload Coverage Reports
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-reports
path: |
coverage.xml
- name: Fail if tests failed
if: always()
run: |
if [ "${SKIPPED_TESTS:-0}" -gt 0 ]; then
echo "ERROR: $SKIPPED_TESTS test(s) were skipped in CI"
exit 1
fi
if [ "${FAILED_SUITES:-0}" -gt 0 ]; then
echo "ERROR: $FAILED_SUITES test suite(s) failed"
exit 1
fi
if [ "${CRASHED_SUITES:-0}" -gt 0 ]; then
echo "ERROR: $CRASHED_SUITES test suite(s) crashed"
exit 1
fi
echo "All test suites passed"
- name: Scan repository assets with qtmesh scan
if: success()
env:
DISPLAY: :99
QT_QPA_PLATFORM: xcb
LIBGL_ALWAYS_SOFTWARE: 1
MESA_GL_VERSION_OVERRIDE: "3.3"
run: |
TEST_BIN=""
if [ -f "./build/debug/QtMeshEditor" ]; then
TEST_BIN="./build/debug/QtMeshEditor"
elif [ -f "./build/bin/QtMeshEditor" ]; then
TEST_BIN="./build/bin/QtMeshEditor"
elif [ -f "./bin/QtMeshEditor" ]; then
TEST_BIN="./bin/QtMeshEditor"
fi
if [ -z "$TEST_BIN" ]; then
echo "No QtMeshEditor binary found, skipping scan"
exit 0
fi
echo "Scanning repository assets using qtmesh.yml..."
"$TEST_BIN" --cli scan || {
echo "::warning::Asset scan found errors (see qtmesh.yml)"
true
}
####################################################################
# MacOS Deploy
####################################################################
build-n-cache-assimp-macos:
needs: scan-assets-qtmesh
runs-on: macos-latest
steps:
- name: Pin newest stable Xcode (consistent SDK across all macOS jobs)
run: |
# All three macOS jobs must use the SAME Xcode/SDK: the OGRE/Assimp
# builds bake the active SDK's absolute libz.tbd path into their
# CMake export, and the consumer build links against it. Different
# default Xcodes per job → "No rule to make target '<SDK>/libz.tbd'".
DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1)
if [ -z "$DEV" ]; then DEV="$(xcode-select -p)"; fi
echo "Selected Xcode: $DEV"
sudo xcode-select -s "$DEV"
echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV"
# Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop
# CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun`
# defaults to (on these images that was Xcode 26.5 even with 26.3
# selected), so OGRE's CMake export baked a 26.5 libz.tbd path that
# then failed to link under 26.3. Exporting SDKROOT makes clang AND
# CMake resolve system libs under the SAME pinned SDK everywhere.
SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)"
[ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV"
echo "Pinned SDKROOT: $SDKROOT_PATH"
# The per-job runner images can carry DIFFERENT newest Xcodes
# (e.g. producer image has 26.5, consumer image only 26.3). Fold the
# resolved Xcode app into the cache key so each job only restores a
# cache built under its OWN Xcode; build-macos rebuilds OGRE on a
# miss (steps below) so a mismatch self-heals instead of failing
# with "No rule to make target '.../Xcode_XX/...libz.tbd'".
echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV"
- name: change folder permissions
run: |
sudo mkdir /usr/local/lib
sudo mkdir /usr/local/include
sudo chmod 777 /usr/local/lib
sudo chmod 777 /usr/local/include
- name: Cache Assimp
id: cache-assimp-macos
uses: actions/cache@v3
env:
cache-name: cache-assimp-macos
with:
path: |
/usr/local/lib/cmake
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
#key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('/home/runner/work/QtMeshEditor/QtMeshEditor/assimp') }}
# Need to delete manually if needed to rebuild. Until I find a better solution for detecting changes in the assimp repo.
# NOTE: assimp is NOT Xcode-keyed (unlike ogre): it's a plain static lib
# that doesn't bake absolute SDK paths, so one assimp cache works across
# Xcode versions and stays shared so the ogre-rebuild-on-miss can use it.
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-
- if: steps.cache-assimp-macos.outputs.cache-hit != 'true'
name: Check out Assimp repo
uses: actions/checkout@master
with:
repository: assimp/assimp
ref: v${{ env.ASSIMP_VERSION }}
path: /Users/runner/work/QtMeshEditor/QtMeshEditor/assimp
- if: steps.cache-assimp-macos.outputs.cache-hit != 'true'
name: Build Assimp repo
# Draco built standalone below (not via -DASSIMP_BUILD_DRACO) — see the
# Linux job for the rationale (Assimp's Draco integration is broken per
# platform; we only need Draco's encoder for #506).
run: |
cmake -B /Users/runner/work/QtMeshEditor/QtMeshEditor/assimp-build -S /Users/runner/work/QtMeshEditor/QtMeshEditor/assimp -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
cd /Users/runner/work/QtMeshEditor/QtMeshEditor/assimp-build/
sudo make install -j8
- if: steps.cache-assimp-macos.outputs.cache-hit != 'true'
name: Build & install Draco (standalone static, for #506 glTF compression)
run: |
cmake -B /Users/runner/work/QtMeshEditor/QtMeshEditor/draco-build \
-S /Users/runner/work/QtMeshEditor/QtMeshEditor/assimp/contrib/draco \
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DDRACO_TESTS=OFF -DBUILD_SHARED_LIBS=OFF \
-DCMAKE_OSX_ARCHITECTURES="$(uname -m)"
cmake --build /Users/runner/work/QtMeshEditor/QtMeshEditor/draco-build --target draco_static -j8
sudo cp /Users/runner/work/QtMeshEditor/QtMeshEditor/draco-build/libdraco.a /usr/local/lib/
sudo mkdir -p /usr/local/include/draco
sudo cp -R /Users/runner/work/QtMeshEditor/QtMeshEditor/assimp/contrib/draco/src/draco/. /usr/local/include/draco/
sudo cp /Users/runner/work/QtMeshEditor/QtMeshEditor/draco-build/draco/draco_features.h /usr/local/include/draco/ 2>/dev/null || true
build-n-cache-ogre-macos:
needs: build-n-cache-assimp-macos
runs-on: macos-latest
steps:
- name: Pin newest stable Xcode (consistent SDK across all macOS jobs)
run: |
DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1)
if [ -z "$DEV" ]; then DEV="$(xcode-select -p)"; fi
echo "Selected Xcode: $DEV"
sudo xcode-select -s "$DEV"
echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV"
# Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop
# CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun`
# defaults to (on these images that was Xcode 26.5 even with 26.3
# selected), so OGRE's CMake export baked a 26.5 libz.tbd path that
# then failed to link under 26.3. Exporting SDKROOT makes clang AND
# CMake resolve system libs under the SAME pinned SDK everywhere.
SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)"
[ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV"
echo "Pinned SDKROOT: $SDKROOT_PATH"
# The per-job runner images can carry DIFFERENT newest Xcodes
# (e.g. producer image has 26.5, consumer image only 26.3). Fold the
# resolved Xcode app into the cache key so each job only restores a
# cache built under its OWN Xcode; build-macos rebuilds OGRE on a
# miss (steps below) so a mismatch self-heals instead of failing
# with "No rule to make target '.../Xcode_XX/...libz.tbd'".
echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV"
- name: change folder permissions
run: |
sudo mkdir /usr/local/lib
sudo mkdir /usr/local/include
sudo chmod 777 /usr/local/lib
sudo chmod 777 /usr/local/include
- name: Cache Assimp
id: cache-assimp-macos
uses: actions/cache@v3
env:
cache-name: cache-assimp-macos
with:
path: |
/usr/local/lib/cmake
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}
restore-keys: |
${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-
- name: Cache Ogre
id: cache-ogre-macos
uses: actions/cache@v3
env:
cache-name: cache-ogre-macos
with:
path: ${{github.workspace}}/ogre/SDK
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}
- if: steps.cache-ogre-macos.outputs.cache-hit != 'true'
name: Check out ogre repo
uses: actions/checkout@master
with:
repository: OGRECave/ogre
ref: v${{ env.OGRE_VERSION }}
path: ${{github.workspace}}/ogre
- if: steps.cache-ogre-macos.outputs.cache-hit != 'true'
name: Build Ogre3D repo
run: |
cd ${{github.workspace}}/ogre/
sudo cmake -S . -DOGRE_BUILD_PLUGIN_ASSIMP=ON -Dassimp_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }}/ \
-DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON -DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON \
-DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF -DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF \
-DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF -DOGRE_BUILD_COMPONENT_PYTHON=OFF \
-DOGRE_INSTALL_TOOLS=OFF -DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF -DOGRE_BUILD_LIBS_AS_FRAMEWORKS=OFF \
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
sudo make install -j8
build-macos:
needs: [build-n-cache-assimp-macos, build-n-cache-ogre-macos]
runs-on: macos-latest
steps:
- name: Pin newest stable Xcode (consistent SDK across all macOS jobs)
run: |
DEV=$(ls -d /Applications/Xcode_*.app/Contents/Developer 2>/dev/null | sort -V | tail -1)
if [ -z "$DEV" ]; then DEV="$(xcode-select -p)"; fi
echo "Selected Xcode: $DEV"
sudo xcode-select -s "$DEV"
echo "DEVELOPER_DIR=$DEV" >> "$GITHUB_ENV"
# Pin SDKROOT too. xcode-select / DEVELOPER_DIR alone don't stop
# CMake's find_package(ZLIB) from resolving to whatever SDK `xcrun`
# defaults to (on these images that was Xcode 26.5 even with 26.3
# selected), so OGRE's CMake export baked a 26.5 libz.tbd path that
# then failed to link under 26.3. Exporting SDKROOT makes clang AND
# CMake resolve system libs under the SAME pinned SDK everywhere.
SDKROOT_PATH="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null)"
[ -n "$SDKROOT_PATH" ] && echo "SDKROOT=$SDKROOT_PATH" >> "$GITHUB_ENV"
echo "Pinned SDKROOT: $SDKROOT_PATH"
# The per-job runner images can carry DIFFERENT newest Xcodes
# (e.g. producer image has 26.5, consumer image only 26.3). Fold the
# resolved Xcode app into the cache key so each job only restores a
# cache built under its OWN Xcode; build-macos rebuilds OGRE on a
# miss (steps below) so a mismatch self-heals instead of failing
# with "No rule to make target '.../Xcode_XX/...libz.tbd'".
echo "XCODE_TAG=$(basename "$(dirname "$(dirname "$DEV")")")" >> "$GITHUB_ENV"
- name: change folder permissions
run: |
sudo mkdir -p /usr/local/lib
sudo mkdir -p /usr/local/include
sudo chmod -R 777 /usr/local/lib
sudo chmod -R 777 /usr/local/include
- uses: actions/checkout@v3
with:
submodules: true
- run: |
cd ${{github.workspace}}/src/dependencies/ogre-procedural
git checkout master
git pull
- name: Install Qt
uses: jurplel/install-qt-action@v3
with:
aqtversion: ${{ env.AQT_VERSION }}
version: ${{ env.QT_VERSION }}
host: 'mac'
target: 'desktop'
arch: 'clang_64'
tools: 'tools_cmake'
# qtmultimedia: performance capture (ENABLE_MOCAP, epic #869)
modules: 'qtmultimedia'
- name: Debug Qt Installation
run: |
echo "=== Qt Installation Debug ==="
echo "Qt installation directory:"
ls -la /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/
echo "Looking for lib directories:"
find /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/ -name "lib" -type d
echo "Looking for QtWidgets.framework:"
find /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/ -name "QtWidgets.framework" -type d
echo "Architecture of current runner:"
uname -m
echo "Available Qt architectures:"
file /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/*/bin/qmake 2>/dev/null || echo "qmake not found"
- name: Cache Assimp
id: cache-assimp-macos
uses: actions/cache@v3
env:
cache-name: cache-assimp-macos
with:
path: |
/usr/local/lib/cmake
/usr/local/include/assimp
/usr/local/include/contrib
/usr/local/include/draco
/usr/local/lib/pkgconfig/assimp.pc
/usr/local/lib/libassimp*
/usr/local/lib/libdraco*
/usr/local/lib/libzlibstatic.a
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}
- name: Cache Ogre
id: cache-ogre-macos
uses: actions/cache@v3
env:
cache-name: cache-ogre-macos
with:
path: ${{github.workspace}}/ogre/SDK
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ env.MACOS_CACHE_VERSION }}-${{ env.XCODE_TAG }}
# If this runner image's Xcode differs from the one the producer cached
# under, the key above misses. Rebuild OGRE here under THIS job's Xcode so
# the SDK's baked libz.tbd path matches what we link against (self-heals the
# cross-image Xcode mismatch instead of failing on a stale libz.tbd path).
- if: steps.cache-ogre-macos.outputs.cache-hit != 'true'
name: Check out ogre repo (cache miss)
uses: actions/checkout@master
with:
repository: OGRECave/ogre
ref: v${{ env.OGRE_VERSION }}
path: ${{github.workspace}}/ogre
- if: steps.cache-ogre-macos.outputs.cache-hit != 'true'
name: Build Ogre3D repo (cache miss)
run: |
cd ${{github.workspace}}/ogre/
sudo cmake -S . -DOGRE_BUILD_PLUGIN_ASSIMP=ON -Dassimp_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }}/ \
-DOGRE_BUILD_PLUGIN_DOT_SCENE=ON -DOGRE_BUILD_RENDERSYSTEM_GL=ON -DOGRE_BUILD_RENDERSYSTEM_GL3PLUS=ON \
-DOGRE_BUILD_RENDERSYSTEM_GLES2=OFF -DOGRE_BUILD_TESTS=OFF -DOGRE_BUILD_TOOLS=OFF -DOGRE_BUILD_SAMPLES=OFF \
-DOGRE_BUILD_COMPONENT_CSHARP=OFF -DOGRE_BUILD_COMPONENT_JAVA=OFF -DOGRE_BUILD_COMPONENT_PYTHON=OFF \
-DOGRE_INSTALL_TOOLS=OFF -DOGRE_INSTALL_DOCS=OFF -DOGRE_INSTALL_SAMPLES=OFF -DOGRE_BUILD_LIBS_AS_FRAMEWORKS=OFF \
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}}
sudo make install -j8
- name: Configure CMake
env:
OGRE_DIR: ${{github.workspace}}/ogre/SDK/CMake/
run: |
# Detect the actual Qt path structure
if [ -d "/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/macos" ]; then
QT_ARCH_DIR="macos"
elif [ -d "/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/clang_64" ]; then
QT_ARCH_DIR="clang_64"
else
echo "ERROR: Could not find Qt installation directory"
ls -la /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/
exit 1
fi
echo "Using Qt architecture directory: $QT_ARCH_DIR"
# Configure with dynamic architecture detection and proper universal binary support
sudo cmake -S . \
-DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} \
-DCMAKE_CXX_FLAGS="-g" -DCMAKE_C_FLAGS="-g" \
-DENABLE_STABLE_DIFFUSION=ON \
-DENABLE_ONNX=ON \
-DENABLE_TRELLIS_CPP=ON \
-DQTMESH_ONNX_GPU=OFF \
-DENABLE_MOCAP=ON \
-DENABLE_DRACO=ON \
-DCMAKE_OSX_ARCHITECTURES="$(uname -m)" \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DASSIMP_DIR=/usr/local/lib/cmake/assimp-${{ env.ASSIMP_DIR_VERSION }} \
-DASSIMP_INCLUDE_DIR=/usr/local/include/assimp \
-DQt6_DIR=/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/cmake/Qt6 \
-DQT_DIR=/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/cmake/Qt6 \
-DQt6GuiTools_DIR=/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/cmake/Qt6GuiTools \
-DOGRE_DIR=${{github.workspace}}/ogre/SDK/CMake/
- name: Build
# Build your program with the given configuration
run: |
sudo make install -j8
- name: Verify bundled trellis-cli (macOS)
run: |
# ggml defaults to static on Apple, but Metal/backends can still
# emit dylibs — same class of bug as Linux exit 127 if we ship the
# CLI without them. Fail the job if any ggml dylib is referenced
# but missing from Contents/MacOS (codesign loop already signs *).
set -euo pipefail
CLI="${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/trellis-cli"
MACOS_DIR="${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS"
if [ ! -f "$CLI" ]; then
echo "ERROR: ENABLE_TRELLIS_CPP=ON but trellis-cli missing from .app"
ls -la "$MACOS_DIR" | head -40
exit 1
fi
echo "=== otool -L trellis-cli ==="
otool -L "$CLI"
missing=0
while read -r line; do
lib=$(echo "$line" | awk '{print $1}')
case "$lib" in
*ggml*)
base=$(basename "$lib")
case "$lib" in
/System/*|/usr/lib/*) ;;
*)
if [ ! -f "$MACOS_DIR/$base" ]; then
echo "ERROR: $lib referenced but $base not in Contents/MacOS"
missing=1
fi
;;
esac
;;
esac
done < <(otool -L "$CLI" | tail -n +2)
if [ "$missing" -ne 0 ]; then
exit 1
fi
echo "trellis-cli present; ggml dylibs (if any) colocated"
- name: Upload debug symbols to Sentry
if: github.event_name == 'release' && github.event.action == 'published'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: |
BIN_PATH="${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor"
sudo dsymutil "$BIN_PATH" -o "${{github.workspace}}/bin/QtMeshEditor.dSYM"
sudo strip "$BIN_PATH"
curl -sL https://sentry.io/get-cli/ | bash
sentry-cli debug-files upload --include-sources "${{github.workspace}}/bin/QtMeshEditor.dSYM"
- name: Copy Qt libs to app folder
run: |
# Detect the actual Qt path structure
if [ -d "/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/macos" ]; then
QT_ARCH_DIR="macos"
elif [ -d "/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/clang_64" ]; then
QT_ARCH_DIR="clang_64"
else
echo "ERROR: Could not find Qt installation directory"
exit 1
fi
echo "Copying Qt frameworks from: /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/"
# Create proper macOS app bundle structure
sudo mkdir -p ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks
sudo mkdir -p ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/platforms
# Copy Qt frameworks to proper Frameworks directory
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtWidgets.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtCore.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtGui.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtDBus.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickWidgets.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuick.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQml.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQmlModels.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQmlMeta.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtNetwork.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtOpenGL.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickControls2.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickControls2 not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickControls2Impl.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickControls2Impl not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickControls2Basic.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickControls2Basic not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickControls2BasicStyleImpl.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickControls2BasicStyleImpl not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickDialogs2.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickDialogs2 not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickDialogs2Utils.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickDialogs2Utils not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickDialogs2QuickImpl.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickDialogs2QuickImpl not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickTemplates2.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickTemplates2 not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQuickLayouts.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQuickLayouts not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQmlCore.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQmlCore not found, continuing..."
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/lib/QtQmlWorkerScript.framework ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "QtQmlWorkerScript not found, continuing..."
# Copy plugins to proper PlugIns directory
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/plugins/platforms/* ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/platforms/
# Qt 6: HTTPS / QNetworkAccessManager needs TLS plugins (qsecuretransportbackend, etc.)
sudo mkdir -p ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/tls
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/plugins/tls/* ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/tls/
# Copy QML plugins for QtQuick components
sudo mkdir -p ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/qml
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/qml/QtQuick ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/qml/
sudo cp -R /Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/qml/QtQml ${{github.workspace}}/bin/QtMeshEditor.app/Contents/PlugIns/qml/
# Copy Assimp libraries to MacOS directory
sudo cp -R /usr/local/lib/libassimp* ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/ || echo "Assimp libraries not found, continuing..."
# macdeployqt scans -qmldir for QML imports and bundles the module
# plugins (Controls, Layouts, Dialogs, ...) the manual copy misses —
# without it the inspector panel renders blank on installed bundles.
MACDEPLOYQT="/Users/runner/work/QtMeshEditor/Qt/${{ env.QT_VERSION }}/$QT_ARCH_DIR/bin/macdeployqt"
if [ -x "$MACDEPLOYQT" ]; then
echo "Running macdeployqt against app bundle..."
deploy_log=$(mktemp)
sudo "$MACDEPLOYQT" "${{github.workspace}}/bin/QtMeshEditor.app" \
-qmldir="${{github.workspace}}/qml" \
-verbose=2 \
-no-strip > "$deploy_log" 2>&1
rc=$?
tail -60 "$deploy_log"
rm -f "$deploy_log"
if [ $rc -ne 0 ]; then
echo "::error::macdeployqt exited with status $rc"
exit $rc
fi
else
echo "::error::macdeployqt not found at $MACDEPLOYQT"
exit 1
fi
# Fix QML plugin rpaths so they resolve Qt frameworks inside the
# bundle. macdeployqt leaves the original Qt-SDK rpath
# (@loader_path/../../../lib) on the plugins, which at runtime
# points to Contents/PlugIns/qml/lib/ — a directory that doesn't
# exist in the bundle. Plugins fail to load with
# "plugin qtquickcontrols2plugin not found"
# because their dependent frameworks (QtQuickControls2 etc.) can't
# be resolved. Adding @executable_path/../Frameworks — which always
# resolves to Contents/Frameworks for any dylib loaded by the
# main executable — makes Qt's frameworks visible to the plugins.
echo "Fixing QML plugin rpaths..."
APP_BUNDLE="${{github.workspace}}/bin/QtMeshEditor.app"
find "$APP_BUNDLE/Contents/PlugIns/qml" -name "*.dylib" -type f 2>/dev/null | while read -r plugin; do
sudo install_name_tool -add_rpath @executable_path/../Frameworks "$plugin" 2>/dev/null || true
sudo install_name_tool -add_rpath @loader_path/../../../../Frameworks "$plugin" 2>/dev/null || true
done
# Also fix regular Qt plugins (platforms, tls, etc.) and CA-root
# drivers for the same reason.
find "$APP_BUNDLE/Contents/PlugIns" -maxdepth 3 -name "*.dylib" -type f \
! -path "*/qml/*" 2>/dev/null | while read -r plugin; do
sudo install_name_tool -add_rpath @executable_path/../Frameworks "$plugin" 2>/dev/null || true
done
- name: Prepare for packing
run: |
sudo cp -R ${{github.workspace}}/bin/media ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/media
# Remove large dance model files used only for testing
sudo rm -f ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/media/models/"Twist Dance.fbx"
sudo rm -f ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/media/models/"Rumba Dancing.fbx"
sudo rm -f ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/media/models/"Hip Hop Dancing.fbx"
sudo cp -R ${{github.workspace}}/bin/cfg ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/cfg
sudo cp -R ${{github.workspace}}/bin/profiles ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/profiles
sudo cp -R ${{github.workspace}}/resources/icon.icns ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/media
sudo mkdir -p ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Resources
# Create qt.conf to help Qt find plugins in the bundle.
# QmlImports (Qt 6.5+) supersedes Qml2Imports (deprecated), but
# we set both for compat with anything still keying off the older
# name during plugin discovery.
echo -e "[Paths]\nPlugins = PlugIns\nQmlImports = PlugIns/qml\nQml2Imports = PlugIns/qml" | sudo tee ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Resources/qt.conf
sudo cp -R ${{github.workspace}}/resources/icon.icns ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Resources
sudo cp -R ${{github.workspace}}/bin/Info.plist ${{github.workspace}}/bin/QtMeshEditor.app/Contents/
# Fix library paths for proper app bundle structure.
# macdeployqt may have already added these rpaths, so ignore
# "would duplicate path" errors.
sudo install_name_tool -add_rpath @executable_path/../Frameworks ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor 2>/dev/null || true
sudo install_name_tool -add_rpath @loader_path/ ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor 2>/dev/null || true
# Fix all dylibs in MacOS directory to use @rpath for their dependencies
echo "Fixing dylib install names and rpaths..."
for dylib in ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/*.dylib; do
if [ -f "$dylib" ]; then
dylib_name=$(basename "$dylib")
echo "Processing $dylib_name"
# Change the install name to use @rpath
sudo install_name_tool -id "@rpath/$dylib_name" "$dylib" 2>/dev/null || true
# Add rpath to find other dylibs in the same directory
sudo install_name_tool -add_rpath @loader_path/ "$dylib" 2>/dev/null || true
# Fix references to build machine paths
otool -L "$dylib" 2>/dev/null | grep -E "runner|build|SDK" | awk '{print $1}' | while read -r dep; do
dep_name=$(basename "$dep")
echo " Fixing dependency: $dep -> @rpath/$dep_name"
sudo install_name_tool -change "$dep" "@rpath/$dep_name" "$dylib" 2>/dev/null || true
done
fi
done
- name: Code Sign Application (for Sequoia compatibility)
run: |
echo "=== Code Signing for macOS Sequoia Compatibility ==="
# Debug: Show app bundle structure
echo "App bundle structure:"
find ${{github.workspace}}/bin/QtMeshEditor.app -type f -name "Qt*" | head -20
echo ""
echo "Framework structure:"
ls -la ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/ || echo "No Frameworks directory"
ls -la ${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/QtCore.framework/ || echo "No QtCore.framework"
# Simplified signing approach - sign everything we can find
echo "=== Signing all binaries in app bundle ==="
# Sign Qt frameworks - try both versioned and direct paths
echo "Signing Qt frameworks..."
for framework in QtCore QtGui QtWidgets QtDBus QtQuickWidgets QtQuick QtQml QtQmlModels QtQmlMeta QtNetwork QtOpenGL QtQuickControls2 QtQuickControls2Impl QtQuickControls2Basic QtQuickControls2BasicStyleImpl QtQuickDialogs2 QtQuickDialogs2Utils QtQuickDialogs2QuickImpl QtQuickTemplates2 QtQuickLayouts QtQmlCore QtQmlWorkerScript; do
framework_path="${{github.workspace}}/bin/QtMeshEditor.app/Contents/Frameworks/${framework}.framework"
if [ -d "$framework_path" ]; then
echo "Processing $framework framework..."
# Try to sign the versioned executable
if [ -f "$framework_path/Versions/Current/$framework" ]; then
echo "Signing versioned executable: $framework_path/Versions/Current/$framework"
sudo codesign --force --sign - "$framework_path/Versions/Current/$framework" 2>/dev/null || echo "Failed to sign versioned executable"
fi
# Try to sign the direct executable
if [ -f "$framework_path/$framework" ]; then
echo "Signing direct executable: $framework_path/$framework"
sudo codesign --force --sign - "$framework_path/$framework" 2>/dev/null || echo "Failed to sign direct executable"
fi
# Sign the framework bundle (this is the most important)
echo "Signing framework bundle: $framework_path"
sudo codesign --force --sign - "$framework_path" 2>/dev/null || echo "Failed to sign framework bundle"
else
echo "Framework $framework not found"
fi
done
# Sign all dylibs in the app bundle
echo "Signing dynamic libraries..."
find ${{github.workspace}}/bin/QtMeshEditor.app -name "*.dylib" -type f | while read -r dylib; do
echo "Signing dylib: $dylib"
sudo codesign --force --sign - "$dylib" 2>/dev/null || echo "Failed to sign $dylib"
done
# Sign all executables in the app bundle
echo "Signing executables..."
find ${{github.workspace}}/bin/QtMeshEditor.app -type f -perm /111 | while read -r exec_file; do
# Skip if it's a directory or symlink
if [ -f "$exec_file" ] && [ ! -L "$exec_file" ]; then
# Check if it's a binary file (not a script)
if file "$exec_file" | grep -q -E "(Mach-O|executable|shared library)"; then
echo "Signing executable: $exec_file"
sudo codesign --force --sign - "$exec_file" 2>/dev/null || echo "Failed to sign $exec_file"
fi
fi
done
# Sign the main executable specifically
echo "Signing main executable..."
main_exec="${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor"
if [ -f "$main_exec" ]; then
echo "Signing main executable: $main_exec"
sudo codesign --force --sign - "$main_exec" || echo "Failed to sign main executable (non-fatal)"
else
echo "Main executable not found at $main_exec"
ls -la ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/
fi
# Finally sign the entire app bundle WITH the camera/microphone
# entitlements (epic #869). NOTE: this is still an ad-hoc signature
# (`--sign -`); macOS only PROMPTS for camera access when the app
# is signed with a real Apple Developer ID + notarized. Until this
# pipeline gains a Developer ID cert (secret) + notarization step,
# the live-camera capture will be blocked on end-user machines the
# same way it is on an ad-hoc dev build (the CLI `qtmesh mocap`
# video path and file-based capture are unaffected). The
# entitlements + Info.plist NSCameraUsageDescription are in place so
# that flipping to a Developer ID signature is the ONLY remaining
# step to enable the prompt. See docs/MOCAP.md.
echo "Signing app bundle (with camera entitlements)..."
ENT="${{github.workspace}}/cfg/QtMeshEditor.entitlements"
sudo codesign --force --sign - --entitlements "$ENT" \
${{github.workspace}}/bin/QtMeshEditor.app \
|| sudo codesign --force --sign - ${{github.workspace}}/bin/QtMeshEditor.app \
|| echo "Failed to sign app bundle (non-fatal)"
echo "Code signing process completed"
# Verify the signing
echo "Verifying code signature..."
codesign --verify --verbose ${{github.workspace}}/bin/QtMeshEditor.app || echo "Verification failed (non-fatal)"
- name: Verify App Bundle
run: |
# Verify the app bundle structure
ls -la ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/
file ${{github.workspace}}/bin/QtMeshEditor.app/Contents/MacOS/QtMeshEditor
- name: Pack
run: |
set -euo pipefail
brew install create-dmg
# github.ref_name is "867/merge" on a PR — the slash makes
# create-dmg treat "QtMeshEditor-867/merge-MacOS.dmg" as a path,
# cd into the nonexistent "QtMeshEditor-867/" dir, and write the
# DMG under the wrong name (the real, deterministic cause of the
# macOS Pack failures on PRs, not a create-dmg flake). Sanitize
# any '/' out of the basename. Release tags (X.Y.Z) are unaffected.
REF="$(echo "${{github.ref_name}}" | tr '/' '-')"
DMG="QtMeshEditor-${REF}-MacOS.dmg"
APP="${{github.workspace}}/bin/QtMeshEditor.app"
# create-dmg also mounts a disk image + AppleScript-positions the
# icons, which can race on CI. Retry a few times, and if it still
# fails fall back to a plain hdiutil DMG so packaging never blocks
# the build on a transient tooling flake. The pretty layout is
# only cosmetic - the fallback DMG installs identically.
make_pretty_dmg() {
rm -f "$DMG"
sudo create-dmg \
--volname "QtMeshEditor Installer" \
--volicon "${{github.workspace}}/resources/icon.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "QtMeshEditor.app" 200 190 \
--app-drop-link 600 185 \
"$DMG" "$APP"
}
ok=0
for attempt in 1 2 3; do
echo "create-dmg attempt ${attempt}..."
if make_pretty_dmg; then ok=1; break; fi
echo "create-dmg attempt ${attempt} failed; detaching stray mounts and retrying"
hdiutil detach "/Volumes/QtMeshEditor Installer" -force 2>/dev/null || true
sleep 5
done
if [ "$ok" -ne 1 ]; then
echo "create-dmg failed 3x - falling back to a plain hdiutil DMG"
rm -f "$DMG"
staging="$(mktemp -d)"
cp -R "$APP" "$staging/"
ln -s /Applications "$staging/Applications"
hdiutil create -volname "QtMeshEditor Installer" \
-srcfolder "$staging" -ov -format UDZO "$DMG"
fi
ls -la "$DMG"
- if: github.event_name == 'release' && github.event.action == 'published'
uses: actions/upload-artifact@v4
with:
name: QtMeshEditor-MacOS
path: ${{github.workspace}}/QtMeshEditor-${{github.ref_name}}-MacOS.dmg
- if: github.event_name == 'release' && github.event.action == 'published'
uses: xresloader/upload-to-github-release@main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
file: QtMeshEditor-${{github.ref_name}}-MacOS.dmg
update_latest_release: true
overwrite: false
verbose: true
####################################################################
# Notify Sentry of new release (associate commits for source context)
####################################################################
notify-sentry:
needs: [build-windows, build-linux, build-macos]
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Install sentry-cli
run: |
curl -sL https://sentry.io/get-cli/ | bash
- name: Create Sentry release and associate commits
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
run: |
VERSION="qtmesheditor@${{ github.ref_name }}"
echo "Creating Sentry release: $VERSION"
sentry-cli releases new "$VERSION"
sentry-cli releases set-commits "$VERSION" --auto
sentry-cli releases finalize "$VERSION"
echo "Sentry release $VERSION created and finalized"
####################################################################
# Sign release artifacts (minisign / Ed25519) — epic #439 spike follow-up
####################################################################
sign-release-artifacts:
needs: [build-windows, build-linux, build-macos]
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
env:
RELEASE_TAG: ${{ github.ref_name }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Wait for release assets to appear
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="${RELEASE_TAG}"
REQUIRED=(
"QtMeshEditor-${VERSION}-bin-Windows.zip"
"QtMeshEditor-${VERSION}-setup-Windows.exe"
"qtmesheditor_amd64.deb"
"qtmesheditor_arm64.deb"
"QtMeshEditor-${VERSION}-MacOS.dmg"
)
missing_required() {
local present
present=$(gh release view "$VERSION" --json assets --jq '.assets[].name')
local missing=()
for name in "${REQUIRED[@]}"; do
if ! printf '%s\n' "$present" | grep -Fxq "$name"; then
missing+=("$name")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
printf '%s\n' "${missing[@]}"
return 1
fi
return 0
}
for i in 1 2 3 4 5 6 7 8 9 10; do
if missing=$(missing_required); then
echo "All required release assets present for $VERSION"
exit 0
fi
echo "Release $VERSION still missing (attempt $i):"
printf ' - %s\n' $missing
sleep 30
done
echo "::error::Timed out waiting for required release assets"
exit 1
- name: Download release artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="${RELEASE_TAG}"
mkdir -p release-assets
REQUIRED=(
"QtMeshEditor-${VERSION}-bin-Windows.zip"
"QtMeshEditor-${VERSION}-setup-Windows.exe"
"qtmesheditor_amd64.deb"
"qtmesheditor_arm64.deb"
"QtMeshEditor-${VERSION}-MacOS.dmg"
)
for name in "${REQUIRED[@]}"; do
gh release download "$VERSION" --dir release-assets --pattern "$name"
if [ ! -f "release-assets/$name" ]; then
echo "::error::Missing downloaded artifact: $name"
exit 1
fi
done
ls -la release-assets/
- name: Sign release artifacts
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${MINISIGN_SECRET_KEY:-}" ]; then
echo "::error::MINISIGN_SECRET_KEY secret is not configured"
exit 1
fi
echo "$MINISIGN_SECRET_KEY" | base64 -d > /tmp/minisign.key
chmod 600 /tmp/minisign.key
cd release-assets
VERSION="${RELEASE_TAG}"
REQUIRED=(
"QtMeshEditor-${VERSION}-bin-Windows.zip"
"QtMeshEditor-${VERSION}-setup-Windows.exe"
"qtmesheditor_amd64.deb"
"qtmesheditor_arm64.deb"
"QtMeshEditor-${VERSION}-MacOS.dmg"
)
sign_file() {
local f="$1"
local trusted="timestamp:$(date +%s) file:$(basename "$f")"
if [ -n "${MINISIGN_PASSWORD:-}" ]; then
printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s /tmp/minisign.key \
-m "$f" -x "$f.minisig" -t "$trusted"
else
minisign -S -s /tmp/minisign.key -m "$f" -x "$f.minisig" -t "$trusted"
fi
}
for f in "${REQUIRED[@]}"; do
if [ ! -f "$f" ]; then
echo "::error::Required artifact missing before signing: $f"
exit 1
fi
sign_file "$f"
done
sha256sum "${REQUIRED[@]}" | LC_ALL=C sort -k2 > SHA256SUMS
sign_file SHA256SUMS
rm -f /tmp/minisign.key
- name: Verify signatures (smoke test)
run: |
set -euo pipefail
PUB=$(grep -v '^untrusted' packaging/updater/minisign.pub | grep -v '^#' | tr -d '\n\r')
cd release-assets
for sig in *.minisig; do
base="${sig%.minisig}"
minisign -V -m "$base" -x "$sig" -P "$PUB" -q
done
- name: Upload signatures and checksum manifest
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${RELEASE_TAG}" \
release-assets/*.minisig \
release-assets/SHA256SUMS \
packaging/updater/minisign.pub \
--clobber
####################################################################
# Update Homebrew Cask after macOS release
####################################################################
update-homebrew-cask:
needs: build-macos
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
env:
RELEASE_TAG: ${{ github.ref_name }}
steps:
- name: Wait for DMG to be available
run: |
echo "Waiting for DMG to be uploaded to release..."
sleep 30
- name: Download DMG from release
run: |
VERSION="${RELEASE_TAG}"
DMG_URL="https://github.com/fernandotonon/QtMeshEditor/releases/download/${VERSION}/QtMeshEditor-${VERSION}-MacOS.dmg"
echo "Downloading DMG from: $DMG_URL"
# Retry download up to 5 times with increasing delays
for i in 1 2 3 4 5; do
if curl -L -f -o QtMeshEditor.dmg "$DMG_URL"; then
echo "Download successful"
break
else
echo "Download attempt $i failed, waiting..."
sleep $((i * 30))
fi
done
if [ ! -f QtMeshEditor.dmg ]; then
echo "Failed to download DMG after all retries"
exit 1
fi
- name: Calculate SHA256
id: sha256
run: |
SHA256=$(shasum -a 256 QtMeshEditor.dmg | awk '{print $1}')
echo "SHA256: $SHA256"
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
- name: Check out QtMeshEditor (for the canonical cask template)
uses: actions/checkout@v4
with:
path: qtmesheditor-src
- name: Clone Homebrew tap repository
run: |
git clone https://x-access-token:${{ secrets.HOMEBREW_TAP_TOKEN }}@github.com/fernandotonon/homebrew-qtmesheditor.git
cd homebrew-qtmesheditor
git config user.name "GitHub Actions Bot"
git config user.email "actions@github.com"
- name: Generate Cask file from canonical template
run: |
VERSION="${RELEASE_TAG}"
SHA256="${{ steps.sha256.outputs.sha256 }}"
CASK_FILE="homebrew-qtmesheditor/Casks/qtmesheditor.rb"
TEMPLATE="qtmesheditor-src/packaging/macos/qtmesheditor.rb.in"
echo "Generating cask for version $VERSION (sha256 $SHA256)"
# Always regenerate the ENTIRE cask from the in-repo template — never
# sed-patch or append to the existing tap file. (The old approach
# appended a second `postflight do...end` AFTER the cask's closing `end`,
# which Homebrew parsed as a top-level call and rejected with
# "undefined method 'postflight'", breaking every install — #718. It also
# used single-quote sed patterns that no longer match the double-quote
# DSL.) The template owns the whole cask, so version/sha + structure stay
# correct and the file can never drift.
sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHA256@/$SHA256/g" \
"$TEMPLATE" > "$CASK_FILE"
echo "Generated cask file:"
cat "$CASK_FILE"
- name: Commit and push changes
run: |
cd homebrew-qtmesheditor
git add Casks/qtmesheditor.rb
git commit -m "Update QtMeshEditor to ${RELEASE_TAG}" || echo "No changes to commit"
git push
####################################################################
# WinGet Publish (after Windows build)
####################################################################
winget-publish:
needs: build-windows
if: github.event_name == 'release' && github.event.action == 'published'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Submit WinGet manifest
shell: pwsh
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
run: |
$ErrorActionPreference = "Stop"
if (-not $env:WINGET_TOKEN) {
Write-Host "::error::WINGET_TOKEN secret is missing. Configure it in repo secrets with a classic PAT that has 'public_repo' scope."
exit 1
}
# Sanity-check the token before wingetcreate uses it. A revoked or
# scope-less PAT shows up here as a clear 401/403 instead of
# wingetcreate's generic "Failed to connect to GitHub".
Write-Host "Validating WINGET_TOKEN against GitHub API..."
$headers = @{
Authorization = "token $env:WINGET_TOKEN"
Accept = "application/vnd.github+json"
"User-Agent" = "QtMeshEditor-CI"
}
try {
$userResp = Invoke-WebRequest -Uri "https://api.github.com/user" -Headers $headers -UseBasicParsing
$scopes = $userResp.Headers['X-OAuth-Scopes']
$login = ($userResp.Content | ConvertFrom-Json).login
# X-OAuth-Scopes is only populated for classic PATs.
# Fine-grained PATs return an empty / missing scopes header.
# wingetcreate --submit has consistently been observed to
# fail with "Resource not accessible by personal access token"
# when given a fine-grained PAT, regardless of its
# configured fine-grained permissions on microsoft/winget-pkgs
# (the fork step uses an API endpoint fine-grained tokens
# can't reach). Fail fast with a clear remediation.
if ($scopes) {
Write-Host "Token OK for user '$login' with classic PAT scopes: $scopes"
if (($scopes -notmatch 'public_repo') -and ($scopes -notmatch '\brepo\b')) {
Write-Host "::error::WINGET_TOKEN is a classic PAT but has scopes '$scopes'; wingetcreate --submit needs 'public_repo' or 'repo'."
Write-Host "::error::Regenerate the token at https://github.com/settings/tokens/new with 'public_repo' checked and update the WINGET_TOKEN repo secret."
exit 1
}
} else {
Write-Host "::error::WINGET_TOKEN validates as user '$login' but reports NO X-OAuth-Scopes — this is a fine-grained PAT."
Write-Host "::error::wingetcreate --submit requires a CLASSIC PAT with 'public_repo' scope; fine-grained PATs cannot fork / open PRs against microsoft/winget-pkgs."
Write-Host "::error::Regenerate at https://github.com/settings/tokens/new (NOT /tokens/new?type=beta) with 'public_repo' checked and update WINGET_TOKEN."
exit 1
}
} catch {
Write-Host "::error::Token validation failed: $($_.Exception.Message). Rotate WINGET_TOKEN (classic PAT with 'public_repo')."
exit 1
}
# Download wingetcreate standalone exe from GitHub releases
$wgcUrl = "https://github.com/microsoft/winget-create/releases/latest/download/wingetcreate.exe"
Invoke-WebRequest -Uri $wgcUrl -OutFile wingetcreate.exe
$rawTag = "${{ github.event.release.tag_name }}"
$ver = $rawTag -replace '^v', ''
$url = "https://github.com/fernandotonon/QtMeshEditor/releases/download/${rawTag}/QtMeshEditor-${rawTag}-bin-Windows.zip"
# Wait for the release asset to be downloadable (GitHub CDN propagation).
# Treat "still not available after all retries" as a hard error —
# wingetcreate would otherwise fail later with a confusing message.
Write-Host "Waiting for release asset to be available..."
$assetReady = $false
for ($attempt = 1; $attempt -le 10; $attempt++) {
try {
$resp = Invoke-WebRequest -Uri $url -Method Head -ErrorAction Stop
Write-Host "Asset available (attempt $attempt)"
$assetReady = $true
break
} catch {
Write-Host "Attempt ${attempt}: asset not yet available, waiting 30s..."
Start-Sleep -Seconds 30
}
}
if (-not $assetReady) {
Write-Host "::error::Release asset $url never became available."
exit 1
}
# Sync the fork of microsoft/winget-pkgs with upstream before
# submitting.
#
# wingetcreate branches the new manifest off the fork's default
# branch. When that branch has fallen behind upstream, the tool
# refuses with "The forked repository could not be synced with the
# upstream commits" and no amount of retrying helps — the 3.40.1
# release failed exactly this way with the fork 1627 commits
# behind. Syncing here makes the job self-healing.
#
# The REST merge-upstream endpoint is used rather than a git push:
# it is a fast-forward that cannot discard fork-local commits.
# It does fail with 422 when upstream has touched
# .github/workflows/** and the token lacks `workflow` scope, which
# is expected for a `public_repo` PAT — that case is reported
# clearly rather than treated as fatal, because wingetcreate can
# still fork-and-branch from a fork that is merely behind.
Write-Host "Syncing fork of microsoft/winget-pkgs with upstream..."
$forkOwner = $login
try {
$cmpUri = "https://api.github.com/repos/microsoft/winget-pkgs/compare/microsoft:master...${forkOwner}:master"
$cmp = (Invoke-WebRequest -Uri $cmpUri -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
Write-Host "Fork is $($cmp.ahead_by) ahead / $($cmp.behind_by) behind upstream."
$syncFailed = $false
if ($cmp.behind_by -gt 0) {
$syncUri = "https://api.github.com/repos/${forkOwner}/winget-pkgs/merge-upstream"
$syncBody = @{ branch = "master" } | ConvertTo-Json
try {
Invoke-WebRequest -Uri $syncUri -Method Post -Headers $headers `
-Body $syncBody -ContentType "application/json" -UseBasicParsing | Out-Null
Write-Host "Fork synced to upstream master."
} catch {
# PowerShell Core throws HttpResponseException here, whose
# .Response can be null; read the status defensively so a
# failure inside the catch cannot mask the real error.
$status = 0
if ($_.Exception.PSObject.Properties['Response'] -and $_.Exception.Response) {
try { $status = [int]$_.Exception.Response.StatusCode } catch { $status = 0 }
}
$isScopeError = ($status -eq 422) -or ($_.ErrorDetails.Message -match 'workflow')
$syncFailed = $true
if ($isScopeError) {
# Upstream regularly edits .github/workflows/**, and a
# classic PAT with only `public_repo` may not write those,
# so merge-upstream 422s until the fork is synced by a
# token that has `workflow` scope (or via the web UI).
# This is not recoverable from inside the job.
Write-Host "::error::Fork sync refused: upstream changed files this token may not write (.github/workflows/**, which needs 'workflow' scope)."
Write-Host "::error::Fix once, then releases keep working:"
Write-Host "::error:: a) open https://github.com/${forkOwner}/winget-pkgs and press 'Sync fork', or"
Write-Host "::error:: b) give WINGET_TOKEN the 'workflow' scope in addition to 'public_repo'."
} else {
Write-Host "::warning::Fork sync failed with HTTP ${status}: $($_.Exception.Message)."
}
}
}
if ($cmp.ahead_by -gt 0) {
Write-Host "::warning::Fork is $($cmp.ahead_by) commit(s) AHEAD of upstream. wingetcreate may refuse to sync it."
Write-Host "::warning::If submit fails, reset the fork's master to upstream — those commits are not needed for manifest submission."
}
# Re-read the state. wingetcreate refuses outright when the fork
# is behind ("The forked repository could not be synced with the
# upstream commits"), which is how 3.40.1 failed, so a fork that
# is still behind here means the submit below cannot succeed.
# Fail now with the remediation rather than after three retries.
if ($syncFailed) {
$after = (Invoke-WebRequest -Uri $cmpUri -Headers $headers -UseBasicParsing).Content | ConvertFrom-Json
if ($after.behind_by -gt 0) {
Write-Host "::error::Fork is still $($after.behind_by) commit(s) behind upstream and could not be synced automatically."
Write-Host "::error::wingetcreate cannot branch from a stale fork — this is what failed the 3.40.1 release."
Write-Host "::error::Sync the fork using one of the options above, then re-run this job."
exit 1
}
}
} catch {
Write-Host "::warning::Could not determine fork sync state: $($_.Exception.Message). Continuing to submit."
}
# wingetcreate --submit goes through the GitHub Contents API (PUT
# /contents/:path), which works with a classic PAT that only has
# `public_repo` scope. The previous approach (manual `git clone` +
# `git push` to the fork) required write-access on the fork via
# git-over-HTTPS, a different permission path that was 403-denied.
#
# Retry up to three times on transient failures — wingetcreate's
# "Failed to connect to GitHub" sometimes hits during runner DNS
# flakes or microsoft/winget-pkgs being under load after a release.
# We capture stderr so we can detect the "Resource not accessible
# by personal access token" auth failure and bail immediately
# (retrying that is pointless; the token lacks permissions).
$submitOk = $false
$authErrorSeen = $false
$lastOutput = @()
for ($attempt = 1; $attempt -le 3; $attempt++) {
Write-Host "Submitting WinGet manifest for version $ver (attempt $attempt)..."
$output = & .\wingetcreate.exe update FernandoTonon.QtMeshEditor `
--version $ver `
--urls $url `
--submit `
--token $env:WINGET_TOKEN 2>&1
$lastOutput = $output
# Echo the tool's output so users can read it in the logs.
$output | ForEach-Object { Write-Host $_ }
if ($LASTEXITCODE -eq 0) {
$submitOk = $true
break
}
if ($output -match 'Resource not accessible by personal access token') {
$authErrorSeen = $true
break
}
Write-Host "::warning::wingetcreate exited $LASTEXITCODE on attempt $attempt."
if ($attempt -lt 3) {
Start-Sleep -Seconds 30
}
}
if (-not $submitOk) {
if ($authErrorSeen) {
Write-Host "::error::wingetcreate reported 'Resource not accessible by personal access token'."
Write-Host "::error::This means WINGET_TOKEN lacks the required scope to fork microsoft/winget-pkgs and open a PR."
Write-Host "::error::Fix: regenerate a CLASSIC PAT at https://github.com/settings/tokens/new with 'public_repo' checked, update the WINGET_TOKEN repo secret, then re-run this job."
} else {
Write-Host "::error::wingetcreate submit failed after 3 attempts. Check:"
Write-Host "::error:: 1. WINGET_TOKEN is still valid and has 'public_repo' scope."
Write-Host "::error:: 2. The fork's master is not AHEAD of upstream. This job syncs a fork that is"
Write-Host "::error:: behind, but cannot rebase one that has its own commits. Fix at"
Write-Host "::error:: https://github.com/${forkOwner}/winget-pkgs — reset master to upstream."
Write-Host "::error:: 3. If the log shows 'could not be synced with the upstream commits', press"
Write-Host "::error:: 'Sync fork' on that page once, then re-run this job."
Write-Host "::error:: 4. https://www.githubstatus.com for GitHub API availability."
}
exit 1
}
Write-Host "WinGet PR submitted successfully."
# Echo the created PR's URL at the top of our own action log
# so it's easy to find from the release build later. We do NOT
# post a comment on the winget-pkgs PR itself: winget's review
# flow is driven by wingetbot and Microsoft maintainers, and
# external contributor comments don't add value (and could
# risk tripping keyword-based automation).
foreach ($line in $lastOutput) {
if ($line -match 'Pull request.*?(https://github\.com/microsoft/winget-pkgs/pull/\d+)') {
Write-Host "::notice::WinGet PR created: $($Matches[1])"
break
}
}
####################################################################
# Snap Store Publish (after Linux .deb is built)
####################################################################
snap-publish:
needs: build-linux
if: github.event_name == 'release' && github.event.action == 'published'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3.5.3
- uses: actions/download-artifact@v4
with:
# Snap remains amd64-only for now — pull the amd64 .deb specifically.
name: linux-binaries-amd64
path: .
- name: Verify snap input artifact
run: test -f ./qtmesheditor_amd64.deb
- uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1
id: build
# Snap Store allows only one revision in manual review at a time. When a
# prior upload is still queued, publish fails until it is rejected in
# https://dashboard.snapcraft.io/ — retry with backoff for transient cases.
- name: Publish to Snap Store
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_STORE_TOKEN }}
run: |
SNAP="${{ steps.build.outputs.snap }}"
for attempt in 1 2 3 4 5 6; do
echo "::group::Snap publish attempt ${attempt}/6 (${SNAP})"
set +e
snapcraft upload "$SNAP" --release stable 2>&1 | tee /tmp/snap-upload.log
rc=${PIPESTATUS[0]}
set -e
echo "::endgroup::"
if [ "$rc" -eq 0 ]; then
exit 0
fi
if grep -q 'Waiting for previous upload' /tmp/snap-upload.log; then
echo "::warning::Snap Store review queue blocked this upload."
echo "::warning::Open https://dashboard.snapcraft.io/, find qtmesheditor uploads still 'In review', and click 'Reject and remove from review queue' for stale ones."
echo "::warning::Then re-run the failed snap-publish job from GitHub Actions."
fi
if [ "$attempt" -lt 6 ]; then
sleep $((attempt * 60))
fi
done
exit 1
####################################################################
# Docker Image Publish (after Linux .deb is built)
####################################################################
docker-publish:
needs: build-linux
runs-on: ubuntu-latest
if: github.event_name == 'release' && github.event.action == 'published'
permissions:
contents: read
packages: write
env:
REGISTRY_GHCR: ghcr.io
GHCR_IMAGE: fernandotonon/qtmesh
DOCKERHUB_IMAGE: fernandotr1/qtmesh
steps:
- uses: actions/checkout@v4
- name: Download both-arch .deb artifacts from build-linux
uses: actions/download-artifact@v4
with:
# build-linux uploads linux-binaries-amd64 and linux-binaries-arm64;
# merge both into the build context so the multi-arch Dockerfile can
# COPY qtmesheditor_${TARGETARCH}.deb per platform.
pattern: linux-binaries-*
merge-multiple: true
path: .
- name: Verify both .deb inputs are present
run: |
ls -la ./qtmesheditor_amd64.deb ./qtmesheditor_arm64.deb
test -f ./qtmesheditor_amd64.deb
test -f ./qtmesheditor_arm64.deb
- name: Extract version from downloaded package
id: version
run: |
VERSION=$(dpkg-deb -f ./qtmesheditor_amd64.deb Version | sed 's/-.*//')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Extracted version: $VERSION"
- name: Set up QEMU (arm64 emulation for the arm64 image layer)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_GHCR }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute Docker tags
id: tags
run: |
VERSION="${{ steps.version.outputs.version }}"
TAGS="${{ env.REGISTRY_GHCR }}/${{ env.GHCR_IMAGE }}:${VERSION}"
TAGS="${TAGS},${{ env.REGISTRY_GHCR }}/${{ env.GHCR_IMAGE }}:latest"
TAGS="${TAGS},${{ env.DOCKERHUB_IMAGE }}:${VERSION}"
TAGS="${TAGS},${{ env.DOCKERHUB_IMAGE }}:latest"
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image (multi-arch)
uses: docker/build-push-action@v5
with:
context: .
# Native linux/amd64 (Intel Macs, Linux x64) + linux/arm64 (Apple
# Silicon Macs, ARM servers). Buildx pushes a single multi-arch
# manifest; `docker run` on each host auto-selects the matching image.
platforms: linux/amd64,linux/arm64
push: true
provenance: false
build-args: |
VERSION=${{ steps.version.outputs.version }}
tags: ${{ steps.tags.outputs.tags }}
labels: |
org.opencontainers.image.title=qtmesh
org.opencontainers.image.description=qtmesh CLI - 3D mesh conversion, optimization, and animation tools
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.source=https://github.com/fernandotonon/QtMeshEditor
- name: Verify image
run: |
IMAGE="${{ env.REGISTRY_GHCR }}/${{ env.GHCR_IMAGE }}:${{ steps.version.outputs.version }}"
# Confirm the pushed manifest advertises BOTH arches. Parse the plain
# `imagetools inspect` "Platform: linux/arm64" lines (whitespace-
# tolerant) rather than a Go-template — `{{println .OS "/" .Arch}}`
# space-separates its args and prints "linux / arm64", which a
# 'linux/arm64' grep never matched (spurious verify failure on 3.29.0).
echo "=== Manifest platforms ==="
PLATFORMS="$(docker buildx imagetools inspect "$IMAGE" | awk '/Platform:/ {print $2}')"
echo "$PLATFORMS"
echo "$PLATFORMS" | grep -qx 'linux/arm64' || { echo "arm64 missing from manifest" >&2; exit 1; }
echo "$PLATFORMS" | grep -qx 'linux/amd64' || { echo "amd64 missing from manifest" >&2; exit 1; }
# Check for missing shared libraries before running (amd64 variant,
# pulled natively on this amd64 runner).
echo "=== Checking for missing shared libraries (amd64) ==="
docker run --rm --platform linux/amd64 --entrypoint bash "$IMAGE" -c \
'LD_LIBRARY_PATH=/usr/lib/qtmesheditor ldd /usr/share/qtmesheditor/qtmesheditor 2>&1 | grep "not found" && exit 1 || echo "All libraries found"'
# Run --help to verify CLI works end-to-end
docker run --rm --platform linux/amd64 "$IMAGE" --help
# Smoke-test the arm64 variant under QEMU (the layer only installs a
# .deb; this confirms the arm64 binary loads + CLI dispatches).
echo "=== arm64 variant smoke test (QEMU) ==="
docker run --rm --platform linux/arm64 "$IMAGE" --version || {
echo "::warning::arm64 --version under QEMU failed (emulation-only check)"
true
}
- name: Scan repository assets via Docker
run: |
IMAGE="${{ env.REGISTRY_GHCR }}/${{ env.GHCR_IMAGE }}:${{ steps.version.outputs.version }}"
echo "=== Scanning repository assets via Docker ==="
docker run --rm \
-v "${{ github.workspace }}:/workspace" \
"$IMAGE" scan --config /workspace/qtmesh.yml --json || {
echo "::warning::Docker asset scan reported issues"
true
}