CI/CD is a set of practices in software development that aims to automate the process of building, testing, and releasing software.
Here's a breakdown of the two parts:
Continuous integration (CI)
Involves frequently merging code changes from multiple developers into a central repository. This is typically done with every small change or a few times a day. Automated tests are then run to ensure the merged code doesn't break anything. This helps catch bugs early and prevents them from accumulating.
Continuous delivery (CD)
Automates the delivery of code changes to different environments, like testing or staging. This allows for frequent releases and makes it easier to deploy new features or fixes. In some cases, CD can also refer to continuous deployment, which means automatically deploying changes directly to production.
By automating these tasks, CI/CD helps development teams release software faster and more reliably. It also improves the quality of software by catching bugs early in the development process.
In the next sections, we will review the CI/CD Pipeline setup in GitHub Actions for jfs-web.
The primary workflow for jfs-web is defined in .github/workflows/cicd.yml.
Here's an overview of the steps in that workflow:
The CI workflow is triggered on every push and pull request to the main branch. It consists of the following jobs:
The CD workflow is triggered after the CI workflow completes successfully. It consists of the following jobs:

Here's a demo of the CI/CD pipeline in action:
Here are some snippets from my GitHub Actions setup:
This job generates a unique image tag for this workflow run that gets used in the subsequent jobs. Here's an example tag 2024-07-01--04-58-9fc1eb90.
setup:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.image_tag.outputs.image_tag }}
steps:
- name: Determine Image Tag
id: image_tag
run: |
export SHA=${{ github.sha }}
export SHORT_SHA=${SHA:0:8}
export DATE=$(date -u "+%Y-%m-%d--%H-%M")
export FULL_TAG=${DATE}-${SHORT_SHA}
echo "image_tag=${FULL_TAG}"
echo "image_tag=${FULL_TAG}" >> "$GITHUB_OUTPUT"
echo "image_tag=${FULL_TAG}" >> "$GITHUB_STEP_SUMMARY"
This job uses the setup-env, build, and package actions. This downloads dependencies, compiles css and templ files, and packages the application using a dockerfile.
build:
needs: [setup]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Build Environment
uses: ./.github/actions/setup-env
- name: Build Application
uses: ./.github/actions/build
- name: Package Application
uses: ./.github/actions/package
with:
IMAGE_TAG: ${{ needs.setup.outputs.image_tag }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Note: Build and Test jobs reuse similar actions because each job is a seperate GitHub Runner VM. This can be improved by building and caching the built files.
This job uses the setup-env, build, and test actions. This downloads dependencies, compiles css and templ files, and runs a go test.
test:
needs: [setup]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Build Environment
uses: ./.github/actions/setup-env
- name: Build Application
uses: ./.github/actions/build
- name: Test Application
uses: ./.github/actions/test
Note: Build and Test jobs reuse similar actions because each job is a seperate GitHub Runner VM. This can be improved by building and caching the built files.
This job uses the security-scan action to scan the docker image for os and library vulnerabilities using aquasecurity/[email protected].
security_scans:
needs: [setup, build]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Security Scan
uses: ./.github/actions/security-scan
with:
IMAGE_TAG: ${{ needs.setup.outputs.image_tag }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
This job uses the publish action to publish the docker image to Google Cloud Platform Artifact Registry. Under the hood, I use google-github-actions/auth@v2 to authenticate with my GCP account, login to the artifact registry with docker/login-action@v3 and push the docker image.
publish_image:
needs: [setup, build, test, security_scans]
runs-on: ubuntu-latest
permissions:
packages: "read"
contents: "read"
id-token: "write"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Publish Application
uses: ./.github/actions/publish
with:
IMAGE_TAG: ${{ needs.setup.outputs.image_tag }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GCP_REGION: ${{ vars.GCP_REGION }}
GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
GCP_WIP: ${{ vars.GCP_WIP }}
GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }}
GCP_CONTAINER_REPO: ${{ vars.GCP_CONTAINER_REPO }}
This job uses the deploy action to deploy the application to Google Cloud Platform. Under the hood, I use google-github-actions/auth@v2 to authenticate with my GCP account, and deploy a new release to Cloud Run with the google-github-actions/deploy-cloudrun@v2 action.
deploy:
needs: [setup, publish_image]
runs-on: ubuntu-latest
permissions:
packages: "read"
contents: "read"
id-token: "write"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy Application
uses: ./.github/actions/deploy
with:
IMAGE_TAG: ${{ needs.setup.outputs.image_tag }}
GCP_REGION: ${{ vars.GCP_REGION }}
GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }}
GCP_WIP: ${{ vars.GCP_WIP }}
GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }}
GCP_CONTAINER_REPO: ${{ vars.GCP_CONTAINER_REPO }}