Introduction
As some of you may know, I’ve been working on a Minecraft clone for quite a while now- in fact, in May it’ll be half a year, which is crazy, because it didn’t feel like that long. Time flies when you’re having fun!
One thing I felt was important to do early on was to make sure the process of getting it into the hands of players and playtesters wasn’t a huge pain in the ass. For my previous game, Wiz vs. Kingdom, I was working in PICO-8, so both source and binary executable were more often than not a single file. But now I’m in Godot, and my game compilation steps are complex, and they continue to be. At any rate, who wants to haphazardly throw zip files around Discord and Google Drive? Not me.
What is CI/CD?
I think at this point in the post I should explain what CI/CD is. It stands for Continuous Integration/Continuous Deployment (or Continuous Delivery.) Here’s an article about it. In so many words, it is, to quote Geeks4Geeks, “a practice that automates the process of building, testing, and releasing software.” It’s related to DevOps which is kind of like IT to make your programmers as effective as possible, or to make the process of making/testing/releasing software as low friction as possible.
According to Exo, we don’t really use continuous integration in games that much- games don’t typically need the type of testing consumer software does, though they do of course need testing (it’s just that the kind of testing games needs is much harder to automate.)
But what I’m focused on here is the delivery/deployment part- the part where you can release the game at any time, without a complex series of human-led steps for getting your binaries online.
How to set up CI/CD for your Godot project using Forgejo Actions
Part 1: Source – Where my project is
So, I use the git host on t/suki, a nice way for me to work on a project without playing into Microsoft’s filthy hands (I say as a Windows user…)
t/suki uses Forgejo, which is basically like a Github alternative. It can mirror your git repo and do version control, etc, etc. It’s quite wonderful, and I recommend everyone on t/suki to use it to host their projects (as long as it doesn’t strain the server.)
This is the easy part.
Part 2: Actions – Github & Forgejo
This is the hard part.
So for CI/CD, Github has something called Github Actions, which, on doing something with your project (pushing to the remote, pulling, pressing a button on the site) will do a series of automated tasks related to your project. This is what it might look like on Github.
All the stuff Github Actions can do is basically the kind of stuff you want out of a CI/CD pipeline. It can copy your repo to a virtual machine to manipulate it, upload “artifacts” (files produced during the automated process) and more. You have to specify the specific workflow/action in a .yaml file in your repo.
Forgejo can do all this too, which is nice- it also has Actions (called, well, Forgejo Actions).
Now, Forgejo is a little more hands-on, as you may have guessed, so the process of setting it up is far more complicated than Github Actions, in my opinion.
The following is a snippet of my workflow .yaml file for my Minecraft clone- I’ll explain it later on. The good news is that generally, Github Actions syntax is very similar to Forgejo Actions syntax.
name: 'godot-ci export'
on:
push:
branches:
- 'release'
env:
EXPORT_NAME: materia
jobs:
export-windows:
runs-on: docker
container:
image: halfcourtyeet/godot-ci:latest
steps:
- name: Checkout Repo
uses: actions/checkout@v6
- name: Windows Build
run: |
mkdir -v -p build/windows
EXPORT_DIR="$(readlink -f build)"
VERSION="$(cat version.txt)"
godot --headless --verbose --export-release "Win64" "$EXPORT_DIR/windows/$EXPORT_NAME-$VERSION.exe"
- name: Upload to Itch
run: |
EXPORT_DIR="$(readlink -f build)"
echo ${{ secrets.BUTLER_API_KEY }} > ./butler_creds
butler -i ./butler_creds push "$EXPORT_DIR/windows/" halfcourtyeet/materia:win64 --userversion-file version.txt
Part 3: Runners – Linux, Ubuntu, WSL
Wait no, this is the hard part.
Github hosts its own “runners” (virtual/remote machines that run the actions you specify), and optionally allows you to use your own runner(s) to run your workflows. In Forgejo, this is required.
Typically these are just Ubuntu Linux machines somewhere. I don’t currently have access to a Linux machine (though at some point I do want to buy some cloud space.) So I set up a Windows Subsystem for Linux system. I don’t know if I would recommend it over just buying some server space somewhere. I’m using Ubuntu, I think that’s the default one WSL recommends.
I installed WSL, and installed the Forgejo Runner software on my WSL system. Then I registered it. ← that step is very important because where you generate the token for the runner determines the scope of the runner’s usage. In my case, only my not-minecraft repo, when logged into my account, can use my runner, since I don’t want people running stuff on my PC. The different types of scope are as follows below:
You will also have to generate an example config file to use, which is pretty easy.
Once you set up the runner, it should show up in any of the above branches like so:
This is in my repo under settings/actions/runners. I recommend doing this until we one day set up a git.tsuki.games global runner that can compile and release your Godot project for anyone who needs it.
Now, on my WSL instance, I switch to the runner user and run the daemon, i typically just run this little shell script. the cd ~ is actually redundant, lol, I just removed that.

