feat(gen3d): Pixal3D backend across UI, CLI and MCP #3866
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |