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:
- GUT (Godot Unit Test): Allows writing unit tests in GDScript and running them headlessly from the command line.
- 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 usebarichello/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.ymlfile! 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
- Log into your Itch.io account.
- Click your profile picture in the top right -> Settings.
- In the left sidebar, select Developer -> API Keys.
- Click Generate new API key (or copy an existing one).
Add Secrets to Your GitHub Repository
- Open your game’s repository on GitHub.
- Go to the Settings tab.
- In the left sidebar, expand Secrets and variables and click Actions.
- 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:
- 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. - 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.
- 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 analphapage.
