Today almost every software project, from a small utility to a big game, lives in a version control system, most often on GitHub - a service that stores code together with its full history of changes. And in almost every team, each code change is automatically checked and built on a server, and the finished version reaches users just as automatically. This approach is called CI/CD. From the outside it looks like a tool for big companies, but in fact you can set it up for free even for a one-person project.
By profession I am a QA engineer (QA - quality assurance), but I am more and more drawn to development: I want to create projects of my own. The first such project is Taveronen: The Beginning - a small idle game about a tavern where gold piles up by itself. I developed it myself in Godot, a free open-source engine, and released it on itch.io, a platform for indie games, in two versions: for the browser and for Windows. The game logic is covered by automated tests written with the GUT framework (Godot Unit Test), and I have a separate article about them. Some details here are specific to Godot, but the approach itself works for any project.
Two things pushed me to set up CI/CD. The first was manual work. I ran the tests myself: open the editor, press Run All, wait for the result. I released versions myself too: build the game for the browser and for Windows, pack the builds into archives, replace the archives on the game page, then download the game and check that it starts. About fifteen minutes of repetitive actions where it is easy to miss something. The second reason was my career: CI/CD is mentioned in almost every test automation job posting, and I wanted to see what is behind it on a project of my own.
The Actions tab on GitHub: the history of pipeline runs. Every release of the game goes the same way, from the tests to publishing on itch.ioNow the tests run by themselves after every change, and a new version comes out with a single action: I mark the version in Git, and GitHub runs the tests, builds the game, checks it in a real browser and on real Windows, and uploads it to the game page. In this article I will walk through how I set it all up step by step, how I made sure the checks really catch errors, and which mistakes I made along the way. At the end I will honestly sort out what a small project actually needs and what I did just to learn. The whole project is in an open repository, the link is at the end.
What CI/CD is, in plain words
CI (continuous integration) means that every code change is checked automatically: the server runs the tests, and the change gets a mark showing whether it passed. CD (continuous delivery) means that after successful checks the server builds the project and takes it all the way to release, including uploading it to the platform. The decision of when to release still stays with a human.
The chain of steps a change goes through is called a pipeline. Picture a factory assembly line with a quality control department: if the inspector finds a defect, the part does not move on. Automated tests work exactly like that inspector, and in this role they are called a quality gate: a red test stops the release, and a broken version never reaches the players. CI/CD is not testing itself but the automation of the path from code to player, yet it is the tests that make it possible to trust a machine with that path. So the pipeline is a joint effort of development and quality assurance: its value depends on the checks.
My pipeline lives in GitHub Actions. It is an automation service built into GitHub: it runs the commands you define on GitHub servers in response to events in the repository, such as a push, a new tag or a button click. Projects use it to run tests, build programs and publish them. I chose it because my project is already on GitHub, and for public repositories the service is free.
It is configured with text files in YAML (a markup language where nesting is set by indentation) in the .github/workflows folder. Each file is called a workflow, it consists of jobs, and jobs consist of steps. Jobs run on a clean computer that GitHub provides for the duration of the run: usually with Linux, but you can ask for Windows too. There is no Godot and none of my settings there, so the pipeline installs everything it needs by itself.
In the end the pipeline consists of two files: tests.yml checks every change, and release.yml releases a new version. I built it one step at a time and will describe it in the same order, and the overall diagram comes at the end.
Git comes first
Every pipeline starts with a repository. Previously, the game's source code lived only on my computer: if the disk had died, all the work would have been lost. So a repository on GitHub is also a backup with the full history of changes. I learned to work with Git from the command line in courses, but for everyday work I prefer GitHub Desktop, where everything is done with buttons. To follow the rest of the article, four terms are enough:
- Commit - a saved snapshot of changes with a short description.
- Push - sending commits to GitHub. This is what starts the pipeline.
- Branch - a separate line of changes. The main branch is called main, and it holds the working version.
- Pull request (PR) - a proposal to merge changes from a branch into main, showing the difference in files and the results of the checks.
With these words we can move on to the first pipeline file.
Step 1. Tests run by themselves
The first pipeline file, tests.yml, tells GitHub: after every push to any branch, take a clean computer, install Godot on it and run all the tests. Here is its essence (simplified):
name: Tests on: push: branches: ["**"] # run on every push to any branch jobs: test: runs-on: ubuntu-24.04 # a clean Linux computer from GitHub steps: - name: Check out the repository uses: actions/checkout@v7 # copy the project to that computer - name: Set up Godot uses: ./.github/actions/setup-godot # download Godot and import the project - name: Run GUT tests run: godot --headless -s addons/gut/gut_cmdln.gd -gexit # run all tests without a window The "on" block says when to run: on every push to any branch. This means unfinished work in separate branches is checked too, before it gets into main. Then come three steps: copy the project, install Godot and run the tests.
GUT tests run inside Godot itself, so the engine has to be started for a test run. A window is not needed on the server, and the --headless flag starts Godot without one. The -gexit flag tells GUT to close Godot after the run: without it the program would stay open, and the step would hang until the timeout. GUT reads where to look for tests from the .gutconfig.json settings file in the project root: it says that tests live in the test folder and its subfolders, and that file names start with test_. So it is enough to put a new test into that folder, and the pipeline will pick it up by itself.
How does GitHub know whether the tests passed? By the exit code, and this simple mechanism is what all of CI rests on. Every program reports a number when it finishes: 0 means everything is fine, anything else means something went wrong. If at least one test fails, GUT exits with code 1, the step turns red, and the commit gets a red cross instead of a green check mark. No magic.
The file is ready. All that is left is to put it into the .github/workflows folder, commit and push: GitHub finds the file by itself and starts the pipeline. The result appears in the Actions tab of the repository, and the next question is whether that result can be trusted.
Don't trust the green check mark: make it red yourself
The first run took 22 seconds and was green right away. That made me suspicious: the check mark only says that the command did not report an error, but it does not tell you whether the tests actually ran. So I opened the step log - the record of everything the command printed (the Actions tab, the run in question, the test job, the Run GUT tests step). The GUT summary showed the same numbers as in the editor, and only then could the check mark be trusted.
The GUT summary in the log of the first run: the same numbers as in the editor, so the tests really ranBut even a green run does not prove that the pipeline can catch errors: a test that has never been red may simply be unable to turn red. In QA this is a familiar idea: a check has to be checked. So in a separate branch I changed the expected number of items in the shop test from 7 to 8. The run turned red, and the log had everything needed to figure out why:
The log of a deliberately broken test: which test failed, what was expected, on which line, and exit code 1 at the very end- test_shop_has_seven_items, at line 21 - which test failed and on which line.
- [Failed]: [7] expected to equal [8] - the essence of the error: there are 7 items, but the test expected 8.
- Shop should have 7 items - the message I wrote myself in the check, so that a failure immediately shows what it is about.
- Process completed with exit code 1 - the exit code that turned the step red.
I deleted the branch afterwards, and nothing got into main: this kind of experiment is exactly what branches are for. Since then I have a rule: every new check has to fail at least once before I trust it.
Step 2. A report everyone can see
A log is good for investigating, but it is inconvenient to show to anyone, and even I struggle to spot what I need in it. So I added an Allure report - a tool that turns test results into a neat website. GUT writes the results into a standard file that many tools understand (JUnit XML), Allure builds a report from it, and the pipeline publishes it to GitHub Pages - free website hosting straight from a repository. The report is built even when tests fail, because that is when it is needed most, and it is published only from the main branch.
The Allure report on GitHub Pages: tests are grouped by file, and everything is visible without opening the logsThe main advantage of the report is that it opens with an ordinary link, without a GitHub account and without knowing where to look for logs. Allure is widely used by testing teams, so such a report also presents the work in a format familiar to the industry.
Step 3. A smoke test: what GUT cannot check
A Godot game is made of scenes: the menu, the intro, the tavern. The New Game button unloads the current scene and loads a new one. But GUT tests themselves run inside the game, in their own scene, and a real scene change would unload them in the middle of a run. That is why in the GUT tests the transition is replaced with a stub, and nothing checked that the buttons actually open the right screens.
This gap was closed by a smoke test - a short check of the most important things that shows whether the main functions of the program work at all. In testing it is usually the first check of a new build: if the smoke test fails, there is no point in testing further. My smoke test is a separate script that is itself the scene tree (SceneTree): scenes change inside it while it stays in place. It presses the buttons that change the screen one by one and after each press checks which scene has opened:
extends SceneTree # this script IS the scene tree, so a real scene change cannot destroy it func _run() -> void: # the whole smoke run (shortened here) await _open_scene(MENU_SCENE) # open the main menu and wait until it is ready _press_button("NewGameButton") # press the button, the real scene change starts await _wait_frames() # the scene change finishes at the end of the frame, so we wait a little _check_scene(INTRO_SCENE, "New Game (no save) opens the intro") # the intro must be open now # ... the other checks go the same way ... quit(1 if failures > 0 else 0) # at the end: exit code 1 if any check failed, otherwise 0 The pipeline reads the exit code the same way as with GUT. In tests.yml the smoke test runs after the GUT tests with the condition if: always(): it runs even if the GUT tests failed, so one red step does not hide another. I saw the smoke test fail too, by deliberately breaking one of its checks. The player's real save is not touched during the run: the path to the save file is swapped for a test one.
The smoke test in CI: six transitions passed, one is deliberately broken, and the whole step turned redImportant: the smoke test does not run the finished build that a player downloads, but the source files of the project, like the run button in the editor, only without a window and on Linux. It knows nothing about how the finished build behaves on a player's computer, and we will come back to that.
Step 4. The game builds itself
This is where CD begins, and its first part is the build: exporting the project into finished files for the player. For this Godot needs export templates - prebuilt "engines" that it puts inside the build. The template archive weighs about 1.3 GB, but I need only four files from it: the templates for Windows (regular and console), the template for the browser, and the file with the template version number. So on every run the pipeline extracts only those. The export itself is two windowless Godot commands, for Windows and for the browser. Godot does not create the output folders by itself, so they have to be created in advance. That was the first small trap.
The build is a separate job and starts only after the tests are green: in YAML this is the line needs: test. Finished builds are saved as artifacts - files you can download from the run page. To check, I downloaded the Windows build from CI and compared it with one built by hand in the editor: the game starts and works the same, the file sizes are equal, and a content comparison showed they match byte for byte. So the server builds exactly the same thing I do.
The run page: the test and build jobs on the left (the build waits for the tests), the artifacts below - finished builds and screenshots from the browser testArtifacts are kept for a week: that is enough to download and check a build, and the storage does not fill up with old files. But building the game is only half the job. Now we need to make sure the built game works.
Step 5. Checking the web version in a real browser
The web version has its own risks: files may fail to load, and the game may fail to start. So the next addition was an e2e test (end-to-end: a check through the real interface, the way a person plays).
For it I took Playwright - a library from Microsoft that controls a browser from code: it opens a page, clicks, takes screenshots and reads the browser console, where errors end up. The browser is Chromium, the open-source Google project that Chrome, Edge and many others are built on. Playwright downloads its own Chromium build of a strictly defined version, so the test always runs in the same browser. The test itself is written in Python. It is run by pytest, the most widely used testing framework for Python: it finds functions whose names start with test_ and reports which passed and which failed. The pytest-playwright plugin gives each test a clean browser tab, and Pillow, an image library, compares the screenshots.
All of this happens not on my computer but on a GitHub server, in the same build job, right after the export. The pipeline installs the Python packages and Chromium there, and the test starts a small web server from the Python standard library: a web build cannot be opened with a double click, the browser runs it only from a server. The browser on the server has no window, but it renders the page completely. This way the test checks exactly the build that will go to the players, and if it is red, the pipeline goes no further.
The test follows the path of a new player: the game loads without errors, New Game opens the intro, Continue opens the tavern. This is where two quirks of games came up that articles about web testing usually do not mention.
The game has no buttons a browser could find. On a website a button is a page element with text, and a test finds it almost the way a person does. Godot, however, draws the whole game on a single canvas, and to the browser it is just a picture. So the test clicks by coordinates: New Game is the point (945, 178), and the browser window is always exactly 1280 by 720 pixels so the button does not shift. Simple, but fragile: if the buttons in the menu are rearranged, the coordinates will have to change.
How do you tell that the screen has changed? The test compares screenshots before and after the click. But the menu is animated (fire in the fireplace, stars), and two screenshots never match completely. So the test converts both screenshots to grayscale (each pixel becomes one brightness number from 0 to 255), subtracts them pixel by pixel and counts the share of pixels that changed by more than 30. Weaker differences are just animation flicker. I measured it: the idle menu changes by about 0.2%, and a new screen by more than 40%. I set the threshold at 20%:
before = take_screenshot(page, "02_before_new_game.png") # a picture of the main menu before the click page.mouse.click(*NEW_GAME_BUTTON) # click the New Game button by its coordinates page.wait_for_timeout(CLICK_WAIT_MS) # wait until the intro is drawn after = take_screenshot(page, "03_intro.png") # a picture of the screen after the click share = changed_share(before, after) # the share of changed pixels, from 0.0 to 1.0 assert share > MIN_CHANGED, f"New Game did not change the screen: only {share:.1%} of pixels changed" # MIN_CHANGED = 20% Step by step: the test takes a screenshot of the menu, clicks the New Game button, waits until the game draws the new screen, and takes a second screenshot. The changed_share function calculates the share of pixels that noticeably changed between them. The last line is the check (assert): if less than 20% of the screen changed, the test fails with a clear message saying how many percent changed. The check for the Continue button works the same way.
Once this test turned red on the same code where it had just been green: only 0.2% of the screen changed, so the game did not react to the click. Most likely the server took longer than usual to load the game, and the click came before the menu was ready. Such a test is called flaky. You cannot just rerun it until it is green: if you get used to random red, one day a real failure will be dismissed as random too. The failure happened once, so I keep an eye on the test, and if it happens again, I will give the game more time to load. Screenshots help to investigate such cases: the pipeline saves them as an artifact on every run, even when the test fails.
Step 6. A real .exe on real Windows
The web build was now checked by the browser, but the pipeline only built the Windows version, and whether it started had to be checked by hand. The smoke test does not replace this: it runs the project on Linux, not the finished .exe.
A finished build is not the same as the project in the editor: the game is packed into a .pck file next to the .exe, and the engine runs in release mode. An export error shows up only when the build itself is started. In studios a finished build is usually launched first by QA engineers, and this is known as a build verification test. I decided to automate this check as well. The plan was:
- On a GitHub server with Windows, take the artifact with the Windows build.
- Run the game without a window for 300 frames and let it close by itself (the --headless and --quit-after 300 flags). A frame is one step of the game loop in which the game updates its logic and picture. 300 frames are more than enough to load the main menu.
- Check that the game started, loaded the main menu and wrote no errors to the log.
The hardest part turned out to be reading what the game prints at startup: the release .exe is built as a program without a console, and its output cannot be captured the usual way. Godot itself came to the rescue: the --log-file flag writes the log to a file, and with the --verbose flag the entire loading process ends up there. The sign of success became the line about loading the main menu:
Completed load for: 'res://scenes/main_menu.tscn' I wrote the check as a PowerShell script (the Windows command shell), test/ci/check_windows_build.ps1. It starts the game, waits up to 60 seconds for it to finish and then reads the log. The time limit is needed in case the game hangs: for example, without the .pck file it shows an error window and waits for someone to press OK, and on a server there is nobody to press it. The essence of the script (simplified):
$MainSceneMarker = "Completed load for: 'res://scenes/main_menu.tscn'" # this log line appears when the main menu is loaded # Start the game: no window, close after 300 frames, write a detailed log to our file $GameArguments = @("--headless", "--quit-after", "$QuitAfterFrames", "--verbose", "--log-file", "`"$LogFile`"") $GameProcess = Start-Process -FilePath $GameExe.FullName -ArgumentList $GameArguments -PassThru # start the game $Finished = $GameProcess.WaitForExit($MaxWaitSeconds * 1000) # wait up to 60 seconds, the time is in milliseconds # ... if the game hangs, stop it and remember the failure; read the log into $LogLines ... # Check 1: the main menu scene was loaded if (-not ($LogLines | Where-Object { $_.Contains($MainSceneMarker) })) { # no log line contains the marker text $FailureLines += "The main menu was not loaded (no line: $MainSceneMarker)" } # Check 2: no error lines, except the harmless one printed when the game is closed early $ErrorLines = $LogLines | Where-Object { ($_ -match "^(SCRIPT ERROR|USER ERROR|ERROR|Parse Error)") -and ($_ -notmatch "still in use at exit") } If the exit code is not 0, the menu line is missing or the log contains errors, the script lists the reasons, prints the whole game log and exits with code 1. Even a healthy game prints one ERROR line when it is closed early; that line is harmless, and the script skips it.
As usual, I checked the script both ways, and it paid off. Its first version reported FAIL on a working build: the menu line was searched for with an operator that compares the whole line, while in the log the needed text is followed by a technical tail. And I made the check fail on purpose by putting a nonexistent main menu name into the script.
The pipeline got a new windows-check job. It runs after the build, but on a Windows computer (windows-2025): it downloads the artifact with the .exe and runs the script.
The check has its limits too: the game runs without a window, so the check does not see the picture, the buttons or the sound. But it will catch a build that does not start at all. In the end, each check covers its own area:
- GUT tests - the game logic;
- the smoke test - starting the project and moving between screens;
- the browser test - the finished web build;
- the Windows check - the finished .exe.
Now that both builds are checked, they can be handed to the players.
Step 7. Uploading to itch.io
Uploading is done by the official itch.io tool, butler. It is a console program: it has no window and is controlled by commands. I did not have to install it myself: on every run the pipeline downloads it to the server, just like Godot. butler uploads builds into channels (mine are windows and web) and sends only the changed parts of files. It needs an API key (API - application programming interface) from my itch.io account. The key must not be stored in a public repository, so it lives in Secrets - encrypted storage in the repository settings, and in the logs it is replaced with asterisks.
Uploading is a separate publish job. It starts only when both the build and the Windows check are green, so a build that does not start will not reach the players:
publish: needs: [build, windows-check] # start only when the build AND the Windows check are green if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') # only main or a version tag can upload To avoid uploading something wrong by accident, you can do a dry run of the upload first. For that I added a manual run (in YAML this is workflow_dispatch): in the Actions tab you press Run workflow, choose a branch and decide whether to tick the publish box. Three cases are possible:
- The box is unticked (the default) - a trial run. butler runs with the --dry-run flag: it lists the files it would upload and changes nothing on itch.io.
- The box is ticked, the main branch is chosen - a real upload.
- Any other branch is chosen - the publish job is skipped, even with the box ticked. This way test branches never reach itch.io.
Besides the manual run, a real upload happens when a new version is released by tag, covered in the next step.
I made the first real upload only after a trial one. Before it, I deleted the old, manually uploaded archives from the game page and enabled in-browser play for the web channel once. After that butler puts new builds into the same entries, and the page settings are preserved.
Step 8. Release by tag
I wanted a new version to come out with a single action, and Git has tags for that. A tag is a label on a specific commit, usually with a version number, for example v1.0.7. Unlike a branch, a tag does not move, so you can always see which code a version was built from.
Releases are handled by the second file, release.yml. It starts when a version tag arrives on GitHub, and the first thing it does is call tests.yml as a whole, so the tests are described once and every release starts with them:
on: push: tags: ["v*"] # a version tag like v1.0.7 starts a release jobs: test: uses: ./.github/workflows/tests.yml # the same tests as on every push build: needs: test # start only when the tests are green The version number is stored in three places in the project: in the settings (the label in the main menu takes it from there) and in two property fields of the Windows build. It is easy to set a tag and forget to bump the version in the project. So the first step of the build compares the number from the tag with all three places and stops if they do not match.
I deliberately chose not to bump the number automatically. A tag is my decision that "this goes out to the players", and the number shows what kind of release it is: a fix (1.0.7 → 1.0.8), a new feature (1.0 → 1.1) or a big update (1.x → 2.0). That decision is better left to a human. Besides, automation had its downsides: a separate script is easy to forget when releases are rare, a number written only on the server would not get into the repository and my local builds, and branch protection would not let the pipeline write it back to main (more on that in the "Safety net" section).
I also pinned the versions of all the tools: Godot, butler, the Python packages, the job computer (ubuntu-24.04 rather than ubuntu-latest) and the GitHub actions (ready-made building blocks for steps, for example actions/checkout@v7) are all written as exact numbers. For example, butler is downloaded by a number from a variable in release.yml rather than by the LATEST link, and the Python packages for the browser test are listed in requirements.txt with "==", meaning exactly that version. The Playwright version also determines which Chromium it downloads. Without pinning, one day a new version of some program would come out, and the pipeline would break by itself even though I had changed nothing. The flip side is that I have to update the versions myself.
The first release by tag was 1.0.5, a tiny version with almost no changes, so that there was nothing to break. One tag, and a little over three minutes later both builds were on itch.io. And I tested the whole chain, including the Windows check, with the technical release 1.0.8, in which the game itself did not change: test, build, windows-check and publish took less than three minutes, and butler sent a patch of less than 80 KB for a Windows build of more than 150 MB.
Safety net: protecting main, Dependabot and rollback
These three techniques are overkill for a one-person project, but this is how teams work, and I added them to learn.
A protected main. Nothing stopped me from pushing broken code straight into main, and the red cross would only appear after the error was already in the working version. GitHub lets you protect a branch with a set of rules (a ruleset): Settings → Rules → Rulesets → New branch ruleset. My "Protect main" set has four rules:
- Restrict deletions - the main branch cannot be deleted.
- Block force pushes - the history of main cannot be forcibly rewritten.
- Require a pull request before merging - changes get into main only through a pull request. The number of required approvals is zero: there is nobody but me to approve.
- Require status checks to pass - a pull request can be merged only when the test check from tests.yml is green.
There are no exceptions even for the repository owner, and GitHub Desktop now warns right away if you try to commit directly to main. I tested the protection with a red check too: I opened a pull request with a deliberately broken test, and the Merge pull request button became inactive.
Dependabot. Pinned versions get outdated. Dependabot is a bot built into GitHub: once a month it checks whether new versions of GitHub actions have come out and opens a pull request with the update by itself. To the bot, Godot and butler are just numbers in my files, so I update them myself.
The catch is that the bot's branch is checked only by tests.yml, while an updated action may also be used in release.yml. So before merging I run release.yml manually on the bot's branch with the publish box unticked. This could be automated, but I deliberately kept the manual run: updates come once a month, and automation would require extra conditions in release.yml and spend Windows server minutes every time. For such a rare case, checking by hand is cheaper.
For the same reason I did not trust the bot with the Python packages, even though it can track them. They are needed by the browser test, which runs only in release.yml: a pull request with a new version of Playwright (and with it a new Chromium) would turn green without ever starting a browser. So I update the Python packages myself and run the browser test locally first.
Rollback. If a released version is broken, it is enough to run the release manually from the tag of the last working version. The pipeline will take the project and the pipeline files themselves as they were at the moment of the tag, and upload the game again. I tested it: after releasing 1.0.6, I brought version 1.0.5 back to itch.io, and then 1.0.6 again. Both times butler sent only the difference between the builds, so the rollback took a couple of minutes.
This is one more reason to release versions by tag: each version is tied to its commit forever, so any of them can be brought back with the same pipeline, without hunting for old archives on your computer.
What a release looks like now
- Create a branch for the release (for example, release-1.0.8) and bump the version number in three places.
- Commit, push the branch and open a pull request.
- Wait for the green check and merge the changes into main.
- Put the v1.0.8 tag on the latest commit of main and push it.
After that the pipeline checks the version, runs the tests, builds the game, checks it in the browser and on Windows, and only then uploads it to itch.io. All that is left to do by hand is the devlog text on the game page.
Mistakes I made
- A tag on the wrong commit. I put the v1.0.6 tag on the second-to-last commit of main. Nothing broke, but since then I check the tag before pushing it.
- A red cross that was not my fault. Once only the report publishing step failed because of a GitHub outage, and the next run passed. Lesson: first check exactly which step is red, and only then look for an error in the code.
- A server error when pushing. A couple of times GitHub Desktop returned "Internal Server Error". This is a temporary service failure: commits and tags are not lost, just try again in a minute.
- A workaround for a problem that did not exist. The game icon is stored in the export settings not as a file path but as a hidden identifier. I decided that on a clean server Godot would not find this identifier and would put the default icon into the .exe, and I even added a workaround step to the pipeline. Comparing the real files showed that the server finds the icon by itself, and I removed the workaround. Lesson: check a guess against the real files first.
- The job name is part of the protection. The required check in the rules is referenced by the job name. Rename the job, and every pull request will hang waiting.
- The README fell behind the pipeline. After the Windows check appeared, the project description still described the old chain. The pipeline checks code, not text, so now I finish every pipeline change by updating the README.
Does a small project need all this?
I split everything I did into three groups.
A must, even if you work alone:
- Git and a remote repository. It takes time to figure out, but it pays off: the project gets a full history of changes and a backup.
- Running tests automatically on every push. If you already have tests, it is one small file, and errors start showing up right after a change, not before a release.
- The habit of seeing every check fail, including in the pipeline. A test that fails in the editor does not guarantee that the pipeline will notice: the pipeline may not find the tests or may not get the exit code, and the run will stay green. So every new check is worth breaking on purpose at least once to see the red cross on GitHub. Without that, the automation runs on faith.
Useful if you release often or have a web version:
- Building on a server and uploading automatically save time with every update.
- Checks of finished builds in the browser and on Windows are probably the most valuable part: they look at what the player will get.
- Version checks and pinned tool versions cost almost nothing but save you from unpleasant surprises.
Overkill for a solo project, but useful for learning: protecting main, Dependabot, the Allure report and rollback through the pipeline. A solo developer can get by with reading logs, manual version updates and uploading an old archive, but practicing team techniques on my own project was worth it.
Some things I deliberately did not add, for example a linter that checks code style: it produced hundreds of remarks about long lines, and I would have had to rewrite working code just for formatting. Deciding not to do something is also a decision, as long as it is a conscious one. CI/CD pays off when a project is alive and keeps getting updates. In big studios everything is on a larger scale, but the ideas are the same: changes are checked automatically, a red check stops the release, and the release itself is the same every time.
Summary
Checking used to start with the Run All button, and now it starts with an ordinary push. Releasing has come down to a tag: after that the game goes through the tests, gets built, is checked in the browser and on Windows, and only then lands on itch.io. The same every time, with no "forgot to tick the box" or "built the wrong version". The next level would be continuous deployment, where every change in main goes to the players right away without a separate release command, but I want to decide myself when a new version comes out.
Two paths through the pipeline: a regular push checks changes, and a version tag takes the game from the tests to the itch.io pageMaking my own game, I saw CI/CD from two sides: as a developer who wants to release new versions quickly and without errors, and as a QA engineer who is responsible for making the checks along the way trustworthy. My main takeaway is this: quality assurance in this chain is not on the side but at its very foundation. A machine can be trusted with the pipeline only because there are checks at every step.
That is why working on quality does not end with writing automated tests. You need to check finished builds where people will play them, investigate failed runs, keep an eye on flaky tests and know exactly what each check catches and what remains unprotected, and close those gaps. No wonder experience with CI/CD is so valued in test automation.
If you want to try it yourself, start with one file that runs the tests on every push, and make it fail once right away. Everything else can be added one step at a time. My whole pipeline is in an open repository: if you make a copy of it (a fork) and enable Actions, the tests will start running for you too (only the upload to itch.io will not work without your own key).
Further reading
I tested everything described here on my own project, and for details it is best to go to the official documentation:









