diff --git a/.github/actions/setup-godot-cpp/action.yml b/.github/actions/setup-godot-cpp/action.yml
index 33f0927fb..f5f93f3cc 100644
--- a/.github/actions/setup-godot-cpp/action.yml
+++ b/.github/actions/setup-godot-cpp/action.yml
@@ -1,71 +1,110 @@
name: Setup godot-cpp
-description: Setup build dependencies for godot-cpp.
+description: Setup build dependencies for godot-cpp (SCons or CMake).
inputs:
platform:
required: true
description: Target platform.
- em-version:
- default: 4.0.11
- description: Emscripten version.
- windows-compiler:
- required: true
- description: The compiler toolchain to use on Windows ('mingw' or 'msvc').
type: choice
options:
- - mingw
+ - linux
+ - windows
+ - macos
+ - android
+ - web
+
+ compiler:
+ description: Compiler toolchain (auto selects sensible default per platform).
+ required: false
+ default: auto
+ type: choice
+ options:
+ - auto
+ - gcc
+ - clang
- msvc
- default: mingw
+ - mingw
+
mingw-version:
default: 12.2.0
- description: MinGW version.
+ description: MinGW version (used only for windows + mingw).
+
ndk-version:
default: r28b
description: Android NDK version.
+
+ em-version:
+ default: 3.1.74
+ description: Emscripten version (recommended for Godot 4.x web).
+
buildtool:
+ description: Build tool to prepare (scons or cmake).
default: scons
- description: scons or cmake
+ type: choice
+ options:
+ - scons
+ - cmake
+
scons-version:
- default: 4.4.0
+ default: 4.8.0
description: SCons version.
-
runs:
using: composite
steps:
- - name: Setup Python (for SCons)
+ - name: Setup Python
uses: actions/setup-python@v5
with:
- python-version: 3.x
+ python-version: 3.12
- - name: Setup Android dependencies
- if: inputs.platform == 'android'
+ # === Compiler Toolchain Setup ===
+
+ - name: Setup Clang on Linux or Windows
+ if: ${{ (inputs.platform == 'linux' || inputs.platform == 'windows') && inputs.compiler == 'clang' }}
+ uses: egor-tensin/setup-clang@v2
+ with:
+ version: 18
+
+ - name: Setup MinGW (Windows)
+ if: ${{ inputs.platform == 'windows' && (inputs.compiler == 'mingw' || (inputs.compiler == 'auto' && inputs.compiler != 'msvc')) }}
+ uses: egor-tensin/setup-mingw@v2
+ with:
+ version: ${{ inputs.mingw-version }}
+
+ # MSVC requires no extra setup on windows-latest runners (Visual Studio is preinstalled)
+
+ - name: Setup Android NDK
+ if: ${{ inputs.platform == 'android' }}
uses: nttld/setup-ndk@v1
with:
ndk-version: ${{ inputs.ndk-version }}
link-to-sdk: true
- - name: Setup Web dependencies
- if: inputs.platform == 'web'
+ - name: Setup Emscripten (Web)
+ if: ${{ inputs.platform == 'web' }}
uses: mymindstorm/setup-emsdk@v14
with:
version: ${{ inputs.em-version }}
no-cache: true
- - name: Setup MinGW for Windows/MinGW build
- if: inputs.platform == 'windows' && inputs.windows-compiler == 'mingw'
- uses: egor-tensin/setup-mingw@v2
- with:
- version: ${{ inputs.mingw-version }}
+ # macOS uses AppleClang by default β no extra setup needed on macos-latest
+
+ # === Build Tool Setup ===
- name: Setup SCons
if: ${{ inputs.buildtool == 'scons' }}
shell: bash
run: |
- python -c "import sys; print(sys.version)"
python -m pip install scons==${{ inputs.scons-version }}
scons --version
- - name: Install Ninja
+ - name: Install Ninja (for CMake)
if: ${{ inputs.buildtool == 'cmake' }}
- uses: ashutoshvarma/setup-ninja@master
+ uses: ashutoshvarma/setup-ninja@v1.1
+
+ - name: Debug chosen configuration
+ shell: bash
+ run: |
+ echo "Platform : ${{ inputs.platform }}"
+ echo "Compiler : ${{ inputs.compiler }}"
+ echo "Buildtool: ${{ inputs.buildtool }}"
diff --git a/.github/workflows/ci-cmake.yml b/.github/workflows/ci-cmake.yml
index 6b64a1ddd..7c47fee56 100644
--- a/.github/workflows/ci-cmake.yml
+++ b/.github/workflows/ci-cmake.yml
@@ -3,7 +3,7 @@ on:
workflow_call:
env:
- # Only used for the cache key. Increment version to force clean build.
+ # Only used for the cache key. Increment version to force a clean build.
GODOT_BASE_BRANCH: master
# Used to select the version of Godot to run the tests with.
GODOT_TEST_VERSION: master
@@ -24,62 +24,170 @@ jobs:
config-flags:
-DCMAKE_C_COMPILER_LAUNCHER=sccache
-DCMAKE_CXX_COMPILER_LAUNCHER=sccache
+ -DCMAKE_C_LINKER_LAUNCHER=sccache
+ -DCMAKE_CXX_LINKER_LAUNCHER=sccache
-DGODOTCPP_ENABLE_TESTING=ON
-DGODOTCPP_BUILD_PROFILE="test/build_profile.json"
SCCACHE_GHA_ENABLED: "true"
+ DEBUG_BUILD_START: ""
+ RELEASE_BUILD_START: ""
strategy:
fail-fast: false
matrix:
include:
+ # Linux-gcc
- name: π§ Linux (GCC, Makefiles)
os: ubuntu-22.04
platform: linux
config-flags: -DCMAKE_BUILD_TYPE=Release
- artifact-name: godot-cpp-linux-glibc2.27-x86_64-release.cmake
+ artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release
artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a
run-tests: true
+ godot-test-arch: "linux.x86_64"
+ godot-master-workflow: linux_builds.yml
+ godot-master-artifact: linux-editor-mono
+ - name: π§ Linux (GCC, Makefiles) for Godot 4.6
+ os: ubuntu-22.04
+ platform: linux
+ config-flags:
+ -DCMAKE_BUILD_TYPE=Release
+ -DGODOTCPP_API_VERSION=4.6
+ artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot46
+ artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a
+ run-tests: true
+ godot-test-versions: "4.6-stable"
+ godot-test-arch: "linux.x86_64"
+ godot-master-workflow: linux_builds.yml
+ godot-master-artifact: linux-editor-mono
+
+ - name: π§ Linux (GCC, Makefiles) for Godot 4.5
+ os: ubuntu-22.04
+ platform: linux
+ config-flags:
+ -DCMAKE_BUILD_TYPE=Release
+ -DGODOTCPP_API_VERSION=4.5
+ artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot45
+ artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a
+ run-tests: true
+ godot-test-versions: "4.5-stable 4.6-stable"
+ godot-test-arch: "linux.x86_64"
+ godot-master-workflow: linux_builds.yml
+ godot-master-artifact: linux-editor-mono
+
+ - name: π§ Linux (GCC, Makefiles) for Godot 4.4
+ os: ubuntu-22.04
+ platform: linux
+ config-flags:
+ -DCMAKE_BUILD_TYPE=Release
+ -DGODOTCPP_API_VERSION=4.4
+ artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot44
+ artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a
+ run-tests: true
+ godot-test-versions: "4.4-stable 4.5-stable 4.6-stable"
+ godot-test-arch: "linux.x86_64"
+ godot-master-workflow: linux_builds.yml
+ godot-master-artifact: linux-editor-mono
+
+ - name: π§ Linux (GCC, Makefiles) for Godot 4.3
+ os: ubuntu-22.04
+ platform: linux
+ config-flags:
+ -DCMAKE_BUILD_TYPE=Release
+ -DGODOTCPP_API_VERSION=4.3
+ artifact-name: godot-cpp-linux-glibc2.27-x86_64-cmake-release-godot43
+ artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a
+ run-tests: true
+ godot-test-versions: "4.3-stable 4.4-stable 4.5-stable 4.6-stable"
+ godot-test-arch: "linux.x86_64"
+ godot-master-workflow: linux_builds.yml
+ godot-master-artifact: linux-editor-mono
+
+ # Linux-clang
+ - name: π§ Linux (Clang, Makefiles)
+ os: ubuntu-22.04
+ platform: linux
+ compiler: clang
+ config-flags: -DCMAKE_BUILD_TYPE=Release
+ artifact-name: godot-cpp-linux-clang-x86_64-cmake-release
+ artifact-path: cmake-build/bin/libgodot-cpp.linux.template_release.x86_64.a
+ run-tests: true
+ godot-test-arch: "linux.x86_64"
+ godot-master-workflow: linux_builds.yml
+ godot-master-artifact: linux-editor-mono
+
+ # linux android
+ - name: π€ Android (arm64, Ninja)
+ os: ubuntu-22.04
+ platform: android
+ config-flags:
+ -G Ninja -DCMAKE_BUILD_TYPE=Release
+ --toolchain ${ANDROID_HOME}/ndk/28.1.13356709/build/cmake/android.toolchain.cmake
+ -DANDROID_PLATFORM=24 -DANDROID_ABI=arm64-v8a
+ artifact-name: godot-cpp-android-arm64-cmake-release
+ artifact-path: cmake-build/bin/libgodot-cpp.android.template_release.arm64.a
+
+ # linux web
+ - name: π Web (wasm32, Ninja)
+ os: ubuntu-22.04
+ platform: web
+ config-flags:
+ -G Ninja -DCMAKE_BUILD_TYPE=Release
+ --toolchain ${EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake
+ artifact-name: godot-cpp-web-wasm32-cmake-release
+ artifact-path: cmake-build/bin/libgodot-cpp.web.template_release.wasm32.a
+ # Linux-double
+ # Linux-dev
+ # Linux-32bit
+
+ # Windows-msvc
- name: π Windows (x86_64, MSVC)
os: windows-2022
platform: windows
compiler: msvc
build-flags: --config Release
- artifact-name: godot-cpp-windows-msvc2019-x86_64-release.cmake
+ artifact-name: godot-cpp-windows-msvc2022-x86_64-cmake-release
artifact-path: cmake-build/bin/libgodot-cpp.windows.template_release.x86_64.lib
- run-tests: false
+ run-tests: true
+ godot-test-arch: "win64.exe"
+ godot-master-workflow: windows_builds.yml
+ godot-master-artifact: windows-editor
+ # Windows-mingw
- name: π Windows (x86_64, MinGW, Ninja)
os: windows-2022
platform: windows
compiler: mingw
config-flags:
-GNinja -DCMAKE_BUILD_TYPE=Release
- -DCMAKE_CXX_COMPILER=cc -DCMAKE_CXX_COMPILER=c++
- artifact-name: godot-cpp-linux-mingw-x86_64-release.cmake
+ -DCMAKE_C_COMPILER=cc -DCMAKE_CXX_COMPILER=c++
+ artifact-name: godot-cpp-windows-mingw-x86_64-cmake-release
artifact-path: cmake-build/bin/libgodot-cpp.windows.template_release.x86_64.a
- run-tests: false
+ run-tests: true
+ godot-test-arch: "win64.exe"
+ godot-master-workflow: windows_builds.yml
+ godot-master-artifact: windows-editor
+ # Windows-clang?
+ # Windows-msys2?
+ # Windows-cygwin?
+ # Windows-emscripten?
+ # Windows-android?
+ # macos
- name: π macOS (universal, Makefiles)
os: macos-latest
platform: macos
config-flags: -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
- artifact-name: godot-cpp-macos-universal-release.cmake
+ artifact-name: godot-cpp-macos-universal-cmake-release
artifact-path: cmake-build/bin/libgodot-cpp.macos.template_release.universal.a
- run-tests: false
-
- - name: π€ Android (arm64, Ninja)
- os: ubuntu-22.04
- platform: android
- config-flags:
- -G Ninja -DCMAKE_BUILD_TYPE=Release
- --toolchain ${ANDROID_HOME}/ndk/28.1.13356709/build/cmake/android.toolchain.cmake
- -DANDROID_PLATFORM=24 -DANDROID_ABI=arm64-v8a
- artifact-name: godot-cpp-android-arm64-release.cmake
- artifact-path: cmake-build/bin/libgodot-cpp.android.template_release.arm64.a
- flags: arch=arm64
- run-tests: false
-
+ run-tests: true
+ godot-test-arch: "macos.universal"
+ godot-master-workflow: macos_builds.yml
+ godot-master-artifact: macos-editor
+ # macos-android?
+ # macos-web?
+ # macos-ios
- name: π iOS (arm64, XCode)
os: macos-latest
platform: ios
@@ -88,57 +196,59 @@ jobs:
--toolchain cmake/ios.toolchain.cmake
-DPLATFORM=OS64
build-flags: --config Release
- artifact-name: godot-cpp-ios-arm64-release.cmake
+ artifact-name: godot-cpp-ios-arm64-cmake-release
artifact-path: cmake-build/bin/libgodot-cpp.ios.template_release.arm64.a
- flags: arch=arm64
- run-tests: false
-
- - name: π Web (wasm32, Ninja)
- os: ubuntu-22.04
- platform: web
- config-flags:
- -G Ninja -DCMAKE_BUILD_TYPE=Release
- --toolchain ${EMSDK}/upstream/emscripten/cmake/Modules/Platform/Emscripten.cmake
- artifact-name: godot-cpp-web-wasm32-release.cmake
- artifact-path: cmake-build/bin/libgodot-cpp.web.template_release.wasm32.a
- run-tests: false
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
submodules: recursive
- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.9
+ with:
+ disable_annotations: true
- name: Setup godot-cpp
uses: ./.github/actions/setup-godot-cpp
with:
platform: ${{ matrix.platform }}
- windows-compiler: ${{ matrix.compiler }}
+ compiler: ${{ matrix.compiler }}
buildtool: cmake
+ - name: Clean committed macOS framework bundles for CMake
+ if: matrix.platform == 'macos'
+ run: |
+ echo "Removing pre-committed framework folders (required for CMake FRAMEWORK TRUE)"
+ rm -rf test/project/bin/libgdexample.macos.template_debug.framework
+ rm -rf test/project/bin/libgdexample.macos.template_release.framework
+
+ # Configure and build the debug target with CMake
- name: Configure godot-cpp-test with template_debug
- run: >
+ id: configure-debug
+ run: |
+ echo "DEBUG_BUILD_START=$(date +%s)" >> $GITHUB_ENV
cmake --log-level=VERBOSE -S . -B cmake-build ${{ env.config-flags }} ${{ matrix.config-flags }}
- name: Build godot-cpp-test (template_debug)
- run: >
- cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }}
+ id: build-debug
+ run: |
+ cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }}
+ echo "DEBUG_BUILD_END=$(date +%s)" >> $GITHUB_ENV
+ # Configure and build the release target with CMake
- name: Configure godot-cpp-test with template_release
- run: >
- cmake --fresh --log-level=VERBOSE -S . -B cmake-build
- -DGODOTCPP_TARGET=template_release ${{ env.config-flags }} ${{ matrix.config-flags }}
+ id: configure-release
+ run: |
+ echo "RELEASE_BUILD_START=$(date +%s)" >> $GITHUB_ENV
+ cmake --fresh --log-level=VERBOSE -S . -B cmake-build -DGODOTCPP_TARGET=template_release ${{ env.config-flags }} ${{ matrix.config-flags }}
- name: Build godot-cpp-test (template_release)
- run: >
- cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }}
-
- - name: Run sccache stat for check
- shell: bash
- run: ${SCCACHE_PATH} --show-stats
+ id: build-release
+ run: |
+ cmake --build cmake-build --verbose --target godot-cpp-test ${{ matrix.build-flags }}
+ echo "RELEASE_BUILD_END=$(date +%s)" >> $GITHUB_ENV
- name: Download latest Godot artifacts
uses: dsnopek/action-download-artifact@1322f74e2dac9feed2ee76a32d9ae1ca3b4cf4e9
@@ -147,40 +257,86 @@ jobs:
repo: godotengine/godot
branch: master
event: push
- workflow: linux_builds.yml
+ workflow: ${{ matrix.godot-master-workflow }}
workflow_conclusion: success
- name: linux-editor-mono
+ name: ${{ matrix.godot-master-artifact }}
search_artifacts: true
check_artifacts: true
ensure_latest: true
path: godot-artifacts
- - name: Prepare Godot artifacts for testing
- if: matrix.run-tests && env.GODOT_TEST_VERSION == 'master'
- run: |
- chmod +x ./godot-artifacts/godot.linuxbsd.editor.x86_64.mono
- echo "GODOT=$(pwd)/godot-artifacts/godot.linuxbsd.editor.x86_64.mono" >> $GITHUB_ENV
-
- - name: Download requested Godot version for testing
- if: matrix.run-tests && env.GODOT_TEST_VERSION != 'master'
- run: |
- wget "https://github.com/godotengine/godot-builds/releases/download/${GODOT_TEST_VERSION}/Godot_v${GODOT_TEST_VERSION}_linux.x86_64.zip" -O Godot.zip
- unzip -a Godot.zip
- chmod +x "Godot_v${GODOT_TEST_VERSION}_linux.x86_64"
- echo "GODOT=$(pwd)/Godot_v${GODOT_TEST_VERSION}_linux.x86_64" >> $GITHUB_ENV
-
- name: Run tests
if: matrix.run-tests
- run: |
- $GODOT --headless --version
- cd test
- # Need to run the editor so .godot is generated... but it crashes! Ignore that :-)
- (cd project && (timeout 30 $GODOT --import --headless >/dev/null 2>&1 || true))
- ./run-tests.sh
+ run: python -X utf8 run-tests.py --verbose --godot-test-versions "${{ matrix.godot-test-versions }}" --godot-test-arch "${{ matrix.godot-test-arch }}"
+ working-directory: test
+ env:
+ GITHUB_STEP_SUMMARY: ${{ env.GITHUB_STEP_SUMMARY }}
- name: Upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact-name }}
path: ${{ matrix.artifact-path }}
if-no-files-found: error
+
+ - name: Show build and sccache statistics
+ if: always()
+ shell: bash
+ run: |
+ {
+ echo "### β±οΈ Build Times"
+ echo ""
+ echo "| Target | Duration |"
+ echo "|---------------------|----------|"
+
+ debug_duration=$((DEBUG_BUILD_END - DEBUG_BUILD_START))
+ release_duration=$((RELEASE_BUILD_END - RELEASE_BUILD_START))
+
+ echo "| template_debug | ${debug_duration}s |"
+ echo "| template_release | ${release_duration}s |"
+ echo ""
+
+ STATS=$("${SCCACHE_PATH}" --show-stats 2>/dev/null || echo "")
+ if [ -n "$STATS" ]; then
+ REQUESTS=$(echo "$STATS" | grep -oP 'Compile requests\s+\K\d+' || echo "0")
+ HITS=$(echo "$STATS" | grep -oP 'Cache hits\s+\K\d+' || echo "0")
+ MISSES=$(echo "$STATS" | grep -oP 'Cache misses\s+\K\d+' || echo "0")
+
+ if [ "$REQUESTS" -gt 0 ]; then
+ HIT_RATE=$(awk "BEGIN {printf \"%.2f\", ($HITS / $REQUESTS) * 100}")
+ else
+ HIT_RATE="0.00"
+ fi
+
+ echo "### πΎ Cache Summary"
+ echo ""
+ echo "**sccache hit rate: ${HIT_RATE}%** (${HITS}/${REQUESTS} requests cached)"
+ if [ "$MISSES" -eq 0 ]; then
+ echo "π **Perfect cache hit** β no cache misses!"
+ else
+ echo "**${MISSES} cache miss(es)**"
+ fi
+ echo ""
+
+ # Collapsible full stats
+ echo ""
+ echo "π Full sccache Statistics
"
+ echo ""
+ echo "| Metric | Value |"
+ echo "|--------|-------|"
+ echo "$STATS" | awk '
+ /^[A-Za-z]/ {
+ gsub(/^[ \t]+|[ \t]+$/, "");
+ gsub(/[ \t]{2,}/, "|");
+ print "| " $0 " |";
+ }
+ '
+ echo ""
+ echo " "
+ else
+ echo "**sccache statistics not available**"
+ fi
+ } >> "$GITHUB_STEP_SUMMARY" || true
+
+ # Stop sccache server (ignore errors)
+ "${SCCACHE_PATH}" --stop-server || true
diff --git a/.github/workflows/ci-scons.yml b/.github/workflows/ci-scons.yml
index b2e6e17fd..cfeeca41c 100644
--- a/.github/workflows/ci-scons.yml
+++ b/.github/workflows/ci-scons.yml
@@ -133,7 +133,7 @@ jobs:
uses: ./.github/actions/setup-godot-cpp
with:
platform: ${{ matrix.platform }}
- windows-compiler: ${{ contains(matrix.flags, 'use_mingw=yes') && 'mingw' || 'msvc' }}
+ compiler: ${{ contains(matrix.flags, 'use_mingw=yes') && 'mingw' || 'msvc' }}
buildtool: scons
- name: Generate godot-cpp sources only
diff --git a/.github/workflows/runner.yml b/.github/workflows/runner.yml
index 62f9327d9..2bf228c77 100644
--- a/.github/workflows/runner.yml
+++ b/.github/workflows/runner.yml
@@ -31,7 +31,8 @@ jobs:
with:
files_yaml: |
sources:
- - '.github/workflows/*.yml'
+ - '.github/workflows/runner.yml'
+ - '.github/workflows/static_checks.yml'
- '**/*.py'
- '**/*.cpp'
- '**/*.hpp'
@@ -40,9 +41,11 @@ jobs:
- 'test/build_profile.json'
- 'gdextension/extension_api.json'
scons:
+ - '.github/workflows/ci-scons.yml'
- '**/SConstruct'
- '**/SCsub'
cmake:
+ - '.github/workflows/ci-cmake.yml'
- '**/CMakeLists.txt'
- '**/*.cmake'
- name: echo sources changed
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 239d28f59..cdd6104e1 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -29,6 +29,7 @@ target_link_libraries(${TARGET_NAME} PRIVATE godot-cpp)
### Get useful properties from godot-cpp target
get_target_property(GODOTCPP_SUFFIX godot-cpp GODOTCPP_SUFFIX)
+get_target_property(GODOTCPP_TARGET godot-cpp GODOTCPP_TARGET)
# gersemi: off
set_target_properties(
@@ -60,10 +61,28 @@ set_target_properties(
)
# gersemi: on
-# CMAKE_SYSTEM_NAME refers to the target system
+if(CMAKE_SYSTEM_NAME STREQUAL "Windows" AND MINGW)
+ # Make the GDExtension DLL self-contained (no external MinGW runtime DLLs)
+ target_link_options(${TARGET_NAME} PRIVATE "$,-static-libgcc;-static-libstdc++,>")
+
+ # Also ensure no undefined symbols
+ target_link_options(${TARGET_NAME} PRIVATE -Wl,--no-undefined)
+endif()
+
if(CMAKE_SYSTEM_NAME STREQUAL Darwin)
set_target_properties(
${TARGET_NAME}
- PROPERTIES SUFFIX "" OUTPUT_DIR "${OUTPUT_DIR}/libgdexample.macos.${GODOTCPP_TARGET}.framework"
+ PROPERTIES
+ OUTPUT_NAME "libgdexample.macos.${GODOTCPP_TARGET}"
+ FRAMEWORK TRUE
+ FRAMEWORK_VERSION A
+ MACOSX_FRAMEWORK_IDENTIFIER
+ "com.godot.gdexample" # can be anything, doesn't matter much
+ MACOSX_FRAMEWORK_BUNDLE_VERSION 1.0
+ MACOSX_FRAMEWORK_SHORT_VERSION_STRING 1.0
+ # These help with rpath and universal builds
+ MACOSX_RPATH TRUE
+ INSTALL_NAME_DIR "@rpath"
+ BUILD_WITH_INSTALL_NAME_DIR TRUE
)
endif()
diff --git a/test/run-tests.py b/test/run-tests.py
new file mode 100644
index 000000000..fcd80cc02
--- /dev/null
+++ b/test/run-tests.py
@@ -0,0 +1,665 @@
+#!/usr/bin/env python3
+"""
+run-tests.py - Robust test runner for godot-cpp test project (with temp portable Godot copy)
+"""
+
+import argparse
+import builtins
+import os
+import re
+import shutil
+import signal
+import subprocess
+import sys
+import tempfile
+import time
+import urllib.request
+import zipfile
+from pathlib import Path
+from typing import Any, Dict, List, Tuple
+
+# ----------------------------------------------
+# Configuration
+# ----------------------------------------------
+
+GODOT_EDITOR = os.environ.get("GODOT_EDITOR", "unspecified-editor-binary")
+GODOT_RELEASE = os.environ.get("GODOT_RELEASE", "unspecified-release-binary")
+GODOT_DEBUG = os.environ.get("GODOT_DEBUG", "unspecified-debug-binary")
+
+SCRIPT_DIR = Path(__file__).parent.resolve()
+PROJECT_DIR = (SCRIPT_DIR / "project").resolve()
+
+END_MARKER = "==== TESTS FINISHED ===="
+PASSED_MARKER = "******** PASSED ********"
+FAILED_MARKER = "******** FAILED ********"
+
+TIMEOUT_SEC = 180
+IMPORT_TIMEOUT_SEC = 30
+
+FILTER_INCLUDE_PATTERNS = [
+ re.compile(r"^.*={4}\s*TESTS\s*FINISHED\s*={4}"), # ==== ... ====
+ re.compile(r"^.*PASSES:\s*\d+"), # PASSES:
+ re.compile(r"^.*FAILURES:\s*\d+"), # FAILURES:
+ re.compile(r"^.*\*+\s*PASSED\s*\*+"), # any number of stars around PASSED
+ re.compile(r"^.*\*+\s*FAILED\s*\*+"), # same for FAILED (useful for future)
+]
+FILTER_DISCARD_PATTERNS = [
+ re.compile(r".*"), # Discard everything that hasn't already been included.
+]
+
+IS_WINDOWS = sys.platform.startswith("win")
+
+PORTABLE_EDITOR = "godot-editor.exe" if IS_WINDOWS else "godot-editor"
+PORTABLE_RELEASE = "godot-release.exe" if IS_WINDOWS else "godot-release"
+PORTABLE_DEBUG = "godot-debug.exe" if IS_WINDOWS else "godot-debug"
+PORTABLE_MARKER = "_sc_"
+
+PHASE_CLEANUP = 10
+PHASE_PRE_IMPORT = 20
+PHASE_UNIT_TESTS = 30
+
+verbose: bool = False
+
+# ----------------------------------------------
+# Downloader
+# ----------------------------------------------
+
+
+def download_godot(version: str, arch: str) -> Path:
+ """Download and extract a specific Godot version and architecture."""
+ # Official builds are usually at:
+ # https://github.com/godotengine/godot-builds/releases/download//Godot_v_.zip
+ url = f"https://github.com/godotengine/godot-builds/releases/download/{version}/Godot_v{version}_{arch}.zip"
+
+ download_dir = (SCRIPT_DIR / "godot-builds").resolve()
+ download_dir.mkdir(exist_ok=True)
+
+ zip_path = download_dir / f"Godot_v{version}_{arch}.zip"
+
+ if not zip_path.exists():
+ print(f"β Downloading Godot {version} ({arch})...")
+ try:
+ urllib.request.urlretrieve(url, zip_path)
+ print(" [ DONE ]")
+ except Exception as e:
+ print(f" [ FAILED ] - {e}")
+ return Path()
+
+ # Unzip
+ print(f"β Extracting Godot {version}...")
+ try:
+ with zipfile.ZipFile(zip_path, "r") as zip_ref:
+ zip_ref.extractall(download_dir)
+ print(" [ DONE ]")
+ except Exception as e:
+ print(f" [ FAILED ] - {e}")
+ return Path()
+
+ # Find the binary
+ binary_path = find_godot_binary(download_dir, version, arch)
+
+ if binary_path and binary_path.exists():
+ if not IS_WINDOWS and not binary_path.is_dir():
+ binary_path.chmod(binary_path.stat().st_mode | 0o111)
+ return binary_path.resolve()
+
+ print(f" [ FAILED ] - Could not find binary for Godot {version} in {download_dir}")
+ return Path()
+
+
+def find_godot_binary(download_dir: Path, version: str, arch: str) -> Path:
+ """Search for the Godot binary in the download directory based on common patterns."""
+ binary_name = f"Godot_v{version}_{arch}"
+
+ # 1. Direct match
+ p = download_dir / binary_name
+ if p.is_file():
+ return p
+
+ # 2. Windows executable
+ p = download_dir / (binary_name + ".exe")
+ if p.is_file():
+ return p
+
+ # 3. macOS App bundle
+ p = download_dir / "Godot.app"
+ if p.is_dir():
+ binary_p = p / "Contents" / "MacOS" / "Godot"
+ if binary_p.is_file():
+ return binary_p
+
+ # 4. Fallback: Search for anything starting with Godot_v{version}
+ for f in download_dir.iterdir():
+ if f.is_file() and f.name.startswith(f"Godot_v{version}"):
+ return f
+
+ return Path()
+
+
+def discover_artifacts():
+ global GODOT_EDITOR, GODOT_RELEASE, GODOT_DEBUG
+ artifacts_dir = SCRIPT_DIR.parent / "godot-artifacts"
+ if not artifacts_dir.exists():
+ return
+
+ # Helper to find the best match for a pattern
+ def find_binary(pattern_part):
+ platform_part = "linux" if sys.platform.startswith("linux") else ("windows" if IS_WINDOWS else "macos")
+
+ candidates = []
+ for f in artifacts_dir.iterdir():
+ if f.is_file() and platform_part in f.name and pattern_part in f.name:
+ candidates.append(f)
+
+ if not candidates:
+ # Try without platform part as fallback
+ for f in artifacts_dir.iterdir():
+ if f.is_file() and pattern_part in f.name:
+ candidates.append(f)
+
+ if candidates:
+ # Prefer 'mono' if available, otherwise first one
+ for c in candidates:
+ if "mono" in c.name:
+ return str(c.resolve())
+ return str(candidates[0].resolve())
+ return None
+
+ if GODOT_EDITOR == "unspecified-editor-binary":
+ found = find_binary("editor")
+ if found:
+ GODOT_EDITOR = found
+
+ if GODOT_RELEASE == "unspecified-release-binary":
+ found = find_binary("template_release")
+ if found:
+ GODOT_RELEASE = found
+
+ if GODOT_DEBUG == "unspecified-debug-binary":
+ found = find_binary("template_debug")
+ if found:
+ GODOT_DEBUG = found
+
+
+# ----------------------------------------------
+# Helpers
+# ----------------------------------------------
+def record_test_result(results: List[Dict[str, Any]], version: str, success: bool, duration: int) -> None:
+ status = "β
PASSED" if success else "β FAILED"
+ results.append({"godot_version": version or "master", "status": status, "duration_seconds": duration})
+
+
+def vprint(*args, **kwargs):
+ if verbose:
+ print(*args, **kwargs)
+
+
+def filter_output(lines: List[str]) -> List[str]:
+ result = []
+ for line in lines:
+ cleaned = line.rstrip()
+ if not cleaned:
+ continue
+ if any(pat.search(cleaned) for pat in FILTER_INCLUDE_PATTERNS):
+ result.append(cleaned)
+ continue
+ if any(pat.search(cleaned) for pat in FILTER_DISCARD_PATTERNS):
+ continue
+ result.append(cleaned)
+ return result
+
+
+# ----------------------------------------------
+# Portable Godot
+# ----------------------------------------------
+
+
+def setup_temp_portable_godot():
+ vprint(f"\n{'-' * 10} Making Godot Portable {'-' * 10}") if verbose else print("β Creating portable Godot", end=" ")
+
+ vprint("β Creating portable marker", end=" ")
+ try:
+ (Path.cwd() / PORTABLE_MARKER).touch(exist_ok=True)
+ vprint("[ DONE ]")
+ except OSError:
+ print("[ FAILED ]" if verbose else "[ Failed to create portable marker ]")
+ sys.exit(1)
+
+ targets = [
+ ("editor", GODOT_EDITOR, Path.cwd() / PORTABLE_EDITOR),
+ ("template_release", GODOT_RELEASE, Path.cwd() / PORTABLE_RELEASE),
+ ("template_debug", GODOT_DEBUG, Path.cwd() / PORTABLE_DEBUG),
+ ]
+
+ for name, src, dst in targets:
+ vprint(f"β Copying Godot {name}", end=" ")
+ try:
+ if (
+ not src
+ or src == "unspecified-editor-binary"
+ or src == "unspecified-release-binary"
+ or src == "unspecified-debug-binary"
+ ):
+ vprint("[ SKIPPED (not provided) ]")
+ continue
+
+ src_path = Path(src)
+ if not src_path.exists():
+ vprint(f"[ SKIPPED (not found: {src}) ]")
+ continue
+
+ src_fqp = src_path.resolve(True)
+ if src_fqp == dst.resolve():
+ vprint("[ SKIPPED (same path) ]")
+ continue
+ shutil.copy2(src_fqp, dst)
+
+ # If it's a .NET/Mono build, copy the GodotSharp directory too
+ src_godot_sharp = src_fqp.parent / "GodotSharp"
+ if src_godot_sharp.exists() and src_godot_sharp.is_dir():
+ dst_godot_sharp = Path.cwd() / "GodotSharp"
+ if not dst_godot_sharp.exists():
+ vprint(" (with GodotSharp)", end="")
+ shutil.copytree(src_godot_sharp, dst_godot_sharp)
+
+ # Ensure executable bit on non-Windows
+ if not IS_WINDOWS:
+ dst.chmod(dst.stat().st_mode | 0o111)
+ vprint("[ DONE ]")
+ except OSError as e:
+ print(f"[ {e.strerror} ]")
+ print(f"\tβ {e.filename}")
+ sys.exit(1)
+
+ print("") if verbose else print("[ DONE ]")
+
+
+def cleanup_temp_portable():
+ temp_editor = Path.cwd() / PORTABLE_EDITOR
+ temp_release = Path.cwd() / PORTABLE_RELEASE
+ temp_debug = Path.cwd() / PORTABLE_DEBUG
+ temp_marker = Path.cwd() / PORTABLE_MARKER
+ temp_godot_sharp = Path.cwd() / "GodotSharp"
+ editor_data = Path.cwd() / "editor_data"
+
+ cleaned = False
+ for path in [temp_editor, temp_release, temp_debug, temp_marker]:
+ if path.exists():
+ try:
+ path.unlink()
+ cleaned = True
+ except OSError:
+ if verbose:
+ print(f"β Failed to remove {path}")
+
+ for path in [temp_godot_sharp, editor_data]:
+ if path.exists():
+ try:
+ if path.is_file():
+ path.unlink()
+ else:
+ shutil.rmtree(path)
+ cleaned = True
+ except OSError:
+ if verbose:
+ print(f"β Failed to clean {path}")
+
+ if cleaned:
+ print("β Cleaned [ DONE ]")
+
+
+# ----------------------------------------------
+# Cache & Run
+# ----------------------------------------------
+
+
+def cleanup_godot_cache() -> bool:
+ cache_dir = PROJECT_DIR / ".godot"
+ if cache_dir.exists():
+ if verbose:
+ print("β Cleaning project cache", end=" ")
+ try:
+ shutil.rmtree(cache_dir, ignore_errors=True)
+ if verbose:
+ print("[ DONE ]")
+ except Exception as e:
+ if verbose:
+ print(f"[ FAILED ] {e}")
+ return False
+ return True
+
+
+def run_godot(args: List[str], desc: str, godot_bin: str, timeout_sec: int = TIMEOUT_SEC) -> Tuple[int, str, str, str]:
+ if verbose:
+ print(f"\n{'-' * 10} {desc} {'-' * 10}")
+ print(f"β {godot_bin} {' '.join(args)}")
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ stdout_path = Path(tmpdir) / "stdout.txt"
+ stderr_path = Path(tmpdir) / "stderr.txt"
+
+ cmd = [godot_bin] + args
+
+ try:
+ start = time.time()
+ proc = subprocess.Popen(
+ cmd,
+ stdout=stdout_path.open("wb"),
+ stderr=stderr_path.open("wb"),
+ cwd=os.getcwd(),
+ start_new_session=True,
+ )
+
+ timeout = False
+ while proc.poll() is None:
+ if time.time() - start > timeout_sec:
+ proc.send_signal(signal.SIGTERM)
+ time.sleep(1)
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ timeout = True
+ time.sleep(0.3)
+
+ exit_code = proc.returncode
+ stdout = stdout_path.read_text("utf-8", errors="replace")
+ stderr = stderr_path.read_text("utf-8", errors="replace")
+ full_output = stdout + stderr
+
+ if verbose:
+ print(full_output.rstrip())
+ print(f"\n{'-' * 10} {desc} - exit:{exit_code:#x} {'-' * 10}")
+
+ if timeout:
+ return 124, "TIMEOUT", f"After {timeout_sec}s", full_output
+
+ return exit_code, "DONE", f"Exit code: {exit_code:#x}", full_output
+
+ except Exception as exc:
+ stdout = stdout_path.read_text("utf-8", errors="replace")
+ stderr = stderr_path.read_text("utf-8", errors="replace")
+ full_output = stdout + stderr
+
+ if verbose:
+ print(f"Failed to run Godot: {exc}")
+ print(full_output.rstrip())
+ return 1, "EXCEPTION", f"{exc}", full_output
+
+
+def pre_import_project(godot_bin: Path):
+ if not verbose:
+ print("β Pre-Import", end=" ", flush=True)
+
+ args = ["--path", str(PROJECT_DIR), "--import", "--headless"]
+ exit_code, strcode, msg, output = run_godot(args, "Pre-import", str(godot_bin), timeout_sec=IMPORT_TIMEOUT_SEC)
+ if not verbose:
+ # Show only summary / important parts
+ lines = output.splitlines()
+ filtered = filter_output(lines)
+ if filtered:
+ print("\n".join(filtered))
+
+ print(f"[ {strcode} ]", end=" ")
+ print(f"- {msg}" if msg else "")
+ return exit_code != 0
+
+
+def run_integration_tests(editor_bin: Path) -> bool:
+ print("β Unit/Integration Tests", end=" ", flush=True)
+
+ args = ["--path", str(PROJECT_DIR), "--debug", "--headless", "--quit"]
+ exitcode, strcode, msg, output = run_godot(args, "Unit/Integration tests", str(editor_bin))
+
+ def is_successful(output_text: str) -> bool:
+ return END_MARKER in output_text and PASSED_MARKER in output_text and FAILED_MARKER not in output_text
+
+ if not verbose:
+ print(f"[ {strcode} ]", end=" ")
+ print(f"- {msg}" if msg else "")
+
+ if exitcode == 124:
+ print("> Unit phase: TIMEOUT")
+ return False
+ elif not is_successful(output):
+ print("> Unit phase: FAILED")
+ return False
+ else:
+ return True
+
+
+def generate_extension_docs(editor: Path) -> bool:
+ print("> GDExtension XML DocGen", end=" ", flush=True)
+
+ doc_path = (PROJECT_DIR.parent / "doc_classes").resolve()
+ # Expected files based on test/src classes
+ expected_files = [
+ "Example.xml",
+ "ExampleAbstractBase.xml",
+ "ExampleBase.xml",
+ "ExampleChild.xml",
+ "ExampleConcrete.xml",
+ "ExampleMin.xml",
+ "ExamplePrzykΕad.xml",
+ "ExampleRef.xml",
+ "ExampleRuntime.xml",
+ "ExampleVirtual.xml",
+ ]
+
+ # Run from inside project/ (demo/), pointing --doctool at./
+ args = [
+ "--path",
+ str(PROJECT_DIR),
+ "--doctool",
+ "..",
+ "--gdextension-docs",
+ "--headless",
+ "--quit",
+ ]
+ exitcode, strcode, msg, output = run_godot(args, "GDExtension XML DocGen", str(editor))
+
+ # print the completion of the non-verbose line.
+ if not verbose:
+ print(f"[ {strcode} ]", end=" ")
+ print(f"- {msg}" if msg else "")
+
+ if strcode == "TIMEOUT":
+ if verbose:
+ print("> DocGen phase: TIMEOUT")
+ return False
+
+ if doc_path.exists():
+ xml_files = [f.name for f in doc_path.glob("*.xml")]
+ missing = [f for f in expected_files if f not in xml_files]
+ if missing:
+ print(f"> DocGen phase: FAILED (Missing files: {', '.join(missing)})")
+ return False
+
+ if len(xml_files) > 0:
+ if verbose:
+ print(f"> DocGen doc_classes/ created at: {doc_path} ({len(xml_files)} XML files)")
+ for file in doc_path.glob("*.xml"):
+ print(file)
+ return True
+ if verbose:
+ print("> Warning: DocGen Command succeeded but no doc_classes/*.xml found")
+ return False
+ else:
+ print("> DocGen phase: FAILED (Directory not found)")
+ return False
+
+
+def cleanup_docs():
+ doc_path = (PROJECT_DIR.parent / "doc_classes").resolve()
+ if doc_path.exists():
+ try:
+ shutil.rmtree(doc_path)
+ print("> Cleaned doc_classes [ DONE ]")
+ except OSError:
+ if verbose:
+ print(f"> Failed to remove {doc_path}")
+
+
+# ----------------------------------------------
+# Main
+# ----------------------------------------------
+def main():
+ global verbose
+
+ parser = argparse.ArgumentParser(description="Run godot-cpp test suite")
+ parser.add_argument(
+ "--tests-only",
+ action="store_const",
+ const="unit",
+ dest="mode",
+ help="Only run the integration tests (skip doc xml generation)",
+ )
+ parser.add_argument(
+ "--docs-only",
+ action="store_const",
+ const="docs",
+ dest="mode",
+ help="Only generate GDExtension XML documentation (skip tests)",
+ )
+ parser.add_argument("--verbose", action="store_true", default=False, help="Show full unfiltered Godot output")
+ parser.add_argument(
+ "--quiet", action="store_true", default=False, help="Only exit code (0=success, >0=failure); no output"
+ )
+ parser.add_argument(
+ "--godot-test-versions",
+ type=str,
+ default="",
+ help="Space-separated list of Godot versions to download and test",
+ )
+ parser.add_argument(
+ "--godot-test-arch",
+ type=str,
+ default="",
+ help="Architecture for downloading Godot (e.g., linux.x86_64, win64.exe, macos.universal)",
+ )
+ args = parser.parse_args()
+
+ # store a reference to print
+ original_print = builtins.print
+
+ mode = args.mode or "full"
+ verbose = args.verbose
+
+ if args.quiet:
+
+ def silent(*_args, **_kwargs):
+ pass
+
+ builtins.print = silent
+ else:
+ builtins.print = original_print # restore just in case
+
+ if args.quiet and args.verbose:
+ print("--quiet takes precedence over --verbose", file=sys.stderr)
+ verbose = False
+
+ versions = args.godot_test_versions.split()
+ arch = args.godot_test_arch
+
+ main_version = os.environ.get("GODOT_TEST_VERSION", "master")
+ if main_version != "master":
+ if main_version not in versions:
+ versions.insert(0, main_version)
+
+ if not versions or main_version == "master":
+ if None not in versions:
+ versions.insert(0, None)
+
+ overall_success = True
+
+ results = [] # list of dicts for table + JSON
+
+ for version in versions:
+ global GODOT_EDITOR, GODOT_RELEASE, GODOT_DEBUG
+ if version:
+ print(f"--- Testing against Godot {version} ({arch}) ---")
+ godot_bin = download_godot(version, arch)
+ if not godot_bin:
+ overall_success = False
+ record_test_result(results, version, False, 0)
+ continue
+
+ GODOT_EDITOR = str(godot_bin)
+ GODOT_RELEASE = str(godot_bin)
+ GODOT_DEBUG = str(godot_bin)
+ else:
+ discover_artifacts()
+
+ editor_bin: Path = Path.cwd() / PORTABLE_EDITOR
+ print(f"editor: {GODOT_EDITOR}")
+ print(f"template_release: {GODOT_RELEASE}")
+ print(f"template_debug: {GODOT_DEBUG}")
+ print(f"Project: {PROJECT_DIR}")
+ print(f"Mode: {mode}")
+ print(f"Verbose: {verbose}\n")
+
+ start_time = time.time()
+
+ setup_temp_portable_godot()
+ _ = cleanup_godot_cache()
+ _ = pre_import_project(editor_bin)
+
+ success = True
+ # Perform Integration Testing
+ if mode in ("unit", "full") and success:
+ success = run_integration_tests(editor_bin)
+
+ if mode in ("docs", "full") and success:
+ success = generate_extension_docs(editor_bin)
+
+ cleanup_temp_portable()
+ cleanup_docs()
+
+ duration = int(time.time() - start_time)
+ record_test_result(results, version, success, duration)
+
+ if not success:
+ overall_success = False
+
+ print("-" * 80)
+ status = "PASSED" if success else "FAILED"
+ print(f"TEST SUITE ({version or 'master'}) {status} - took {duration}s")
+
+ # --- Summary table and metrics artifact ---
+ summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
+ if summary_path and results:
+ try:
+ with open(summary_path, "a", encoding="utf-8") as f:
+ platform = os.environ.get("RUNNER_OS", "unknown")
+ f.write(f"\n## Test Results - {platform} ({os.environ.get('RUNNER_OS', 'unknown')})\n\n")
+ f.write("| Godot Version | Status | Duration (s) |\n")
+ f.write("|---------------|------------|--------------|\n")
+ for r in results:
+ f.write(f"| {r['godot_version']:13} | {r['status']:10} | {r['duration_seconds']:12} |\n")
+ except Exception:
+ pass # ignore summary write failures
+
+ # Write JSON artifact for historical tracking
+ metrics_path = SCRIPT_DIR / "test-metrics.json"
+ try:
+ import json
+
+ with open(metrics_path, "w", encoding="utf-8") as f:
+ json.dump(
+ {
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()),
+ "platform": os.environ.get("RUNNER_OS", "unknown"),
+ "runner": os.environ.get("RUNNER_NAME", "unknown"),
+ "results": results,
+ },
+ f,
+ indent=2,
+ )
+ except Exception:
+ pass
+
+ builtins.print = original_print
+ sys.exit(0 if overall_success else 3)
+
+
+if __name__ == "__main__":
+ main()