Building a CI/CD Pipeline from Scratch with GitHub Actions

CI/CD Pipeline Tutorial

Phase 1: Code Quality & Linting

Overview & Objectives

High code quality prevents bugs early in the development cycle. In this phase, you’ll configure automated linting workflows to enforce style rules, catch syntax errors, and generate shareable reports. By the end, you will:

  • Understand GitHub Actions workflow structure for code quality
  • Configure ESLint for JavaScript/TypeScript and Flake8/Black for Python
  • Cache dependencies to speed up CI runs
  • Upload lint results as artifacts for team visibility
  • Verify and exercise your workflows end-to-end

Prerequisites

  • A GitHub repository containing your application code
  • JavaScript/TypeScript projects should have eslint configured (npm install eslint --save-dev)
  • Python projects should have flake8, black, mypy configured (pip install flake8 black mypy)
  • Basic understanding of GitHub Actions and YAML

Step 1: Create the Lint Workflow File

Create a file .github/workflows/lint.yml in your repo root. This defines triggers and jobs for linting.

name: Code Linting
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

Step 2: Define the Lint Job

Under jobs:, define a lint job running on Ubuntu.

jobs:
  lint:
    name: Lint Code
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

Step 3: Configure JavaScript/TypeScript Linting

Add steps to set up Node.js, install dependencies, run ESLint, and upload the JSON report.

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: |
          npm run lint -- --format=json --output-file=eslint-report.json
          npm run lint
        continue-on-error: true

      - name: Upload ESLint report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: eslint-report
          path: eslint-report.json
Tip: Use continue-on-error: true so the workflow continues to upload the report even if lint fails.

Step 4: Configure Python Linting

For Python projects, add a separate step block to run Flake8, Black, and MyPy.

      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install Python tools
        run: pip install flake8 black mypy

      - name: Run Flake8
        run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics

      - name: Run Black
        run: black --check --diff .

      - name: Run MyPy
        run: mypy . --ignore-missing-imports

Step 5: Verify Caching

Ensure dependency installation is cached across runs. The cache: npm option in setup-node reuses the ~/.npm directory.

Step 6: Full Workflow Example

name: Code Linting
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci

      - run: |
          npm run lint -- --format=json --output-file=eslint-report.json
          npm run lint
        continue-on-error: true
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: eslint-report
          path: eslint-report.json

      - uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      - run: pip install flake8 black mypy
      - run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
      - run: black --check --diff .
      - run: mypy . --ignore-missing-imports

Verify Your Work

  • 🔍 Create a branch feature/lint-test, introduce a lint error, and open a PR.
  • ✅ Confirm “Lint Code” job runs, and ESLint report artifact appears.
  • 📂 Download eslint-report.json and inspect errors.

Exercises

  1. Create a separate workflow python-lint.yml that only runs Python linters and uploads a flake8-report.txt artifact.
  2. Modify ESLint configuration to enforce a maximum line length of 100 and verify failures for >100 character lines.

***

Phase 2: Comprehensive Testing Framework

Overview & Objectives

Thorough testing uncovers defects before production. In this phase, you’ll set up a GitHub Actions workflow that runs unit, integration, and end-to-end tests in parallel using a test matrix. You will:

  • Configure a test matrix for three test types
  • Spin up dependent services—PostgreSQL for integration tests
  • Upload coverage reports only for unit tests
  • Optimize concurrency with fail-fast and max-parallel
  • Verify results and extend with a “smoke” test

Prerequisites

  • Node.js project with scripts:
    npm run test:unit, npm run test:integration, npm run test:e2e
  • Python and pytest for Python projects (if applicable)
  • Codecov or similar coverage uploader configured
  • Basic Docker familiarity for services

Step 1: Create Test Workflow

Create .github/workflows/test.yml:

name: Test Pipeline
on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

Step 2: Define Test Job & Matrix

Under jobs:, define test with matrix strategy:

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 3
      matrix:
        test-type: [unit, integration, e2e]

Step 3: Add PostgreSQL Service

