Stackvora.tech

CI/CD with GitHub Actions: From Push to Production

Implementing robust Continuous Integration and Continuous Delivery (CI/CD) pipelines is fundamental for modern software development teams to achieve…

Implementing robust Continuous Integration and Continuous Delivery (CI/CD) pipelines is fundamental for modern software development teams to achieve rapid, reliable, and automated software releases. GitHub Actions provides a native, integrated solution for defining and executing these pipelines directly within your GitHub repositories. This article details building a comprehensive CI/CD workflow, from code push through automated testing, containerization, and deployment to a production environment, using a practical example of a Node.js application.

The core of GitHub Actions is the workflow file, typically a YAML file located in the .github/workflows/ directory of your repository. These files define a series of jobs and steps that execute in response to specific events, such as a push to a branch or a pull_request. We'll explore how to structure these workflows for a multi-stage CI/CD process, leveraging GitHub's hosted runners and integrating with container registries and cloud providers.

Understanding GitHub Actions Workflow Fundamentals

A GitHub Actions workflow is composed of several key elements. An understanding of these components is crucial for designing effective pipelines.

  • name: A human-readable name for your workflow.
  • on: Defines the events that trigger the workflow. Common events include push, pull_request, workflow_dispatch (manual trigger), and schedule (cron-like timing).
  • jobs: A workflow consists of one or more jobs. Each job runs independently by default but can be configured to depend on others using needs.
  • runs-on: Specifies the type of runner that will execute the job. GitHub provides hosted runners for Ubuntu, Windows, and macOS, or you can use self-hosted runners.
  • steps: A sequence of tasks executed within a job. Steps can run commands, execute scripts, or use pre-built "actions" from the GitHub Marketplace.
  • uses: Integrates an action from the GitHub Marketplace or a specified repository. Actions encapsulate common tasks, like checking out code or setting up language environments.
  • with: Passes input parameters to an action.
  • env: Defines environment variables for a step or job.
  • secrets: Securely stores sensitive information, like API keys or passwords, which can be accessed within workflows.
  • if: Conditional execution of steps or jobs based on expressions.

Example: Basic CI Workflow for Node.js

Let's start with a foundational CI workflow that performs testing and linting for a Node.js application. This workflow will be triggered on every push to main and any pull request.

# .github/workflows/ci.yml
name: CI Workflow

on:
  push:
    branches: [main, develop] # Trigger on pushes to main or develop
  pull_request:
    branches: [main, develop] # Trigger on pull requests targeting main or develop

jobs:
  build-and-test:
    runs-on: ubuntu-latest # Use a GitHub-hosted Ubuntu runner
    steps:
      - name: Checkout code
        uses: actions/checkout@v4 # Action to check out your repository's code

      - name: Setup Node.js
        uses: actions/setup-node@v4 # Action to set up a Node.js environment
        with:
          node-version: '20' # Specify Node.js version 20
          cache: 'npm' # Cache npm dependencies for faster builds

      - name: Install dependencies
        run: npm ci # Install project dependencies using npm ci

      - name: Run ESLint
        run: npm run lint # Execute linting scripts

      - name: Run unit tests
        run: npm test -- --coverage # Run tests, generating coverage reports

      - name: Upload coverage report
        if: always() # Ensure this step runs even if previous steps fail
        uses: actions/upload-artifact@v4 # Upload test coverage as an artifact
        with:
          name: coverage-report
          path: coverage/lcov.info # Adjust path based on your coverage tool output

This workflow uses actions/checkout@v4 to get the code, actions/setup-node@v4 to configure the Node.js environment (caching npm dependencies to speed up subsequent runs), and then executes standard npm commands for installing dependencies, linting, and running tests. The actions/upload-artifact@v4 step stores test coverage reports, which can be useful for later analysis or external services.

Containerization and Image Publishing

For cloud-native applications, containerization is a standard practice. After successful CI, the next step often involves building a Docker image and pushing it to a container registry. We'll extend our workflow to include this step, building an image and pushing it to GitHub Container Registry (GHCR).

Integrating Docker Build and Push

This job will depend on the successful completion of the build-and-test job. It will only run on pushes to the main branch to ensure only production-ready code is containerized and published.

# .github/workflows/ci.yml (continued)
  docker:
    needs: build-and-test # This job depends on 'build-and-test' job
    if: github.ref == 'refs/heads/main' # Only run if the push is to the 'main' branch
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write # Grant permission to write to GitHub Packages (GHCR)
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }} # The GitHub user who triggered the workflow
          password: ${{ secrets.GITHUB_TOKEN }} # GitHub's automatically generated token

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }} # Image name for GHCR
          tags: |
            type=sha,format=long # Tag with full commit SHA
            type=ref,event=branch # Tag with branch name (e.g., main)
            type=semver,pattern={{version}} # Optional: Tag with semantic version if applicable

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: . # Build context is the root of the repository
          push: true
          tags: ${{ steps.meta.outputs.tags }} # Use tags generated by docker/metadata-action
          labels: ${{ steps.meta.outputs.labels }} # Use labels generated by docker/metadata-action
          cache-from: type=gha # Cache layers using GitHub Actions cache
          cache-to: type=gha,mode=max # Store layers in GitHub Actions cache

