Implementing Continuous Delivery for Monorepo and Microservice with GitHub Actions
Tags: github, githubactions, cicd, microservices
Published: 2024-03-17 11:03:21

## Introduction

In this article, we explore how to implement CD \(Continuous Delivery\) using GitHub Actions in a monorepo + microservices setup.

## Prerequisites

This section outlines the language and directory structure used as prerequisites.

### Language

The services within the repository are written in TypeScript and managed as workspaces with Yarn Workspaces. They are also assumed to be containerized. However, it is believed that the same mechanism could work even if services are written in different languages.

### Directory Structure

The assumed directory structure is as follows: There is a services directory at the root, and each service is placed in its own directory within this.

Infrastructure management tools like AWS are also managed within the same repository, under `services/infrastructure.`

```text
./
├── .github/
│   └── workflows/
├── package.json
└── serivces/
    ├── infrastructure/
    ├── service-a/
    └── service-b/
```

## Problem

When adopting such a configuration, the scope of deployment becomes a problem. Workflows used in the repository are managed as common resources in `.github/workflows`, and if you write the deployment workflows for `service-a` and `service-b` there, deploying only `service-a` will inadvertently cause `service-b` to be deployed as well. This can lead to increased workflow execution times and, depending on the configuration, unnecessary deployment actions.

## Solution

Therefore, let's consider a method to deploy only the services that have been changed.

### Solution 1 \(Using `paths` Filters\)

A possible solution involves utilizing the functionality of including and excluding paths.

https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#onpushpull_requestpull_request_targetpathspaths-ignore

You can define the relevant paths as follows and prepare a workflow for each service accordingly.

For example, to deploy only `service-a`, the configuration might look like this:

```yaml
name: Deploy service-a

on:
  push:
    paths:
      - 'serivces/service-a/**'

jobs:
  # Steps to deploy service-a go here
```

The disadvantages of this approach include the need to create a separate workflow file for each service, even when the deployment method is the same, which can make workflow file management cumbersome. Additionally, this method does not support dependencies between services.

### Solution 2 \(Using `git diff`\)

Another method is to use Git's capabilities to deploy only the services that have been changed.

You can detect changes to the targeted services by using a command to get the differences from the last commit, and then deploy only the changed services.

Let's explore this approach further.

## Architecture

The workflow will be documented for manual execution \(`workflow_dispatch`\), but it can also be set up to trigger automatically on `push`.

If there are changes to the overall infrastructure managed by IaC, you'll want to deploy those changes before deploying any services, considering the deployment dependencies. This setup should also accommodate dependencies between services, allowing them to be expressed in the same system.

Within the overall deployment workflow, implement the following jobs:

1. Change detection job \(`prepare`\)
2. Infrastructure deployment job
3. Individual service deployment jobs

This architecture allows for a more flexible deployment process, accommodating both the infrastructure and individual services, and addressing the need to manage dependencies between services.

![Flowchart of deployment steps](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eaxx0yna0dkgtw488a29.png)

```mermaid
graph TD;
    prepare-->infrastructure;
    infrastructure-->service-a;
    infrastructure-->service-b;
```

Indeed, having the ability to deploy services individually, in addition to deploying the entire system through one workflow, would offer greater flexibility and convenience. Thus, the solution should also enable workflows to deploy each service separately.

## Detailed Workflow

### Change Detection Job \(`prepare`\)

The change detection can be conducted as follows:

1. Use a script within the `prepare` job to run a `git diff` command against the last commit to identify changed paths.
2. Parse the output of the `git diff` command to determine which services have been modified. This could involve checking if the changes are within the directories specific to each service.
3. Set the output of this job to indicate which services need to be deployed. This can be achieved by setting environment variables or producing a file that subsequent jobs can read to determine whether they should proceed with deployment.

By implementing this job at the beginning of the workflow, you can dynamically decide which subsequent jobs (e.g., infrastructure deployment or individual service deployments) need to run based on the changes detected. This approach minimizes unnecessary deployments, saves time, and ensures that resources are used efficiently.

For individual service deployment workflows, you can adopt a similar change detection mechanism but scoped to the specific service's directory. This way, each service can have its workflow triggered either manually or automatically upon changes to its relevant files, offering a seamless and flexible deployment process tailored to the needs of each service within the monorepo.

```yaml:.github/workflows/deploy.yaml
jobs:
  prepare:
    outputs:
      infrastructure-diff-count: ${{ steps.infrastructure_changes.outputs.diff-count }}
      service-a-diff-count: ${{ steps.service_a_changes.outputs.diff-count }}
      service-b-diff-count: ${{ steps.service_b_changes.outputs.diff-count }}

    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 2
      - id: infrastructure_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/infrastructure | wc -l` >> $GITHUB_OUTPUT
      - id: service_a_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/service-a | wc -l` >> $GITHUB_OUTPUT
      - id: service_b_changes
        run: echo diff-count=`git diff HEAD~ --name-only --relative=services/service-b | wc -l` >> $GITHUB_OUTPUT
```

