The Upload Grind
Let’s be honest, after spending hours coding, debugging, and polishing a game update or a game jam entry, the last thing you often feel like doing is manually:
- Exporting the game for Windows.
- Exporting for Linux.
- Exporting for Web.
- Zipping the web build.
- Opening the Itch.io dashboard.
- Uploading each build to the correct channel.
- Remembering what version you just uploaded.
It’s tedious, repetitive, and surprisingly easy to make a mistake (like uploading the wrong file or forgetting a platform). As 9to5-Grind
, we’re all about optimizing workflows where possible, especially coming from the webdev world where automation is king. So, we decided it was time to automate our Godot game deployments to Itch.io!
Enter: GitLab CI/CD
We host our code on GitLab, which comes with a powerful built-in automation tool called CI/CD (Continuous Integration / Continuous Deployment). The basic idea is simple: you define a set of instructions (a “pipeline”) in a special file (.gitlab-ci.yml
), and GitLab automatically runs those instructions whenever specific events happen, like pushing code to a particular branch.
Our goal: push code to our main branch (or create a release tag), and have GitLab automatically build the game for our target platforms and upload them straight to Itch.io.
The Tools for the Job
To make this magic happen, we needed a few key ingredients:
- Godot Engine (Headless): The command-line version of Godot, which can run exports without needing the graphical editor.
- Godot Export Templates: The necessary files Godot uses to package the game for different platforms (Windows, Linux, Web).
- Butler: Itch.io’s official command-line tool specifically designed for uploading builds.
- GitLab CI/CD Runner: The environment provided by GitLab where our automation script runs.
- A Butler API Key (
BUTLER_API_KEY
): This is the crucial piece for authentication. Important Note: This isn’t the standard API key found in your Itch.io settings page! It’s a special key generated specifically for Butler automation.
Getting the Right Key: BUTLER_API_KEY
This tripped us up initially! To securely allow our GitLab CI job to upload via Butler, we needed to perform a one-time setup locally:
- Download Butler onto our local machine.
- Run the command
butler login
in the terminal. This opens a browser window to authorize Butler with our Itch.io account. - Once authorized, Butler saves credentials locally. Crucially, it also provides instructions (or saves the key to a specific file) on how to get the
BUTLER_API_KEY
value needed for CI environments. This key is what proves to Itch.io that our automated script is allowed to upload on our behalf. (More details on this process can be found in the official Itch.io Butler Login Documentation).
We then took this generated BUTLER_API_KEY
value and stored it securely as a masked and protected variable in our GitLab project’s Settings > CI/CD > Variables. It should never be written directly into the .gitlab-ci.yml
file!
Our Automated Pipeline
With the correct key safely stored, we configured our .gitlab-ci.yml
file to perform the following steps whenever we push to our main
branch:
- Build Stage:
- Download the correct Godot headless engine and export templates (GitLab caches these to speed things up!).
- Run the Godot command-line export process for each platform (Windows, Linux, Web), using the presets defined in our
export_presets.cfg
file. - Zip up the web build, as Itch.io prefers web games uploaded as a single
.zip
file. - ✨ Smart Versioning: Automatically generate a
VERSION.txt
file. - Package all the builds and the
VERSION.txt
file as “artifacts” to be used in the next stage.
Expand build step
# .gitlab-ci.yml
build_game:
stage: build
image: debian:stable-slim
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- godot.zip
- templates.zip
- godot/
- ~/.cache/godot/
- ~/.config/godot/
- ~/.local/share/godot/export_templates/
before_script:
- apt-get update -qq && apt-get install -y -qq unzip wget zip
- GODOT_VERSION="4.4"
- GODOT_RELEASE_NAME="stable"
- GODOT_BASE_URL="https://github.com/godotengine/godot-builds/releases/download/${GODOT_VERSION}-${GODOT_RELEASE_NAME}"
- GODOT_HEADLESS_FILENAME="Godot_v${GODOT_VERSION}-${GODOT_RELEASE_NAME}_linux.x86_64"
- GODOT_TEMPLATES_FILENAME="Godot_v${GODOT_VERSION}-${GODOT_RELEASE_NAME}_export_templates.tpz"
- TEMPLATES_PATH="$HOME/.local/share/godot/export_templates/${GODOT_VERSION}.${GODOT_RELEASE_NAME}"
- |
if [ ! -f godot.zip ]; then
echo "Downloading Godot headless..."
wget -q -O godot.zip "${GODOT_BASE_URL}/${GODOT_HEADLESS_FILENAME}.zip"
unzip -q -o godot.zip -d godot/
chmod +x "godot/${GODOT_HEADLESS_FILENAME}"
else
echo "Using cached Godot headless."
fi
- |
if [ ! -f templates.zip ]; then
echo "Downloading Godot export templates..."
wget -q -O templates.zip "${GODOT_BASE_URL}/${GODOT_TEMPLATES_FILENAME}"
mkdir -p "${TEMPLATES_PATH}"
unzip -q -o templates.zip -d "${TEMPLATES_PATH}"
mv "${TEMPLATES_PATH}/templates/"* "${TEMPLATES_PATH}/" # Correct structure
rm -rf "${TEMPLATES_PATH}/templates"
else
echo "Using cached Godot export templates."
fi
script:
- VERSION_FILE_PATH="$CI_PROJECT_DIR/VERSION.txt"
- GODOT_EXECUTABLE="$CI_PROJECT_DIR/godot/${GODOT_HEADLESS_FILENAME}"
- PROJECT_PATH="$CI_PROJECT_DIR/src"
- BUILD_DIR="$CI_PROJECT_DIR/build"
- echo "Generating VERSION.txt..."
- |
if [ -n "$CI_COMMIT_TAG" ]; then
# If triggered by a tag (e.g., v1.0.0), use the tag name
VERSION_STRING="$CI_COMMIT_TAG (commit: $CI_COMMIT_SHORT_SHA)"
echo "Using tag for version: $VERSION_STRING"
else
# Otherwise, use date, branch, and commit SHA for dev builds
BUILD_TIMESTAMP=$(date +'%Y-%m-%d_%H-%M-%S') # Format: YYYY-MM-DD_HH-MM-SS
VERSION_STRING="DevBuild-${BUILD_TIMESTAMP}-${CI_COMMIT_REF_NAME}-${CI_COMMIT_SHORT_SHA}"
echo "Using dev build format for version: $VERSION_STRING"
fi
echo "$VERSION_STRING" > "$VERSION_FILE_PATH"
- echo "VERSION.txt content:"
- cat "$VERSION_FILE_PATH"
- mkdir -p "$BUILD_DIR/web"
- echo "Exporting for Web..."
- $GODOT_EXECUTABLE --headless --path "$PROJECT_PATH" --export-release "Horror-Manison-Thief" "$BUILD_DIR/web/index.html" # <-- CHANGE Preset Name
- echo "Zipping Web build..."
- cd "$BUILD_DIR/web" && zip -q -r ../web.zip . && cd "$CI_PROJECT_DIR"
artifacts:
paths:
- build/
- VERSION.txt
expire_in: 1 week
- Deploy Stage:
- Download Butler within the CI environment.
- Use Butler and our secure
BUTLER_API_KEY
(pulled from the GitLab variable) topush
each exported build to the correct channel on our Itch.io game page. - Tell Butler to use the content of
VERSION.txt
to label the build on Itch.io.
Expand deploy step
# .gitlab-ci.yml
deploy_to_itch:
stage: deploy
image: debian:stable-slim
needs: [build_game]
before_script:
- apt-get update -qq && apt-get install -y -qq unzip wget
- BUTLER_URL="https://broth.itch.ovh/butler/linux-amd64/LATEST/archive/default"
- echo "Downloading Butler..."
- wget -q -O butler.zip $BUTLER_URL
- unzip -q -o butler.zip
- chmod +x butler
script:
- ITCHIO_USER="CHANGEME"
- ITCHIO_GAME="CHANGEME"
- BUTLER_EXE="$CI_PROJECT_DIR/butler"
- BUILD_DIR="$CI_PROJECT_DIR/build"
- VERSION_FILE="$CI_PROJECT_DIR/VERSION.txt"
- VERSION_ARG=""
- if [ -f "$VERSION_FILE" ]; then VERSION_ARG="--userversion-file $VERSION_FILE"; fi
- echo "Uploading Web build..."
- $BUTLER_EXE push "$BUILD_DIR/web.zip" "${ITCHIO_USER}/${ITCHIO_GAME}:html5" $VERSION_ARG
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
✨ The Smart Versioning Trick
Manually naming builds is another pain point. Did I call the last one v1.1
or v1.1.0
? Was that the build from Tuesday or Wednesday?
Our pipeline now automatically creates that VERSION.txt
file with human-readable info:
- If triggered by a Git Tag (e.g.,
v1.0.2
): The file contains something likev1.0.2 (commit: a1b2c3d4)
. Perfect for official releases! - If triggered by a push to
main
(or another dev branch): The file contains something likeDevBuild-2023-10-27_16-30-00-main-e5f6g7h8
. This clearly marks it as a development build with the exact date, time, branch, and commit it came from.
Butler uses this file to label the uploads on Itch.io, so we always know exactly what build is live!
The Result? Sweet Relief!
Now, our deployment process is as simple as:
git push origin main