GITHUB ACTIONS CI/CD PIPELINE git push Trigger Workflow on: push Lint Code Quality Checks eslint, fmt Test Unit / Integration Tests jest, pytest Build Compile Artifacts npm build Deploy Production Environment CF Pages, AWS .github/workflows/ci.yml Every git push triggers automated lint, test, build, and deploy steps

Every modern development team needs a reliable way to test, build, and deploy code automatically. GitHub Actions provides a powerful CI/CD platform built directly into GitHub, eliminating the need for external services like Jenkins or CircleCI. In this guide, you will learn how to create workflows from scratch, run automated tests, build artifacts, and deploy applications — all triggered by a simple git push.

What Is CI/CD?

Continuous Integration (CI) is the practice of automatically testing and validating code every time a developer pushes changes. Instead of waiting until release day to discover bugs, CI catches them immediately.

Continuous Deployment (CD) extends CI by automatically deploying validated code to staging or production environments. Together, CI/CD creates a pipeline that takes code from commit to production with minimal manual intervention.

Why it matters: Teams using CI/CD ship faster, catch bugs earlier, and spend less time on manual release processes. A well-configured pipeline can reduce deployment time from hours to minutes.

GitHub Actions Core Concepts

Before writing your first workflow, understand these building blocks:

Workflows

A workflow is an automated process defined in a YAML file inside .github/workflows/. A repository can have multiple workflows — one for testing, another for deployment, and so on.

Jobs

A workflow contains one or more jobs. By default, jobs run in parallel, but you can configure them to run sequentially using dependencies.

Steps

Each job contains a series of steps. A step can either run a shell command or use a pre-built action from the GitHub Marketplace.

Runners

A runner is the virtual machine that executes your jobs. GitHub provides hosted runners with Ubuntu, Windows, and macOS. You can also configure self-hosted runners for specialized needs.

Actions

Actions are reusable units of code. The GitHub Marketplace offers thousands of community-built actions for common tasks like checking out code, setting up language runtimes, and deploying to cloud providers.

Creating Your First Workflow

Create a file at .github/workflows/ci.yml in your repository:

name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

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

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

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run tests
        run: npm test

This workflow does the following:

  1. Triggers on pushes to main or develop, and on pull requests targeting main
  2. Runs on the latest Ubuntu runner
  3. Checks out your repository code
  4. Sets up Node.js version 20
  5. Installs dependencies using npm ci (clean install, faster and more reliable than npm install)
  6. Runs the linter and test suite

Push this file to your repository and navigate to the Actions tab to watch it execute.

Workflow Triggers

GitHub Actions supports many trigger events. Here are the most common:

on:
  # Trigger on push to specific branches
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'package.json'

  # Trigger on pull requests
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]

  # Scheduled runs (cron syntax)
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 6 AM UTC

  # Manual trigger from the GitHub UI
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        default: 'staging'
        type: choice
        options:
          - staging
          - production

Tip: Use paths filters to avoid running expensive workflows when only documentation files change. This saves runner minutes and speeds up your feedback loop.

Using Pre-Built Actions

The actions/ organization on GitHub provides essential actions:

steps:
  # Checkout your code
  - uses: actions/checkout@v4

  # Setup various language runtimes
  - uses: actions/setup-node@v4
    with:
      node-version: '20'

  - uses: actions/setup-python@v5
    with:
      python-version: '3.12'

  - uses: actions/setup-go@v5
    with:
      go-version: '1.22'

  # Upload build artifacts
  - uses: actions/upload-artifact@v4
    with:
      name: build-output
      path: dist/

  # Download artifacts from another job
  - uses: actions/download-artifact@v4
    with:
      name: build-output

Always pin actions to a specific major version (e.g., @v4) to avoid unexpected breaking changes.

Environment Variables and Secrets

Environment Variables

Define variables at the workflow, job, or step level:

env:
  NODE_ENV: production
  API_URL: https://api.knowledgexchange.xyz

jobs:
  build:
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: postgres://localhost:5432/testdb
    steps:
      - name: Print environment
        run: echo "Building for $NODE_ENV"
        env:
          BUILD_NUMBER: ${{ github.run_number }}

Secrets

Never hardcode sensitive values. Store them in Settings > Secrets and variables > Actions, then reference them:

steps:
  - name: Deploy to production
    run: ./deploy.sh
    env:
      API_KEY: ${{ secrets.API_KEY }}
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Warning: Secrets are masked in logs but can still be accidentally exposed. Never echo secrets directly or write them to files that get uploaded as artifacts.

Matrix Builds

Test across multiple versions, operating systems, or configurations simultaneously:

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18, 20, 22]
      fail-fast: false

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

      - run: npm ci
      - run: npm test

This creates 9 parallel jobs (3 OS x 3 Node versions). Setting fail-fast: false ensures all combinations run even if one fails, giving you a complete picture of compatibility.

Excluding and Including Combinations

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    node-version: [18, 20]
    exclude:
      - os: windows-latest
        node-version: 18
    include:
      - os: ubuntu-latest
        node-version: 22
        experimental: true

Caching Dependencies

Speed up workflows by caching downloaded dependencies:

