CI/CD Pipeline With GitHub Actions: A Practical Guide

by Admin 54 views
CI/CD Pipeline with GitHub Actions: A Practical Guide

Hey guys! Today, we're diving into creating a robust CI/CD pipeline using GitHub Actions. We'll focus on automating the build and deployment of both a frontend and backend application to production, triggering these actions whenever changes occur in their respective directories. Plus, we'll tackle the crucial aspect of version bumping. Let's get started!

Understanding the Goal

Our primary objective is to set up an automated system that streamlines the software delivery process. This means that whenever a developer pushes changes to the frontend or backend codebase, our pipeline should automatically:

  1. Build the application.
  2. Run tests to ensure code quality.
  3. If all tests pass, deploy the application to the production environment.

Additionally, we need a mechanism to automatically update the application's version number following semantic versioning principles (x.x.patch, x.minor.patch, major.minor.patch). This is essential for tracking releases and managing dependencies.

Project Structure and Assumptions

Before we begin, let's assume the following project structure:

/
├── frontend/
│   ├── Dockerfile
│   └── ...
├── backend/
│   ├── Dockerfile
│   └── ...
├── .github/
│   └── workflows/
│       ├── frontend.yml
│       └── backend.yml
├── docker-compose.yml (Optional)
└── ...

We have separate directories for the frontend and backend, each containing its own Dockerfile for containerization. The .github/workflows directory will house our GitHub Actions workflow files.

Key Components:

  • Frontend Application: A web application that interacts with the backend.
  • Backend Application: An API or server-side application that serves data to the frontend.
  • Docker: Used to containerize both applications for consistent deployment.
  • GitHub Actions: Our CI/CD platform for automating the build, test, and deployment processes.

Step 1: Setting Up the Backend CI/CD Pipeline

Let's start by configuring the CI/CD pipeline for the backend application. This involves creating a workflow file in .github/workflows directory named backend.yml.

Creating the backend.yml Workflow

Here's a sample backend.yml file:

name: Backend CI/CD

on:
  push:
    branches:
      - main # or your main branch name
    paths:
      - 'backend/**'

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Node.js (if applicable)
        uses: actions/setup-node@v3
        with:
          node-version: '16'

      - name: Install dependencies
        run: cd backend && npm install # or yarn install

      - name: Run tests
        run: cd backend && npm test # or yarn test

      - name: Build Docker image
        run: |
          cd backend
          docker build -t jauntdetour-backend:latest .
          docker tag jauntdetour-backend:latest jauntdetour-backend:${GITHUB_SHA}

      - name: Push Docker image to Docker Hub (or other registry)
        if: github.ref == 'refs/heads/main'
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          docker push jauntdetour-backend:latest
          docker push jauntdetour-backend:${GITHUB_SHA}

      - name: Deploy to Production (example using SSH)
        if: github.ref == 'refs/heads/main'
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            docker stop jauntdetour-backend || true
            docker rm jauntdetour-backend || true
            docker pull jauntdetour-backend:latest
            docker run -d --name jauntdetour-backend -p 8000:8000 jauntdetour-backend:latest

