In the three previous lessons CicloUrbana reached Heroku, AWS and Kubernetes. In all three there was an implicit actor who was still human: somebody running git push heroku main, aws ecs update-service or helm upgrade from their laptop, with their personal credentials, trusting that they had run the tests beforehand and had not left anything half-committed.
That actor is the last handcrafted point in the project, and this lesson removes it. We are going to build a pipeline that, on every git push, compiles, runs the complete module 6 suite —Testcontainers included—, analyses quality, builds and scans the image, publishes it tagged with the commit SHA, deploys it to pre, passes a smoke test and, after an explicit approval, takes it to production. Nobody touches a server. And with that, the tests we wrote in module 6 stop being an exercise in personal discipline and become a gate nobody can skip.
Contents
- Continuous integration, delivery and deployment
- Why CI is what gives the tests their value
- Anatomy of a pipeline
- GitHub Actions: the concepts
- The integration workflow:
ci.yml - The delivery workflow:
cd.yml - Secrets in the pipeline
- Quality inside the pipeline
- Versioning and releasing
- Deploying to
preand toprod, and rolling back - Database migrations in the pipeline
- Making the pipeline fast and reliable
- Alternatives and DORA metrics
- Common Mistakes and Tips
- Exercises
- Continuous integration, delivery and deployment
The three terms are used as synonyms and they denote different things. The difference lies in how far the automation goes.
| Continuous integration (CI) | Continuous delivery (CD) | Continuous deployment | |
|---|---|---|---|
| What it automates | Compiling and testing every change integrated into the main branch | All of the above + leaving every change ready to deploy | All of the above + deploying to production with no intervention |
| Where it ends | A verdict: green or red | An artefact published and validated in pre |
The change in the citizens' hands |
| Who decides to deploy | Nobody: there is no deployment | A person, pressing a button | Nobody: if it is green, it ships |
| What it demands of the team | A reliable, fast test suite; integrating daily | Also: automated environments and a compatible schema | Also: total confidence, observability, automatic rollback |
| Risk if maturity is lacking | Low | Medium | High |
The practical distinction worth retaining: continuous delivery means you could deploy at any moment; continuous deployment means you always do. The difference between them is a business and confidence decision, not a technological one: it is exactly the manual approval step of section 10.
What we build for CicloUrbana: full continuous integration, continuous delivery as far as pre automatically, and deployment to prod with approval. It is the sensible choice for a municipal service in its first year. Once the pipeline has gone months without surprises and the observability of module 9 is in place, removing that approval will be a small step.
- Why CI is what gives the tests their value
In module 6 we wrote a considerable suite: unit tests for Ribalta's fares, Mockito doubles, @WebMvcTest and @DataJpaTest slices, the access matrix with spring-security-test and *IT tests against real PostgreSQL 16 with Testcontainers. All of that runs with ./mvnw verify.
The problem is that ./mvnw verify is run by whoever remembers to. And the familiar patterns show up: somebody is in a hurry and pushes without running them; somebody runs them but only for the module they touched; a test has been failing for two weeks and the team has normalised the red; a test passes on the laptop of whoever wrote it because they have a row in their local database that nobody else has.
CI cuts all four off at the root:
| Without CI | With CI |
|---|---|
| The tests are run by whoever feels like it | They always run, on every push and every pull request |
| In each person's environment | In a clean, reproducible environment |
| The result is known to whoever launched them | The result is public and blocks the merge |
| A broken test can live on for weeks | The main branch does not accept a change that is red |
| "It works on my machine" | If it does not pass in CI, it does not pass |
That last point is the underlying cultural change: CI is the referee. And there is one technical detail that is decisive for us: the GitHub Actions runner already has Docker installed and running, so the Testcontainers of 06-05 work with no configuration at all. The integration tests against real PostgreSQL, which were the most valuable piece of module 6, are also the ones CI runs with the greatest fidelity.
- Anatomy of a pipeline
flowchart TD
A[git push / pull request] --> B[checkout]
B --> C[Maven dependency cache]
C --> D[compile]
D --> E[unit tests · Surefire]
E --> F[static analysis · Sonar/SpotBugs]
F --> G[integration tests · Failsafe + Testcontainers]
G --> H[coverage · jacoco:check]
H --> I[build image]
I --> J[scan image · Trivy]
J --> K[publish to the registry]
K --> L[deploy to pre]
L --> M[smoke tests]
M --> N{approval}
N -->|yes| O[deploy to prod]
N -->|no| P[end]
O --> Q[verify and watch]
Two principles govern that order. The first: the fast things and the things that fail most, first. Compiling takes seconds and catches the most common error; integration tests take minutes. Putting the slow work at the start makes a silly mistake cost ten minutes instead of thirty.
The second: every stage is a gate. If one fails, the following ones do not run. A pipeline that carries on "because it was only the static analysis" is not a gate, it is an ornament.
- GitHub Actions: the concepts
| Concept | What it is | In CicloUrbana |
|---|---|---|
| Workflow | A YAML file in .github/workflows/ with a complete process |
ci.yml and cd.yml |
| Trigger | The event that fires it | push, pull_request, workflow_dispatch, release |
| Job | A set of steps that run on the same machine | verify, image, deploy-pre |
| Step | A reusable action or a shell command | actions/checkout, ./mvnw verify |
| Runner | The machine that runs the job | ubuntu-latest, with Docker already available |
| Matrix | Repeating a job with combinations of parameters | Testing with Java 21 and 25 |
| Artifact | A file one job produces and another consumes | The JAR, the test reports |
| Secret | Encrypted value injected at run time | Registry and cluster credentials |
| Environment | A target with its own rules: approval, branches, secrets | pre and prod |
Two important properties: every job starts on a clean machine (hence the need for caches and artifacts to pass things between jobs), and jobs run in parallel unless needs: is declared, which is what enforces the order of the gates.
- The integration workflow:
ci.yml
ci.yml# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main] # every integration into the main branch
pull_request:
branches: [main] # and every proposed change, before merging
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true # if two pushes arrive in a row, cancel the previous one
permissions:
contents: read # least privilege: only read the code
checks: write # and publish the test report
jobs:
verify:
name: Compile, test and analyse
runs-on: ubuntu-latest
timeout-minutes: 25 # no run may hang indefinitely
steps:
- name: Check out the code
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history: Sonar and git-commit-id need it
- name: Set up Java 21 with a Maven cache
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven # caches ~/.m2/repository keyed on the pom.xml hash
- name: Check the code formatting
run: ./mvnw -B spotless:check
- name: Compile, test and package
run: ./mvnw -B verify
env:
TESTCONTAINERS_RYUK_DISABLED: 'false'
SPRING_PROFILES_ACTIVE: test
- name: Publish the test report
if: always() # also when the tests fail: that is when it matters most
uses: mikepenz/action-junit-report@v4
with:
report_paths: '**/target/*-reports/TEST-*.xml'
- name: Publish the JaCoCo coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: jacoco-coverage
path: target/site/jacoco/
retention-days: 14
- name: Save the JAR for the following workflows
uses: actions/upload-artifact@v4
with:
name: ciclourbana-jar
path: target/ciclourbana.jar
retention-days: 7Line by line, what you need to understand:
on: pushandpull_requestagainstmainis the operational definition of continuous integration: you verify what is proposed for integration and what has already been integrated.concurrencywithcancel-in-progressavoids spending runner minutes verifying a commit that has already been superseded: on an active repository it easily saves a third of the consumption. Andtimeout-minutes: 25stops a hung process from consuming hours.- Explicit
permissions. By default theGITHUB_TOKENmay have broad permissions; declaring them at a minimum stops a compromised third-party action from writing to the repository. fetch-depth: 0. By defaultcheckoutfetches a single commit; Sonar needs the history to attribute new lines, and thegit-commit-idplugin that feeds the/actuator/infoof 07-01 needs the Git data.cache: mavenis the setting with the best effort-to-benefit ratio: without it, every run downloads the whole dependency tree again —Spring Boot, Hibernate, Spring Security, MapStruct, the drivers— and adds two or three minutes to every build../mvnw -B verifyis the heart of the workflow.-B(batch) disables colours and progress bars, which in a CI log only generate noise. Andverify, nottest:testonly runs Surefire (the*Testclasses), whereasverifyalso runs Failsafe (the*ITclasses), which are the Testcontainers ones from 06-05. Usingtestin CI would mean leaving out precisely the tests that most resemble production.- Testcontainers work with no configuration because the
ubuntu-latestrunner comes with Docker installed and the daemon running. The first run pulls thepostgres:16-alpineimage (about 15 seconds) and from then on the*ITtests run against real PostgreSQL. if: always()on the reports is subtle and decisive: without it, a failedverifyaborts the job and the report is not published, which is exactly what you need in order to know what failed. Withalways(), the JUnit report appears annotated on the pull request, line by line.
When container services are needed. GitHub Actions lets you declare auxiliary services in a job:
services:
postgres:
image: postgres:16-alpine
env: { POSTGRES_PASSWORD: test, POSTGRES_DB: ciclourbana }
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s --health-retries 5With Testcontainers this is not needed, and it is important to understand why: Testcontainers starts and stops the container from the test code itself, with the same configuration in CI and on the developer's laptop — which is exactly the environment parity we were after. services make sense when the tests do not use Testcontainers, or for dependencies that cannot be brought up from Java.
And the gate. A workflow that reports but does not block is of no use. In Settings → Branches → Branch protection rules you have to require the verify check to pass before a merge into main is possible. Without that configuration, the pipeline is a report; with it, it is a guarantee.
- The delivery workflow:
cd.yml
cd.yml# .github/workflows/cd.yml
name: CD
on:
push:
tags: ['v*'] # v2.4.1 triggers the delivery
workflow_dispatch: # and it can be launched by hand from the interface
permissions:
contents: read
packages: write # publish to GHCR
id-token: write # OIDC: temporary credentials, no stored keys
env:
REGISTRY: ghcr.io
IMAGE: ${{ github.repository_owner }}/ciclourbana
jobs:
image:
name: Build, scan and publish the image
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- name: Package without repeating the tests
run: ./mvnw -B -DskipTests package
# The tests already passed in ci.yml on this very commit.
- name: Compute tags and metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Authenticate against the registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # temporary, generated for this run
- name: Build and publish
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64 # essential for Fargate (08-03)
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan the image
uses: aquasecurity/[email protected]
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
severity: 'HIGH,CRITICAL'
exit-code: '1' # a serious vulnerability STOPS the delivery
deploy-pre:
needs: image
runs-on: ubuntu-latest
environment:
name: pre
url: https://pre.ciclourbana.ribalta.example
steps:
- uses: actions/checkout@v4
- name: Deploy to pre
run: |
helm upgrade --install ciclourbana ./charts/ciclourbana \
-n ciclourbana-pre -f values-pre.yaml \
--set image.tag=${{ needs.image.outputs.tag }} \
--atomic --timeout 8m
- name: Smoke test
run: |
curl -fsS --retry 10 --retry-delay 6 --retry-all-errors \
https://pre.ciclourbana.ribalta.example/actuator/health/readiness
curl -fsS https://pre.ciclourbana.ribalta.example/api/v1/stations | jq -e 'length == 4'
deploy-prod:
needs: [image, deploy-pre]
runs-on: ubuntu-latest
environment:
name: prod # PROTECTED environment: requires manual approval
url: https://ciclourbana.ribalta.example
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
helm upgrade ciclourbana ./charts/ciclourbana \
-n ciclourbana-prod -f values-prod.yaml \
--set image.tag=${{ needs.image.outputs.tag }} \
--atomic --timeout 10m
- name: Verify the deployed version
run: |
curl -fsS https://ciclourbana.ribalta.example/actuator/info \
| jq -e '.build.version == "${{ needs.image.outputs.tag }}"'The underlying decisions in this file:
It is triggered by a tag, not by every push. v2.4.1 is a deliberate act: somebody decides that this commit is a release. It is the boundary between continuous integration (every change) and delivery (releases).
The tests are not repeated. -DskipTests is not a cheat: ci.yml already ran them on this very commit. Repeating them would double the time without adding information.
Triple tagging. docker/metadata-action produces 2.4.1 (the readable semantic version), sha-9f3a2b1 (the exact, unambiguous commit identifier) and latest all at once. It is what makes it possible to answer with certainty the question "what code is running in Ribalta?": you compare the SHA from /actuator/info with the one in the repository.
environment: prod is the manual approval. By configuring that environment in GitHub with required reviewers, the job stops and waits for an authorised person to approve it in the interface. It is the exact line that separates continuous delivery from continuous deployment: removing the environment's protection turns one into the other.
The scanner stops the delivery. exit-code: '1' with severity HIGH,CRITICAL means that a serious vulnerability in the image prevents publication, linking up with the hardening of 07-04 and with OWASP Dependency-Check from 05-05.
The smoke test with --retry. After a helm upgrade, the pods take a while to be ready; without retries, the curl would fail simply by arriving too early. And the jq -e 'length == 4' checks something real —Ribalta's four stations— and not just that the process responds.
- Secrets in the pipeline
The pipeline needs credentials to publish images and deploy. It is, by definition, a system with elevated permissions and therefore a target.
| Mechanism | What it is | Assessment |
|---|---|---|
| Repository secret | An encrypted value in secrets.NAME |
Acceptable; it is a long-lived credential that has to be rotated |
| Environment secret | The same, but bound to pre or prod |
Better: production credentials only exist in the production job |
GITHUB_TOKEN |
A token generated for each run and revoked when it finishes | Ideal for GHCR and the repository itself |
| OIDC | GitHub issues a signed token that the provider exchanges for temporary credentials | The best option: there is no stored key at all |
OIDC with AWS, which eliminates long-lived keys entirely:
- name: Temporary AWS credentials with no stored keys
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::111122223333:role/ciclourbanaDeployRole
aws-region: eu-west-1There is no AWS_ACCESS_KEY_ID anywhere. GitHub issues a signed OIDC token that identifies the repository, the workflow and the branch; AWS validates it against a trust relationship and returns credentials that expire in an hour. And the trust policy must restrict which repository and which branch can assume the role:
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:sub":
"repo:ribalta-council/ciclourbana:environment:prod" }
}Without that condition, any GitHub repository could assume the role. It is OIDC's most serious and most frequent configuration mistake.
Prominent warning: never print a secret in the logs. GitHub masks values registered as secrets, but the masking breaks easily: if the secret is transformed (base64-encoded, trimmed, concatenated), the derived value is not masked. A forgotten debugging echo, a set -x in a script or a tool that dumps its configuration can leave a credential in a log that may well be public. And if it happens, the only correct response is to rotate the secret: deleting the log is not enough, because it may have been read or replicated.
Three more rules: least privilege —the deployment role can update the service, not delete the database—; explicit permissions in every workflow; and pinning third-party actions by SHA (uses: acme/action@a1b2c3d) rather than by a moving tag, because a tag can be repointed at malicious code.
- Quality inside the pipeline
The pipeline is the only place where a quality rule is always enforced. What is worth putting in, and in what order:
| Check | Tool | When it runs | Does it break the build? |
|---|---|---|---|
| Formatting and likely errors | Spotless/Checkstyle; SpotBugs/Error Prone | Before and after compiling | Yes: it is objective and trivial to fix |
| Quality and technical debt | SonarQube / SonarCloud | After the tests | Yes, through a quality gate on new code |
| Coverage | jacoco:check |
During verify |
Yes, with a realistic threshold |
| Vulnerable dependencies | OWASP Dependency-Check | Nightly + on main |
Yes for CRITICAL |
| Dependency updates | Dependabot | Scheduled | No: it opens pull requests |
| Image vulnerabilities | Trivy, docker scout |
After building the image | Yes for HIGH/CRITICAL |
The coverage threshold from 06-01, now as a real gate:
<execution>
<id>check-coverage</id>
<goals><goal>check</goal></goals>
<configuration><rules><rule><element>BUNDLE</element><limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.70</minimum></limit>
<limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.60</minimum></limit>
</limits></rule></rules></configuration>
</execution>And the warning already made in 06-01, more important here because it is now mandatory: a threshold that is too high produces junk tests. If reaching 90 % is required in order to merge, somebody will write tests with no assertions that exercise code just to push the percentage up. A threshold of 70 % on lines is demanding and honest. And the most useful criterion is not overall coverage but the coverage of new code: that is what Sonar does with its quality gate, and it stops a legacy codebase with low coverage from blocking any progress.
Vulnerable dependencies, picking up from 05-05:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- name: OWASP Dependency-Check
run: ./mvnw -B org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=9-DfailBuildOnCVSS=9 breaks the build only for critical vulnerabilities. A low threshold generates so many false positives that the team learns to ignore them, and an ignored check is worse than no check at all. This analysis is best run in a separate job scheduled at night, because it downloads the vulnerability database and can take several minutes. And Dependabot (.github/dependabot.yml) complements it by opening pull requests that update versions: since each one goes through ci.yml, upgrading Spring Boot stops being a leap of faith.
- Versioning and releasing
Semantic versioning (MAJOR.MINOR.PATCH) applied to CicloUrbana:
| Change | Version | Example in CicloUrbana |
|---|---|---|
| Compatible fix (PATCH) | 2.4.0 → 2.4.1 |
Fixing the rounding of a fare |
| Compatible feature (MINOR) | 2.4.1 → 2.5.0 |
A new incidents endpoint |
| Breaking change (MAJOR) | 2.5.0 → 3.0.0 |
Removing a field from the public response |
For an API with external clients —Ribalta's mobile app— the MAJOR part is a contract: a breaking change forces you to version the API (/api/v2/...) and to keep the previous one for a grace period.
./mvnw versions:set -DnewVersion=2.4.1 -DgenerateBackupPoms=false
git commit -am "Version 2.4.1"
git tag -a v2.4.1 -m "Fare rounding fix"
git push origin main --follow-tags # the tag triggers cd.ymlAnd the circle closes with 07-01: Spring Boot's build-info plugin and git-commit-id record the version, the SHA and the date inside the artefact, and Actuator exposes them:
curl -s https://ciclourbana.ribalta.example/actuator/info | jq '.build, .git.commit.id.abbrev'
# { "version": "2.4.1", "time": "2026-09-01T09:14:22Z" }
# "9f3a2b1"That SHA is the definitive answer to "what is deployed?", and it makes the deployment verifiable rather than merely believable. The release notes, generated from the commit messages since the previous tag, complete the trail: somebody investigating an incident can go from the SHA the server reports to the exact list of changes that went in.
- Deploying to
pre and to prod, and rolling back
pre and to prod, and rolling backThe workflow in section 6 implements the policy: pre automatic, prod with approval. What the person approving must be able to see before pressing: that CI is green, that pre has been running that version for a while, that the smoke test passed and which changes are going in.
The strategies from 08-01, now automated:
| Strategy | How it is implemented in the pipeline |
|---|---|
| Rolling | helm upgrade with maxUnavailable: 0 (08-04) or minimumHealthyPercent=100 on ECS (08-03) |
| Blue-green | Two services and a step that switches the load balancer's target |
| Canary | A deployment with a traffic weight and a pause that measures metrics before continuing |
The rollback, which is the part most often neglected, has three levels depending on the severity:
# 1. Automatic, if the deployment does not converge: --atomic already does it
# 2. Manual and immediate: go back to the previous image tag
helm rollback ciclourbana -n ciclourbana-prod
aws ecs update-service --cluster ciclourbana --service ciclourbana-web \
--task-definition ciclourbana:41 --force-new-deployment
# 3. Through the pipeline: relaunch cd.yml with the previous tag
gh workflow run cd.yml -f version=2.4.0And the underlying condition, repeated for the third time in the module because it is the one that ruins the most deployments: going back to the previous image only works if the database schema is still compatible with it.
- Database migrations in the pipeline
The migration is the only step in the pipeline that cannot be undone. Everything else —the image, the configuration, the number of replicas— goes back with one command; an applied migration stays applied.
Hence the rule that governs the whole module: the expand/contract pattern from 04-08 is the condition for being able to roll back the application without rolling back the schema.
flowchart LR
subgraph D1["Deployment 1 · expand"]
A1[Migration: ADD the new column] --> A2[Code: writes to both]
end
subgraph D2["Deployment 2 · switch the read"]
B1[No migration] --> B2[Code: reads and writes only the new one]
end
subgraph D3["Deployment 3 · contract"]
C1[Migration: DROP the old column] --> C2[Code: unchanged]
end
D1 --> D2 --> D3
In each of the three deployments, the previous version of the application still works against the resulting schema. That property is what makes helm rollback a safe operation rather than a gamble.
How it translates into the pipeline:
- A step of its own for the migration, before touching the live instances: Helm's
pre-upgradehook (08-04), the one-off ECS task (08-03) or Heroku's release phase (08-02). If it fails, the deployment stops and the previous version carries on serving. - An automatic check in the pull request that rejects migrations with
DROP COLUMN,RENAMEor incompatible type changes when they arrive alongside the code that uses them: ten lines ofgrepthat prevent an incident. ddl-auto: validatein every environment (04-08): an instance whose schema does not match fails at startup, clearly and immediately, instead of failing query by query.- Verification in
prewith the previous version before approving production: it is the direct proof that a rollback will be possible.
- Making the pipeline fast and reliable
A slow pipeline gets avoided, and a pipeline that fails for no reason gets ignored. Both pathologies have the same ending: the team stops trusting it and goes back to deploying by hand.
| Goal | How to achieve it |
|---|---|
| CI in under 10 minutes | Maven cache, parallel jobs, -DskipTests in the workflows that do not test |
| Early feedback | Formatting and compilation first; integration afterwards |
| Parallelise | Independent jobs with no needs: run at the same time: tests, analysis, security |
| Effective caches | cache: maven and cache-from/to: type=gha for the Docker layers |
| No flaky tests | An absolute ban: a test that fails one time in twenty is fixed or deleted |
| Fail fast | timeout-minutes on every job and cancel-in-progress |
On flaky tests you have to be blunt. A test that sometimes fails teaches the team to relaunch the run instead of reading the error, and that habit destroys the value of the whole pipeline: the day it fails for real, somebody will press "retry". The usual causes are well known —timing dependencies (Thread.sleep instead of waiting for a condition), shared state between tests, an assumed execution order, and dates and time zones— and when fixing it will take time, the correct answer is @Disabled with a link to the issue, not leaving it failing.
Target times: CI under 10 minutes and the complete delivery as far as pre under 20. Above that, people start batching changes "so as not to spend a run", and batching changes is precisely the opposite of continuous integration.
- Alternatives and DORA metrics
| Tool | Model | Notes |
|---|---|---|
| GitHub Actions | Hosted, YAML | Integrated with the repository; a huge catalogue of actions |
| GitLab CI | Hosted or self-hosted, .gitlab-ci.yml |
Very complete, with environments and a registry included |
| Jenkins | Self-hosted, Jenkinsfile |
Maximum flexibility; you have to maintain it |
| CircleCI / Azure DevOps | Hosted | Fast and with good caching; the second is strong in Microsoft environments |
| Tekton / Argo Workflows | On Kubernetes | Cluster-native; they fit with GitOps (08-04) |
The concepts are the same in all of them: triggers, stages, steps, artifacts, caches, secrets, approvals and environments. The syntax changes, not the design; what you learned with ci.yml and cd.yml transfers to any of them in an afternoon.
And what to look at after deploying. The DORA metrics are the standard framework for measuring delivery health:
| Metric | What it measures | Healthy team benchmark |
|---|---|---|
| Deployment frequency | How often a change reaches production | At least weekly; the best, daily |
| Lead time for changes | From commit to production | Less than a day |
| Change failure rate | What percentage of deployments causes an incident | Below 15 % |
| Time to restore service | How long it takes to recover | Less than an hour |
What is valuable about these four is that they balance each other out: deploying a lot but breaking things constantly shows up badly in the third; never deploying "out of prudence" shows up badly in the first two and, paradoxically, in the fourth as well, because a large, rare deployment is far harder to roll back than a small, frequent one. The industry's counter-intuitive conclusion is solid: deploying more often makes the system more stable, not less, because each deployment is small, comprehensible and easy to undo.
And the immediate signals in the ten minutes after a deployment are those of 08-01: readiness green on every instance, zero restarts, stable 5xx rate and latency, no new exceptions in the log and /actuator/info showing the expected SHA. Measuring them properly is the subject of module 9.
Common Mistakes and Tips
Using mvn test instead of mvn verify. It only runs Surefire and leaves out the Testcontainers *IT tests, which are the most valuable ones. The pipeline goes green without ever having tested against real PostgreSQL.
Publishing the report without if: always(). When the tests fail —the only moment the report matters— the step does not run and there is nothing to read.
Not protecting the main branch. Without branch protection, CI reports but does not prevent a red merge: it is an expensive ornament.
Printing secrets in the log. A debugging echo or a transformation of the value breaks the masking; if it happens, rotate the secret, because deleting the log is not enough. And OIDC without a sub condition leaves a role assumable from any GitHub repository.
Recompiling in the delivery workflow. If cd.yml rebuilds from scratch, what gets deployed is not exactly what was tested. Reuse the artefact or build once from the same commit.
Living with flaky tests. They teach the team to press "retry" and destroy trust in the whole pipeline.
An unrealistic coverage threshold. A mandatory 90 % produces tests with no assertions. Measure the coverage of new code.
Tip: the pipeline is code. It is reviewed in a pull request, commented on and refactored. A three-hundred-line ci.yml with duplicated steps has the same problem as a three-hundred-line class.
Tip: use act or a test branch to iterate. Debugging a workflow through git push is slow and fills the history with "testing CI" commits: use a disposable branch and workflow_dispatch. And pin third-party actions by SHA, because a tag like @v4 can be repointed and a SHA cannot.
Exercises
Exercise 1
Write CicloUrbana's ci.yml workflow with two parallel jobs: one that compiles and runs the unit tests (Surefire) and another that runs the integration tests with Testcontainers (Failsafe), plus a third job that depends on both and publishes the summary. Explain what is gained and what is lost compared with the single job in section 5, how the build result is shared between jobs, and what would have to be configured in the repository for the pipeline to be a gate and not a report.
Exercise 2
CicloUrbana's pipeline takes 28 minutes and the team has started batching changes so as not to wait. The measured times are: dependency download 4 min, compilation 1 min, unit tests 2 min, SonarCloud analysis 3 min, OWASP Dependency-Check 7 min, integration tests with Testcontainers 6 min, image build 4 min, Trivy scan 1 min. Design an optimisation plan that brings feedback down to under 10 minutes, with the estimated time after each measure and a justification of why each change is safe.
Exercise 3
Version 2.6.0 is deployed to production at 10:00. At 10:07 the alarms show 12 % of 5xx on /api/v1/rentals and p99 latency through the roof. The release includes a migration V12__add_rentals_index.sql and a change in RentalService. Describe the exact sequence of actions in the first fifteen minutes, decide whether it can be rolled back and under what conditions, and propose the concrete pipeline improvements that would have prevented or contained the incident.
Solutions
Solution 1
name: CI
on:
push: { branches: [main] }
pull_request: { branches: [main] }
concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }
permissions: { contents: read, checks: write }
jobs:
unit-tests:
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- name: Compile and install without tests (reusable)
run: ./mvnw -B -DskipTests install
- name: Unit tests (Surefire)
run: ./mvnw -B surefire:test
- uses: actions/upload-artifact@v4
if: always()
with: { name: unit-test-reports, path: target/surefire-reports/ }
integration-tests:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: '21', cache: maven }
- name: Integration tests (Failsafe + Testcontainers)
run: ./mvnw -B verify -DskipUnitTests
- uses: actions/upload-artifact@v4
if: always()
with: { name: integration-test-reports, path: target/failsafe-reports/ }
summary:
needs: [unit-tests, integration-tests]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- uses: mikepenz/action-junit-report@v4
with: { report_paths: '**/TEST-*.xml' }What is gained. The total time goes from being the sum to being the maximum of the two jobs: if the unit tests take 3 minutes and the integration ones 8, the result arrives in 8 instead of 11. And the feedback is more useful: a unit failure shows up after 3 minutes without waiting for the Testcontainers. Besides, each job has its own timeout tuned to what it does.
What is lost. Each job starts on a clean machine, so the compilation happens twice —around 60-90 seconds duplicated— and the Maven cache is downloaded twice (although from GitHub's cache, which is fast). The file is longer and there are more pieces to maintain. With a small suite, the single job of section 5 is simpler and not noticeably slower; the split starts to pay off when the integration tests go beyond 5 minutes.
How the result is shared. With actions/upload-artifact and download-artifact, which is what the summary job does. If you wanted to avoid the double compilation, the pattern would be a preceding job that compiles once and uploads target/ as an artifact; in practice, for a project the size of CicloUrbana, the saving does not justify the complexity. The if: always() on summary is essential: without it, a failure in either of the two jobs would prevent the report from being published.
For it to be a gate, in Settings → Branches: require a pull request before merging into main, mark unit-tests, integration-tests and summary as required checks, require the branch to be up to date with main before merging (so that what is really tested is the result of the integration), and forbid direct pushes, administrators included. Without that last point, the pipeline has a back door.
Solution 2
Diagnosis. The sequential total is 28 minutes, but not all of it belongs on the critical path of the feedback. The key is separating what a developer needs to know within minutes from what can be known later.
| Measure | Change | Time after the measure |
|---|---|---|
| 1. Move OWASP to a nightly job | It is not information that should block a pull request; its vulnerability database changes daily, not per commit | 28 → 21 min |
| 2. Maven cache | cache: maven in setup-java: the download drops from 4 min to ~30 s |
21 → 17.5 min |
| 3. Parallelise into three jobs | unit-tests+Sonar (1+2+3=6 min) and integration-tests (6 min) at the same time |
17.5 → ~12 min |
4. Move the image to cd.yml |
Building and scanning it adds nothing to the pull request: it is only needed at delivery | 12 → ~7 min |
| 5. Docker layer cache | cache-from/to: type=gha in cd.yml: the build drops from 4 min to ~1 |
(improves cd.yml) |
6. cancel-in-progress |
Stops spending minutes on superseded commits | An effect across the whole |
Result: pull request feedback in about 7 minutes, below the target, with the complete delivery (image + scan + deployment to pre) in another 6-8 only when a version is tagged.
Why each change is safe:
- (1) OWASP still runs daily and on every
pushtomain, breaking the build forCVSS ≥ 9; the only thing that changes is that it does not block every pull request, and it should not: a vulnerability published this morning was not introduced by the change under review. - (2) The cache is invalidated by the
pom.xmlhash, so a dependency change forces a full download: there is no risk of building against stale artefacts. (5) The same with the Docker layers: if a layer changes, it is rebuilt. - (3) The two jobs are independent; the only trade-off is compiling twice, some 60 seconds that the parallelism more than makes up for.
- (4) It is consistent with the module's design —
ci.ymlverifies,cd.ymldelivers— and the tagged commit is the same one that went throughci.yml. (6) Cancelling a stale run removes no verification: the most recent commit includes the previous one.
And a cultural measure, without which the techniques are not enough: the time target must be explicit and monitored. If CI goes back above 10 minutes, it is treated as an incident, not as something that just happens. The warning sign is the one the exercise describes: when people batch changes so as not to wait, the integration has stopped being continuous.
Solution 3
Minutes 0-2: contain, do not investigate. The rule from 08-01 is that the rollback criterion is decided in advance and applied without debate. 12 % of 5xx comfortably exceeds any reasonable threshold, so:
kubectl get pods -n ciclourbana-prod # are they alive? restarting?
curl -s https://ciclourbana.ribalta.example/actuator/info | jq '.build.version' # confirm it is 2.6.0And before anything else, check whether a rollback is possible, which depends entirely on the migration:
Minute 2: the decision. V12 adds an index. It is an additive, backwards compatible migration: version 2.5.0 works perfectly against a schema with one extra index. So yes, it can be rolled back, and it is done immediately:
helm rollback ciclourbana -n ciclourbana-prod
kubectl rollout status deploy/ciclourbana -n ciclourbana-prodIf V12 had been a DROP COLUMN or a RENAME, the answer would be the opposite: rolling back would have made the situation worse —the scenario of exercise 3 in 08-01— and you would have to fix forward with an urgent corrective deployment.
Minutes 3-8: confirm the recovery. Watch that the 5xx rate returns to the baseline, that p99 latency comes down and that /actuator/info shows 2.5.0. Communicate the status: the council must know that the service is restored before it asks.
Minutes 8-15: start investigating, now without pressure. The two hypotheses, in order of likelihood:
- Creating the index locked the table.
CREATE INDEXwithoutCONCURRENTLYtakes a lock that blocks writes torentalswhile it is being built. On a large table that is minutes during which everyPOST /api/v1/rentalswaits and ends up exhausting the pool'sconnection-timeout→5xxand latency through the roof. It fits the symptom perfectly, and it explains why it affects/rentalsand not the rest. - A regression in
RentalService: a new N+1 query (04-06), an over-long transaction (04-07) or an unhandled exception.
How to tell them apart: the logs for the 10:00-10:07 window and the pool metrics (hikaricp.connections.pending, 09-03). If there are connections waiting and locks in PostgreSQL, it is the first; if there are application exceptions, it is the second.
Concrete improvements to the pipeline:
| Improvement | What it prevents |
|---|---|
A review rule: every CREATE INDEX on PostgreSQL must use CONCURRENTLY and run outside a transaction, and every migration with a DROP/RENAME must justify its compatibility |
The most likely root cause |
An automatic check in the pull request looking for CREATE INDEX without CONCURRENTLY and DROP COLUMN in db/migration |
The rule depending on somebody remembering |
Rehearsing the migration in pre with realistic volume |
An index that takes 20 ms with 100 rows and 4 minutes with 2 million |
| A canary or phased deployment instead of a full rolling one | 100 % of the traffic suffering the failure from the first minute |
Automatic rollback on an alarm: if 5xx exceeds 5 % for 3 minutes after a deployment, roll back with no intervention |
The 7 minutes it took somebody to notice |
| A deployment window outside the peak rental hour | The impact on the number of citizens affected |
A smoke test with load in pre, not just a curl |
The problem appearing for the first time in production |
And an underlying observation that sums up the whole module: the pipeline worked exactly as designed —it built, tested, scanned, deployed to pre, asked for approval and deployed—. What was missing was not automation, but a verification that represented production conditions: a realistic data volume in pre and a rule that captured a known dangerous migration pattern. Pipelines do not prevent mistakes on their own; they prevent the mistakes somebody has bothered to encode as a check, and every incident is the opportunity to add one more.
Conclusion
The path from commit to the citizens of Ribalta is complete and automated. You draw a precise distinction between continuous integration, continuous delivery and continuous deployment, and you know that what separates the last two is not technology but a decision about trust —the protected environment: prod—. You have seen why CI is what turns the module 6 suite into a real guarantee: it stops being run by whoever remembers, in each person's environment, and starts running always, on a clean machine, with a public verdict that blocks the merge.
You have CicloUrbana's complete pipeline in two files: a ci.yml annotated line by line, with a Maven cache, ./mvnw -B verify running Surefire and Failsafe with Testcontainers on the Docker the runner already brings, reports published with if: always() and the branch protection that turns it into a gate; and a cd.yml that is triggered by a tag, builds and scans the image with Trivy, publishes it with triple tagging —semantic version, commit SHA and latest—, deploys to pre, passes a real smoke test against the four stations and waits for a human approval before touching production.
You know how to manage secrets with environments, GITHUB_TOKEN and OIDC with no long-lived keys, with the sub condition that stops any repository from assuming your role, and with the warning that a secret printed in a log is rotated, not deleted. You have quality as part of the pipeline —Spotless, SpotBugs, Sonar on new code, jacoco:check with an honest threshold, nightly OWASP Dependency-Check, Dependabot and the image scan—, semantic versioning tied to Actuator's build-info that answers which SHA is running in Ribalta, the rollback in its three levels and the expand/contract pattern as the condition for that rollback to be possible. And you know what makes a pipeline used or ignored: under ten minutes, with no flaky tests, with every stage failing fast.
CicloUrbana is in production and it gets there on its own. The question is no longer whether it works nor how it is deployed, but how it behaves: what latency a rental really has at peak time, which query consumes 40 % of the database time, how many times the same thing gets recalculated, what is going on inside the JVM when memory rises and never comes down. Module 9, Performance and Monitoring, answers all of that: performance and pool tuning, caching with Spring Cache, business metrics with Micrometer on top of the Actuator from 07-01, Prometheus and Grafana, log management and distributed tracing. Until now we have built and delivered the Ribalta network; from now on we are going to watch it run and make it fast.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
