DISCLAIMER

This article is a port of the original Medium article.

Setting up automated testing and deployment as an indie developer might seem pointless at first. But as soon as your game grows beyond a simple prototype, manual exports, broken builds, and untracked version numbers quickly become a headache.

In this article, we’ll build a clean, automated CI/CD pipeline for Godot using GitHub Actions and Itch.io Butler.

To test this setup in a semi real-world scenario, I started with GDQuest’s “Your First Vampire Survival Game” template.

By the end of this guide, you will have a pipeline that catches bugs on every Pull Request, prevents broken builds from ever reaching players, and deploys clean releases to Itch.io automatically whenever you push a new release tag.

Prerequisites
This guide assumes you have basic familiarity with Git (commits, pushes, and tags) and with the GUT (Godot Unit Test) add-on.

Setting Up Your Add-ons

We utilize two add-ons:

  1. GUT (Godot Unit Test): Allows writing unit tests in GDScript and running them headlessly from the command line.
  2. GDScript Linter: Analyzes code syntax and formatting consistency across the codebase.

Make sure both add-ons are installed and enabled under Project -> Project Settings -> Plugins.

The Pipeline Architecture

Before diving into YAML, let’s take a look at the high-level flow of our pipeline:

The high-level flow of our pipeline

The great thing about this structure is that it’s fully modular. If you aren’t ready for the CD pipeline yet but still want automated testing and linting, you can implement just the CI job first.

Your first CI workflow

We use GitHub Actions to handle automation. You can create a new workflow by navigating to the Actions tab in your repository and clicking New Workflow.

This creates a YAML file under .github/workflows/. For our baseline CI system, we create .github/workflows/pipeline.yml:

name: Godot CI Pipeline

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  workflow_dispatch:

jobs:
  ci:
    name: Lint & Unit Tests
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.7.1

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Run GDScript Linter
        run: |
          godot --headless -s res://addons/gdscript-linter/analyzer/analyze-cli.gd

At first it might look intimidating, but let’s walk through it.

on: Defines when this workflow runs. Currently any push or Pull Request to main will trigger it automatically.

container: Uses Docker to run Godot headlessly. barichello/godot-ci comes pre-installed with Godot. Make sure the container tag matches your local Godot version!

steps: The sequence of actions to perform. Every job starts by checking out your repository code (actions/checkout@v4) before running shell commands.

At this stage, the YAML dictates that whenever a change is pushed to the main branch, it spins up a container, checks out the project, and runs the GDScript Linter. It might not seem like much yet, but in the next step we will add even more features.

Note on container:
If you’re unfamiliar with Docker, think of a container as a temporary, pre-configured cloud computer. Instead of spending 10 steps manually downloading and installing Godot inside GitHub Actions every time a test runs, we use barichello/godot-ci, which is a ready-to-use virtual environment with Godot and export templates already installed.

Expanding the CI Job

Now that the basic structure is working, we can add unit tests, download binary assets cleanly with Git LFS, and verify that export templates set up properly:

name: Godot CI Pipeline

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  workflow_dispatch:

jobs:
  ci:
    name: Lint & Unit Tests
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.7.1

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          lfs: true   # Downloads binary LFS assets (images/audio)
    
# CRITICAL: Generates the .godot/ cache and registers plugin class_names  
      - name: Pre-Import Assets
        run: godot --headless --editor --quit || true

      - name: Run GDScript Linter
        run: |
          godot --headless -s res://addons/gdscript-linter/analyzer/analyze-cli.gd

      - name: Run GUT Unit Tests
        run: |
          godot --headless -s addons/gut/gut_cmdln.gd

      - name: Setup Export Templates
        run: |
          mkdir -v -p ~/.local/share/godot/export_templates
          mv /root/.local/share/godot/export_templates/4.7.1.stable ~/.local/share/godot/export_templates/4.7.1.stable

Notice two critical additions here:
lfs: true: Forces Git to pull actual binary textures/audio instead of tiny Git LFS text pointer files.
godot --headless --editor --quit : Because GitHub Actions starts with a completely empty repository cache, Godot hasn’t scanned your project or plugins yet. Running an editor pass forces Godot to generate the .godot/ import cache and register custom plugin classes (like GUT’s GutTest). Without this step, running GUT or your linter immediately will fail with a Missing class_names error!

The CD system

Now that every commit to the main branch runs our CI checks, it’s time to automate publishing releases to Itch.io.

Creating a Second Job

By default, GitHub Actions lets you define multiple jobs inside the jobs: section. Let‘s add a basic export job below our ci job:

jobs:
  ci:
    # (our linter and testing steps live here)

  export-and-deploy:
    name: Export Web & Push to Itch.io
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.7.1

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Build Web Export
        run: |
          mkdir -v -p build/web
          godot --headless --export-release "Web" build/web/index.html

