Lab 2: CI/CD & Testing¶
Goals¶
This lab is built around one deliberate order of work: plan what you want your Mission Logic to do better, write the tests that describe that behaviour, and only then implement it. Around that core sit reviewed merge requests, a short report, useful logging, and a GitLab pipeline that verifies changes before publishing container images. This is the first checkpoint across the eight assignment labs (Labs 1–8); use the Checkpoint 1 milestone to track this deadline's work.
For full details on the deliverables of this lab, see the Practicalities section.
You can view the Lab 2 Slidedeck here
Update model-lib dependency
At the time of writing, the latest version of model-lib is 1.2.2.
Make sure to update your pom.xml to use the latest version,
so you enjoy the latest fixes and features.
Check Model Lib package registry to see the latest version.
Introduction¶
Lab 1 left you with a working, if very naive, logic-service: five Mission API routes, a component tracking the missions you are playing, and a decision logic that does exactly one thing, move each squad member to a random adjacent tile.
This lab is about two things that reinforce each other:
- Preparing for collaboration through project planning. Planning who does what, agreeing on how you branch and merge, and documenting that for yourselves all pay off the moment two of you touch the logic in the same week.
- Putting a safety net of automated tests around the logic as it grows. Manually restarting the game and watching your colony move around does not scale once the logic has more than one rule, and it tells you nothing about whether last week's rule still works after this week's change. Automated tests, run on every push, do.
How you are expected to work this lab¶
For each decision rule, follow this order. Set up CI and collaboration in parallel, as noted below:
- Plan first. Decide what you want your Mission Logic to do better, write each piece down as a work item, give it a priority and an owner. No code yet. → Project planning
- Agree on how you collaborate. A branching strategy and merge requests that actually get reviewed; you describe both in your Checkpoint 1 Report. → Git collaboration & branching strategy
- Make informed decisions, then write the test before the code. Consult the game manual, decide what each rule should do and when it should take priority, then write a test that fails because the behavior does not exist yet. → Decision-making, Test-driven development
- Only then implement. Make each failing test pass, refactor, and log what your logic decides. → Implementing your Mission Logic, Logging
- Publish verified work. A required pipeline runs
./mvnw verifybefore Jib publishes a commit-specific or release image. Put verification onmainearly, confirm it runs for merge requests, then immediately enable branch protection and merge checks. → GitLab CI/CD pipeline
For a three-person team, plan the shared test fixtures and verification job together at the start. Once the first pipeline is working and main is protected, split the required decision rules among the team, review each other's changes as they land, and add Jib publishing before the final tag. The order within each rule remains test before implementation; do not postpone all testing until the end of the week. Cache experiments are optional, not a prerequisite for publishing.
Steps 3 and 4 are not interchangeable
Writing the implementation first and adding a test afterwards is not test-driven development, and it is not what this lab asks for. A test written after the fact gets shaped around the code that already exists: it confirms what you built rather than describing what you wanted, and it never fails, so you never find out whether it would catch the bug it is supposed to catch.
Use the scan-state test for at least one inspectable red-green merge request: link its work item, keep a commit adding a failing test (preceded, if needed, by a small stub commit so the test compiles), and follow it with a commit making that test pass. Link the MR in your Checkpoint 1 Report and describe how your team makes sure tests come first.
A note on AI
Using an AI coding assistant while working on this lab? See Using AI for our stance on this course, and a template AGENTS.md you can drop into your repository to keep it in teaching-assistant mode rather than solving the lab for you.
Project planning¶
Assignment: planning and Checkpoint 1
Create a GitLab milestone named Checkpoint 1 and assign every work item and merge request for this deadline to it. Create work items for the required features and improvements, assign an owner to each, and use labels to categorise them.
Link the milestone from your Checkpoint 1 Report. The milestone provides the deadline's work list and progress.
Plan items for the minimum scope and for shared work such as the pipeline and the test fixtures. The milestone is the place to see the current checkpoint's work and progress, so create it at the start rather than waiting until the report is due.
Before writing any code, take a step back. What features do you want to add to your Mission Logic this lab? What improvements does the codebase itself need (better structure, a fixture class for tests, and so on)? What is the priority of each, and who is going to work on it? This section is about answering those questions and writing the answers down: the tests come after that, and the implementation only after those.
Minimum Mission Logic scope¶
By the end of this lab, your service must implement all five Mission API endpoints according to the game manual, including scan-completed. Your minimum Mission Logic must make deliberate movement decisions, extract resources, deliver carried resources, and use large-deposit information received through scan events when deciding where to go. Random movement alone does not meet this requirement. You decide the exact strategy and how to structure it; the Decision-making section gives you a way to reason about those choices.
Combat, looting and orbital call-ins are deliberately outside the scope of this lab; they come later. Do not let experiments with them replace the required behaviors or their tests.
Where to start?¶
Lab 1 only asked for one behaviour: move each squad member to a random adjacent tile. For Lab 2, replace that baseline with the required behaviors below. The game manual describes the actions and mechanics; it is up to your team to decide how to combine them into a coherent strategy.
- Close the resource loop. Extract resources when standing on a deposit (
ExtractResourceAction), and return them to the landing craft (DropOffResourceAction) when your chosen policy says the cargo should be secured. Using an existing additional drop-off point is optional; deploying one requires an orbital call-in and is not required here. Consider crate capacity, distance, and the risk of leaving resources in a squad member's inventory when a mission ends. The latestMissioncontainsMissionControl.landingCraftLocation()(see the model-lib Javadoc); use the current location, not a fixed spawn position, since a wipeout can move the craft. - Move with intent. Choose destinations based on the mission state and what the squad member can see. For example, explore when no useful deposit is known, move toward a visible deposit, and take a newly scanned large deposit into account. Decide how to handle a target that is not currently visible or is no longer available.
- Use scan results. Your service receives a
scan-completedcall whenever its orbital scan finds a large resource deposit. Instead of returning501 Not Implemented, store the event's location and resource type alongside that mission's state and return204 No Content. Keep discoveries when a turn update replaces the storedMission, make them available to the decision logic on later action requests, and clear them when the mission ends.
These are starting points for the minimum scope, not a prescribed algorithm. In particular, decide when to extract, when to return cargo, how to select a destination, and what to do when no useful deposit is known. Record the preconditions and priorities for those decisions in your work items.
Decision-making¶
Assignment: decision-making
For the required Mission Logic scope, use the manual to identify action preconditions and relevant game priorities. Capture your intended behavior and rule ordering in work items before implementation. In your Checkpoint 1 Report, describe one decision where more than one reasonable option existed: what you chose and why, when the rule applies and what takes precedence over it, and the test that captures it.
The game manual is the source of truth for what actions mean and when they are accepted. Before coding a rule, look up its action in the Actions table, read the related game mechanics, and identify its preconditions. Actions whose preconditions are not met may be silently ignored by the game, so returning the right Java type is not enough: your logic must decide when the action is valid. The manual's Action Priority section explains the game's resolution order for actions submitted in one turn; it is distinct from the rule order you choose within your own decision logic.
For each planned rule, record in its work item:
- The situation and information available to the squad member, including what may or may not be visible.
- The action to take and the preconditions that must hold for it to succeed.
- Which other rules take precedence, and what should happen when this rule does not apply.
- Important edge cases, such as an empty deposit, a full crate, a newly scanned deposit, or no known target.
There is no single required priority order for your strategy. Make a reasoned choice that follows the game rules, explain it to your teammates, and encode both the positive and the negative cases in tests (see Red, green, refactor). A useful starting point is to write the rules in plain language before choosing how to represent them in Java.
Tracking work in GitLab¶
In this course we track all of that using GitLab's work items, its unified name for issues, tasks and similar planning items, found under Plan > Work items (this section used to just be called "Issues"; a work item of type Issue is still what you'll be creating). Having a work item per feature or improvement provides structure, accountability and a space for discussion, so your team can work in parallel without stepping on each other's toes.
Your team has its own subgroup on GitLab, with the logic-service project underneath it, and you have full access to both. Work Items on a group level are called Epics and are only needed when you want to track a large feature that spans multiple projects; for this lab, you only need to create work items on the project itself.
Labels categorise work items so you can filter and group them. A plain label such as enhancement or logic:combat is enough, and a work item can carry any number of them. If you want mutually exclusive categories, GitLab also supports scoped labels, written as key::value with a double colon: every label sharing the same key replaces the others, so applying priority::high to a work item that already has priority::low swaps them instead of stacking both. Using them is optional. An example set to start from:
| Key/label | Example values |
|---|---|
priority:: |
high / medium / low |
type:: |
feature / bug / chore |
| plain, non-scoped | ci-cd, tests, logic:combat, logic:resources, logic:movement, ... |
You don't have to use exactly this taxonomy, but whatever you choose should be described in your Checkpoint 1 Report and applied consistently. Decide on a set of labels and create them under Manage > Labels (or on the fly from the work item creation form).
With Where to start? above in mind, create work items for each feature or improvement and assign an owner to coordinate it. An owner is responsible for keeping the work moving, not necessarily the only person who may change the code. A split that can work is for each student to lead a coherent decision-logic area while coordinating shared work (the CI pipeline, test fixtures) as a team. Avoid three isolated implementations that only meet at the end: agree on interfaces, rule priorities, and review expectations early. Work that touches everyone, such as the first pipeline or the shared test fixtures, is a good candidate to tackle together (pair or mob programming) instead of handing it to one person and reviewing it at the end. Explore the Plan section on your repository: Work items, Boards and Milestones can help you visualise who is working on what and how far along the lab you are.
Git collaboration & branching strategy¶
Assignment: Git collaboration
Use GitHub Flow: create feature/fix branches and merge them into protected main through merge requests. Require a successful pipeline and at least one approval from a teammate other than the author before merging. Describe the workflow in your Checkpoint 1 Report.
All three team members must participate in merge request reviews (each student must review at least one MR). Include links to a merged, reviewed MR and the red-green TDD MR in your Checkpoint 1 Report.
Protect main as soon as verification works
Put a verification job on main and confirm that a merge-request pipeline also runs it. Immediately then protect main against direct pushes, require a successful pipeline and a teammate's approval before merge, and verify that a failing MR pipeline blocks merging. Do this before the remaining feature MRs, not at the deadline. Enabling the pipeline requirement before an MR pipeline exists could block the MR introducing that pipeline. These controls prevent merges when required checks fail or are missing; they cannot prove passing code is defect-free.
Branching strategy (GitHub Flow)¶
Working directly on main makes it easy to disrupt teammates or break their build. Feature branches and GitLab merge requests (GitHub's pull requests) let you review changes before they reach main.
Use the following simple branching strategy:
- Create a new branch for each feature or improvement you want to implement. Give the branch a descriptive name, prefaced by the type of issue it tackles (e.g.
feature/loot-corpsesorbug/wrong-wrap-direction). Using this naming convention, you can easily filter branches in the GitLab UI and Git clients. - When you are done working on a feature or improvement, create a merge request to merge the branch into
main. This allows you to review the changes and discuss them with your team members. If the merge request is approved, the branch can be merged intomainand deleted. If it is not approved, make additional changes to the branch and push them; the merge request updates automatically. - If
mainchanges while you are working, update your feature branch before merging. Choose either merge or rebase as described in Keeping your branch up to date, and describe your team's choice in your report.
This branching strategy is commonly referred to as GitHub Flow. Git Flow and GitLab Flow are different strategies; for this lab, use feature/fix branches and merge requests into a protected main, not a long-lived develop branch. GitKraken has an overview of GitHub Flow and other branching strategies.
Merge requests, protected branches, and reviews¶
GitLab's own Merge requests documentation covers MRs (GitHub's Pull Requests, if that's more familiar) in full detail; here we'll keep to what matters for this lab.
When you push a branch to the remote, GitLab shows a banner on the repository page with a button to create a merge request. You can also create one manually from the Merge Requests page, where you pick a source and target branch. From there you can add a description, assignees, reviewers and labels, much like a work item, and optionally mark the MR as a draft while it's still work in progress.
Use the GitLab CLI
The glab command-line tool can create and inspect GitLab work items and merge requests without leaving your terminal. For example, glab mr create --web starts a merge request from the current branch and opens the browser so you can complete its title, description, and reviewers. Run glab from inside your clone: elsewhere it assumes gitlab.com, unless you set GITLAB_HOST=gitlab.stud.atlantis.ugent.be. See the glab mr create documentation and GitLab CLI installation and authentication guide.
From that point on, new commits pushed to the branch show up in the MR automatically. Comments can be attached to specific lines of code, which is useful for discussing changes, and can be marked resolved once addressed. When you're ready, the Merge button merges the branch into the target and closes the MR. Squashing is also an option here and can keep history clean. However, for this lab, do not squash the commits that demonstrate a red-green cycle: the failing test and its fix should be visible in the MR for review.
An MR by itself is only a proposal: nothing stops a teammate from merging it unreviewed, or from pushing straight to main and skipping the MR entirely, unless you configure GitLab to stop them. These project settings turn "we agreed to use merge requests" into something GitLab actually enforces:
- Protected branches (
Settings > Repository > Branch rules, selectmain): set Allowed to push and merge to "No one", somaincan only ever change through a merge request, never a direct push. Allowed to merge controls who can click that MR's Merge button once it's ready. - Merge checks (
Settings > Merge requests): enable Pipelines must succeed under Merge checks, so the Merge button stays disabled until verification passes; and add an approval rule under Merge request approvals requiring at least one approval, so a teammate has to actually look at the change before it can land. Use GitLab's merge request pipeline documentation to ensure your job is included in MR pipelines, not only branch pipelines. Test the safeguards with a deliberately failing branch and confirm the Merge button is unavailable until the checks pass and a teammate approves.
Keeping your branch up to date¶
When main receives changes after you branched, bring those changes into your feature branch before merging it. Git supports two common approaches:
- Merge
maininto your branch. This preserves the existing commits and records a merge commit. It is explicit and safe for a branch other people are also using, but adds merge commits to its history. - Rebase your branch onto
main. This replays your branch's commits on top of the updatedmain, creating a linear history. Rebasing rewrites commit IDs, so avoid rebasing commits that teammates have already based work on; coordinating before rewriting shared history prevents confusion.
Neither approach is universally best for this small team project. Try to understand the history each creates, agree on a team convention, and describe it in your report. Do not force-push a shared branch without coordinating with everyone using it. Git's branching and rebasing guide explains the mechanics and trade-offs.
Test-driven development¶
Assignment: test-first development
Write tests for each new decision rule before its implementation, including positive cases (the rule fires) and negative cases (it must not). Demonstrate one meaningful red-green cycle in a merge request, with separate commits visible for review. Link that MR in your Checkpoint 1 Report.
Write a failing test before implementing each new decision rule. This section covers the red-green-refactor cycle and how to construct test data; the next section tests the HTTP layer end to end. Coverage thresholds are not required here.
Red, green, refactor¶
Test-Driven Development (TDD) flips the usual order: instead of writing code and then a test to check it, you write a small, failing test for the behaviour you want first (red), write just enough code to make it pass (green), then clean up the result without changing its behaviour (refactor), all before moving to the next piece of behaviour.
Each rule has a precondition (for extraction: "standing on a non-empty deposit, with room left in the crate"). Test both sides:
- Positive test (the "happy path"): set up a situation where the precondition holds and assert that the rule produces its action.
- Negative test: set up a situation where the precondition does not hold and assert that the rule does not fire, and what happens instead.
- Boundary test: set up the situation right at the edge of the precondition, where off-by-one mistakes hide. A crate that is exactly full, or a deposit with 1 resource left versus 0.
For extraction, test a deposit at the member's location, no deposit, and a full crate.
Run the test and watch it fail before implementing the rule. A test that has never failed might have a wrong fixture or an assertion that would hold for any Action. The red phase checks that the test can detect the missing behaviour.
Make sure the test fails for the right reason. A test that does not compile because the class or method does not exist yet is not a meaningful red: it says nothing about behaviour. Create the smallest stub that lets the test compile (one that returns NoAction, say), commit that on its own, then commit the failing test. The red result you show in your merge request should be an assertion failure that names the missing behaviour.
The same goes for negative tests. Against a stub that does nothing, assertThat(action).isNotInstanceOf(ExtractResourceAction.class) passes immediately and proves nothing. Assert what the squad member does instead (with a full crate: that it heads back to the landing craft), so the test cannot pass until your rules really exist.
Writing unit tests¶
JUnit Jupiter, Mockito and AssertJ are already available: they come in transitively through the spring-boot-starter-webmvc-test test-scoped dependency your Lab 1 pom.xml already declares, so no new dependency is needed for plain unit tests.
Warnings about Java agents in the test output
On recent JDKs, running tests prints warnings such as Mockito is currently self-attaching to enable the inline-mock-maker or A Java agent has been loaded dynamically. They come from how Mockito's bytecode library attaches itself and are harmless for this lab; a failing test looks very different (FAIL, with an assertion message).
Where do the tests go?¶
Maven expects tests in src/test/java, in the same package as the class they test: the folder structure mirrors src/main/java. A test for src/main/java/idlab/colonyops/colonyN/logic_service/logic/ColonyMissionLogic.java therefore lives in src/test/java/idlab/colonyops/colonyN/logic_service/logic/ColonyMissionLogicTest.java (the generated LogicServiceApplicationTests shows the pattern). Name unit tests <Class>Test; integration tests end in IT, which is what the pipeline uses to tell them apart.
Test-first starts with the class under test, so create that first, even if it is an empty stub (this is the stub commit from Red, green, refactor). Then let IntelliJ IDEA create the test next to it:
- Place the caret on the class name and press Alt+Enter, then choose Create Test. See Create tests in the IntelliJ documentation.
- In the dialog, pick JUnit Jupiter (listed as JUnit5) as the testing library and accept the default name (
<Class>Test) and the package. IntelliJ places the new class in the test sources root, in the matching package. - From then on, Ctrl+Shift+T jumps between a class and its test.
Run a single test with the green arrow in the gutter next to it, or all tests of a class with the arrow next to the class name (JUnit in IntelliJ IDEA). JetBrains' page also shows adding a junit-jupiter dependency to the pom.xml; you do not need that, JUnit Jupiter comes with the starter you already have. If IntelliJ offers to add JUnit to the classpath anyway, reload the Maven project first, as in Lab 1.
The same tests run from the command line, which is exactly what the pipeline will do later:
./mvnw test # unit tests only (*Test), fast
./mvnw test -Dtest=ColonyMissionLogicTest # a single test class
./mvnw verify # unit tests, then integration tests (*IT)
./mvnw verify -Dit.test=MissionApiIT # a single integration test (unit tests still run first)
If a test passes in your IDE but fails with Maven (or the other way around), trust Maven: that is what the pipeline runs.
Building the test data¶
Testing a decision-logic method means constructing a Mission and an ActionRequest that describe the situation you want to test, and asserting on the Action that comes back. Because these are nested model-lib objects (a Mission carries a Planet and a MissionControl, an ActionRequest carries a SquadMember and a list of EntitiesAtLocation), constructing one by hand for every test gets repetitive fast.
Build a small test fixture helper
Write a small helper class (e.g. MissionFixtures) with builder-style methods for the Mission, ActionRequest and other objects you construct repeatedly, with sensible defaults for what you don't care about in a given test. Check the model-lib Javadoc to find the real constructors and fields for each class.
Here is what a test for the resource drop-off rule from Where to start? could look like, assuming a MissionFixtures helper along the lines above. MissionLogic and ColonyMissionLogic below are just names for this example; use whatever you called your own decision-logic interface and implementation:
class MissionLogicTest {
private final MissionLogic logic = new ColonyMissionLogic();
@Test
void dropsOffCargoWhenAtLandingCraftWithSomethingToDeposit() {
Location landingCraft = new Location(3, 3);
Mission mission = MissionFixtures.aMission()
.withLandingCraftAt(landingCraft)
.build();
ActionRequest request = MissionFixtures.anActionRequest()
.atLocation(landingCraft)
.carrying(5)
.build();
Action action = logic.decideAction(mission, request);
assertThat(action).isInstanceOf(DropOffResourceAction.class);
}
}
Write the same kinds of test, positive and negative, for every new rule you add this lab.
This example hands the logic only a Mission and an ActionRequest. Your rules about scanned deposits need more than that: a scan-completed event arrives in a different request than the one that needs it, and the next PUT hands you a fresh Mission. Keep discoveries in per-mission state separate from the replaced Mission, and pass that state to the decision logic (or let it read it through an injected component). Unit tests should be able to supply known discoveries directly; an integration test should confirm they survive a turn update and disappear on mission end.
Integration testing¶
Assignment: integration tests
Add Spring Boot integration tests that drive all five Mission API endpoints end to end. Include scan-completed with a DepositFoundEvent, assert its documented response, and verify that mission state remembers the discovered deposit across a turn update. Include the documented unknown-missionId behavior (404 Not Found) for every endpoint that takes a missionId, which is all of them except POST /missions.
Some checks describe behavior already implemented in Lab 1, but scan handling is new. Write the scan-state test first and include its red-green cycle in the merge request required above.
Unit tests check one class in isolation; integration tests catch issues that only appear once the controller, state service and logic are wired together as they run in production, such as a wrong request mapping or a mistake in how the controller talks to the state service.
Create an integration test the same way as a unit test (see Where do the tests go?), but name the class <Something>IT, for instance MissionApiIT, and keep it next to your unit tests in src/test/java. Run it with ./mvnw verify, or ./mvnw verify -Dit.test=MissionApiIT for just that class: plain ./mvnw test does not run *IT classes.
Spring Boot makes this easy to do in-process, without needing the real game server: @SpringBootTest with RANDOM_PORT starts the full application and an embedded web server, so a test can call your actual HTTP endpoints. Use RestTestClient to drive those calls; see Spring's testing with a running server documentation. The same API also works when bound to MockMvc for a faster controller test.
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class MissionApiIT {
@Autowired
RestTestClient restClient;
@Test
void turnUpdateForUnknownMissionReturnsNotFound() {
Mission update = MissionFixtures.aMission().withId("does-not-exist").build();
restClient.put().uri("/missions/does-not-exist")
.body(update)
.exchange()
.expectStatus().isNotFound();
}
}
Drive the full lifecycle this way: start a mission, request an action, send a scan-completed event, update the turn, and end the mission. Assert the documented status codes and 404 for each of the four endpoints that takes a missionId when it is unknown. A small helper or a JUnit @ParameterizedTest (available through the starter) can reduce repetition. For scan-completed, assert through the appropriate service or a later action request that the discovery survives the turn update and can influence the logic; after deletion, confirm the mission state is gone. To inspect an action response, chain .expectBody(Action.class).returnResult().getResponseBody() onto the exchange. This suite checks the HTTP layer; use the local game server to see how the strategy plays.
Only one annotation needed
@AutoConfigureRestTestClient (org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient) is all RestTestClient needs, no extra dependency. RestTestClient is built on RestClient (part of core spring-web), unlike the older TestRestTemplate, which needs a separate spring-boot-restclient dependency for its RestTemplateBuilder, worth knowing if you come across TestRestTemplate in older tutorials or Stack Overflow answers.
Implementing your Mission Logic¶
Assignment: Mission Logic
Implement all behavior in the minimum Mission Logic scope: deliberate movement, resource gathering/extraction and delivery, use of large-deposit scan information, and all five Mission API endpoints. Combat, looting, and orbital call-ins are not part of this lab. Every new rule must be covered by tests written first.
Before coding each rule, use the game manual to identify action preconditions and relevant priorities. Record the intended behavior in work items and cover positive and negative cases with tests.
Use the work items and failing tests to implement each rule, one at a time.
For each rule you planned:
- Run its test and confirm it fails (red). If it already passes, the test is not testing what you think it is — fix the test first.
- Write the smallest amount of logic that makes it pass (green). Resist implementing the next three rules while you're in there; they have their own tests and their own cycle.
- Clean up what you just wrote (refactor): extract a method, rename a variable, collapse a duplicated condition, then re-run the tests to confirm the behaviour didn't move.
A few things worth keeping in mind as the logic grows:
- Keep each decision reachable on its own. How you structure the decision logic is entirely your call — there is no shape this course expects. The one constraint the tests impose is that it must be possible to set up a single situation and assert on the single decision it leads to. Logic that reaches a decision through one long, unbroken block of code makes that hard: not because of its shape, but because there's no seam for a test to aim at. Whatever structure you pick, make sure each behaviour you add has one.
- Keep related work together. Keep a failing-test commit and its implementation commit in the same merge request so a reviewer can follow the sequence. One work item per feature or improvement helps planning, but related small items (such as fixtures and pipeline setup) may share a reviewed MR; they do not each require a separate branch.
- Never weaken a test to make it pass. A test you wrote earlier that starts failing because of a new rule is the safety net doing its job: either the new rule is wrong, or your old expectation was — and in that case say so explicitly in the commit message instead of quietly deleting an assertion.
- Still run it against the game. Tests tell you a rule behaves exactly as you specified it; they can't tell you it was a good rule. The local game server is where you find that out.
Logging¶
Assignment: logging
Add SLF4J logging to the Mission API controller and Mission Logic. Choose appropriate levels for mission lifecycle events, decision reasoning, and unknown-missionId cases. Set the Mission Logic package's level to DEBUG in application.yaml, and confirm the reasoning is visible.
Logging is the other thing you add while implementing. Once your Mission Logic has more than a handful of rules, "restart and watch it in the game UI" stops being a useful debugging tool: you can't tell which rule fired for a given squad member, or why, just by watching a tile move. A log line per decision is a lot cheaper than a debugger session.
Writing log statements¶
Use SLF4J (org.slf4j.Logger and LoggerFactory), already transitively available through spring-boot-starter-webmvc, no new dependency needed. It's the de facto standard for application code in a Spring Boot project: Logback, Spring Boot's default logging implementation, implements the SLF4J API natively, so coding directly against org.slf4j.Logger avoids the extra Commons Logging facade Spring Framework uses internally for its own log statements. Avoid System.out.println: it can't be filtered by level or redirected the way a logging framework's output can.
public class ColonyMissionLogic implements MissionLogic {
private static final Logger log = LoggerFactory.getLogger(ColonyMissionLogic.class);
@Override
public Action decideAction(Mission mission, ActionRequest request) {
SquadMember member = request.member();
// ... your decision logic, logging what it considered and why it settled on this ...
log.debug("{} at {}: nothing to harvest or chase -> random step", member.getId(), member.getLocation());
return moveRandomly(request);
}
}
Use {} placeholders, as above, rather than string concatenation: SLF4J defers formatting the message when DEBUG is off. Java still evaluates method arguments first, so avoid expensive calls inside log.debug(...) unless you guard them with log.isDebugEnabled().
Pick levels deliberately, not just because they exist:
DEBUGfor the reasoning behind a decision: which rule fired, and why, for one squad member's turn. This is what you turn on when a specific member is doing something you don't expect.INFOfor mission lifecycle events: a mission starting or ending, a new turn beginning. These happen at a rate you can actually read.WARNfor the unknown-missionIdcases that already return a 404: they mean the game backend and your bookkeeping have drifted apart, worth noticing even though the response code already handles it correctly.
Logging every squad member's decision at INFO defeats the purpose: with decision requests for the squad hitting the logic-service every few seconds, the one INFO line you actually want to see (a mission starting) drowns in per-member noise.
Setting log levels per package¶
Spring Boot's own recommendation for most cases is the simplest one: set logging.level.<package> in application.yaml, no extra dependency or configuration file needed.
This turns on DEBUG, and with it your rule-tracing, for just the Mission Logic package, while everything else keeps its default level (INFO). Scope a level to the narrowest package that covers what you're debugging rather than raising the root logger, or you'll drown your own output in framework-level noise from Spring itself.
A level set this way applies from startup and stays fixed until you edit the file and restart the application: you decide up front which packages to watch, rather than changing it while the application is running.
Changing levels at runtime, later
Spring Boot Actuator exposes a /actuator/loggers endpoint that gets and sets a logger's level over HTTP without restarting the application, useful once you're deploying to Kubernetes and want to inspect a live container for thirty seconds instead of redeploying it with a different config. We'll introduce Actuator, and this endpoint, together with the rest of observability (health, metrics) in a later lab. application.yaml is enough for now.
GitLab CI/CD pipeline¶
Assignment: CI pipeline
Configure a GitLab pipeline with two stages: verify and publish.
- The
verifyjob runs./mvnw --batch-mode verifyon merge-request pipelines, branch pushes, and Git-tag pipelines. Configure Maven Failsafe so*IT.javaintegration tests run as part ofverify. Caching Maven dependencies is optional. - The
publishjob runs only after verification succeeds, and uses Jib to publish an image. A merge-request pipeline and a push to the default branch publishcommit.<short-sha>(thecommit.prefix is required, see Image tags). A Git-tag pipeline publishes an image tagged with that Git tag, and alsolatest. No other pipeline publisheslatest, and a plain push to a feature branch only runsverify. - Protect
main, require a passing pipeline and teammate approval, and verify that a failing merge-request pipeline blocks the merge.
In your Checkpoint 1 Report, link the pipeline for your final tag.
Primer: how GitLab CI/CD works¶
Continuous integration (CI) means that every change is built and tested automatically, by a machine, the moment it is pushed, so a broken change shows up within minutes instead of at the end of the week. Continuous delivery (CD) extends that to producing something you could release from every verified change; here that something is a container image, and deploying it follows in later labs.
In GitLab all of this is described in one file at the root of your repository, .gitlab-ci.yml. GitLab reads it whenever something happens in your project (a push, a merge request, a new tag) and starts a pipeline for it:
The vocabulary you need:
- Pipeline: one run of
.gitlab-ci.yml, for one trigger. - Stage: a group of jobs. Stages run one after the other, and a later stage only starts when the previous one succeeded. That is what lets
publishwait forverify. - Job: the unit of work: a name, the stage it belongs to and a
scriptof shell commands. Jobs in the same stage run in parallel. A job that fails (a command exits with a non-zero code) fails its stage and, by default, stops the pipeline. - Runner: the machine that actually executes jobs; it is not your laptop. For each job the runner starts a fresh container from the
image:you specify, checks out your commit in it, runs the script and reports success or failure back to GitLab. - Cache and artifacts: that container is thrown away after the job, so nothing survives unless you keep it on purpose. A cache stores files you want to reuse in later pipelines, such as downloaded dependencies. It is a best-effort speed-up, not a guarantee. Artifacts carry a job's output to later jobs or make it downloadable.
- Rules and variables: the same commit can cause different pipelines depending on what triggered it (a branch push, a merge request, a tag).
workflow: rulesdecides which pipelines are created at all andrulesdecides which jobs run in them, based on predefined variables that GitLab fills in, such asCI_COMMIT_BRANCH,CI_COMMIT_TAGorCI_REGISTRY_IMAGE. - Container registry: every GitLab project has one, under Deploy > Container registry. This is where the images you publish end up.
GitLab's own CI/CD documentation covers all of this in detail; the CI/CD YAML reference and Predefined CI/CD variables are the pages you will keep coming back to.
Our starter pipeline will be limited to two responsibilities for now: verify whether a change is safe to merge, then publish an image only after verification succeeds. Keep these responsibilities distinct. Building an image is part of publishing it in this lab, so there is no need for a separate build stage followed by a package stage that builds the same image again.
1. Verify the change¶
Maven's Surefire plugin runs unit-test classes such as *Test.java during test. Configure the Maven Failsafe Plugin to run integration tests named *IT.java in the integration-test and verify phases. The Spring Boot parent manages the plugin version, but you still need to configure its executions. Follow the official Failsafe usage guide and Maven lifecycle guide, then confirm ./mvnw verify runs both kinds of tests locally.
A pipeline should never be the first place you try something. Everything a job does is a command you can run on your own machine, and if ./mvnw --batch-mode verify fails locally it will fail in CI as well (--batch-mode only switches off colours and progress bars, which clutter job logs). Get the command working locally first, then put it in a job.
Start with this small skeleton. It runs tests but does not yet include publishing or rules for which pipeline types to create:
stages:
- verify
- publish
verify:
stage: verify
image: eclipse-temurin:25.0.4.1_1-jdk-noble
script:
- ./mvnw --batch-mode verify
Caching Maven dependencies is optional. If you choose to add a cache, use GitLab's cache documentation. Compare two verify job runs before and after caching: does the second cached run restore the cache, and does it spend less time downloading dependencies? Decide whether the key should be branch-specific or shared based on pom.xml, and be able to explain the trade-off. Do not delay the required MR checks or Jib publishing for this experiment.
The cache is saved, but the second run is no faster
GitLab can only cache paths inside your project directory: the caching documentation states that caches define their paths relative to the project directory and cannot link to files outside it. Check where Maven keeps its local repository by default, and look up how to move it (the property is called maven.repo.local).
Use GitLab's CI/CD YAML reference and merge request pipeline documentation to ensure the required verification job runs in merge requests as well as branch pipelines. Decide how workflow: rules should avoid duplicate pipelines without accidentally omitting the MR check: GitLab describes the problem under Avoid duplicate pipelines, and the quickest way to understand it is to push a branch, open a merge request and count the pipelines GitLab starts.
Once verify has passed on main and in a merge-request pipeline, immediately enable branch protection, the pipeline merge check and the teammate approval rule described above. Then test the protection: use a branch with a deliberately failing test, open an MR, and confirm GitLab prevents merge until the check passes. Remove the deliberate failure afterward. Do this before merging the remaining features or adding publishing. These settings block a merge through the configured workflow when a required job fails or is missing; they cannot prove that passing code is defect-free.
2. Publish verified images¶
Why Jib, and not Docker?¶
The usual way to build an image is to write a Dockerfile and run docker build. That needs a Docker daemon, and a CI job already runs inside a container on a runner. Running Docker commands in such a job takes special runner configuration, as GitLab's page on building Docker images in CI/CD explains. The common approaches all have a price: Docker-in-Docker needs the runner to run privileged containers, and sharing the host's Docker socket is, in GitLab's words, a way to "effectively disable the container's security mechanisms and expose your host to privilege escalation". Because every job also starts from a clean slate, layers that an earlier job built are not there to reuse.
There are daemonless builders for ordinary Dockerfiles (the same page lists BuildKit and Buildah), and for Java applications there is a simpler route: Jib. It knows your Maven project, so it needs neither a Dockerfile nor a daemon: it assembles the image layers itself and pushes them straight to the registry. The trade-off is that Jib is specific to JVM build tools and gives you less freedom than a Dockerfile, for instance for installing operating-system packages. For a Spring Boot service whose image is "a Java runtime plus our own layers" that is a good deal; for anything else you would reach for a Dockerfile with a daemonless builder.
Using Jib¶
Jib builds a container image straight from your Maven project. It puts dependencies, resources and your own classes in separate layers, so when only your code changed, only that small layer has to be pushed again. The jib:build goal pushes the image directly to a registry, which is what a CI job needs; jib:buildTar and jib:dockerBuild produce the image locally instead, handy for trying out your configuration before the pipeline does it for you. Read the Jib Maven plugin documentation, in particular the Quickstart and the from and to entries under Extended Usage, and select a Java 25-compatible release on Maven Central (the course material was tested with 3.5.2).
Three practical points before you write the job:
- Jib packages what is already in
target/classes; it does not compile for you. Run it together withcompile(orpackage), as in./mvnw compile jib:build. On a clean checkout,./mvnw jib:buildalone fails with a message telling you exactly that. - The registry location and the credentials come from GitLab's predefined CI/CD variables (
CI_REGISTRY_IMAGE,CI_REGISTRY_USER,CI_REGISTRY_PASSWORD), which you hand to Jib through itstoconfiguration properties (see the Jib documentation for the property names). Refer to the variables, never paste their values into a file. - Use GitLab job rules to decide in which pipelines the job runs (merge request, default branch, Git tag) and keep them explicit, so no other pipeline can publish an image,
latestleast of all.
One setting is worth adding to the plugin from the start. By default Jib stamps every image with the epoch (1 January 1970) as its creation time, so that identical input always produces an identical image. A side effect is that every image, locally in docker images and in your registry, looks decades old, which makes it hard to see whether a build really produced something new. For this course, ask Jib to use the time of the build instead, in the <configuration> of the jib-maven-plugin entry in your pom.xml (see the container entry in the Jib documentation):
<configuration>
<container>
<creationTime>USE_CURRENT_TIMESTAMP</creationTime>
</container>
</configuration>
The price is that two builds of the same commit are no longer byte-for-byte identical. For this lab that does not matter.
Try it locally first (required)¶
Before adding the publish job, build and run a Jib image locally to check what it produces. You do not need a registry or a pipeline: Jib can build into the Docker daemon on your own machine, the same one you used for the game server in Lab 1. Add the Jib plugin to your pom.xml (the documentation linked above shows how), then:
./mvnw compile jib:dockerBuild # build the image into your local Docker
docker images logic-service # it is called <artifactId>:<version>
docker run --rm -p 8080:8080 logic-service:0.0.1-SNAPSHOT
Stop your IDE run of the application first, as both want port 8080. The container publishes the same port, so the game server from Lab 1 can still reach it and you can watch your containerised service play a mission. The CREATED column of docker images should show a few seconds ago; if it shows decades, check the creation time setting above.
If you have no Docker daemon, ./mvnw compile jib:buildTar writes target/jib-image.tar instead, which docker load --input target/jib-image.tar can import elsewhere. At minimum, confirm that the tar build succeeds and inspect the Jib build log.
Now experiment with tags, which is what the pipeline will do with its registry address:
./mvnw compile jib:dockerBuild -Djib.to.image=logic-service:commit.abc1234 -Djib.to.tags=latest
docker images logic-service
Compare the image ids of the two tags in the listing: one build, one image, two names. Then try the same command with -Djib.to.image=logic-service (no tag), and with -Djib.to.tags= (empty). What happens, and what would that mean in a pipeline? The pipeline job uses the same flags with jib:build, which pushes to the registry instead of your local Docker, plus the registry address and credentials from the next section.
Base image¶
Jib adds only your application layers; the operating system and the Java runtime come from a base image. Unless you configure from.image, Jib chooses eclipse-temurin:<n>-jre for the Java version your project is built with (see the from entry in the Jib documentation), which for this course is eclipse-temurin:25-jre. The build log tells you which one it used: look for Using base image with digest.
The default is fine for this lab, but it is worth knowing what you are building on:
- JRE, not JDK. The runtime does not need a compiler, so the image is smaller and has less in it to patch and attack.
- A tag is a moving target.
25-jreis rebuilt by its publisher when security fixes land. Jib warns that the base image is not pinned to a digest: you get those fixes automatically on your next build, but two builds of the same commit can end up with different base layers. Pinning a digest (eclipse-temurin:25-jre@sha256:...) trades that for reproducibility, at the price of requiring you to update the tag yourself. - When you would override it: to pin a digest, to use another distribution, or to use a hardened image your organisation provides, via
from.imagein the plugin configuration.
Image tags¶
An image is addressed as <repository>:<tag>. Each pipeline that passes verification publishes the tags below; Jib builds the image once and pushes it under every tag you give it (to.image for the first, to.tags for the additional ones).
| Tag | Published by | Purpose |
|---|---|---|
commit.<short-sha> |
merge-request pipelines and pushes to the default branch | Identifies exactly which source revision produced the image. Ephemeral: expect many of them. |
the Git tag, e.g. Checkpoint1 |
Git-tag pipelines | A deliberate release with a stable, human-readable name. |
latest |
Git-tag pipelines (together with the Git tag) | Moving pointer to the most recent release. It never moves on an ordinary push, not even to main. |
A plain push to a feature branch only runs verify, which is quick feedback while you are still working. Opening a merge request is how a developer signals "this is ready to be looked at", and that is when the pipeline does the rest of its work, including publishing an image that a reviewer could run.
The commit. prefix is not decoration. Every merge-request push and every commit on main produces a tag, so the registry fills up quickly, and the next lab introduces a cleanup policy that removes old tags automatically. Giving all ephemeral tags one recognisable prefix lets that policy match commit.* while latest and your release tags can never match it, so they are kept. (A bare SHA cannot be told apart from a release name by a pattern.) A tag may contain letters, digits, _, . and -, so commit.<short-sha> is valid. Never publish any of these tags from a failed or unverified pipeline.
The publish job should wait for the verify stage to pass. Choose one job that builds the image once and gives it the tags that fit the pipeline it runs in, without repeating the same image build unnecessarily.
Pipeline decisions¶
Use the official documentation above to make these decisions yourself rather than pasting a full pipeline recipe, and be able to explain them:
- Which
workflowor job rules create verification pipelines for merge requests, branch pushes, and Git tags without creating confusing duplicates? - If you add an optional Maven cache, what should its key include, and what should happen when dependencies change?
- How will the publish job pick the right tags for each kind of pipeline (merge request, default branch, Git tag), including
latestonly for a Git tag, without rebuilding the same image unnecessarily? - Which job must be successful before
maincan be updated, and how will GitLab enforce that?
Practicalities¶
Assignment: Checkpoint 1 report and hand-in
Before Tuesday, 13 October 2026 at 23:59, create a work item titled Checkpoint 1 Report, labeled Report and Checkpoint 1. Create those labels if they do not already exist. Use the report template below as its description and fill in every section. Merge the final commit, create and push an annotated Checkpoint1 tag with a fitting message, wait for its verification and publication pipeline, then link that pipeline in the report before the deadline.
This checkpoint must be completed before Tuesday, 13 October 2026 at 23:59.
Warning
All support outside of the physical Lab session is handled via GitLab work items (Plan > Work items). Learn more about the issue tracker and how to request support here. Do not expect support outside of the normal office hours (Monday to Friday, from 9 AM until 5PM).
Report template¶
The report is the description of the Checkpoint 1 Report work item, and the template lists everything we look at. Paste it into the description and fill in each section. Keep answers short and specific, and link to evidence in GitLab instead of describing it.
<!-- Fill in every section. Keep answers short and specific (a few sentences or bullets) and link to evidence in GitLab instead of describing it. Delete each prompt (in italics) once it is answered. -->
## Evidence
- Milestone: _link to the `Checkpoint 1` milestone_
- Pipeline of the `Checkpoint1` tag: _link_
- Reviewed MRs: _link at least one merged MR and identify an MR reviewed by each of the three team members (the same MR may count for more than one reviewer)_
- Scan-state red-green MR: _link to the work item and MR; identify the failing-test commit and the following implementation commit_
## How we work
### Work items
- **Labels.** _Which labels do we use, and what does each one mean?_
| Label | Meaning |
| ----- | ------- |
| | |
- **Planning and picking up work.** _How did we decide what to build and in which order? What happens between "I take this work item" and "it is done"?_
### Branches and merge requests
- **Branch names.** _Which convention do we use? Give an example._
- **Keeping a branch up to date.** _Do we merge or rebase `main` into a feature branch, and why?_
- **Review and squashing.** _How many approvals do we require, and from whom? When do we squash and when do we not?_
- **Tests first.** _How do we make sure tests are written and run before each new decision rule is implemented?_
## A Mission Logic decision
_Describe one decision where more than one reasonable option existed: what you chose and why, when the rule applies and what takes precedence over it, and a link to the test that captures it._
## Challenges
_What went wrong or took longer than expected, and how did you deal with it?_
Offer the template in the work item form
GitLab can show the template in the Choose a template dropdown when you create a work item. Save it as .gitlab/issue_templates/Checkpoint1Report.md in your repository and merge it into the default branch, see description templates. Pasting it works just as well.
Checklist¶
For each new decision rule, the assessed order is plan, failing test, implementation. Start the verification pipeline early and enable merge safeguards as soon as it works for MRs.
- Create a
Checkpoint 1milestone at the start; assign all work items and merge requests for this checkpoint to it. - Create work items for the required features and improvements, with an owner and labels.
- Use GitHub Flow with feature/fix branches, protected
main, and merge requests; include at least one merged MR with teammate review. - All three team members have participated in reviews and can explain the shared pipeline and key Mission Logic decisions.
- Write tests for each new rule before its implementation, including positive and negative cases, and confirm the red result is meaningful. Link the scan-state red-green MR in the report.
- Add Spring Boot integration tests for all five Mission API endpoints, including
scan-completedretaining a discovery across a turn update and the unknown-missionId404for every endpoint that takes amissionId. - Implement all five Mission API endpoints, deliberate movement, resource gathering/extraction and drop-off, and use of large-deposit scan information. Combat, looting, and orbital call-ins are not part of this lab.
- Add SLF4J logging to your Mission API and Mission Logic, with log levels chosen deliberately, and set your Mission Logic package's level via
logging.level.<package>inapplication.yaml(see Logging). - Add a required CI verification job that runs
./mvnw --batch-mode verifyon merge-request, branch-push, and Git-tag pipelines. It must fail on test failures. Maven dependency caching is optional. - As soon as
verifyruns onmainand an MR, protectmainagainst direct pushes and require a successful pipeline and teammate approval before merging further work. Confirm a failing MR pipeline blocks merge. - Build and run a Jib image locally (or use
jib:buildTarif Docker is unavailable), then add apublishjob that runs only after verification. Publishcommit.<short-sha>from merge-request pipelines and pushes to the default branch, and the Git tag pluslatestfrom Git-tag pipelines. A plain feature-branch push only verifies. - Complete the
Checkpoint 1 Reportfrom the template. Push an annotatedCheckpoint1tag on the final merged commit, check that its pipeline succeeds and link that pipeline in the report before the deadline.
Tagging through GitLab UI: use Code > Tags > New Tag. You can also tag using git directly: git tag -a Checkpoint1 -m "Final commit for Checkpoint 1." followed by git push origin Checkpoint1. More info on tags and the difference between lightweight and annotated tags is in the Git tag documentation.