4 min read

Building Clearly Bounded CI/CD Pipelines with GitHub Actions

Justin Yoo

In my prevous post, we built a workflow, using GitHub Actions to build, test and deploy a static web app to Azure Blob Storage. Throughout this post, I'm extending the previous workflow with bounded CI/CD pipelines to separate concerns.

You can download the sample codes used in this post at this GitHub repository.

Separate Deployment from Build

We discussed four fundamental concepts – Workflow, Event, Runner and Action in my previous post. These concepts are bare minimum information to build a Workflow with GitHub Actions. In addition to them, in order to build bounded pipelines, another concept, Job, needs to bring in. Job is a logical grouping that contains Runner and series of Actions. It can be defined multiple times in one Workflow and run at the same time or one after another, based on the definition.

Here's the workflow from the previous post. Under the jobs attribute, we can see one Job, named build_and_publish.

name: Publish Static Web App to Azure Blob Storage
on: push
jobs:
build_and_publish:
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v1
- name: Login to Azure
uses: Azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Install npm packages
shell: bash
run: |
cd $GITHUB_WORKSPACE/src/WebApp
npm install
- name: Build app
shell: bash
run: |
cd $GITHUB_WORKSPACE/src/WebApp
npm run build
- name: Test app
shell: bash
run: |
cd $GITHUB_WORKSPACE/src/WebApp
npm run test:unit
- name: Publish app
uses: Azure/cli@v1.0.0
with:
azcliversion: latest
inlineScript: |
az storage blob upload-batch -s $GITHUB_WORKSPACE/src/WebApp/dist -d \$web --account-name ${{ secrets.STORAGE_ACCOUNT_NAME }}

Redefine Build Job

The last action is Publish app. In fact, this action is more accurate if we change it to "upload an artifact" as the final step before deployment. Let's change it to the appropriate one. We're going to use the action, called upload-artifact. app is the name of the artifact.

- name: Upload app
uses: actions/upload-artifact@v1
with:
name: app
path: src/WebApp/dist

After updating the workflow, push it back to GitHub repo, and it will trigger the updated workflow. The workflow doesn't push the artifact to Azure Blob Storage. Instead, it uploads the artifact to the designated location on the pipeline.

Now, we've got the existing Job redefined. Let's add another Job for deployment.

Define Deployment Job

There is no fixed or definite way of the application deployment scenario. But Here are over-simplified two scenarios. One runs the Jobs sequentially, one after another, and the other runs deployment jobs in parallel.

In the first scenario, the deploy_to_dev job should have a dependency on its previous job, build_and_publish, deploy_to_test on deploy_to_dev, and deploy_to_prod on deploy_to_test. In other words, if one job fails, the next job can't be run. Therefore, declare the dependency like below:

...
jobs:
build_and_publish:
runs-on: ubuntu-latest
...
deploy_to_dev:
needs: build_and_publish
runs-on: ubuntu-latest
...
deploy_to_test:
needs: deploy_to_dev
runs-on: ubuntu-latest
...
deploy_to_prod:
needs: deploy_to_test
runs-on: ubuntu-latest
...

On the other hands, the second scenario shows all deployment job has only the dependency on the build_and_publish job. Therefore, the Workflow definition can look like below:

...
jobs:
build_and_publish:
runs-on: ubuntu-latest
...
deploy_to_dev:
needs: build_and_publish
runs-on: ubuntu-latest
...
deploy_to_test:
needs: build_and_publish
runs-on: ubuntu-latest
...
deploy_to_prod:
needs: build_and_publish
runs-on: ubuntu-latest
...

In this scenario, we publish two static websites – one for DEV and the other for PROD. For the deployment job, we use another action, called download-artifact.

- name: Download app
uses: actions/download-artifact@v1
with:
name: app
path: src/WebApp/dist

Based on this action, let's add two jobs, deploy_to_devdeploy_to_prod, to the existing workflow.

deploy_to_dev:
needs: build_and_publish
runs-on: ubuntu-latest
steps:
- name: Download app
uses: actions/download-artifact@v1
with:
name: app
path: src/WebApp/dist
- name: Login to Azure
uses: Azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Publish app
uses: Azure/cli@v1.0.0
with:
azcliversion: latest
inlineScript: |
az storage blob upload-batch -s $GITHUB_WORKSPACE/src/WebApp/dist -d \$web --account-name ${{ secrets.STORAGE_ACCOUNT_NAME }}
deploy_to_prod:
needs: deploy_to_dev
runs-on: ubuntu-latest
steps:
- name: Download app
uses: actions/download-artifact@v1
with:
name: app
path: src/WebApp/dist
- name: Login to Azure
uses: Azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Publish app
uses: Azure/cli@v1.0.0
with:
azcliversion: latest
inlineScript: |
az storage blob upload-batch -s $GITHUB_WORKSPACE/src/WebApp/dist -d \$web --account-name ${{ secrets.STORAGE_ACCOUNT_NAME_2 }}

We should make sure one thing here. Each Job has its own Runner and runs on it. Once after one Job is over, the Runner is also removed. In other words, although we logged into Azure in the previous Job, it doesn't necessarily mean that the login credentials are carried over to the next Job. Therefore, like the workflow definition above, each Job should log in to Azure first.

Once redefinition complete, let's push the change and see the result. The first screenshot is the result of the build_and_publish job.

And this is the last job, deploy_to_prod.

We only defined two distinctive actions – download artifact and deploy to Azure, which is a bare minimum for deployment. But there can be many other scenarios from business requirements. For example, either integration testing or end-to-end testing can be added to each job, respectively.


So far, we've built bounded CI/CD pipelines to separate concerns and responsibilities on each job.