I am building a workflow using GitHub actions to automate test runs for my current Godot project. I have written two independent actions and one workflow which references them. Of these two actions - install and unit-test - only the latter is causing an issue.
In my current set-up I am also using act to simulate the GitHub Action behaviour locally so to avoid pushing every single change to my remote branch.
I will try to summarize what the workflow is supposed to do before posting some code snippets. My goal for the workflow is to have it install a clean version of Godot - given a specific version or the latest available one otherwise - before running the tests. The artifact is uploaded using actions/upload-artifact@v4. The unit-test action is then called, which downloads the artifact, checks for the addon I am using for the testing suite - GUT - Godot Unit Testing - and installs it if missing. It then runs the command to execute tests.
As mentioned, this exact setup, with the exact same workflow yaml files works locally when run through act, but fails due to some parsing error on GitHub. What could be causing this? Perhaps some permission problems?
-- Follows code snippets --
Main workflow - the one under .github/workflows:
name: Build & Deploy pipeline
# Controls when the workflow will run
on:
push: {}
pull_request: {}
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
jobs:
install-godot:
runs-on: ubuntu-latest
steps:
- name: Checkout Current Branch
uses: actions/checkout@v4
with:
lfs: true
- name: Install Godot Binary
uses: ./src/test/resources/actions/godot-install
with:
install-path: '/home/runner/godot-linux'
id: install-binary
unit-test:
runs-on: ubuntu-latest
steps:
- name: Checkout Current Branch
uses: actions/checkout@v4
with:
lfs: true
- name: Run Unit Tests
uses: ./src/test/resources/actions/godot-test
with:
install-path: '/home/runner/godot-linux'
gut-params: '-gconfig=res://src/test/resources/.gutconfigs.json'
needs: install-godot
GUT Testing action:
name: 'GUT Testing Godot'
description: 'Runs the Godot Unit Testing (GUT) suite'
# Specifies when to run the workflow
on:
workflow_dispatch:
# Lists all the workflow available arguments/settings
inputs:
install-path:
description: 'The path to Godot Bin/Executable'
type: string
required: true
project-dir:
description: 'The project directory in which the action is to be executed. Must end with a path-separator. e.g. ./MyGame/'
type: string
required: false
default: './'
gut-version:
description: 'The target GUT addon version to install - if not available'
type: string
required: false
default: 'latest'
gut-params:
description: 'A custom set of GUT parameters (see GUT doc for more info)'
type: string
required: false
# Lists all the jobs for the current workflow
runs:
using: "composite"
steps:
- name: Download Godot artifact
# if: ${{ !env.ACT }}
uses: actions/download-artifact@v4
with:
name: godot-artifact
path: ${{ inputs.install-path }}
- name: Install GUT addon
shell: bash
run: |
echo "🔍 Checking for GUT addon install..."
if [[ -n ${{ inputs.project-dir }} ]]; then
cd ${{ inputs.project-dir }}
fi
GUT_PATH="./addons/gut/"
if [[ ! -d "${GUT_PATH}" ]]; then
echo "⚠️ WARNING: GUT addon not found - Installing version ${{ inputs.gut-version }}"
mkdir -p "${GUT_PATH}"
GUT_VERSION=${{ inputs.gut-version }}
if [ $GUT_VERSION == 'latest' ]; then
GUT_VERSION=$(git ls-remote --refs --tags https://github.com/bitwes/Gut v* | sort -t '/' -k 3 -V | tail -n 1 | cut -d '/' -f 3)
fi
echo "🔍 Downloading GUT version: ${GUT_VERSION}"
git clone --quiet --depth 1 --branch ${GUT_VERSION} --single-branch https://github.com/bitwes/Gut ${GUT_PATH}
else
echo "✅ GUT addon already installed - skipping job"
exit 0
fi
- name: Execute Project Re-Import
shell: bash
run: |
echo "⚙️ Executing Godot to Import dependencies"
chmod u+x ${{ inputs.install-path }}/godot
${{ inputs.install-path }}/godot --headless -d --import --path "$PWD"
- name: Run Unit Tests
id: unit-test
shell: bash
run: |
TEMP_FILE=/tmp/gut.log
chmod u+x ${{ inputs.install-path }}/godot
${{ inputs.install-path }}/godot --headless -s ${{ inputs.project-dir }}/addons/gut/gut_cmdln.gd -d --path "$PWD" ${{inputs.gut-params}} -gexit 2>&1 | tee $TEMP_FILE
if grep -q "No tests ran" "$TEMP_FILE" || grep -qE "Asserts\s+none" "$TEMP_FILE"; then
echo "⚠️ WARNING: No tests ran! Please check test directory and parameters"
exit 1
fi
if ! grep -q "All tests passed" "$TEMP_FILE"; then
echo "❌ ERROR: One or more tests failed!"
exit 1
fi
echo "✅ All Unit Tests passed successfully"
exit 0
Here is - one of - the Error(s) I am getting when the job Run Unit Tests is run on GitHub - I am omitting the rest as they are mostly the same - :
Debugger Break, Reason: 'Invalid call. Nonexistent function 'new' in base 'GDScript'.'
*Frame 0 - res://src/test/unit/entity/player/PlayerTest.gd:10 in function 'before_all'
Enter "help" for assistance.
debug> * test_ready_call_should_correctly_set_all_properties
Debugger Break, Reason: 'Parser Error: Expected superclass name after "extends".'
*Frame 0 - res://addons/gut/not_a_real_file/gut_dynamic_script_8.gd:1 in function ''
Enter "help" for assistance.
debug> SCRIPT ERROR: Parse Error: Expected superclass name after "extends".
at: GDScript::reload (res://addons/gut/not_a_real_file/gut_dynamic_script_8.gd:1)
[ERROR]: Could not create script from source. Error: 43
at line -1
This error does not come unexpectedly. Soon after triggering the --headless --import commmand I can see a series of errors in the logs, like:
SCRIPT ERROR: Parse Error: Could not find type "CustomMultiplayerSpawner" in the current scope.
at: GDScript::reload (res://src/mygame/level/Level.gd:8)
Debugger Break, Reason: 'Parser Error: Could not find type "CustomMultiplayerSpawner" in the current scope.'
SCRIPT ERROR: Parse Error: Cannot use simple "@export" annotation because the type of the initialized value can't be inferred.
*Frame 0 - res://src/mygame/level/Level.gd:8 in function ''
at: GDScript::reload (res://src/mygame/level/Level.gd:8)
Enter "help" for assistance.
debug>
Debugger Break, Reason: 'Parser Error: Could not resolve external class member "main_hand_spawner".'
*Frame 0 - res://src/mygame/entity/player/Player.gd:138 in function ''
Enter "help" for assistance.
SCRIPT ERROR: Parse Error: Could not resolve external class member "main_hand_spawner".
at: GDScript::reload (res://src/mygame/entity/player/Player.gd:138)
ERROR: Failed to load script "res://src/mygame/entity/player/Player.gd" with error "Parse error".
at: load (modules/gdscript/gdscript.cpp:3022)
debug>
I can't stress enough that this does not happen localy when using act.
For the sake of completeness I will include this PlayerTest.gd script as an example, eventhough I doubt that is the culprit as it works fine both in-engine and when using act.
class_name PlayerTest
extends GutTest
var pl_res: Resource = load("res://src/mygame/entity/player/Player.gd")
var pl_vis_res: Resource = load("res://src/mygame/entity/player/PlayerVisual.gd")
var cc_res: Resource = load("res://src/mygame/entity/components/CameraComponent.gd")
var pl: Player
func before_all() -> void:
pl = pl_res.new()
func test_ready_call_should_correctly_set_all_properties() -> void:
var d_pl: Player = autofree(partial_double(pl_res).new())
var d_pl_vis: PlayerVisual = autofree(partial_double(pl_vis_res).new())
var d_cc: CameraComponent = autofree(partial_double(cc_res).new())
stub(d_pl.reset_to_player_camera).to_do_nothing()
stub(d_cc._init_camera).to_do_nothing()
var test_id: int = autofree(1)
var test_username: String = autofree("Test-Username")
SteamManager.steam_user_id = test_id
SteamManager.steam_username = test_username
d_pl.name = autofree(str("123"))
d_pl.third_person_model = d_pl_vis
d_pl.camera_component = d_cc
d_pl_vis.main_hand_container = autofree(Node3D.new())
d_pl._ready()
assert_eq(d_pl.user_peer_id, 123)
assert_eq(d_pl.user_steam_id, test_id)
assert_eq(d_pl.username_steam, test_username)
assert_called_count(d_pl.reset_to_player_camera, 1)
func test_should_set_head_pivot_position(p = use_parameters([
autoqfree(Vector3.UP), autoqfree(Vector3.DOWN), autoqfree(Vector3(-3.25, 1.05, 0.99))
])) -> void:
pl.head_pivot = autofree(Node3D.new())
pl.set_head_pivot_pos(p)
assert_eq(pl.head_pivot.position, p)
func test_should_reset_head_pivot_position_and_rotation_to_default() -> void:
var hp: Node3D = autofree(Node3D.new())
hp.position = autofree(Vector3(99.0, -99.0, 99.0))
hp.rotation_degrees = autofree(Vector3(-180.11, -45.25, 91.3))
pl.head_pivot = hp
pl.reset_head_pivot_pos()
assert_eq(pl.head_pivot.position, autofree(Vector3(0.0, 1.45, 0.0)))
assert_eq(pl.head_pivot.rotation_degrees, autofree(Vector3(0.0, 180.0, 0.0)))
func after_all() -> void:
pl.free()
Extra notes
From what I have gathered online, some issues could be related to the missing .godot folder which is included in the .gitignore by default. I have that folder included in my ignore list, however I am forcefully triggering the re-import (see --import) to avoid the issue. Doing this indeed fixed it locally, but there still seems to be an issue during the import as the parsing problem seems to be closely related to that (imho).
I have tried double-checking all the parameters with no success.
Also, just for reference this is the command I run when starting the workflow with act:
act --rm --artifact-server-path /home/runner/godot-linux
godot-installhas no effect on theunit-testjob, because they're separate jobs.