Automated export and publish using a shell script
During my career making games, I spent a lot of time building and exporting projects. Often, the same project needs to be exported several times for different builds, different versions, different operating systems... The compiled project then needs to be uploaded to different distribution platforms and it's all done manually.
Recently I came up with the solution to use a shell script in the root of the project that automatically performs all the necessary step for building and publishing a project. Since I use this mostly for my own projects, I can run the script on my local machine whenever I want to perform a release. No need to set up CI/CD.
I use Godot engine to make my projects, but a similar process can be applied to different engines, as long as they offer a way to export a project from the command line. The script I will show is a Linux shell script, since that's what I use daily, but a similar result can also be achieved with a Windows batch script.
Creating a temporary directory
This first step is to create a directory to use as our build output.
Although we could also create a directory on our desktop and use that, if we were to run this script anywhere other than our local machine, this would pose a security risk.
We can use mktemp to create a temporary directory and get its name.
temp_dir=$(mktemp -d)
We also need to remember to delete the temporary directory once we are done using it. We can do that using a trap. This will ensure that the temporary directory is deleted when our script exits, regardless of whether it terminated gracefully or it was interrupted.
cleanup () {
rm -rf "$temp_dir"
}
trap 'cleanup' EXIT
We can now use the temporary directory as our build output.
Exporting from the command line
This passage is specific for the Godot engine. If you're using a different engine, you just need to make sure it has a way to export a project from the command line.
We'll start by defining some export presets in the same way you normally do to export your project. You can refer to the documentation for more information on how to export a project. In my case, I have two export templates for every platform: one for the demo and one for the full game. Note that it isn't necessary to specify an export path.
Once the export templates have been defined, they can be used to export the project from the command line with the following command.
echo "Exporting Linux demo"
mkdir "$temp_dir/demo_linux"
godot --headless --export-release "Linux Demo" "$temp_dir/demo_linux/game_executable.x86_64"
The --headless option is used to export the project without opening the editor GUI.
The --export-release option is the one used for exporting release projects.
The third parameter is the name of the export template that we defined in the editor.
Finally, the last parameter is the path at which to export the project, which should be the project's executable.
Notice that before exporting the project we are creating the directory where the exported project will be saved.
The last thing to do is to repeat this step for each export template you defined.
echo "Exporting Windows demo"
mkdir "$temp_dir/demo_windows"
godot --headless --export-release "Windows Demo" "$temp_dir/demo_windows/game_executable.exe"
Adding license files
The question of how to manage third party assets is brought up often in gamedev spaces.
In my project, I have a directory called third_party_licenses that contains several text files with the license of all the assets I used that is shipped with the game next to the executable.
Since there are a few additional (optional) steps before we can copy the licenses directory into the build directory, we will first copy it to the temporary directory.
echo "Copying license files"
cp -r "./third_party_licenses" "$temp_dir/licenses"
Since the licenses directory is inside my Godot project, it also needs to contain an empty .gdignore file.
This file tells Godot to not import the contents of the directory into the engine and to not show it in the editor.
Since it is a file needed by the editor, it needs not to be included in the output.
rm "$temp_dir/licenses/.gdignore"
In order to comply with the Godot license, we also need to include the engine's license, which we can get directly from the engine's repository.
We can use curl to retrieve the license and store it in a text file next to the other license files.
echo "Retrieving additional license files"
curl -o "$temp_dir/licenses/godot.txt" https://raw.githubusercontent.com/godotengine/godot/refs/heads/master/LICENSE.txt
curl -o "$temp_dir/licenses/godot_copyright.txt" https://raw.githubusercontent.com/godotengine/godot/refs/heads/master/COPYRIGHT.txt
curl -o "$temp_dir/licenses/godot_logo.txt" https://raw.githubusercontent.com/godotengine/godot/refs/heads/master/misc/logo/LICENSE.txt
Now that we're done, we can copy the licenses into the directory that will be uploaded to Itch and Steam.
echo "Copying license files"
cp -r "$temp_dir/licenses" "$temp_dir/demo_linux/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/demo_windows/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/demo_macos/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/linux/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/windows/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/macos/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/web/third_party_licenses"
Since our project also probably comes with its own license file, it's a good idea to add that too.
echo "Copying license"
cp "./license.txt" "$temp_dir/demo_linux/license.txt"
cp "./license.txt" "$temp_dir/demo_windows/license.txt"
cp "./license.txt" "$temp_dir/demo_macos/license.txt"
cp "./license.txt" "$temp_dir/linux/license.txt"
cp "./license.txt" "$temp_dir/windows/license.txt"
cp "./license.txt" "$temp_dir/macos/license.txt"
cp "./license.txt" "$temp_dir/web/license.txt"
Git tag and project version check
This step is technically optional, but it's a good way to keep your project clean if you're using Git.
In my previous projects, I would often forget to tag the commits and to update the project's version before publishing a release. For this reason I added some additional checks at the top of the script that cause the script to exit before the project is built if something is wrong.
First of all, we can make sure our project doesn't contain any uncommitted changes before building it.
if [ -n "$(git status --porcelain)" ]; then
echo "Working directory is not clean"
exit
fi
Using git status we can check if there are any uncommitted changes in the working directory.
The --porcelain options makes sure that the output of the command can be parsed with a script.
We then check if the output is not empty using -n and if it's not, we print an error and exit the script.
The second check we can make, is to check if the current commit has been tagged. Tags in Git are usually used to indicate on which commit a version of the project was built. We can get the tag at the current head and make sure that it is not empty.
version=$(git tag --points-at HEAD)
if [ -z "$version" ]; then
echo "No version tag on current commit"
exit
fi
If we want to be extra careful, we can also check that the version we are building corresponds to the one defined in our Godot project.
We can use a regular expression to parse the project.godot file and look for the part where the project's version is defined,
then we can check if it matches the tag at the current commit.
If the two don't match, then we exit the script before exporting the game.
regex="config/version=\"([[:digit:]\.]+)\""
while read line; do
if [[ $line =~ $regex ]]; then
if [[ $version != ${BASH_REMATCH[1]} ]]; then
echo "Version $version does not match ${BASH_REMATCH[1]} defined in project.godot"
exit
fi
break
fi
done < project.godot
Uploading to Itch
Now that our project has been built, it is time to upload it. Builds can be uploaded to Itch using Butler, their command-line tool.
On my system, Butler is installed using my distribution's package manager, which means I can freely use it in a shell script. If you are on Windows, or you are using a different package manager, refer to the official installation guide.
We first need to log into Itch.io using Butler. Luckily, this is quite straightforward.
butler login
When you run this script, a login page will be opened in your default browser and will ask for your Itch.io credentials. If you have 2FA enabled on your account, it will probably ask for it as well. Once you are logged in, the script will continue to run.
Once logged in, we can upload builds using the butler push command.
The first argument is the path to the directory you want to upload and the second one is the project you're uploading.
butler push "$temp_dir/demo_linux" username/gamename:linux-demo --userversion "$version"
butler push "$temp_dir/demo_windows" username/gamename:windows-demo --userversion "$version"
butler push "$temp_dir/demo_macos" username/gamename:mac-demo --userversion "$version"
butler push "$temp_dir/web" username/gamename:web-demo --userversion "$version"
Of course, remember to replace username with your Itch.io username and gamename with the name of your game. Both the username and the name of the game should be written in lowercase and with dashes instead of spaces.
The part after the ":" is the channel name.
Using the correct channel name will make sure that your build is properly tagged.
For instance, if the channel name contains linux it will be tagged as a Linux executable,
if it contains win or windows it will be tagged as a Windows executable.
The --useversion tag allows you to specify which version to use for this build.
Thank goodness we already have the version to use from the previous step!
The last step is to log out of Itch.io after we uploaded our builds. We can do so by using the cleanup function we defined earlier.
# Cleanup function to execute when the script exits
cleanup () {
# Remove the temporary directory
rm -rf "$temp_dir"
# Log out from itch
butler logout --assume-yes
}
trap 'cleanup' EXIT
Uploading to Steam
Unfortunately, uploading to Steam isn't as easy as uploading to Itch. Valve also has its command-line tool, but it's slightly more complicated to use.
On my system, I have the steamcmd AUR package installed.
If you are on Windows, you may need to specify the full path to the executable file.
We first need to define some build files. I put these inside my project's directory.
steam_sdk_scripts
|-.gdignore
|-build_demo.vdf
|-build_demo_linux.vdf
|-build_demo_macos.vdf
|-build_demo_windows.vdf
|-build_full.vdf
|-build_full_linux.vdf
|-build_full_macos.vdf
|-build_full_windows.vdf
Since these files need a relative path to our build output, our script will need to copy them to our temporary directory.
cp -r "./steam_sdk_scripts" "$temp_dir/steam_sdk_scripts"
The build_demo.vdf file looks like this:
"AppBuild"
{
// Remember to replace this with your App ID
"AppID" "______"
"Desc" "Description that will appear on your build in Steamworks"
"Preview" "0"
// Relative path from this script to the content root
// In our case, this is the temporary directory
"ContentRoot" ".."
"Depots"
{
// Remember to replace these with your depot IDs
"______" "build_demo_linux.vdf"
"______" "build_demo_windows.vdf"
"______" "build_demo_macos.vdf"
}
}
The build_demo_linux.vdf file and the other demo files look similar to this:
"DepotBuild"
{
// Set your assigned depot ID here
"DepotID" "______"
// Include all files recursivley
"FileMapping"
{
// Path to the build output for our linux build
"LocalPath" "./demo_linux/*"
// This is a path relative to the install folder of your game
"DepotPath" "."
// If LocalPath contains wildcards, setting this means that all
// matching files within subdirectories of LocalPath will also
// be included.
"Recursive" "1"
}
}
Once all the build files are in place, we can upload our project to Steamworks.
steamcmd +login Username +run_app_build "$temp_dir/steam_sdk_scripts/build_demo.vdf" +quit
Remeber to replace Username with your actual Steam username.
When you run the script, you will first be asked to enter your Steam password into steamcmd. You may also be asked to confirm the login using the Steam mobile app.
We are not done yet. Our build has now been uploaded to Steamworks, but it is not yet visible to the public. For security reasons (I suppose) there is no way to automate this step. You will need to log into Steamworks from your browser, access your build page, and publish the build from there.
Full code
Here is the full version of the script we have written so far:
#!/bin/sh
# Abort the script if any command fails even when using piped statements
set -eo pipefail
# Make sure the working directory is clean before exporting
if [ -n "$(git status --porcelain)" ]; then
echo "Working directory is not clean"
exit
fi
# The project's version corresponds to the tag at the current commit
version=$(git tag --points-at HEAD)
# Make sure a version tag has been provided
if [ -z "$version" ]; then
echo "No version tag on current commit"
exit
fi
# Make sure the project's version corresponds to the git tag
regex="config/version=\"([[:digit:]\.]+)\""
while read line; do
if [[ $line =~ $regex ]]; then
if [[ $version != ${BASH_REMATCH[1]} ]]; then
echo "Version $version does not match ${BASH_REMATCH[1]} defined in project.godot"
exit
fi
break
fi
done < project.godot
# Start building the project
echo "Building version $version"
# Create a temporary directory to use as output
temp_dir=$(mktemp -d)
# Cleanup function to execute when the script exits
cleanup () {
# Remove the temporary directory
rm -rf "$temp_dir"
# Log out from itch
butler logout --assume-yes
}
trap 'cleanup' EXIT
# Export the Linux demo
echo "Exporting Linux demo"
mkdir "$temp_dir/demo_linux"
godot --headless --export-release "Linux Demo" "$temp_dir/demo_linux/game_executable.x86_64"
# Export the Windows demo
echo "Exporting Windows demo"
mkdir "$temp_dir/demo_windows"
godot --headless --export-release "Windows Demo" "$temp_dir/demo_windows/game_executable.exe"
# Export the macOS demo
echo "Exporting macOS demo"
mkdir "$temp_dir/demo_macos"
godot --headless --export-release "macOS Demo" "$temp_dir/demo_macos/Game.app"
# Export the Linux build
echo "Exporting for Linux"
mkdir "$temp_dir/linux"
godot --headless --export-release "Linux" "$temp_dir/linux/game_executable.x86_64"
# Export the Windows build
echo "Exporting for Windows"
mkdir "$temp_dir/windows"
godot --headless --export-release "Windows" "$temp_dir/windows/game_executable.exe"
# Export the macOS build
echo "Exporting for macOS"
mkdir "$temp_dir/macos"
godot --headless --export-release "macOS" "$temp_dir/macos/Game.app"
# Export the web demo
echo "Exporting web demo"
mkdir "$temp_dir/web"
godot --headless --export-release "Web Demo" "$temp_dir/web/index.html"
# Copy the license file in each build
echo "Copying license"
cp "./license.txt" "$temp_dir/demo_linux/license.txt"
cp "./license.txt" "$temp_dir/demo_windows/license.txt"
cp "./license.txt" "$temp_dir/demo_macos/license.txt"
cp "./license.txt" "$temp_dir/linux/license.txt"
cp "./license.txt" "$temp_dir/windows/license.txt"
cp "./license.txt" "$temp_dir/macos/license.txt"
cp "./license.txt" "$temp_dir/web/license.txt"
# Copy the third party licenses directory to the temporary directory
echo "Copying license files"
cp -r "./third_party_licenses" "$temp_dir/licenses"
rm "$temp_dir/licenses/.gdignore"
# Add additional licenses
echo "Retrieving additional license files"
curl -o "$temp_dir/licenses/godot.txt" https://raw.githubusercontent.com/godotengine/godot/refs/heads/master/LICENSE.txt
curl -o "$temp_dir/licenses/godot_copyright.txt" https://raw.githubusercontent.com/godotengine/godot/refs/heads/master/COPYRIGHT.txt
curl -o "$temp_dir/licenses/godot_logo.txt" https://raw.githubusercontent.com/godotengine/godot/refs/heads/master/misc/logo/LICENSE.txt
# Add the third party licenses directory to each project build
echo "Copying license files"
cp -r "$temp_dir/licenses" "$temp_dir/demo_linux/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/demo_windows/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/demo_macos/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/linux/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/windows/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/macos/third_party_licenses"
cp -r "$temp_dir/licenses" "$temp_dir/web/third_party_licenses"
# Log in to Itch to upload builds
butler login
# Push the demo build to Itch
butler push "$temp_dir/demo_linux" username/gamename:linux-demo --userversion "$version"
butler push "$temp_dir/demo_windows" username/gamename:windows-demo --userversion "$version"
butler push "$temp_dir/demo_macos" username/gamename:mac-demo --userversion "$version"
butler push "$temp_dir/web" username/gamename:web-demo --userversion "$version"
# Copy the Steam scripts to the temporary directory because they need absolute paths
cp -r "./steam_sdk_scripts" "$temp_dir/steam_sdk_scripts"
# Push the demo build to Steam
steamcmd +login Username +run_app_build "$temp_dir/steam_sdk_scripts/build_demo.vdf" +quit
You can also see a working example here: Twenty Games Challenge.