This covers the GitHub Actions setup for automating Unity builds with GameCI and deploying them to itch.io with butler. Two workflow files do the work; everything is manually triggered.
The build and upload steps are separate so you can build without deploying, or re-deploy an old build without rebuilding. The upload workflow is reusable it can be called by the build workflow or triggered manually.
As next steps, I plan connecting this to GitHub Releases so that every push/merge to the main branch generates a draft of release notes (ie. with a release drafter) and a build artifact, and all I'd have to do afterwards is simply edit the release notes and hit a button.
I use a custom build profile. It generates an .asset file which you need to point to in BUILD_PROFILE_PATH. Not sure whether this code would work without one, but it's easy to create one and it's a good practice.
I had to override the scene list in my build profile, for some reason it wouldn't use the global scene list and fail.
You would need to create the itch.io page beforehand, publish at least one HTML release and mark it as such. This is a limitation of itch.io API.
More info:
First build can take a lot of time (1 hour for me) and fail for various reasons. Be ready that you'll waste some time fixing those failures. After the first successful build, the subsequent builds become a lot faster (mine is currently at 15 minutes) thanks to caching. If all your builds take equally long, check the logs of the caching action – could be that you're using a wrong cache key.
In addition to that, my builds were always failing at first. I suppose that's because of using too much memory, which has something to do with Unity 6 WebGL builds. My investigation brought me to this topic: WebGL build takes a lot of time.
Setting m_CodeOptimization: 2 in the build profile helped me resolve the issue. This is the WasmCodeOptimization enum that corresponds to Disk Size instead of the standard Disk Size with LTO. This can be edited manually in the profile's .asset file, or selected from a dropdown in Unity:
I haven't tested the build script with the other platforms (mac, windows etc), even though they are present in the GitHub Action dropdown.
If it fails, most likely it would be an easy fix: the "build/${{ inputs.targetPlatform }}/${{ inputs.targetPlatform }}" part of the butler call will have to change to point to the actual build folder that corresponds to the target platform.
GameCI is engine agnostic but the happy path exists only for Unity users. Godot and Unreal are supported via GameCI CLI, so theoretically you could roll your own setup.
More info:
- GameCI FAQ: What game engines are supported (+further recommendations)?
- GameCI built-in engine support
- game-ci/cli GitHub Action
- Godot: Exporting for the Web
Before the workflows run, you need to set up the following Secrets and Variables under repository's Settings → Secrets and variables → Actions:
| Name | Type | Value |
|---|---|---|
UNITY_LICENSE |
Secret | Contents of your Unity .ulf license file |
UNITY_EMAIL |
Secret | Unity account email |
UNITY_PASSWORD |
Secret | Unity account password |
ITCH_API_KEY |
Secret | API key from itch.io → Settings → API keys |
ITCH_USERNAME |
Variable | Your itch.io username |
ITCH_SLUG |
Variable | The game's URL slug on itch.io (e.g. coiled-kingdoms) |
BUILD_PROFILE_PATH |
Variable | A path to the build profile asset, i.e. Assets/Settings/Build Profiles/Web - Desktop - Release.asset |
The Unity credentials are required by GameCI to activate a license on the runner. You get the .ulf file by running the GameCI license activation workflow once. More details you can find in GameCI Activation Guide.
I am always suspicious to third-party software that uses such sensitive credentials, but at the end of the day this is on Unity for not offering a better CI integration on a Free license. A common practice in this case is to use a "robotic" account, i.e. an email/password pair that only exists for the purpose of being used on CI, but it's a legal grey zone until proven otherwise.
File: .github/workflows/build-unity-project.yaml
Trigger: Manual (workflow_dispatch) from the Actions tab.
name: Build the game
run-name: Building the `${{ github.ref }}` branch (${{ inputs.targetPlatform }})
on:
workflow_dispatch:
inputs:
targetPlatform:
description: Target platform
required: true
default: WebGL
type: choice
options:
- WebGL
- StandaloneWindows64
- StandaloneOSX
- StandaloneLinux64
- Android
- iOS
upload_to_itch:
description: Upload to itch.io after build
type: boolean
default: false
itch_channel:
description: itch.io channel (only used when uploading)
default: html5
required: false
jobs:
setup:
runs-on: ubuntu-latest
steps:
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub."
- name: Free disk space
uses: jlumbroso/free-disk-space@v1.3.1
with:
tool-cache: false
android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Check out repository code
uses: actions/checkout@v6
with:
ref: ${{ github.ref }}
- name: Create LFS file list
run: git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-assets-id
- name: Restore LFS cache
uses: actions/cache@v5
id: lfs-cache
with:
path: .git/lfs
key: ${{ runner.os }}-lfs-${{ hashFiles('.lfs-assets-id') }}
- name: Git LFS Pull
run: |
git lfs pull
git add .
git reset --hard
- uses: actions/cache@v5
with:
path: Library
key: Library-${{ inputs.targetPlatform }}-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
restore-keys: |
Library-${{ inputs.targetPlatform }}-
## Uncomment this if you have tests in your project
# - name: Run tests
# uses: game-ci/unity-test-runner@v4
# env:
# UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
# UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
# UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
# with:
# githubToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build project
uses: game-ci/unity-builder@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
targetPlatform: ${{ inputs.targetPlatform }}
buildProfile: ${{ vars.BUILD_PROFILE_PATH }}
- name: Upload the build as artifact
uses: actions/upload-artifact@v7
with:
name: Build-${{ inputs.targetPlatform }}-${{ github.run_id }}
path: build
upload-to-itch:
needs: setup
if: ${{ inputs.upload_to_itch }}
permissions:
actions: read
uses: ./.github/workflows/upload-build-to-itch.yaml
with:
run_id: ${{ github.run_id }}
targetPlatform: ${{ inputs.targetPlatform }}
channel: ${{ inputs.itch_channel }}
secrets: inherit-
Free disk space — GitHub's ubuntu-latest runners only have ~14 GB free. Unity builds (especially WebGL) need more. This action removes Android SDK, .NET SDKs, Haskell, large apt packages, and Docker images to reclaim ~10–12 GB before the build starts. Paranoid disclaimer: it's another third-party action that has too much control over your build.
-
Checkout — pulls code and LFS (if you have it configured) from GitHub.
-
LFS cache — creates a fingerprint of all LFS files, then caches
.git/lfsby that hash. On re-runs where assets haven't changed, LFS files are restored from cache instead of re-downloaded from the remote.git lfs pullthen checks out the actual file contents, and thegit add . && git reset --hardensures that the newline symbols are correctly normalized (I'm not sure if that step is necessary but theoretically this could resolve cache issues). -
Library cache — caches Unity's
Library/folder, which is where Unity stores imported asset data. Re-importing from scratch on every run would add significant time. The cache key is per-platform (Library-WebGL-<hash>) because the same asset imports differently for WebGL vs Windows. Therestore-keysfallback (Library-WebGL-) means a partial cache hit (e.g. a new asset was added) still reuses most of the existing imported state. -
Build project — uses GameCI's
unity-builder. It runs Unity headlessly in a Docker container with the right Unity version.buildProfilepoints to a custom Build Profile asset in the repo that configures compression, optimization, and target scenes for the web release. -
Upload artifact — stores the build output as a GitHub Actions artifact named
Build-WebGL-<run_id>. The run ID in the name lets the upload workflow fetch the right artifact later. Artifacts expire after 90 days by default. -
upload-to-itch (job) — only runs if
upload_to_itchwas checked. It calls the second workflow as a reusable workflow (workflow_call), passing the run ID, platform, and channel through.secrets: inheritforwards all secrets so the upload workflow can useITCH_API_KEY.
File: .github/workflows/upload-build-to-itch.yaml
Trigger: Called automatically by workflow 1, OR triggered manually with a specific run ID.
The manual trigger is useful when you want to deploy to itch at a convenient time, or re-push an old build.
name: Upload build to itch
run-name: Uploading ${{ inputs.targetPlatform }} build from run `${{ inputs.run_id }}` to itch.io
on:
workflow_dispatch:
inputs:
run_id:
description: ID of the "Build the game" run that produced the build artifact
required: true
targetPlatform:
description: Target platform (must match the build run)
required: true
default: WebGL
type: choice
options:
- WebGL
- StandaloneWindows64
- StandaloneOSX
- StandaloneLinux64
- Android
- iOS
channel:
description: itch.io channel to push to
default: html5
required: true
workflow_call:
inputs:
run_id:
type: string
required: true
targetPlatform:
type: string
required: true
channel:
type: string
required: true
jobs:
itchio-upload:
runs-on: ubuntu-latest
permissions:
actions: read
steps:
- name: Setup butler
uses: remarkablegames/setup-butler@v3
- name: Download build artifact
uses: actions/download-artifact@v8
with:
name: Build-${{ inputs.targetPlatform }}-${{ inputs.run_id }}
path: build
run-id: ${{ inputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# https://itch.io/docs/butler/pushing.html
- name: Upload to itch.io
run: butler push "build/${{ inputs.targetPlatform }}/${{ inputs.targetPlatform }}" ${{ vars.ITCH_USERNAME }}/${{ vars.ITCH_SLUG }}:${{ inputs.channel }} --userversion ${{ inputs.run_id }}
env:
BUTLER_API_KEY: ${{ secrets.ITCH_API_KEY }}-
permissions: actions: read— required so the workflow can download artifacts from a different workflow run. Without this thedownload-artifactstep gets a 403. -
Setup butler — installs butler, itch.io's official upload CLI, via
remarkablegames/setup-butler. This is simpler and more reliable than downloading butler manually withcurland a bit more secure than using an all-inclusive action like butler-publish-itchio-action (which is totally valid too and works perfectly fine). -
Download build artifact — downloads the artifact from the build run using its
run-id. The artifact nameBuild-WebGL-<run_id>must exactly match what the build workflow uploaded, which is why both workflows taketargetPlatformas an input. -
Upload to itch.io — calls
butler pushwith:- the local build directory (
build/WebGL/WebGL) - the target:
username/game-slug:channel --userversionset to the GitHub run ID (a monotonically increasing number, sufficient as a version string for itch.io)
- the local build directory (
- Go to Actions → Build the game → Run workflow
- Choose the target platform (default: WebGL)
- Optionally check "Upload to itch.io after build" and set the channel
- Click Run workflow
To upload a previously built artifact to itch.io without rebuilding:
- Find the run ID of the build (visible in the URL when you open a past run)
- Go to Actions → Upload build to itch → Run workflow
- Paste the run ID, match the platform, set the channel
- Click Run workflow