steps:
  - uses: actions/checkout@v4

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

  - run: npm ci

For more control, use the cache action directly:

steps:
  - name: Cache pip packages
    uses: actions/cache@v4
    with:
      path: ~/.cache/pip
      key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
      restore-keys: |
        ${{ runner.os }}-pip-

  - name: Install dependencies
    run: pip install -r requirements.txt

The cache key is based on the hash of your lock file, so the cache automatically invalidates when dependencies change.

Building and Uploading Artifacts

Build your application and save the output for later jobs or download:

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

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

      - run: npm ci
      - run: npm run build

      - name: Upload build artifact
        uses: actions/upload-artifact@v4
        with:
          name: production-build
          path: dist/
          retention-days: 7

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifact
        uses: actions/download-artifact@v4
        with:
          name: production-build
          path: dist/

      - name: Deploy
        run: echo "Deploying from dist/ directory"

The needs: build keyword ensures the deploy job waits for the build job to complete successfully.

Practical Example: Node.js Application

Here is a complete CI/CD pipeline for a Node.js application:

name: Node.js CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

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

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

      - run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run unit tests
        run: npm test -- --coverage

      - name: Upload coverage report
        if: github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  build:
    needs: lint-and-test
    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 build

      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/

  deploy:
    needs: build
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy dist/ --project-name=my-app

Practical Example: Python Application

name: Python CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.11', '3.12']

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: testpassword
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install -r requirements-dev.txt

      - name: Run flake8 linting
        run: flake8 src/ --count --show-source --statistics

      - name: Run pytest
        run: pytest --cov=src --cov-report=xml
        env:
          DATABASE_URL: postgres://postgres:testpassword@localhost:5432/testdb

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage.xml
          token: ${{ secrets.CODECOV_TOKEN }}

Notice how this workflow uses service containers to spin up a PostgreSQL database for integration tests. GitHub Actions manages the container lifecycle automatically.

Deploying a Static Site to Vercel

name: Deploy to Vercel

on:
  push:
    branches: [main]

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

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

      - run: npm ci
      - run: npm run build

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
          working-directory: ./

Reusable Workflows

Avoid duplicating workflow logic across repositories by creating reusable workflows:

# .github/workflows/reusable-test.yml
name: Reusable Test Workflow

on:
  workflow_call:
    inputs:
      node-version:
        required: false
        type: string
        default: '20'
    secrets:
      npm-token:
        required: false

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ inputs.node-version }}
          cache: 'npm'
      - run: npm ci
        env:
          NPM_TOKEN: ${{ secrets.npm-token }}
      - run: npm test

Call it from another workflow:

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

on:
  push:
    branches: [main]

jobs:
  test:
    uses: ./.github/workflows/reusable-test.yml
    with:
      node-version: '20'
    secrets:
      npm-token: ${{ secrets.NPM_TOKEN }}

Conditional Execution and Job Outputs

Control when steps and jobs run using conditionals:

jobs:
  check:
    runs-on: ubuntu-latest
    outputs:
      should-deploy: ${{ steps.changes.outputs.deploy }}
    steps:
      - uses: actions/checkout@v4

      - name: Check for deployment-relevant changes
        id: changes
        run: |
          if git diff --name-only HEAD~1 | grep -qE '^(src/|package\.json)'; then
            echo "deploy=true" >> "$GITHUB_OUTPUT"
          else
            echo "deploy=false" >> "$GITHUB_OUTPUT"
          fi

  deploy:
    needs: check
    if: needs.check.outputs.should-deploy == 'true'
    runs-on: ubuntu-latest
    steps:
      - run: echo "Deploying application..."

Best Practices

  1. Pin action versions — Use @v4 instead of @main to avoid unexpected breakage
  2. Use npm ci over npm install — It is faster and ensures reproducible builds
  3. Enable caching — Cache dependencies to dramatically reduce workflow run times
  4. Set fail-fast: false in matrix builds — See all failures, not just the first one
  5. Use environments — Define production and staging environments with approval gates
  6. Keep secrets minimal — Only grant the permissions your workflow actually needs
  7. Add status badges — Display build status in your README:
![CI](https://github.com/username/repo/actions/workflows/ci.yml/badge.svg)
  1. Use concurrency — Cancel redundant workflow runs when new commits are pushed:
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

Debugging Failed Workflows

When a workflow fails, use these techniques:

  • Check the logs in the Actions tab for the exact error message
  • Add debug logging by setting the ACTIONS_STEP_DEBUG secret to true
  • Use act to run workflows locally: act -j test (requires Docker)
  • SSH into the runner for interactive debugging using mxschmitt/action-tmate
- name: Debug with tmate
  if: failure()
  uses: mxschmitt/action-tmate@v3
  timeout-minutes: 15

Summary

GitHub Actions provides everything you need to build a robust CI/CD pipeline without leaving GitHub. Start with a simple test workflow, then gradually add build steps, deployment targets, and matrix testing. The key is to automate the repetitive tasks that slow your team down — let machines handle testing and deployment so developers can focus on writing code.

For more on programming best practices and development workflows, explore our Programming articles. If you are deploying to cloud infrastructure, check out our guides on cloud hosting and server configuration.