When the daemon is running, we can actually run the action based on the info in that .yaml file I previewed for you above.
Almost.
Part 4: Docker Images
If you’re reading this far, welcome to Hellfuck. Take a seat on the chair of nails and let’s continue.
All we have right now is an Ubuntu instance ready to accept Forgejo action requests. We don’t actually have anywhere to like, work with the files of the repo, or whatever else we’re doing. Just rawdogging it in the Ubuntu instance we have right now would be folly, and it’s also just not how that works.
You may notice a few lines in that .yaml file above:
runs-on: docker
container:
image: halfcourtyeet/godot-ci:latest
so runs-on is the Ubuntu daemon thingy we set up. image is the place where the action’s commands are actually going to take place.
There are various container solutions out there. I chose to use Docker, but podman looks really cool. Notably, I’m not using Docker for Windows because it’s like 4 gigs and eats memory like I eat pretzels and beef jerky (which is to say, too much.) I’m using docker inside the WSL Ubuntu system instead.
Docker images are like little ready-made tiny virtual machines you can cook up by building them from a Dockerfile, but people typically host the pre-built images online.
I made my own Docker image from “godot-ci”, by abarichello, which is a Docker image that has the Godot version you need pre-installed with all the export templates (and butler which we’ll get to later.) I found Barichello’s image and Dockerfile template a little bit lacking so I tweaked it and made my own, which is here, since you’ll probably need to build the image yourself. I should really host it somewhere or something.
FROM ubuntu:noble
LABEL author="Halfcourt Yeet"
USER root
SHELL ["/bin/bash", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
git-lfs \
unzip \
wget \
zip \
adb \
nodejs \
osslsigncode \
&& rm -rf /var/lib/apt/lists/*
# When in doubt, see the downloads page: https://github.com/godotengine/godot-builds/releases/
ARG GODOT_VERSION="4.6"
# Example values: stable, beta3, rc1, dev2, etc.
# Also change the `SUBDIR` argument below when NOT using stable.
ARG RELEASE_NAME="stable"
# This is only needed for non-stable builds (alpha, beta, RC)
# e.g. SUBDIR "/beta3"
# Use an empty string "" when the RELEASE_NAME is "stable".
ARG SUBDIR=""
ARG GODOT_TEST_ARGS=""
ARG GODOT_PLATFORM="linux.x86_64"
RUN wget https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}-${RELEASE_NAME}/Godot_v${GODOT_VERSION}-${RELEASE_NAME}_${GODOT_PLATFORM}.zip \
&& wget https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}-${RELEASE_NAME}/Godot_v${GODOT_VERSION}-${RELEASE_NAME}_export_templates.tpz \
&& mkdir -p ~/.cache \
&& mkdir -p ~/.config/godot \
&& mkdir -p ~/.local/share/godot/export_templates/${GODOT_VERSION}.${RELEASE_NAME} \
&& unzip Godot_v${GODOT_VERSION}-${RELEASE_NAME}_${GODOT_PLATFORM}.zip \
&& mv Godot_v${GODOT_VERSION}-${RELEASE_NAME}_${GODOT_PLATFORM} /usr/local/bin/godot \
&& unzip Godot_v${GODOT_VERSION}-${RELEASE_NAME}_export_templates.tpz \
&& mv templates/* ~/.local/share/godot/export_templates/${GODOT_VERSION}.${RELEASE_NAME} \
&& rm -f Godot_v${GODOT_VERSION}-${RELEASE_NAME}_export_templates.tpz Godot_v${GODOT_VERSION}-${RELEASE_NAME}_${GODOT_PLATFORM}.zip
ADD getbutler.sh /opt/butler/getbutler.sh
RUN bash /opt/butler/getbutler.sh
RUN /opt/butler/bin/butler -V
ENV PATH="/opt/butler/bin:${PATH}"
RUN godot -v -e --quit --headless ${GODOT_TEST_ARGS}
# Godot editor settings are stored per minor version since 4.3.
# `${GODOT_VERSION:0:3}` transforms a string of the form `x.y.z` into `x.y`, even if it's already `x.y` (until Godot 4.9).
RUN echo '[gd_resource type="EditorSettings" format=3]' > ~/.config/godot/editor_settings-${GODOT_VERSION:0:3}.tres
RUN echo '[resource]' >> ~/.config/godot/editor_settings-${GODOT_VERSION:0:3}.tres
So I built this Dockerfile as my own image which my Forgejo Runner Daemon can recognize during the Forgejo Action defined in the .yaml file. None of these words are in the Bible.
Speaking of words:
Part 5: Let’s talk about what that .yaml file actually means
I think it may help to break down that .yaml file step by step, since that’s where the actual action “commands” are issued.
name: 'godot-ci export'
on:
push:
branches:
- 'release'
This is just defining the name of the greater “action” that you’ll see later in the actions tab of your Forgejo git repo. the on means that whenever I push to the release branch specifically, this workflow is triggered and runs.
env:
EXPORT_NAME: materia
This is an “environment” variable that you can use later during your actions. It’s user-defined, and you can put anything there. It’s just nice to reuse. In this case, I’m giving the desired name of my game’s .exe once, so I can reuse it during multiple builds (of Windows, Mac, and Linux in this case.)
jobs:
export-windows:
runs-on: docker
container:
image: halfcourtyeet/godot-ci:latest
This brings us to our first job. Jobs are kind of like the supercategory under which you do more concrete stuff in a series of steps. So the export-windows job has 3 steps: It checks out the repo (see below), it compiles the game source code into a binary, and it uploads it to itch.io.
steps:
- name: Checkout Repo
uses: actions/checkout@v6
Our first step is to check the repo out (basically copy it to inside the Docker image we made earlier). Also, FYI, the Docker image is now running in a “container”, which is basically like a shell for us to do stuff with the image. It’s ephemeral, which is to say, it only exists while we have stuff for it to do. The great thing about the Docker image inside a container is we can do whatever we want with it, and it resets to its original state next time we use it.
The uses keyword here is saying hey, actually, for this step, run someone else’s action that’s pre-defined. We’re using a Forgejo-defined action here, which is actually a mirror of sorts of an action by the Github guys. So much for getting away from their ecosystem!
- name: Windows Build
run: |
mkdir -v -p build/windows
EXPORT_DIR="$(readlink -f build)"
VERSION="$(cat version.txt)"
Here, we make a folder for the Windows build. the -p makes recursive directories, and the -v prints to the console the result (probably just the directory of the new folder.
Then we define some temporary variables- EXPORT_DIR uses some funky Linux syntax to get the absolute path of the build folder using the readlink command. Finally, we define a VERSION variable using the version.txt provided in my Github repo (you’ll see later why we love this little version.txt file.. Exo has a comment, though, on why it’s not best practices… Exo if you’re reading this please comment below on the forum post.)
godot --headless --verbose --export-release "Win64" "$EXPORT_DIR/windows/$EXPORT_NAME-$VERSION.exe"
Finally finally, we ask Godot to compile our game!! There’s a lot of $ variables here, but don’t be alarmed- all the variables are defined above.
Congratulations. On that little Docker container image, in your Ubuntu Linux system, is a brand-new copy of your game for Windows, all nice and zipped up and ready for someone to play. And look, you didn’t have to lift a finger! ![]()
![]()
(Though maybe, if you were unlucky an insane gremlin like me, you spent 2 weeks slaving away at this with a dream and a stubborn refusal to ask Claude a damn thing. ![]()
)
Wait, but like… How do we actually get that zip file?
Part 6: Butler and uploading to itch.io
- name: Upload to Itch
run: |
EXPORT_DIR="$(readlink -f build)"
echo ${{ secrets.BUTLER_API_KEY }} > ./butler_creds
butler -i ./butler_creds push "$EXPORT_DIR/windows/" halfcourtyeet/materia:win64 --userversion-file version.txt
The final step in our journey might be a bit of a curveball, but this is how you get your sweet-ass game uploaded to the internet.
Now ordinarily if you wanted to keep your game to yourself, you could do another step in the action that does uses: upload-action@v4 or whatever. This would upload your Godot game executable as an artifact, which is something you can download from your Forgejo workflow terminal on the website. An artifact can be any file, in this case it would probably be a zip archive.
But we don’t want to keep our game to ourselves. Like the rapper Mos Def, we are world citizens. We need to share this game with the world!
This is where itch.io’s command-line program butler comes into play. What you’ll want to do is install it on your Linux instance, then get your API key by logging in. See that ${{ secrets.BUTLER_API_KEY }} line? Whatever API key you generate from itch.io and butler, you’re gonna want to store it in the secrets section of your actions settings, for privacy (do NOT store it globally, I recommend at least having the scope of this key at user-level, if not repo-level.) Also if it’s not obvious, this API key is how itch.io knows you’re the one authorized to be uploading stuff to your account.
Once that’s done we can push a build right to your itch.io project of choice. You can read the documentation to see how itch tends to prefer you tag your files by platform. It will scoop up everything in a folder (in this case, our build/windows folder) and upload it right to the interwebs under the windows tag.
If those steps ran smoothly, and you didn’t get any red X errors during your Forgejo run…
Well BAM, there it is.
Recap
So to recap:
You push your repo to Forgejo → the workflow in your .forgejo\workflows is triggered → your Daemon (which needs to be running) catches it → It executes everything inside your container of choice (Docker/podman/etc) using a (typically pre-built) image → inside that image, your game is copied into the container → Godot compiles your game sources into a binary → butler uploads that binary to itch.io.
This guide was showing off how to do this for a Windows build but it’s basicallly the exact same for Linux and Mac. Oh, very important, you need to have export presets already for your Godot project, defined in export_presets.cfg.
Conclusion
God damn. This took like two weeks of off-and-on-work to do and I still don’t know if it was worth it. I suppose if anyone can get help from this guide, then I have no regrets. For my money, this should absolutely make my workflow smoother, but it’s going to be even more complicated soon because rather than just download pre-made export templates and a pre-compiled Godot engine, I’ll need to compile it all myself using double-point precision! This is so I can support large numbers for the coordinates of my infinite world. Why oh why did I choose to make a voxel game! Woe is me! ![]()
I hope you enjoyed this guide, and that you can use it for your own project. I’m sure there are many things you can do to make it way less complicated (maybe switching to Podman and not using WSL are two suggestions.) Also, like I said before, probably best to use Forgejo’s tagging features. I use a version.txt to track what version of the game I’m releasing, but I wouldn’t recommend that.
P.S. I’d say a lot here can be extrapolated to also automate uploading to Steam.
Byeee!! ![]()