Here are the key point:

> **Point**
> **Set fetch-depth to 2 in actions/checkout@v3**
> 
> To obtain the differences from the previous commit using `git diff HEAD~`, you need to set the `fetch-depth` to 2. If this is not set, the previous commit cannot be fetched, and you will not be able to perform a diff comparison.
> This precaution is essential because the default behavior of `actions/checkout@v3` is to fetch only the latest commit \(i.e., with a `fetch-depth` of 1\). This shallow fetch is faster as it downloads less history, but it's not suitable for workflows that need to compare changes between commits. Setting `fetch-depth` to 2 ensures that the action fetches the last two commits, allowing `git diff` to correctly identify any changes made since the last commit. This setup is crucial for the change detection mechanism in CI/CD workflows that rely on comparing different versions of the codebase to determine the scope of changes and the necessity for deployments.

### Infrastructure Deployment Job

We won't detail the infrastructure deployment code, but we will mainly mention the parts related to the previous job, the change detection job.

We define a deployment workflow for the infrastructure itself. By defining `workflow_dispatch`, we can execute the workflow for the infrastructure itself. Also, to make it callable from the overall workflow, we define `workflow_call` as well.

```yaml:.github/workflows/deploy-infrastructure.yaml
on:
  workflow_dispatch:
    inputs:
      stage:
        required: true
        type: string
  workflow_call:
    inputs:
      stage:
        required: true
        type: string

jobs:
  deploy:
    # setup, install, deploy, etc.
    # If using AWS CDK, deploy with commands like yarn workspace infrastructure cdk deploy --all --require-approval=never
```

Add the workflow to deploy the infrastructure itself to the overall deployment workflow.

```yaml:.github/workflows/deploy.yaml
jobs: # Written to align the indent, but it's the same job as prepare
  infrastructure_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.infrastructure-diff-count > 0 }}
    needs:
      - prepare
    uses: '.github/workflows/deploy-infrastructure.yaml'
    with:
      stage: ${{ inputs.stage }}
    secrets: inherit
```

Here are the key points:

> **Point 1**
> **Use if statements to determine the necessity of job execution**
> 
> Use `needs.prepare.outputs.infrastructure-diff-count > 0` to get the number of changed files obtained from the previous job and execute only if there are changes.

> **Point 2**
> **Use needs to indicate dependencies**
> 
> ```
> needs: 
>   - prepare
> ```
> shows that it depends on the prepare job. It is possible to specify multiple dependencies, which can represent waiting until the deployment of the infrastructure or another dependent service is completed.

> **Point 3**
> **Call the individual infrastructure workflow with uses**
> 
> You can call another workflow set up with `workflow_call` using `uses: path to workflow file`. If secrets are needed, they can be passed when calling. Specifying `inherit` allows passing them collectively, but it's also possible to specify them individually.

### Deployment Jobs for Each Service

The deployment jobs for each service are almost the same as those for deploying infrastructure, so only the key points are described.

We define a deployment workflow for each individual service. Services that can be deployed with the same workflow will reuse that workflow by accepting the service name as input.

```yaml:.github/workflows/deploy-service.yaml
on:
  workflow_dispatch:
    inputs:
      stage:
        required: true
        type: string
      service:
       required: true
       type: choice
       options:
         - service-a
         - service-b
  workflow_call:
    inputs:
      stage:
        required: true
        type: string
      service:
       required: true
       type: string

jobs:
  deploy:
    # setup, install, deploy, etc.
    # The workflow is reused by switching the target service based on inputs.service
```

Add the workflow for deploying individual infrastructure to the overall deployment workflow.

```yaml:.github/workflows/deploy.yaml
jobs: # Written to align the indentation, but it's the same job as prepare
  service_a_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.service-a-diff-count > 0 }}
    needs:
      - prepare
      - infrastructure_deploy
    uses: '.github/workflows/deploy-service.yaml'
    with:
      stage: ${{ inputs.stage }}
      service: service-a
    secrets: inherit

  service_b_deploy:
    if: ${{ !cancelled() && !failure() && needs.prepare.outputs.service-b-diff-count > 0 }}
    needs:
      - prepare
      - infrastructure_deploy
      # - service_a_deploy
      # If dependent on service-a, specify as above to wait for the completion of service-a's deployment
    uses: '.github/workflows/deploy-service.yaml'
    with:
      stage: ${{ inputs.stage }}
      service: service-b
    secrets: inherit
```

The key point is as follows:

> **Point**
> **Add cancelled(), failure() to the if statement to determine whether to execute the job**
> 
> Add `!cancelled() && !failure() && ` to the conditions of the if statement. This is to ensure that the workflow stops if it is cancelled, and also to ensure that this job does not skip if the jobs it depends on are skipped.

## Summary

With GitHub monorepo + microservices, it was possible to deploy only the services that have changes. The triggers for the workflow and the conditions for detecting changes can be adjusted according to each project.