CI/CD for Godot Projects: Automated Testing with GitHub Actions

CI/CD for Godot Projects: Automated Testing with GitHub Actions

Godot 4 runs headlessly with --headless — no display required in CI. Combined with GUT's CLI runner, you can run your full test suite in GitHub Actions on every push. This guide covers setting up the CI pipeline, caching the Godot binary, running GUT tests, and reporting results.

Key Takeaways

Godot 4 supports --headless for CI. No Xvfb or virtual display needed. Run godot --headless and the engine runs without a window.

GUT has a dedicated CLI script. addons/gut/gut_cmdln.gd accepts test directories, prefixes, and verbosity flags. Pass -gexit_on_complete to return a non-zero exit code on failure.

Cache the Godot binary. Downloading Godot (100MB+) on every CI run adds 30-60 seconds. Cache it by version hash for fast pipelines.

Import your project before running tests. Godot must import resources on first run. Use a cache action or run godot --headless --import as a separate step.

Fail the build on test failure. GUT returns exit code 1 on test failure. GitHub Actions treats non-zero exit codes as failures — no extra configuration needed.

CI/CD for Godot: What's Required

Running Godot tests in CI requires:

  1. The Godot binary (headless version or standard with --headless flag)
  2. Your GUT plugin installed in the project
  3. A Godot project that's been imported (.godot/ directory)
  4. The GUT CLI command to run tests

Godot 4 includes headless mode in the standard binary via --headless. You don't need a separate headless build.

Basic GitHub Actions Workflow

# .github/workflows/test.yml
name: Godot Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  GODOT_VERSION: "4.2.2"

jobs:
  test:
    name: Run GUT Tests
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          submodules: recursive  # If GUT is a submodule

      - name: Cache Godot binary
        id: cache-godot
        uses: actions/cache@v4
        with:
          path: ~/.local/share/godot/
          key: godot-${{ env.GODOT_VERSION }}

      - name: Download Godot
        if: steps.cache-godot.outputs.cache-hit != 'true'
        run: |
          mkdir -p ~/.local/share/godot/
          cd ~/.local/share/godot/
          wget -q "https://github.com/godotengine/godot/releases/download/${{ env.GODOT_VERSION }}-stable/Godot_v${{ env.GODOT_VERSION }}-stable_linux.x86_64.zip"
          unzip -q "Godot_v${{ env.GODOT_VERSION }}-stable_linux.x86_64.zip"
          chmod +x "Godot_v${{ env.GODOT_VERSION }}-stable_linux.x86_64"
          ln -s "Godot_v${{ env.GODOT_VERSION }}-stable_linux.x86_64" godot

      - name: Add Godot to PATH
        run: echo "$HOME/.local/share/godot" >> $GITHUB_PATH

      - name: Cache Godot import cache
        uses: actions/cache@v4
        with:
          path: .godot/
          key: godot-import-${{ hashFiles('project.godot', 'addons/**', 'scripts/**', 'scenes/**') }}
          restore-keys: |
            godot-import-

      - name: Import Godot project
        run: |
          godot --headless --import --quit-after 100
        continue-on-error: true  # Import step may exit with non-zero

      - name: Run GUT tests
        run: |
          godot --headless \
            -s addons/gut/gut_cmdln.gd \
            -gdir=res://test \
            -gprefix=test_ \
            -gsuffix=.gd \
            -glog=2 \
            -gexit_on_complete

Understanding the GUT CLI Flags

Flag Description
-gdir=res://test Directory containing test scripts
-gprefix=test_ File prefix for test scripts
-gsuffix=.gd File extension for test scripts
-glog=2 Log level: 0=errors only, 1=summary, 2=all, 3=verbose
-gexit_on_complete Exit Godot when tests finish (required for CI)
-gselect=test_player.gd Run only specific test file
-gunit_test_name=test_player_health Run only specific test method
-gconfig=res://test/.gutconfig.json Load configuration from file

GUT Configuration File

Instead of long CLI flags, use a .gutconfig.json file:

// test/.gutconfig.json
{
    "dirs": ["res://test"],
    "prefix": "test_",
    "suffix": ".gd",
    "log_level": 2,
    "exit_on_complete": true,
    "include_subdirs": true
}

