You met GitHub Actions in the core programme, where a workflow ran a linter and tests on your pull requests. Today you write one yourself, for your Spring Boot project — and by the end of this chapter, broken code will physically not be able to reach main.
A workflow is a YAML file in a folder GitHub watches: .github/workflows/. Create .github/workflows/ci.yaml in your Week 11 project:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Run tests
run: mvn --batch-mode verify
Here is a quick reminder on how Github Actions work:
on — the triggers. This workflow runs on every pull request, and again on every push to main (so the merged result is also tested).jobs — a workflow contains one or more jobs; each job gets its own fresh runner. runs-on: ubuntu-latest asks for a Linux machine.steps — the commands, in order. A step is either uses (a reusable action someone published) or run (a shell command, exactly as you would type it).actions/checkout — remember: the runner starts empty. This step clones your repository onto it. Forget it, and Maven finds no pom.xml.actions/setup-java — installs a JDK. distribution: temurin is the same Temurin you use as a Docker base image; java-version: '25' matches your project.mvn --batch-mode verify — the exact command from your machine. --batch-mode only makes the logs CI-friendly (no download progress bars).<aside> 💡
That little cache: maven line saves the downloaded dependencies between runs. Without it, every run re-downloads all of Maven's jars; with it, later runs are minutes faster. You will measure the difference in the practice exercises.
</aside>
<aside> ⚠️
YAML survival rules. YAML is a new language and it is picky:
'25' unquoted, YAML may read them as numbers and confuse tools. Most “my workflow does not start” problems are one of these three.Need a refresher? Check out Appendix 1: YAML syntax from the Core program
</aside>
Commit the file on a branch, push, and open a pull request. Go to the Actions tab and watch your workflow appear and start running.
Then, most likely: ❌. Open the log and you will find your integration tests failing with something like:
org.postgresql.util.PSQLException:
Connection to localhost:5432 refused. Check that the hostname and port
are correct and that the postmaster is accepting TCP/IP connections.
Of course. Your integration tests talk to PostgreSQL — and the runner is a fresh machine with no database on it. On your laptop, Postgres is simply there because you started it once with Docker Compose and it stayed. The runner has only what you declared, and you declared no database.
This error is the whole point of CI in one line: the runner just told you exactly what your project silently assumes about the machine it runs on.
You already know the fix conceptually — in Docker Compose, your api service had a postgres service next to it. GitHub Actions has the same idea, called a service container: a container that starts before your job and is thrown away after it. Add it to the test job, and give the test step the connection details as environment variables — relaxed binding, exactly as in Docker Compose and on Render:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Run tests
run: mvn --batch-mode verify
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
SPRING_DATASOURCE_USERNAME: postgres
SPRING_DATASOURCE_PASSWORD: postgres
Two details worth noticing:
ports: - 5432:5432 publishes the container's port to the runner, so your tests reach it at localhost:5432 — the same mapping idea as in Docker Compose.--health-cmd options make the job wait until Postgres is actually ready before your steps start. Without them, tests can race a database that is still booting — a classic source of builds that fail only sometimes.Push again. Green. ✅
<aside> ⚠️
This throwaway database is for tests only. Your real data stays in Neon; the pipeline never touches it. Every run starts with an empty testdb — which is exactly what tests want: a clean, predictable starting point.
</aside>
Red builds are not failures of yours — they are the system working. What matters is how fast you can read one:
ERROR or BUILD FAILURE. Later errors are usually consequences of the first one.Tests run: 14, Failures: 1 … and names the class and method. That is your starting point, not the stack trace's last line.mvn verify — on your machine. If it is green locally and red in CI, the difference is the environment, and now you know where to look.💬 Question: the build is green on your branch, you merge, and the push to main turns red — without you changing anything. How can that happen?
Right now the check runs, but a red ❌ is only advice — the Merge button still works. Let's take that button away:
main.test job.From now on, code enters main only through a pull request, and only when CI is green. The merge button literally greys out on a red build.
<aside> ⌨️
Hands on: set the ruleset up now, then test it — make a test fail on purpose on a branch, open a PR, and watch GitHub refuse the merge. Fix the test, push, and watch the button come back. That is your gate working.
</aside>
services: block, including the health-check options.The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.