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.
./
├── .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.
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:
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:
- Change detection job (
prepare
) - Infrastructure deployment job
- 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.
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:
- Use a script within the
prepare
job to run agit diff
command against the last commit to identify changed paths. - 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. - 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.
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@v3To obtain the differences from the previous commit using
git diff HEAD~
, you need to set thefetch-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 ofactions/checkout@v3
is to fetch only the latest commit (i.e., with afetch-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. Settingfetch-depth
to 2 ensures that the action fetches the last two commits, allowinggit 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.
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.
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 executionUse
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 dependenciesneeds: - 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 usesYou can call another workflow set up with
workflow_call
usinguses: path to workflow file
. If secrets are needed, they can be passed when calling. Specifyinginherit
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.
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.
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 jobAdd
!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.
Top comments (1)
That's so amazing. Thank you to share, I just implemented something similar based on this.