Explanation of the Workflow

  • name: Defines the name of the workflow.
  • on: Specifies the trigger for the workflow. In this case, it's triggered on push events to the main branch when files in the backend/** directory are modified.
  • jobs: Defines the jobs to be executed.
    • build: This job runs on an Ubuntu runner.
      • steps: A sequence of tasks to be performed.
        • Checkout code: Checks out the code from the repository.
        • Set up Node.js: Sets up Node.js environment (if your backend uses Node.js).
        • Install dependencies: Installs the backend dependencies.
        • Run tests: Executes the backend tests.
        • Build Docker image: Builds the Docker image for the backend.
        • Push Docker image to Docker Hub: Pushes the Docker image to Docker Hub (you'll need to configure secrets for your Docker Hub username and password).
        • Deploy to Production: Deploys the application to the production server using SSH (you'll need to configure secrets for your production host, username, and SSH key). This step pulls the latest image from the container registry, stops the currently running container, removes it, and then starts a new container with the updated image. Make sure the port mapping -p 8000:8000 matches your application's port.

Setting Up Secrets

You'll need to configure the following secrets in your GitHub repository settings (Settings -> Secrets -> Actions):

  • DOCKERHUB_USERNAME: Your Docker Hub username.
  • DOCKERHUB_PASSWORD: Your Docker Hub password.
  • PRODUCTION_HOST: The hostname or IP address of your production server.
  • PRODUCTION_USER: The username for SSH access to your production server.
  • PRODUCTION_SSH_KEY: The SSH private key for accessing your production server.

Important Considerations:

  • Security: Never commit your secrets directly to the repository. Always use GitHub Secrets.
  • Deployment Strategy: The deployment step uses a simple SSH command to stop, remove, pull, and run the Docker container. Consider more sophisticated deployment strategies for zero-downtime deployments in production environments, such as blue-green deployments or rolling updates.
  • Error Handling: Add error handling to your deployment script to ensure that deployments are rolled back if they fail.

Step 2: Setting Up the Frontend CI/CD Pipeline

The process for setting up the frontend CI/CD pipeline is very similar to the backend. Create a new workflow file named frontend.yml in the .github/workflows directory.

Creating the frontend.yml Workflow

name: Frontend CI/CD

on:
  push:
    branches:
      - main # or your main branch name
    paths:
      - 'frontend/**'

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16'

      - name: Install dependencies
        run: cd frontend && npm install # or yarn install

      - name: Run tests
        run: cd frontend && npm test # or yarn test

      - name: Build Docker image
        run: |
          cd frontend
          docker build -t jauntdetour-frontend:latest .
          docker tag jauntdetour-frontend:latest jauntdetour-frontend:${GITHUB_SHA}

      - name: Push Docker image to Docker Hub
        if: github.ref == 'refs/heads/main'
        run: |
          docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
          docker push jauntdetour-frontend:latest
          docker push jauntdetour-frontend:${GITHUB_SHA}

      - name: Deploy to Production (example using SSH)
        if: github.ref == 'refs/heads/main'
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            docker stop jauntdetour-frontend || true
            docker rm jauntdetour-frontend || true
            docker pull jauntdetour-frontend:latest
            docker run -d --name jauntdetour-frontend -p 80:80 jauntdetour-frontend:latest

Explanation of the Workflow

This workflow is almost identical to the backend workflow, with the following key differences:

  • name: Frontend CI/CD.
  • paths: Triggers on changes in the frontend/** directory.
  • Docker image name: Uses jauntdetour-frontend for the Docker image.
  • Port mapping: The docker run command maps port 80 to port 80. Adjust this to match your frontend application's port if needed.

Reusing Secrets

You can reuse the same secrets (DOCKERHUB_USERNAME, DOCKERHUB_PASSWORD, PRODUCTION_HOST, PRODUCTION_USER, PRODUCTION_SSH_KEY) that you configured for the backend workflow.

Important Considerations:

  • Frontend Build Process: Ensure that your frontend build process generates optimized and production-ready assets. This might involve bundling, minification, and other optimization techniques.
  • Caching: Consider caching dependencies and build artifacts to speed up the build process. GitHub Actions provides caching mechanisms that can be used to store and retrieve these items.

Step 3: Implementing Version Bumping

Now, let's address the crucial part of automating version bumping. We want to automatically increment the version number (x.x.patch) whenever changes are pushed to the repository, unless it's explicitly specified that the release is a major or minor release.

Versioning Strategy

We'll use a simple semantic versioning strategy:

  • Patch Release (x.x.patch): Incremented for bug fixes and minor changes.
  • Minor Release (x.minor.patch): Incremented for new features that are backward compatible.
  • Major Release (major.minor.patch): Incremented for breaking changes.

Implementation Approach

We'll use a combination of tools and techniques to achieve this:

  1. standard-version: A popular Node.js package that automates version bumping based on commit messages following the Conventional Commits specification.
  2. Commit Message Analysis: Analyze commit messages to determine if a major or minor release is required. Keywords like BREAKING CHANGE or feat: can be used to trigger major or minor version bumps, respectively.
  3. GitHub Actions Workflow Modifications: Modify the workflow files to incorporate the version bumping logic.

Detailed Steps

  1. Install standard-version:

    Add standard-version as a dev dependency to both your frontend and backend projects.

    cd frontend
    npm install --save-dev standard-version
    
    cd backend
    npm install --save-dev standard-version
    
  2. Modify Workflow Files:

    Update your backend.yml and frontend.yml files to include the version bumping steps.

    name: Backend CI/CD
    
    on:
      push:
        branches:
          - main # or your main branch name
        paths:
          - 'backend/**'
    
    jobs:
      build:
        runs-on: ubuntu-latest
    
        steps:
          - name: Checkout code
            uses: actions/checkout@v3
            with:
              fetch-depth: 0 # Important for standard-version
    
          - name: Set up Node.js
            uses: actions/setup-node@v3
            with:
              node-version: '16'
    
          - name: Install dependencies
            run: cd backend && npm install # or yarn install
    
          - name: Run tests
            run: cd backend && npm test # or yarn test
    
          - name: Determine Version Bump
            id: version-bump
            run: |
              cd backend
              if git log -1 --pretty=%B | grep -q "BREAKING CHANGE"; then
                echo "::set-output name=release_type::major" 
              elif git log -1 --pretty=%B | grep -q "feat:"; then
                echo "::set-output name=release_type::minor"
              else
                echo "::set-output name=release_type::patch"
              fi
    
          - name: Bump version and create Changelog
            run: |
              cd backend
              if [ "${{ steps.version-bump.outputs.release_type }}" = "major" ]; then
                npm run standard-version -- --release-as major
              elif [ "${{ steps.version-bump.outputs.release_type }}" = "minor" ]; then
                npm run standard-version -- --release-as minor
              else
                npm run standard-version
              fi
    
          - name: Update package.json version
            id: update-version
            uses: actions/github-script@v6
            with:
              script: |
                const fs = require('fs');
                const path = require('path');
                const packageJsonPath = path.join(__dirname, 'backend', 'package.json');
                const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
                const newVersion = packageJson.version;  
                core.setOutput('new-version', newVersion);
    
          - name: Commit changes
            run: |
              cd backend
              git config --local user.email "actions@github.com"
              git config --local user.name "GitHub Actions"
              git add package.json CHANGELOG.md
              git commit -m "chore(release): v${{ steps.update-version.outputs.new-version }}"
              git push
    
          - name: Create Git tag
            run: |
              cd backend
              git tag v${{ steps.update-version.outputs.new-version }}
              git push origin v${{ steps.update-version.outputs.new-version }}
    
          - name: Build Docker image
            run: |
              cd backend
              docker build -t jauntdetour-backend:latest .
              docker tag jauntdetour-backend:latest jauntdetour-backend:v${{ steps.update-version.outputs.new-version }}
    
          - name: Push Docker image to Docker Hub
            if: github.ref == 'refs/heads/main'
            run: |
              docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_PASSWORD }}
              docker push jauntdetour-backend:latest
              docker push jauntdetour-backend:v${{ steps.update-version.outputs.new-version }}
    
          - name: Deploy to Production (example using SSH)
            if: github.ref == 'refs/heads/main'
            uses: appleboy/ssh-action@v0.1.10
            with:
              host: ${{ secrets.PRODUCTION_HOST }}
              username: ${{ secrets.PRODUCTION_USER }}
              key: ${{ secrets.PRODUCTION_SSH_KEY }}
              script: |
                docker stop jauntdetour-backend || true
                docker rm jauntdetour-backend || true
                docker pull jauntdetour-backend:latest
                docker run -d --name jauntdetour-backend -p 8000:8000 jauntdetour-backend:latest
    
  3. Explanation of the Version Bumping Steps:

    • fetch-depth: 0: This is crucial for standard-version to analyze the commit history.
    • Determine Version Bump: Analyzes the latest commit message for BREAKING CHANGE or feat: keywords to determine the release type (major, minor, or patch).
    • Bump version and create Changelog: Runs standard-version to bump the version number in package.json and generate a CHANGELOG.md file.
    • Update package.json version: Gets the version from package.json and stores as the output variable.
    • Commit changes: Commits the updated package.json and CHANGELOG.md files.
    • Create Git tag: Creates a Git tag for the new version.
    • Build Docker image: Builds the Docker image with the new version tag.
    • Push Docker image to Docker Hub: Pushes the Docker image to Docker Hub with the new version tag.

Important Considerations:

  • Conventional Commits: Encourage your team to follow the Conventional Commits specification for writing commit messages. This ensures that standard-version can accurately determine the appropriate version bump.
  • Customizable Versioning: standard-version is highly customizable. You can configure it to use different commit message patterns or versioning schemes.

Step 4: Testing the Pipeline

Now that we've set up the CI/CD pipelines, it's time to test them out! Make a small change to either the frontend or backend codebase and push it to the main branch. Monitor the GitHub Actions tab in your repository to see the pipeline in action. Verify that the application is successfully built, tested, and deployed to production. Also, check your container registry to confirm that the Docker images are being pushed with the correct tags.

Conclusion

That's it! You've successfully created a CI/CD pipeline using GitHub Actions for your frontend and backend applications. This pipeline automates the build, test, and deployment processes, making it easier to deliver high-quality software quickly and efficiently. Remember to adapt these examples to fit your specific project needs and explore the many other features and capabilities of GitHub Actions.

By implementing these steps, you'll have a fully automated CI/CD pipeline that streamlines your development workflow and ensures that your applications are always up-to-date. This not only saves you time and effort but also reduces the risk of errors and improves the overall quality of your software.