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
- GitHub Actions Docs - Complete reference
- Actions Marketplace - Community actions
- GitHub Actions Examples - Starter templates
Total project time: 60-90 minutes
Congratulations. You've completed Module 12 - GitHub Actions CI/CD.