Build CI/CD with GitHub Actions

intermediate | 120 min read | 2025.12.18

What You’ll Learn in This Tutorial

  • GitHub Actions basic concepts
  • How to write workflow files
  • Automated test execution
  • Building and saving artifacts
  • Environment-specific deployment configuration
  • Secret management and security

Prerequisites: You need a GitHub account. Basic knowledge of Node.js projects will help with understanding.

What is CI/CD? Why is it Necessary?

History of CI/CD

The concept of Continuous Integration (CI) was systematized by Martin Fowler and Kent Beck in 2000.

“Continuous Integration is a software development practice where team members integrate their work frequently” — Martin Fowler

Continuous Delivery/Deployment (CD) extends CI and automates releases to production environments.

Evolution of CI/CD Tools

YearToolFeatures
2004Hudson/JenkinsOn-premise, plugin-based
2011Travis CICloud-based, GitHub integration
2014CircleCIDocker support, fast builds
2017GitLab CIIntegrated with GitLab
2019GitHub ActionsIntegrated with GitHub, marketplace

Benefits of GitHub Actions

  1. Integrated with GitHub: No separate service needed, seamless experience
  2. YAML definition: Infrastructure as Code (IaC)
  3. Marketplace: Rich reusable actions
  4. Free tier: Unlimited for public repositories
  5. Matrix builds: Parallel testing across multiple environments

DORA Metrics (DevOps Metrics)

According to Google’s research (DORA), elite performer teams achieve:

MetricEliteLow Performers
Deployment frequencyMultiple times/dayLess than once/month
Lead timeLess than 1 hourOver 1 month
Change failure rate0-15%46-60%
Recovery timeLess than 1 hourOver 1 week

CI/CD is a key factor in improving these metrics.

Official Documentation: GitHub Actions Documentation

Step 1: GitHub Actions Basics

GitHub Actions works by placing YAML files in the .github/workflows/ directory within your repository.

Basic Structure

# Workflow name
name: CI Pipeline

# Triggers (when to run)
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# Job definitions
jobs:
  job-name:
    runs-on: ubuntu-latest
    steps:
      - name: Step 1
        run: echo "Hello"

Key Concepts

ConceptDescriptionExample
WorkflowAutomation process defined in YAMLCI, deployment
EventTriggers that start workflowspush, pull_request, schedule
JobCollection of steps running on same runnertest, build, deploy
StepIndividual taskscheckout, npm install
ActionReusable task unitactions/checkout@v4
RunnerServer that executes workflowsubuntu-latest, windows-latest

Workflow Lifecycle

flowchart LR
    Event["Event Occurs"] --> Workflow["Workflow Starts"] --> Job["Jobs Execute<br/>(parallel/sequential)"] --> Step["Steps Execute"] --> Done["Complete"]

    subgraph Events["Event Types"]
        direction TB
        E1["push"]
        E2["pull_request"]
        E3["schedule (cron)"]
        E4["workflow_dispatch (manual)"]
        E5["repository_dispatch (API)"]
    end

    Events -.-> Event

Step 2: Creating Your First Workflow

Directory Structure

your-project/
├── .github/
│   └── workflows/
│       └── ci.yml
├── src/
├── package.json
└── README.md

.github/workflows/ci.yml

name: CI

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

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      # Checkout repository
      - name: Checkout repository
        uses: actions/checkout@v4

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

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

      # Run linter
      - name: Run linter
        run: npm run lint

      # Run tests
      - name: Run tests
        run: npm test

      # Upload coverage report
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Difference Between npm ci and npm install

CommandUse CaseFeatures
npm ciCI environmentsStrictly uses package-lock.json, faster
npm installDevelopmentPrioritizes package.json, can update lock

Best Practice: Always use npm ci in CI environments.

Step 3: Matrix Builds

Run parallel tests across multiple Node.js versions and operating systems.

name: Matrix Build

on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        node-version: [18, 20, 22]
      fail-fast: false  # Continue others even if one fails

    steps:
      - uses: actions/checkout@v4

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

      - run: npm ci
      - run: npm test

Excluding and Including Matrix Combinations

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

Step 4: Building and Saving Artifacts

name: Build and Upload

on:
  push:
    branches: [main]

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'

      - name: Install and Build
        run: |
          npm ci
          npm run build

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

  # Use artifacts in next job
  deploy:
    needs: build
    runs-on: ubuntu-latest

    steps:
      - name: Download artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output

      - name: Deploy
        run: |
          echo "Deploying..."
          ls -la

Step 5: Environment Variables and Secrets

Setting Up Secrets

  1. Repository → Settings → Secrets and variables → Actions
  2. Click “New repository secret”
  3. Enter name (e.g., DEPLOY_TOKEN) and value

Types of Secrets

TypeScopeUse Case
Repository secretsSingle repositoryAPI keys, tokens
Environment secretsSpecific environment onlyProduction/staging
Organization secretsAll repos in organizationCommon service accounts
name: Deploy with Secrets

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production  # Specify environment (approval flows can be set)

    steps:
      - uses: actions/checkout@v4

      # Use secrets as environment variables
      - name: Deploy
        env:
          API_KEY: ${{ secrets.API_KEY }}
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: |
          echo "Deploying with API key..."
          ./scripts/deploy.sh

      # GitHub-provided automatic token
      - name: Create Release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release create v1.0.0

