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
| Year | Tool | Features |
|---|---|---|
| 2004 | Hudson/Jenkins | On-premise, plugin-based |
| 2011 | Travis CI | Cloud-based, GitHub integration |
| 2014 | CircleCI | Docker support, fast builds |
| 2017 | GitLab CI | Integrated with GitLab |
| 2019 | GitHub Actions | Integrated with GitHub, marketplace |
Benefits of GitHub Actions
- Integrated with GitHub: No separate service needed, seamless experience
- YAML definition: Infrastructure as Code (IaC)
- Marketplace: Rich reusable actions
- Free tier: Unlimited for public repositories
- Matrix builds: Parallel testing across multiple environments
DORA Metrics (DevOps Metrics)
According to Google’s research (DORA), elite performer teams achieve:
| Metric | Elite | Low Performers |
|---|---|---|
| Deployment frequency | Multiple times/day | Less than once/month |
| Lead time | Less than 1 hour | Over 1 month |
| Change failure rate | 0-15% | 46-60% |
| Recovery time | Less than 1 hour | Over 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
| Concept | Description | Example |
|---|---|---|
| Workflow | Automation process defined in YAML | CI, deployment |
| Event | Triggers that start workflows | push, pull_request, schedule |
| Job | Collection of steps running on same runner | test, build, deploy |
| Step | Individual tasks | checkout, npm install |
| Action | Reusable task unit | actions/checkout@v4 |
| Runner | Server that executes workflows | ubuntu-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
| Command | Use Case | Features |
|---|---|---|
npm ci | CI environments | Strictly uses package-lock.json, faster |
npm install | Development | Prioritizes package.json, can update lock |
Best Practice: Always use
npm ciin 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
- Repository → Settings → Secrets and variables → Actions
- Click “New repository secret”
- Enter name (e.g.,
DEPLOY_TOKEN) and value
Types of Secrets
| Type | Scope | Use Case |
|---|---|---|
| Repository secrets | Single repository | API keys, tokens |
| Environment secrets | Specific environment only | Production/staging |
| Organization secrets | All repos in organization | Common 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
- Principle of least privilege: Only grant necessary permissions
- Secret rotation: Update regularly
- Prevent log exposure: Secrets are automatically masked
- 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
| Target | Key Example | Restore 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=trueACTIONS_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.
Reference Links
Official Documentation
- GitHub Actions Documentation - Official reference
- Workflow syntax - Workflow syntax
- GitHub Actions Marketplace - Reusable actions
Best Practices & Articles
- Martin Fowler - Continuous Integration - The original CI article
- DORA Metrics - DevOps performance metrics
- GitHub Actions Security Best Practices - Security guide
Tools & Resources
- act - Run GitHub Actions locally
- actionlint - Static analysis for workflow files
- GitHub Actions Cheat Sheet - Official cheat sheet
Books
- “Continuous Delivery” (by Jez Humble, David Farley) - The CD textbook
- “The DevOps Handbook” - A comprehensive DevOps guide