Module 12: GitHub Actions CI/CD

08. Mini-Project: Complete CI/CD

Project overview

In this mini-project you'll build a production-ready CI/CD pipeline with GitHub Actions: automatic CI, matrix testing, staging/production deploys, release automation, PR automation, and monitoring. This setup is what professional teams run in production.

This project brings everything you've learned together into one complete pipeline.


Project goals

By the end of this project, you'll have:

CI pipeline with lint, test, build
Matrix testing (Node 18, 20 × Ubuntu, macOS)
Automatic staging deploy (develop branch)
Production deploy with approval (main branch)
Release automation with tags
PR automation (labels, checks)
Caching for performance
Concurrency control
Notifications on Slack/Discord

Estimated time: 60-90 minutes


Project structure

my-project/
├── .github/
│   ├── workflows/
│   │   ├── ci.yml               # CI: lint, test, build
│   │   ├── deploy-staging.yml   # Deploy to staging
│   │   ├── deploy-production.yml # Deploy to production
│   │   ├── release.yml          # Release automation
│   │   ├── pr-automation.yml    # PR labels, checks
│   │   └── stale.yml            # Stale issues cleanup
│   ├── actions/
│   │   └── setup-project/
│   │       └── action.yml       # Composite action
│   └── CODEOWNERS
├── src/
│   └── index.js
├── tests/
│   └── index.test.js
├── package.json
└── README.md

Phase 1: Project setup (10 min)

Create project:

mkdir cicd-project
cd cicd-project
npm init -y
git init

# Install dependencies
npm install --save-dev jest eslint prettier

# Create source file
mkdir src
cat > src/index.js << 'EOF'
function add(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Arguments must be numbers');
  }
  return a + b;
}

function multiply(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw new Error('Arguments must be numbers');
  }
  return a * b;
}

module.exports = { add, multiply };
EOF

# Create test file
mkdir tests
cat > tests/index.test.js << 'EOF'
const { add, multiply } = require('../src/index');

describe('add', () => {
  test('adds two numbers', () => {
    expect(add(1, 2)).toBe(3);
  });

  test('throws on non-numbers', () => {
    expect(() => add('a', 2)).toThrow('Arguments must be numbers');
  });
});

describe('multiply', () => {
  test('multiplies two numbers', () => {
    expect(multiply(3, 4)).toBe(12);
  });

  test('throws on non-numbers', () => {
    expect(() => multiply(null, 2)).toThrow('Arguments must be numbers');
  });
});
EOF

# Add scripts to package.json
npm set-script test "jest"
npm set-script lint "eslint src/ tests/"
npm set-script build "echo 'Build complete'"

# Create directories
mkdir -p .github/workflows
mkdir -p .github/actions/setup-project

Phase 2: Composite action (5 min)

Reusable setup action:

# .github/actions/setup-project/action.yml
name: 'Setup Project'
description: 'Checkout, setup Node, install deps'

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

runs:
  using: 'composite'
  steps:
    - name: Checkout
      uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: ${{ inputs.node-version }}
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
      shell: bash

Phase 3: CI workflow (10 min)

.github/workflows/ci.yml:

name: CI

on:
  push:
    branches: [main, develop]
    paths-ignore:
      - '**/*.md'
      - 'docs/**'
  pull_request:
    branches: [main]

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/actions/setup-project
      - name: Run ESLint
        run: npm run lint

  test:
    name: Test (Node ${{ matrix.node }} on ${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    needs: lint
    
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest]
        node: ['18', '20']
        include:
          - os: macos-latest
            node: '20'
    
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node }}
          cache: 'npm'
      - run: npm ci
      - name: Run tests
        run: npm test -- --coverage
      
      - name: Upload coverage
        if: matrix.os == 'ubuntu-latest' && matrix.node == '20'
        uses: actions/upload-artifact@v3
        with:
          name: coverage
          path: coverage/

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Build
        run: npm run build
      
      - name: Upload build artifact
        uses: actions/upload-artifact@v3
        with:
          name: build
          path: dist/
          retention-days: 7