Security Best Practices

  1. Principle of least privilege: Only grant necessary permissions
  2. Secret rotation: Update regularly
  3. Prevent log exposure: Secrets are automatically masked
  4. Restrict fork access: Limit secret access in PRs
# Explicitly set permissions
permissions:
  contents: read
  packages: write
  id-token: write  # For OIDC authentication

Security Warning: Secrets are automatically masked with *** in logs, but still be careful not to expose them unintentionally.

Step 6: Conditional Execution and Filters

name: Conditional Workflow

on:
  push:
    branches: [main, develop]
    # Run only when specific paths change
    paths:
      - 'src/**'
      - 'package.json'
    # Exclude specific paths
    paths-ignore:
      - '**.md'
      - 'docs/**'

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  deploy-staging:
    needs: test
    runs-on: ubuntu-latest
    # Run only for develop branch
    if: github.ref == 'refs/heads/develop'
    steps:
      - run: echo "Deploying to staging..."

  deploy-production:
    needs: test
    runs-on: ubuntu-latest
    # Run only for main branch with tag
    if: github.ref == 'refs/heads/main' && startsWith(github.ref, 'refs/tags/')
    steps:
      - run: echo "Deploying to production..."

  notify-on-failure:
    needs: [test]
    runs-on: ubuntu-latest
    # Run only on failure
    if: failure()
    steps:
      - name: Notify Slack
        run: echo "Tests failed!"

Condition Examples

# Branch name
if: github.ref == 'refs/heads/main'

# Event type
if: github.event_name == 'pull_request'

# Actor
if: github.actor == 'dependabot[bot]'

# Compound conditions
if: github.ref == 'refs/heads/main' && github.event_name == 'push'

# Based on results
if: success()  # Previous job succeeded
if: failure()  # Previous job failed
if: always()   # Always run
if: cancelled() # On cancellation

Step 7: Using Cache

Cache dependencies to speed up build times.

name: Build with Cache

on: [push]

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'  # Auto cache

      # For manual cache management
      - name: Cache node modules
        id: cache-npm
        uses: actions/cache@v4
        with:
          path: node_modules
          key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # Install only if no cache
      - name: Install dependencies
        if: steps.cache-npm.outputs.cache-hit != 'true'
        run: npm ci

      - run: npm run build

Cache Strategies

TargetKey ExampleRestore Key
npm${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}${{ runner.os }}-node-
pip${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}${{ runner.os }}-pip-
Gradle${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}${{ runner.os }}-gradle-

Step 8: Reusable Workflows

Composite Action

.github/actions/setup-node-and-install/action.yml

name: 'Setup Node and Install'
description: 'Setup Node.js and install dependencies'

inputs:
  node-version:
    description: 'Node.js version'
    required: false
    default: '20'

runs:
  using: 'composite'
  steps:
    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'

    - name: Install dependencies
      run: npm ci
      shell: bash

Usage:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-node-and-install
        with:
          node-version: '20'
      - run: npm test

Reusable Workflow

.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 }}
      - run: npm ci
      - run: npm test

Caller:

name: CI

on: [push]

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

Step 9: Deployment Workflows

Deploy to Vercel

name: Deploy to Vercel

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - 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'

Deploy to AWS S3 + CloudFront

name: Deploy to AWS

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ap-northeast-1

      - name: Build
        run: |
          npm ci
          npm run build

      - name: Deploy to S3
        run: aws s3 sync dist/ s3://${{ secrets.S3_BUCKET }}

      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} \
            --paths "/*"

Debugging Tips

Enable Debug Logs

Add the following to repository Secrets:

  • ACTIONS_STEP_DEBUG = true
  • ACTIONS_RUNNER_DEBUG = true

Debugging Within Workflows

- name: Debug info
  run: |
    echo "Event: ${{ github.event_name }}"
    echo "Ref: ${{ github.ref }}"
    echo "SHA: ${{ github.sha }}"
    echo "Actor: ${{ github.actor }}"
    echo "Repository: ${{ github.repository }}"
    env

- name: Debug context
  run: echo '${{ toJSON(github) }}'

Test Locally (act)

# Install act
brew install act

# Run locally
act push

# Run specific job
act -j test

Reference: nektos/act

Common Mistakes and Anti-Patterns

1. Hardcoding Secrets

# Bad example
- run: curl -H "Authorization: Bearer abc123" https://api.example.com

# Good example
- run: curl -H "Authorization: Bearer ${{ secrets.API_TOKEN }}" https://api.example.com

2. Improper Cache Key Configuration

# Bad example: Fixed key, cache never updates
key: my-cache

# Good example: Include hash of dependency file
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

3. Infinite Loops

# Bad example: Triggered by own commits
on:
  push:
    branches: [main]

jobs:
  commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          echo "update" >> file.txt
          git add .
          git commit -m "Auto update"
          git push

Summary

Using GitHub Actions provides the following benefits:

  • Automatically verify code quality
  • Prevent human errors through deployment automation
  • Share consistent workflows across the team
  • Excellent development experience through GitHub integration

Start with simple test automation and gradually expand to deployment.

Official Documentation

Best Practices & Articles

Tools & Resources

Books

  • “Continuous Delivery” (by Jez Humble, David Farley) - The CD textbook
  • “The DevOps Handbook” - A comprehensive DevOps guide
← Back to list