Integration tests often require a database. Use services: to start PostgreSQL:

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

Step 4: Checkout & Install

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '18'
          cache: npm

      - name: Install dependencies
        run: npm ci

Step 5: Run Tests by Type

Use a bash case to execute each test type:

      - name: Run ${{ matrix.test-type }} tests
        run: |
          case ${{ matrix.test-type }} in
            unit)
              npm run test:unit -- --coverage --ci
              ;;
            integration)
              npm run test:integration -- --ci --testTimeout=30000
              ;;
            e2e)
              npm run test:e2e -- --headless
              ;;
          esac

Step 6: Upload Coverage for Unit Tests

Only unit tests generate coverage:

      - name: Upload coverage report
        uses: codecov/codecov-action@v4
        if: matrix.test-type == 'unit'
        with:
          file: ./coverage/lcov.info
          flags: ${{ matrix.test-type }}
Warning: Ensure your unit tests create coverage/lcov.info. If missing, the upload step will fail.

Step 7: Verify Workflow

  • ▶️ Push to a feature branch and open a PR.
  • 🔢 Confirm three parallel jobs: unit, integration, e2e.
  • 📂 Download the coverage artifact for unit tests.

Exercises

  1. Add a fourth test type, smoke, running a minimal health check script (npm run test:smoke), and include it in the matrix.
  2. Modify the workflow to skip end-to-end tests for pull requests from forks for speed and security.

Extended Tips

  • Retry Tests: For flaky tests, wrap npm run in a retry loop: for i in {1..3}; do npm run test && break; done.
  • Parallelize within a job: Use npm run test:unit -- --maxWorkers=4 to parallelize Jest tests.
  • Test Reports: Generate JUnit XML (--reporters=junit) and upload with actions/upload-artifact for CI dashboards.

Phase 3: Build Optimization & Artifact Management

Why It Matters

Efficient builds accelerate delivery, reduce CI time and cost, and enable reliable downstream jobs. Multi-stage Docker builds produce minimal runtime images, BuildKit caching slashes rebuild times, and artifact uploads decouple build and deploy stages.

What You’ll Learn

  • Multi-stage Dockerfile architecture
  • Inline BuildKit caching in GitHub Actions
  • Cross-platform images with Buildx
  • Uploading images and caches as artifacts

Prerequisites

  • Project Dockerfile supporting production build
  • Familiarity with Docker layers and caching
  • GitHub Actions runner with Docker support

1. Sample Multi-Stage Dockerfile

# Builder stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage
FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --production
EXPOSE 3000
CMD ["node","dist/index.js"]

2. GitHub Actions Workflow