Phase 4: Deploy workflows (15 min)

Staging deploy:

# .github/workflows/deploy-staging.yml
name: Deploy Staging

on:
  push:
    branches: [develop]

concurrency:
  group: deploy-staging
  cancel-in-progress: false

jobs:
  test:
    uses: ./.github/workflows/ci.yml

  deploy:
    name: Deploy to Staging
    needs: test
    runs-on: ubuntu-latest
    environment: staging
    
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci && npm run build
      
      - name: Deploy to staging
        run: |
          echo "🚀 Deploying to staging..."
          echo "Version: $(cat package.json | jq -r .version)"
          echo "Commit: ${{ github.sha }}"
          # Your actual deploy command here
        env:
          DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
      
      - name: Verify deployment
        run: |
          echo "✅ Staging deployment verified"
          # curl -f https://staging.example.com/health || exit 1

Production deploy:

# .github/workflows/deploy-production.yml
name: Deploy Production

on:
  push:
    branches: [main]

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm test

  deploy:
    name: Deploy to Production
    needs: test
    runs-on: ubuntu-latest
    environment: production  # Requires approval
    
    steps:
      - uses: actions/checkout@v3
      
      - uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci && npm run build
      
      - name: Deploy to production
        run: |
          echo "🚀 Deploying to production..."
          echo "Version: $(cat package.json | jq -r .version)"
          # Your actual deploy command
        env:
          DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}
      
      - name: Verify deployment
        run: |
          echo "✅ Production deployment verified"
          # curl -f https://api.example.com/health || exit 1
      
      - name: Notify success
        if: success()
        run: |
          echo "✅ Production deploy successful"
          # curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
          #   -d '{"text":"Production deployed successfully!"}'
      
      - name: Notify failure
        if: failure()
        run: |
          echo "❌ Production deploy failed"
          # curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
          #   -d '{"text":"⚠️ Production deploy FAILED!"}'

Phase 5: Release automation (10 min)

Auto-release on tag:

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    name: Create Release
    runs-on: ubuntu-latest
    permissions:
      contents: write
    
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0  # Full history for changelog
      
      - name: Generate changelog
        id: changelog
        run: |
          # Get changes since last tag
          PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
          
          if [ -n "$PREV_TAG" ]; then
            CHANGES=$(git log ${PREV_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges)
          else
            CHANGES=$(git log --pretty=format:"- %s (%h)" --no-merges)
          fi
          
          echo "changes<<EOF" >> $GITHUB_OUTPUT
          echo "$CHANGES" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
      
      - name: Create GitHub Release
        uses: actions/github-script@v6
        with:
          script: |
            const tag = context.ref.replace('refs/tags/', '');
            const changes = `${{ steps.changelog.outputs.changes }}`;
            
            await github.rest.repos.createRelease({
              owner: context.repo.owner,
              repo: context.repo.repo,
              tag_name: tag,
              name: `Release ${tag}`,
              body: `## Changes\n\n${changes}`,
              draft: false,
              prerelease: tag.includes('-')
            });

Create release:

# Bump version
npm version patch  # or minor, major

# Push tag
git push origin --tags

# Release workflow triggers automatically

Phase 6: PR automation (10 min)

Auto-label PRs:

# .github/workflows/pr-automation.yml
name: PR Automation

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  label:
    name: Auto Label
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    
    steps:
      - uses: actions/github-script@v6
        with:
          script: |
            const pr = context.payload.pull_request;
            const labels = [];
            
            // Size labels
            const size = pr.additions + pr.deletions;
            if (size < 50) labels.push('size/S');
            else if (size < 200) labels.push('size/M');
            else if (size < 500) labels.push('size/L');
            else labels.push('size/XL');
            
            // Type from title
            const title = pr.title.toLowerCase();
            if (title.startsWith('feat')) labels.push('feature');
            else if (title.startsWith('fix')) labels.push('bugfix');
            else if (title.startsWith('docs')) labels.push('documentation');
            else if (title.startsWith('refactor')) labels.push('refactor');
            
            if (labels.length > 0) {
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                labels
              });
            }