Key aspects of this job:

  • needs: build-and-test: Ensures the image is built only after tests pass.
  • if: github.ref == 'refs/heads/main': Crucial for ensuring only the production branch triggers image builds and pushes.
  • permissions: packages: write: Explicitly grants the workflow permission to publish packages to GHCR. This is important for security best practices.
  • docker/login-action@v3: Authenticates with GHCR using the automatically provided GITHUB_TOKEN.
  • docker/metadata-action@v5: A powerful action that automatically generates Docker image tags and labels based on Git references (branch, tag, SHA), pull request numbers, and semantic versioning. This simplifies tag management significantly.
  • docker/build-push-action@v5: Builds the Docker image from the current context and pushes it to GHCR. It leverages GitHub Actions cache (type=gha) for faster rebuilds by reusing image layers. The tags and labels inputs are populated by the metadata-action.

Deployment to Production Environment

The final stage is deploying the successfully built and pushed Docker image to a production environment. This step will also typically run only on the main branch and after the Docker image has been successfully pushed. For this example, we'll demonstrate a deployment to a Kubernetes cluster using kubectl, but similar principles apply to other platforms like AWS ECS, Azure AKS, or Google GKE.

Example: Kubernetes Deployment

This job assumes you have a Kubernetes cluster and a deployment manifest. We'll use actions/setup-kubectl@v3 to configure kubectl and then apply the manifest.

# .github/workflows/ci.yml (continued)
  deploy:
    needs: docker # Depends on the Docker build and push job
    if: github.ref == 'refs/heads/main' # Only deploy from the main branch
    runs-on: ubuntu-latest
    environment: Production # Link to a GitHub Environments for better security and tracking
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Kubeconfig
        run: |
          mkdir -p ~/.kube
          echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config # Store Kubeconfig from a secret
          chmod 600 ~/.kube/config # Set appropriate permissions
        env:
          KUBECONFIG: ${{ secrets.KUBECONFIG }} # Access Kubeconfig from GitHub Secrets

      - name: Setup kubectl
        uses: azure/setup-kubectl@v3 # Action to install and configure kubectl

      - name: Get image tag from Docker metadata
        id: get_image_tag
        run: |
          # The image tag for a main branch push using SHA will be ghcr.io/:
          # We need to extract the exact SHA tag that was pushed.
          # This assumes the 'docker' job used 'type=sha,format=long'
          echo "image_tag=ghcr.io/${{ github.repository }}:${{ github.sha }}" >> $GITHUB_OUTPUT

      - name: Deploy to Kubernetes
        run: |
          # Replace placeholder in deployment manifest with the actual image tag
          # This assumes your deployment.yaml has a placeholder like 'IMAGE_TAG_PLACEHOLDER'
          sed -i "s|IMAGE_TAG_PLACEHOLDER|${{ steps.get_image_tag.outputs.image_tag }}|g" k8s/deployment.yaml
          kubectl apply -f k8s/deployment.yaml -n production # Apply the deployment manifest
          kubectl rollout status deployment/your-app-deployment -n production # Wait for rollout to complete
        working-directory: ./ # Adjust if your k8s manifests are in a sub-directory

Considerations for deployment:

  • environment: Production: Links this job to a GitHub Environment named "Production". Environments provide benefits like required reviewers, wait timers, and environment-specific secrets.
  • secrets.KUBECONFIG: The Kubernetes configuration file, including credentials, should be stored as a GitHub Secret. This is critical for security. The secret's content is typically base64 encoded or directly pasted YAML.
  • azure/setup-kubectl@v3: An action to conveniently install kubectl on the runner.
  • Image Tagging: The docker/metadata-action from the previous job ensures consistent tagging. We retrieve the exact SHA-based tag pushed to GHCR to ensure the correct image version is deployed.
  • sed -i "s|IMAGE_TAG_PLACEHOLDER|...|g" k8s/deployment.yaml: A common pattern to dynamically update the image tag in your Kubernetes deployment manifest before applying it. Ensure your k8s/deployment.yaml contains a placeholder like image: IMAGE_TAG_PLACEHOLDER.
  • kubectl rollout status: Essential for ensuring the deployment completes successfully before the workflow declares success.

Common Pitfalls and Troubleshooting

  • Incorrect permissions: For actions interacting with GHCR (packages: write) or protected resources (like GitHub Environments, which might require id-token: write for OIDC), ensure the correct permissions are set at the job level.
  • Secrets Access: Secrets are not automatically passed to composite actions. Ensure they are explicitly passed using the with block if an action expects them, or directly in env for scripts. Always use ${{ secrets.MY_SECRET }}.
  • Environment Variables: GitHub Actions provides default environment variables (e.g., GITHUB_SHA, GITHUB_REF). Refer to the GitHub Actions documentation for a complete list.
  • Runner Resource Limits: Large projects might hit memory or CPU limits on GitHub's hosted runners. Consider optimizing build steps, caching, or using self-hosted runners.
  • Caching Issues: Ensure your cache keys are effective. A common issue is not invalidating the cache when dependencies change.
  • Conditional Execution (if): Pay close attention to the syntax and context. Use github.ref == 'refs/heads/main' for branch-specific logic, or success(), failure(), always() for step outcome-based conditions.
  • Debugging Workflows: Add echo statements throughout your workflow to print variables. For failed jobs, examine the step logs carefully; error messages often provide direct clues. You can also re-run failed jobs with SSH access if you have a self-hosted runner.
  • YAML Syntax Errors: YAML is whitespace-sensitive. Use a linter or a good IDE with YAML support to catch syntax errors early.

Back to the knowledge base · Ask the AI assistant