Making Jobs Run Sequentially

If you run the workflow now, GitHub Actions will attempt to run ci and export-and-deploy in parallel at the exact same time.

We don’t want to export or deploy if our unit tests or linter fail! To force GitHub Actions to wait for the CI job to finish successfully first, add needs: ci:

export-and-deploy:
    name: Export Web & Push to Itch.io
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.7.1

    # Ensures CD runs ONLY if the CI job completes successfully
    needs: ci

Restricting CD to Release Tags

Right now, every single commit to main would trigger a deploy to Itch.io. To ensure deploys only happen when you explicitly publish a release, add an if: condition to restrict execution to Git tags:

First, update your workflow trigger at the top of the file:

on:
 push:
 branches: [ "main" ]
 tags: [ "*" ] # Listens for ANY tag (0.1.0, v1.0.0, 2026.08.10)

Then gate the export-and-deploy job:

export-and-deploy:
    name: Export Web & Push to Itch.io
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.7.1

    needs: ci

    # Only run deployment when triggered by a Git Tag
    if: startsWith(github.ref, 'refs/tags/')

Putting It Together with Butler & Versioning

Now that our execution sequence and tag restriction are locked down, we add Git LFS, version injection, asset pre-importing, and the Butler upload:

export-and-deploy:
    name: Export Web & Push to Itch.io
    runs-on: ubuntu-latest
    container:
      image: barichello/godot-ci:4.7.1

    needs: ci
    if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4
        with:
          lfs: true           # Downloads binary assets (textures/audio)

      - name: Pre-Import Assets
        run: godot --headless --editor --quit || true

      - name: Inject Git Tag into project.godot
        shell: bash
        run: |
          # Stamps the tag string directly into project.godot for this export
          BUILD_VERSION="${GITHUB_REF_NAME}"
          sed -i 's/config\/version=.*/config\/version="'"$BUILD_VERSION"'"/' project.godot

      - name: Setup Export Templates
        run: |
          mkdir -v -p ~/.local/share/godot/export_templates
          mv /root/.local/share/godot/export_templates/4.7.1.stable ~/.local/share/godot/export_templates/4.7.1.stable

      - name: Build Web Export
        run: |
          mkdir -v -p build/web
          godot --headless --export-release "Web" build/web/index.html

      - name: Deploy to Itch.io via Butler
        env:
          BUTLER_API_KEY: ${{ secrets.BUTLER_CREDENTIALS }}
          ITCH_USER: ${{ secrets.ITCHIO_USERNAME }}
          ITCH_GAME: ${{ secrets.ITCHIO_GAME_SLUG }}
        run: |
          apt-get update && apt-get install -y curl unzip
          curl -L -o butler.zip https://broth.itch.zone/butler/linux-amd64/LATEST/archive/default
          unzip butler.zip
          chmod +x butler
          ./butler push build/web $ITCH_USER/$ITCH_GAME:web

Obtain the Necessary Credentials

Never hardcode your API keys or passwords directly into your pipeline.yml file! If your repository is public (or if it ever becomes public), anyone could grab your API key and upload malicious builds to your Itch.io page.
GitHub provides a feature called Secrets. These are encrypted environment variables stored securely in your repo. These can be safely injected into your container.

Generate an API Key on Itch.io

  1. Log into your Itch.io account.
  2. Click your profile picture in the top right -> Settings.
  3. In the left sidebar, select Developer -> API Keys.
  4. Click Generate new API key (or copy an existing one).

Add Secrets to Your GitHub Repository

  1. Open your game’s repository on GitHub.
  2. Go to the Settings tab.
  3. In the left sidebar, expand Secrets and variables and click Actions.
  4. Click the green New repository secret button to add each of the following three variables:

Secret Name:

BUTLER_CREDENTIALS: abcdef01234...| API key you just generated on Itch.io.
ITCHIO_USERNAME: your_user_name| Your Itch.io account username.
ITCHIO_GAME_SLUG: godot-ci-cd-example| The URL name of your game.

Freeing Yourself from Manual Exports

Setting up your first CI/CD pipeline can feel daunting, but as you can see, it takes just a few lines of YAML to completely change how you ship games.

You can check out the complete, working implementation and workflow file over on the repository here:
[GitHub Repository]

Where to go from here?

Once you have this basic pipeline running, the door is open to expand it based on your project’s needs:

  1. Multi-Platform Exports: Right now we built for Web, but you can add export steps for Windows, macOS, or Linux and have Butler push multiple channels (:win:mac:linux) simultaneously.
  2. Discord/Social Webhooks: Add a final notification step to your CD job that pings a Discord channel or posts to social media the moment Butler successfully publishes a new build.
  3. Automated Itch.io Switching: Send stable releases to your main Itch page, and use a separate git tag rule (like alpha-*) to push experimental builds to an alpha page.