Then the CLI becomes:

godot --headless -s addons/gut/gut_cmdln.gd -gconfig=res://test/.gutconfig.json

Caching Strategy

Godot import caches speed up CI significantly. Two caches matter:

Godot binary cache — by version:

- uses: actions/cache@v4
  with:
    path: ~/.local/share/godot/
    key: godot-${{ env.GODOT_VERSION }}

Project import cache — by source file hash:

- uses: actions/cache@v4
  with:
    path: .godot/
    key: godot-import-${{ hashFiles('project.godot', '**/*.gd', '**/*.tscn') }}
    restore-keys: godot-import-

The import cache stores Godot's compiled .res files. Without it, the import step runs on every push even if nothing changed.

Handling Import Failures

Godot's import step is notoriously flaky in CI. It may exit with a non-zero code even on success. Use continue-on-error: true for the import step but fail on the test step:

- name: Import project
  run: godot --headless --import --quit-after 100
  continue-on-error: true

- name: Run tests
  run: |
    godot --headless -s addons/gut/gut_cmdln.gd \
      -gconfig=res://test/.gutconfig.json
  # No continue-on-error here — test failures MUST fail the build

Adding a Test Status Badge

After your workflow runs, add a badge to your README:

![Tests](https://github.com/your-org/your-game/actions/workflows/test.yml/badge.svg)

Running Tests on Multiple Platforms

For games targeting multiple platforms, test on multiple OS:

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}

    steps:
      - name: Download Godot (Ubuntu)
        if: runner.os == 'Linux'
        run: |
          wget -q "https://github.com/godotengine/godot/releases/download/${{ env.GODOT_VERSION }}-stable/Godot_v${{ env.GODOT_VERSION }}-stable_linux.x86_64.zip"
          # ... unzip and install

      - name: Download Godot (Windows)
        if: runner.os == 'Windows'
        run: |
          Invoke-WebRequest -Uri "https://github.com/godotengine/godot/releases/download/${{ env.GODOT_VERSION }}-stable/Godot_v${{ env.GODOT_VERSION }}-stable_win64.exe.zip" -OutFile godot.zip
          # ... unzip and install

      - name: Run tests
        run: godot --headless -s addons/gut/gut_cmdln.gd -gconfig=res://test/.gutconfig.json

Separating Unit and Integration Tests

Use subdirectories to run fast tests in pre-commit and full tests in CI:

test/
├── unit/           # Fast: no scene instantiation
│   ├── test_player_stats.gd
│   └── test_inventory.gd
└── integration/    # Slower: instantiates scenes
    ├── test_combat_system.gd
    └── test_level_loading.gd
# Run only unit tests on PR
- name: Run unit tests
  run: |
    godot --headless -s addons/gut/gut_cmdln.gd \
      -gdir=res://test/unit \
      -gexit_on_complete

# Run all tests on main branch
- name: Run all tests
  if: github.ref == 'refs/heads/main'
  run: |
    godot --headless -s addons/gut/gut_cmdln.gd \
      -gdir=res://test \
      -gexit_on_complete

Debugging CI Failures

When tests fail in CI but pass locally:

Check the import cache. A stale import cache can cause resource load failures. Clear it by changing the cache key.

Increase log level. Add -glog=3 to see verbose output including which test is running when the failure occurs.

Check for scene-dependent tests. Tests that instantiate scenes are more likely to fail headlessly if they rely on visual nodes (Viewport, Window) that behave differently without a display.

Use -gselect to isolate. Run only the failing test file to reduce noise:

godot --headless -s addons/gut/gut_cmdln.gd \
  -gdir=res://test \
  -gselect=test_combat_system.gd \
  -gexit_on_complete

Summary

A Godot CI pipeline with GUT requires four things:

  1. The Godot binary cached by version
  2. The project imported (with caching)
  3. godot --headless -s addons/gut/gut_cmdln.gd with -gexit_on_complete
  4. Non-zero exit code treated as build failure (automatic in GitHub Actions)

Set this up before you write your first test — having CI from day one means you'll never merge code that breaks existing tests.

Read more