Phase 7: Stale cleanup (5 min)

Auto-close stale issues:

# .github/workflows/stale.yml
name: Stale Issues

on:
  schedule:
    - cron: '0 0 * * 1'  # Weekly on Monday

jobs:
  stale:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    
    steps:
      - uses: actions/stale@v8
        with:
          stale-issue-message: |
            This issue has been inactive for 30 days.
            It will be closed in 7 days unless there's new activity.
          close-issue-message: 'Closed due to inactivity.'
          stale-pr-message: 'This PR has been inactive for 14 days.'
          days-before-stale: 30
          days-before-close: 7
          stale-issue-label: 'stale'
          exempt-issue-labels: 'pinned,priority/high'

Final verification

Test checklist:

# 1. CI runs on PR
git checkout -b feature/test
echo "// test" >> src/index.js
git add . && git commit -m "feat: Test CI"
gh pr create --title "feat: Test CI" --body "Testing CI pipeline"
# Check: GitHub → Actions → CI should run

# 2. Matrix tests
# Check: CI runs on Node 18, 20, Ubuntu + macOS

# 3. Deploy staging
git checkout develop
git merge feature/test
git push
# Check: Deploy Staging workflow runs

# 4. Deploy production
git checkout main
git merge develop
git push
# Check: Deploy Production workflow (with approval)

# 5. Release
npm version patch
git push --tags
# Check: Release workflow creates GitHub Release

# 6. PR labels
# Check: PR has size/S and feature labels

# 7. Caching works
# Check: Second run of CI is faster

Workflow visualization

                    ┌──────────┐
                    │  Push/PR  │
                    └─────┬────┘
                          │
                    ┌─────▼────┐
                    │   Lint    │
                    └─────┬────┘
                          │
              ┌───────────┼───────────┐
              │           │           │
        ┌─────▼──┐  ┌────▼───┐  ┌───▼─────┐
        │Node 18 │  │Node 20 │  │Node 20  │
        │Ubuntu  │  │Ubuntu  │  │macOS    │
        └────┬───┘  └────┬───┘  └────┬────┘
             └───────────┼───────────┘
                         │
                   ┌─────▼────┐
                   │  Build   │
                   └─────┬────┘
                         │
              ┌──────────┴──────────┐
              │                     │
     (develop branch)         (main branch)
              │                     │
        ┌─────▼─────┐       ┌──────▼──────┐
        │  Staging   │       │  Production │
        │  Deploy    │       │  (approval) │
        └────────────┘       └─────────────┘

Success criteria

  • CI runs on every PR (lint → test → build)
  • Matrix testing works (3 combinations)
  • Caching reduces install time
  • Concurrency cancels duplicate runs
  • Deploy staging on develop push
  • Deploy production on main push (with approval)
  • Release automation on tag push
  • PR auto-labeling works
  • Stale cleanup scheduled
  • Composite action reusable

Project summary

You completed:

6 production-ready workflow files
1 reusable composite action
CI pipeline (lint → matrix test → build)
Automatic staging deploy
Production deploy with approval
Release automation with changelog
PR automation (labels, checks)
Scheduled stale cleanup
Performance optimized (cache, concurrency)


Module 12 summary

You completed the full GitHub Actions CI/CD module:

GitHub Actions fundamentals (YAML, triggers, jobs)
Matrix testing (OS × versions)
Deploy automation (staging + production)
Secrets management (repo, env, org)
Advanced workflows (reusable, composite, dispatch)
Performance optimization (caching, artifacts, concurrency)
Complete production-ready CI/CD pipeline

Next module: Module 13: Git Internals (Optional)


Additional resources

  1. GitHub Actions Docs - Complete reference
  2. Actions Marketplace - Community actions
  3. GitHub Actions Examples - Starter templates

Total project time: 60-90 minutes

Congratulations. You've completed Module 12 - GitHub Actions CI/CD.