GitHub Actions CI/CD Tutorial Series — Part 5
In Part 4 of this tutorial series, we covered the following steps:
*) Added more steps to the build job
*) Started adding first steps to the deploy job
If you missed Part 4, you can find it here: Part 4
We stopped at the deployment step
We added the step named “Deploy to Droplet” to the cicd.yml
file and currently our file looks like this:
name: Build & Deployment
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
name: Build Project
runs-on: ubuntu-22.04
steps:
- name: Slack Notification Ci/Cd started
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'CI/CD started :eyes: In the Name of God the Merciful, the Compassionate / Bismillahir Rahmanir Raheem /بسم الله الرحمن الرحيم'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Checkout Repository
uses: actions/checkout@v3
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Run Super-Linter
uses: github/super-linter@v4.10.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_MARKDOWN: false
VALIDATE_SQLFLUFF: false
- name: Super Linter Slack Notification
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Super Linter finished... :heavy_check_mark: nice / lateef / لطيف'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Hub Slack Notification
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Docker Hub Login... :lock: okay / tayyib / طيب'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: habibicoding/task-app-api:latest
context: .
- name: Notify Docker push results
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: ':white_check_mark: pushed habibicoding/task-app-api:latest to Docker Hub... https://hub.docker.com/repository/docker/habibicoding/task-app-api'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
deploy:
name: Deploy Project
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-22.04
steps:
- name: Notify deployment started
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Linode deployment started... :crossed_fingers: so God will / in shaAllah / ان شاء الله خير'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy to Droplet
run: |
ssh -o StrictHostKeyChecking=no -p ${{ secrets.DROPLET_PORT }} ${{ secrets.DEPLOYMENT_USER }}@${{ secrets.DEPLOYMENT_HOST }} "\
docker-compose pull && \
docker-compose up -d && \
docker system prune -af"
Time to add the secrets to GitHub— Yalla | يلا
Open the GitHub page of your repository and head again to Settings -> Secrets and variables -> Actions -> New repository secret
These are all the secrets you should have and they need to be named like this:
Create the Docker Compose file
Use SSH again to connect to your Linode Ubuntu instance:
ssh {your-user}@{your-linode-ip} -p 1022
Create the docker-compose file:
touch docker-compose.yml
Make the docker-compose file executable:
chmod +x docker-compose.yml
List all files in your home directory to check if the docker-compose file is really executable:
ls -la
Now, open the file with a text editor of your choice:
version: '3.7'
services:
api:
image: habibicoding/task-app-api:latest
container_name: backend
environment:
- SERVER_PORT=${SERVER_PORT}
- JPA_DATABASE=${JPA_DATABASE}
- DATASOURCE_URL=${DATASOURCE_URL}
- DATASOURCE_DRIVER_CLASS_NAME=${DATASOURCE_DRIVER_CLASS_NAME}
- DATASOURCE_USERNAME=${DATASOURCE_USERNAME}
- DATASOURCE_PASSWORD=${DATASOURCE_PASSWORD}
ports:
- "${SERVER_PORT}:${SERVER_PORT}"
restart: always
This is a Docker Compose configuration file with version ‘3.7’. You can find the docker-compose version here: https://docs.docker.com/compose/compose-file/compose-file-v3/. The file defines a single service called ‘api’ with the following configuration:
image
: The Docker image to be used for the 'api' service. In this case, it'shabibicoding/task-app-api:latest
, which is expected to be a pre-built image containing your Kotlin Spring Boot app.environment
: This section defines environment variables that will be passed to the 'api' service container. These variables are used for configuring the application, such as setting the server port, database settings, and data source credentials. The values for these environment variables are expected to be set externally (e.g., using an.env
file or passing them when runningdocker-compose
).ports
: This section maps the container's port to the host's port. The${SERVER_PORT}
variable is used for both the container and the host, which means the application will be accessible on the same port on the host machine as it is within the container.restart
: This option configures the restart policy for the 'api' service. In this case, it's set toalways
, which means the Docker daemon will always try to restart the container if it stops, regardless of the exit status.
In summary, this Docker Compose configuration file defines a single service called ‘api’ based on the habibicoding/task-app-api:latest
Docker image. The service uses environment variables for configuration and exposes the application to the specified SERVER_PORT
. The container will restart automatically if it stops.
Time to create the .env
file so our docker-compose.yml docker-compose.yml
can work:
vim .env
Add your locale variables from your IntelliJ IDE to the .env
:
Copy the name and values of your environment variables inside .env
:
In case you haven’t any of these environment variables in your IDE check out either my Medium articles or YouTube playlist.
Your .env
should look like this:
SERVER_PORT={your-config}
JPA_DATABASE={your-config}
DATASOURCE_URL={your-config}
DATASOURCE_DRIVER_CLASS_NAME={your-config}
DATASOURCE_USERNAME={your-config}
DATASOURCE_PASSWORD={your-config}
Now, you can log out from your server and jump back to your locale IntelliJ IDE.
After adding all necessary secrets time to add our last step to the pipeline process:
- name: Notify deployment results
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Linode deployment finished... :rocket: :palms_up_together: Thanks to God / Alhamdulillah / ٱلْحَمْدُ لِلَّٰهِ'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
This will post in the Slack channel when the whole CI/CD process is finished.
The finished cicd.yml
file looks like this:
name: Build & Deployment
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
name: Build Project
runs-on: ubuntu-22.04
steps:
- name: Slack Notification Ci/Cd started
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'CI/CD started :eyes: In the Name of God the Merciful, the Compassionate / Bismillahir Rahmanir Raheem /بسم الله الرحمن الرحيم'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Checkout Repository
uses: actions/checkout@v3
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Run Super-Linter
uses: github/super-linter@v4.10.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VALIDATE_MARKDOWN: false
VALIDATE_SQLFLUFF: false
- name: Super Linter Slack Notification
uses: 8398a7/action-slack@v3.15.1
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Super Linter finished... :heavy_check_mark: nice / lateef / لطيف'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker Hub Slack Notification
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Docker Hub Login... :lock: okay / tayyib / طيب'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Build and push Docker image
uses: docker/build-push-action@v3
with:
push: true
tags: habibicoding/task-app-api:latest
context: .
- name: Notify Docker push results
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: ':white_check_mark: pushed habibicoding/task-app-api:latest to Docker Hub... https://hub.docker.com/repository/docker/habibicoding/task-app-api'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
deploy:
name: Deploy Project
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-22.04
steps:
- name: Notify deployment started
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Linode deployment started... :crossed_fingers: so God will / in shaAllah / ان شاء الله خير'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.7.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Deploy to Droplet
run: |
ssh -o StrictHostKeyChecking=no -p ${{ secrets.DROPLET_PORT }} ${{ secrets.DEPLOYMENT_USER }}@${{ secrets.DEPLOYMENT_HOST }} "\
docker-compose pull && \
docker-compose up -d && \
docker system prune -af"
- name: Notify deployment results
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: 'Linode deployment finished... :rocket: :palms_up_together: Thanks to God / Alhamdulillah / ٱلْحَمْدُ لِلَّٰهِ'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Test your CI/CD pipeline
Change something on your Backend code and create a Pull Request to trigger the CI/CD pipeline.
I will now change the naming of the URI paths. These are the changes I have made in the branch backend-changes-v3
compared to the branches start-project-v1
& cicd-v2
.
We also need to adapt the TaskControllerIntegrationTest
class:
package com.example.cicdtutorial.controller
import com.example.cicdtutorial.data.model.Priority
import com.example.cicdtutorial.data.model.TaskCreateRequest
import com.example.cicdtutorial.data.model.TaskDto
import com.example.cicdtutorial.data.model.TaskUpdateRequest
import com.example.cicdtutorial.exception.TaskNotFoundException
import com.example.cicdtutorial.service.TaskService
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@WebMvcTest(controllers = [TaskController::class])
internal class TaskControllerIntegrationTest(@Autowired private val mockMvc: MockMvc) {
@MockBean
private lateinit var mockService: TaskService
private val taskId: Long = 33
private val dummyDto1 = TaskDto(
33,
"test1",
isReminderSet = false,
isTaskOpen = false,
createdOn = LocalDateTime.now(),
priority = Priority.LOW
)
private val mapper = jacksonObjectMapper()
@BeforeEach
fun setUp() {
mapper.registerModule(JavaTimeModule())
}
@Test
fun `given all tasks when fetch happen then check for size`() {
// GIVEN
val taskDto2 = TaskDto(
44,
"test2",
isReminderSet = false,
isTaskOpen = false,
createdOn = LocalDateTime.now(),
priority = Priority.LOW
)
val expectedDtos: List<TaskDto> = listOf(dummyDto1, taskDto2)
// WHEN
`when`(mockService.getAllTasks()).thenReturn(expectedDtos)
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks"))
// THEN
resultActions.andExpect(MockMvcResultMatchers.status().`is`(200))
resultActions.andExpect(content().contentType(MediaType.APPLICATION_JSON))
resultActions.andExpect(jsonPath("$.size()").value(expectedDtos.size))
}
@Test
fun `when get task by id is called with string instead of integer then check for bad request`() {
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/404L"))
resultActions.andExpect(MockMvcResultMatchers.status().isBadRequest)
}
@Test
fun `when task id does not exist then expect is not found response`() {
`when`(mockService.getTaskById(taskId)).thenThrow(TaskNotFoundException("Task with id: $taskId does not exist!"))
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/$taskId"))
resultActions.andExpect(MockMvcResultMatchers.status().isNotFound)
}
@Test
fun `given open tasks when fetch happen then check for size and isTaskOpen is true`() {
val taskDto2 = TaskDto(
44,
"test2",
isReminderSet = false,
isTaskOpen = true,
createdOn = LocalDateTime.now(),
priority = Priority.LOW
)
`when`(mockService.getAllOpenTasks()).thenReturn(listOf(taskDto2))
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/open"))
resultActions.andExpect(MockMvcResultMatchers.status().`is`(200))
resultActions.andExpect(content().contentType(MediaType.APPLICATION_JSON))
resultActions.andExpect(jsonPath("$.size()").value(1))
resultActions.andExpect(jsonPath("$[0].isTaskOpen").value(taskDto2.isTaskOpen))
}
@Test
fun `given closed tasks when fetch happen then check for size and isTaskOpen is false`() {
// GIVEN
// WHEN
`when`(mockService.getAllClosedTasks()).thenReturn(listOf(dummyDto1))
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/closed"))
resultActions.andExpect(MockMvcResultMatchers.status().isOk)
resultActions.andExpect(content().contentType(MediaType.APPLICATION_JSON))
resultActions.andExpect(jsonPath("$.size()").value(1))
resultActions.andExpect(jsonPath("$[0].isTaskOpen").value(dummyDto1.isTaskOpen))
}
@Test
fun `given one task when get task by id is called then check for correct description`() {
`when`(mockService.getTaskById(33)).thenReturn(dummyDto1)
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/tasks/${dummyDto1.id}"))
resultActions.andExpect(MockMvcResultMatchers.status().`is`(200))
resultActions.andExpect(content().contentType(MediaType.APPLICATION_JSON))
resultActions.andExpect(jsonPath("$.description").value(dummyDto1.description))
}
@Test
fun `given update task request when task gets updated then check for correct property`() {
val request = TaskUpdateRequest(
"update task",
isReminderSet = false,
isTaskOpen = false,
priority = Priority.LOW
)
val dummyDto = TaskDto(
44,
request.description ?: "",
isReminderSet = false,
isTaskOpen = false,
createdOn = LocalDateTime.now(),
priority = Priority.LOW
)
`when`(mockService.updateTask(dummyDto.id, request)).thenReturn(dummyDto)
val resultActions: ResultActions = mockMvc.perform(
MockMvcRequestBuilders.patch("/api/v1/tasks/${dummyDto.id}")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request))
)
resultActions.andExpect(MockMvcResultMatchers.status().isOk)
resultActions.andExpect(content().contentType(MediaType.APPLICATION_JSON))
resultActions.andExpect(jsonPath("$.description").value(dummyDto.description))
}
@Test
fun `given create task request when task gets created then check for correct property`() {
val request = TaskCreateRequest(
"test for db",
isReminderSet = false,
isTaskOpen = false,
createdOn = LocalDateTime.now(),
priority = Priority.LOW
)
val taskDto = TaskDto(
0,
"test for db",
isReminderSet = false,
isTaskOpen = false,
createdOn = LocalDateTime.now(),
priority = Priority.LOW
)
`when`(mockService.createTask(request)).thenReturn(taskDto)
val resultActions: ResultActions = mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/tasks").contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(request))
)
resultActions.andExpect(MockMvcResultMatchers.status().`is`(200))
resultActions.andExpect(content().contentType(MediaType.APPLICATION_JSON))
resultActions.andExpect(jsonPath("$.isTaskOpen").value(taskDto.isTaskOpen))
}
@Test
fun `given id for delete request when delete task is performed then check for the message`() {
val expectedMessage = "Task with id: $taskId has been deleted."
`when`(mockService.deleteTask(taskId)).thenReturn(expectedMessage)
val resultActions: ResultActions = mockMvc.perform(MockMvcRequestBuilders.delete("/api/v1/tasks/$taskId"))
resultActions.andExpect(MockMvcResultMatchers.status().`is`(200))
resultActions.andExpect(content().string(expectedMessage))
}
}
Then commit and push and create a PR for the master
branch. Go to your GitHub repository and click on “Pull requests”:
After that click on the right side on “New pull request”:
Next, look that your branch (in my case backend-changes-v3
) gets merged into the master
branch and click on “Create pull request”:
As a next step, you can write a summary only if you want, or leave it blank and click on “Create pull request”:
Lastly, you should see the Build process running:
With that, we conclude the fourth part of this tutorial series. If you found it useful and informative, give it a clap. Here is Part 6.
Don’t forget to check out the video version of this article on our YouTube channel at https://www.youtube.com/@habibicoding.