name: Build & Cache
on:
  push:
    branches: [ main, develop ]

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        platform: [linux/amd64,linux/arm64]
      fail-fast: false

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

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build with inline cache
        uses: docker/build-push-action@v5
        with:
          context: .
          file: Dockerfile
          platforms: ${{ matrix.platform }}
          tags: myapp:${{ github.sha }}-${{ matrix.platform }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Save image as artifact
        run: |
          docker save myapp:${{ github.sha }}-${{ matrix.platform }} \
            | gzip > image-${{ matrix.platform }}.tar.gz
      - uses: actions/upload-artifact@v4
        with:
          name: image-${{ matrix.platform }}
          path: image-${{ matrix.platform }}.tar.gz
Tip: Use type=gha caching to eliminate manual cache upload steps and leverage GitHub’s built-in cache storage.

3. Verify the Build

  • ✅ Confirm 2 parallel jobs (amd64 & arm64) complete under “Actions.”
  • 📂 Download image-linux/amd64.tar.gz and run gunzip -c image-*.tar.gz | docker load.
  • ⚡ Re-run push to see caching speedup.

Phase 4: Deployment Automation with Blue/Green and Helm

Why Blue/Green Deployments?

Blue/green strategies reduce downtime and risk by running two identical production environments (“blue” and “green”), switching traffic to the new version only after smoke tests pass.

Prerequisites

  • Helm charts in charts/myapp
  • Kubernetes cluster context via KUBECONFIG or service account

1. Workflow Configuration

name: Helm Blue/Green Deploy
on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: ${{ steps.url.outputs.url }}
    steps:
      - uses: actions/checkout@v4

      - name: Setup Helm & kubectl
        uses: azure/setup-helm@v3
        with:
          version: '3.12.0'
      - uses: azure/setup-kubectl@v3
        with:
          version: '1.27.0'

2. Determine Active Color

      - name: Get active color
        id: active
        run: |
          BLUE_EXISTS=$(helm status myapp-blue -n prod && echo yes || echo no)
          if [ "$BLUE_EXISTS" = yes ]; then
            echo "color=green" >> $GITHUB_OUTPUT
          else
            echo "color=blue" >> $GITHUB_OUTPUT
          fi

3. Deploy Inactive Color

      - name: Deploy ${{ steps.active.outputs.color }}
        run: |
          helm upgrade --install myapp-${{ steps.active.outputs.color }} charts/myapp \
            --namespace prod \
            --set image.tag=${{ github.sha }} \
            --wait --timeout 180s

4. Smoke Test & Swap

      - name: Smoke test
        run: |
          HOSTNAME=$(kubectl get svc myapp-${{ steps.active.outputs.color }} -n prod \
            -o jsonpath='{.status.loadBalancer.ingress.hostname}')
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://$HOSTNAME/health)
          if [ "$STATUS" != "200" ]; then exit 1; fi

      - name: Switch service
        run: |
          kubectl patch svc myapp -n prod \
            -p '{"spec":{"selector":{"app":"myapp-'${{ steps.active.outputs.color }}'"}}}'
      - name: Cleanup old
        run: |
          OLD=$( [ "${{ steps.active.outputs.color }}" = "blue" ] && echo green || echo blue )
          helm uninstall myapp-$OLD -n prod

5. Verify Deployment

  • 🌐 Visit myapp service URL to confirm new version.
  • 🔄 Inspect Helm releases: helm list -n prod.

5. Phase 5: Advanced Secrets & Environment Management

What You’ll Learn

  • Integrate HashiCorp Vault and AWS Parameter Store
  • Use External Secrets Operator for Kubernetes
  • Automate secret freshness checks

5.1 HashiCorp Vault Integration

- name: Vault Login
  env:
    VAULT_ADDR: ${{ secrets.VAULT_ADDR }}
    VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
  run: |
    vault kv get -field=password secret/data/myapp/db > db_password.txt

- name: Use DB Password
  run: |
    DB_PASS=$(cat db_password.txt)
    echo "Connecting with DB_PASS=$DB_PASS"

5.2 AWS Parameter Store

- name: Fetch AWS Parameter
  run: |
    aws ssm get-parameter --name "/myapp/db" --with-decryption \
      --query "Parameter.Value" --output text > db_pass.txt

- name: Use DB Password
  run: |
    DB_PASS=$(cat db_pass.txt)
    echo "Param store DB_PASS=$DB_PASS"

5.3 Kubernetes External Secrets

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-db-secret
spec:
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: myapp-db
  data:
    - secretKey: password
      remoteRef:
        key: secret/data/myapp/db
        property: password

Apply via:

kubectl apply -f external-secret.yaml

5.4 Secret Freshness Check

- name: Check Secret Expiry
  run: |
    if [[ -n "${{ secrets.API_TOKEN_EXPIRES }}" ]]; then
      expires=$(date -d "${{ secrets.API_TOKEN_EXPIRES }}" +%s)
      now=$(date +%s)
      days=$(( (expires - now) / 86400 ))
      if [ $days -lt 7 ]; then
        echo "Warning: token expires in $days days"
      fi
    fi

***

6. Phase 6: Troubleshooting & Debugging Deep Dive

What You’ll Learn

  • Enable verbose runner and step logging
  • Use tmate for live debugging
  • Analyze real error logs and apply fixes

6.1 Verbose Logging

env:
  ACTIONS_RUNNER_DEBUG: true
  ACTIONS_STEP_DEBUG: true

6.2 Interactive tmate Session

- name: Setup tmate
  if: failure()
  uses: mxschmitt/action-tmate@v3
  with:
    limit-access-to-actor: true
    github-token: ${{ secrets.GITHUB_TOKEN }}

6.3 Sample Error Log Analysis

npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! myapp@1.0.0 test:unit: `jest --config jest.config.js`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the myapp@1.0.0 test:unit script.

**Fix:** Ensure jest.config.js exists, check syntax errors in tests, install missing devDependencies.

***

7. Phase 7: Performance & Cost Analysis

What You’ll Learn

  • Select cost-effective runners
  • Balance matrix parallelism vs. cost
  • Implement artifact retention policies

7.1 Runner Cost Comparison

Runner Type Price/min Avg Build Time Cost/build
GitHub Ubuntu $0.008 8m $0.064
Self-hosted ARM $0.005 9m $0.045

7.2 Matrix Concurrency Trade-off

With max-parallel: 4, 12 jobs run in 3 waves. Reducing to 2 increases waves but halves peak concurrency cost.

7.3 Artifact Retention

uses: actions/upload-artifact@v4
with:
  name: build-artifacts
  path: dist/
  retention-days: 3  # default 90 days

***

8. Phase 8: Extended Case Studies & Diagrams

8.1 E-Commerce Modernization

Architecture Diagram:

  • Monorepo with 50 microservices
  • Selective builds via git diff
  • Parallel matrix builds

Key Metrics:

Metric Before After
Avg Build Time 15m 5m (↓67%)
Failure Rate 23% 2.5% (↓89%)
Infra Cost $2.8M/yr $0.5M/yr (↓82%)

8.2 Open Source Automation

Workflow Automations:

  • Auto-label first-time contributors
  • Mandatory CodeQL scans on PRs

Impact:

Metric Before After
First-time Merge Rate 12% 52% (↑340%)
Vulnerabilities 45/mo 10/mo (↓78%)

8.3 Financial Compliance Pipeline

Compliance Steps:

  1. Generate immutable metadata JSON
  2. Post to internal audit log API
  3. Verify CloudTrail logs daily

Results:

  • 100% audit pass rate over 24 quarters
  • Manual effort ↓60%

Section 3 of 4: Custom Actions, Advanced YAML Deep Dive, Bonus Tips

9. Developing a Custom GitHub Action in JavaScript

What You’ll Learn

  • Structure of a JavaScript-based GitHub Action
  • Using the GitHub Actions toolkit
  • Publishing an action to the GitHub Marketplace

Prerequisites

  • Node.js and npm installed locally
  • Basic JavaScript knowledge
  • Familiarity with Git and GitHub repositories

1. Scaffold the Action

mkdir actions/hello-world
cd actions/hello-world
npm init -y
npm install @actions/core @actions/github

2. Write the Action Code

// index.js
const core = require('@actions/core');
const github = require('@actions/github');

async function run() {
  try {
    const name = core.getInput('name');
    console.log(`::notice::Hello, ${name}!`);
    core.setOutput('greeting', `Hello, ${name}!`);
  } catch (error) {
    core.setFailed(error.message);
  }
}

run();

3. Define Action Metadata

# action.yml
name: 'Hello World Action'
description: 'Greets the user by name'
inputs:
  name:
    description: 'Name to greet'
    required: true
outputs:
  greeting:
    description: 'The greeting message'
runs:
  using: 'node16'
  main: 'index.js'

4. Use Your Action in a Workflow

jobs:
  demo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/hello-world
        with:
          name: 'Perplexity AI'
      - run: echo "Greeting: ${{ steps.demo.outputs.greeting }}"

5. Publish to Marketplace

  1. Create a public GitHub repo for the action.
  2. Tag a release matching the version in action.yml.
  3. Go to the repository’s “Actions” → “New marketplace release.”

***

10. Advanced YAML Deep Dive

10.1 Dynamic Matrix Generation

Generate matrix values at runtime via fromJson and outputs from previous jobs.

jobs:
  prepare:
    runs-on: ubuntu-latest
    outputs:
      services: ${{ steps.set.outputs.services }}
    steps:
      - id: set
        run: |
          services=$(jq -r '.services | join(",")' services.json)
          echo "::set-output name=services::$services"

  deploy:
    needs: prepare
    strategy:
      matrix:
        service: ${{ fromJson(needs.prepare.outputs.services) }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying ${{ matrix.service }}"

10.2 Conditional Job Execution

Use expressions in if to skip jobs based on variables or files changed.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

  publish-docs:
    needs: build
    runs-on: ubuntu-latest
    if: contains(join(github.event.head_commit.modified, ''), 'docs/')
    steps:
      - run: echo "Publishing docs..."

10.3 Using Anchors for Large Configs

Define a reusable step block and merge with <<: *.

defaults: &defaults
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4

jobs:
  job1:
    <<: *defaults
    steps:
      - run: echo "Job1"

  job2:
    <<: *defaults
    steps:
      - run: echo "Job2"

***

11. Bonus Tips & Tricks

  • Workflow Visualization: View DAG graph under “Actions” to understand job dependencies.
  • Reusable Workflows: Break common tasks into workflow_call workflows.
  • Self-hosted Runners: Tag runners and use runs-on: [self-hosted, linux] for dedicated infrastructure.
  • Security Scanning: Integrate trivy or anchore as pre-deploy steps.
  • Notifications: Send Slack/Teams alerts via webhook actions on failure or on threshold metrics.

Section 4 of 4: Conclusion, Roadmap Recap, Bonus Case Study & Diagrams

12. Conclusion & Action Plan Recap

Congratulations on building a robust, scalable CI/CD pipeline with GitHub Actions! Here’s your action plan:

  • Phase 1: Implement linting workflows with ESLint and Python linters; verify artifacts.
  • Phase 2: Configure a test matrix for unit, integration, and E2E tests; upload coverage.
  • Phase 3: Optimize builds with multi-stage Dockerfiles and BuildKit caching; manage artifacts.
  • Phase 4: Automate deployments using blue/green, Helm, or kubectl; include rollbacks.
  • Phase 5: Secure secrets via Vault, Parameter Store, and External Secrets Operator.
  • Phase 6: Troubleshoot with verbose logs, tmate sessions, and real log analysis.
  • Phase 7: Drive performance and cost efficiency: runner selection, matrix tuning, retention policies.
  • Extended Sections: Develop custom GitHub Actions, leverage advanced YAML, and apply best practices.

13. Bonus Case Study: SaaS Multi-Tenant Deployment

Background: SaaS platform with per-tenant isolated environments, on-demand deployments for 100+ tenants.

Challenges

  • Provision dedicated namespaces per tenant
  • Automate isolated deployments
  • Manage secrets and resource quotas per namespace

Solution Outline

  1. Define a tenant list in JSON in the repo (tenants.json).
  2. Generate dynamic matrix jobs to loop through tenants.
  3. Use Helm with --create-namespace and --namespace flags.
  4. Inject tenant-specific secrets via Kubernetes External Secrets.
  5. Monitor per-namespace resource usage with Prometheus.

Sample Workflow Snippet

jobs:
  deploy-tenants:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        tenant: ${{ fromJson(needs.prepare.outputs.tenants) }}
    needs: prepare
    steps:
      - uses: actions/checkout@v4
      - name: Deploy tenant
        run: |
          helm upgrade --install myapp-${{ matrix.tenant }} charts/myapp \
            --namespace tenant-${{ matrix.tenant }} \
            --create-namespace \
            --set image.tag=${{ github.sha }}

14. Architecture Diagrams

Embed these diagrams in your blog for visual clarity:

  • CI/CD Workflow Diagram: Show each GitHub Actions phase and artifact flow.
  • Blue/Green Deployment: Illustrate traffic switch between environments.
  • SaaS Multi-Tenant: Depict per-tenant namespaces and dynamic matrices.

Leave a Reply

Your email address will not be published. Required fields are marked *