GitHub Actions CI/CD Tutorial Series — Part 5

Habibi Coding | حبيبي كودنق
Nerd For Tech
Published in
10 min readMay 3, 2023
tutorial banner

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

DROPLET_PORT
add your ssh user
add your ssh IP

These are all the secrets you should have and they need to be named like this:

all secrets

Create the Docker Compose file

Use SSH again to connect to your Linode Ubuntu instance:

ssh {your-user}@{your-linode-ip} -p 1022
ssh

Create the docker-compose file:

touch docker-compose.yml
create the docker-compose file

Make the docker-compose file executable:

chmod +x docker-compose.yml
Make the docker-compose file executable

List all files in your home directory to check if the docker-compose file is really executable:

ls -la
check executable status

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
docker-compose.yml

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:

  1. image: The Docker image to be used for the 'api' service. In this case, it's habibicoding/task-app-api:latest, which is expected to be a pre-built image containing your Kotlin Spring Boot app.
  2. 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 running docker-compose).
  3. 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.
  4. restart: This option configures the restart policy for the 'api' service. In this case, it's set to always, 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 .envfile so our docker-compose.yml docker-compose.yml can work:

vim .env
create .env file

Add your locale variables from your IntelliJ IDE to the .env :

Edit Configurations…
click on edit environment variables

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}
example of .env file

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.ymlfile 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-v3compared to the branches start-project-v1 & cicd-v2 .

naming URI

We also need to adapt the TaskControllerIntegrationTestclass:

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 masterbranch. Go to your GitHub repository and click on “Pull requests”:

create PR

After that click on the right side on “New pull request”:

click 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”:

merging current branch into master

As a next step, you can write a summary only if you want, or leave it blank and click on “Create pull request”:

click on Create pull request

Lastly, you should see the Build process running:

build 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.

--

--