Quick Takeaways
What you'll learn in this article
- 1
Basic GitHub Actions workflow understanding triggers, jobs, and steps
- 2
Continuous Integration pipeline with testing and linting
- 3
Multi-environment deployments to staging and production
- 4
Docker image builds with registry pushes
- 5
Matrix testing strategies across Node versions and operating systems
Keep reading for detailed implementation, code examples, and real-world results
After configuring GitHub Actions pipelines for dozens of production applications, I've learned that the difference between a functioning workflow and a truly effective CI/CD pipeline lies in understanding the core concepts, writing efficient jobs, and implementing patterns that scale with your team.
This tutorial takes you from zero GitHub Actions knowledge to building production-ready CI/CD pipelines. We'll cover everything from basic workflows to advanced patterns including matrix builds, caching strategies, deployment automation, and security best practices.
Tutorial Overview
What You'll Build
- Basic GitHub Actions workflow understanding triggers, jobs, and steps
- Continuous Integration pipeline with testing and linting
- Multi-environment deployments to staging and production
- Docker image builds with registry pushes
- Matrix testing strategies across Node versions and operating systems
- Caching and optimization for faster builds
- Secrets management and security patterns
- Reusable workflows for DRY pipeline configuration
Repository Structure
All code for this tutorial is available at github.com/CrashBytes/ByteSizedExamples/tree/main/crashbytes-tutorial-github-actions-ci-cd.
crashbytes-tutorial-github-actions-ci-cd/ โโโ .github/ โ โโโ workflows/ โ โโโ ci.yml # Basic CI workflow โ โโโ cd-staging.yml # Staging deployment โ โโโ cd-production.yml # Production deployment โ โโโ docker-build.yml # Docker image builds โ โโโ matrix-testing.yml # Matrix test strategy โ โโโ reusable-workflow.yml # Reusable workflow template โโโ src/ โ โโโ index.js # Application entry point โ โโโ utils.js # Utility functions โ โโโ api.js # API handlers โโโ tests/ โ โโโ index.test.js # Unit tests โ โโโ integration.test.js # Integration tests โโโ Dockerfile # Container definition โโโ package.json # Dependencies and scripts โโโ README.md # Project documentation
Understanding GitHub Actions Fundamentals
Before diving into workflows, let's establish the core concepts that GitHub Actions is built upon. Understanding these fundamentals will make everything else click into place.
Core Components
GitHub Actions operates on a hierarchy of components that work together to automate your software development lifecycle.
Workflows are the top-level automation definitions. Each workflow is a YAML file stored in the .github/workflows/ directory of your repository. A workflow contains one or more jobs and defines when the automation should run.
Events are triggers that start workflow execution. These can be repository events (push, pull request), scheduled times (cron), manual triggers, or external webhooks. Understanding events is crucial because they determine when your automation activates.
Jobs are collections of steps that run on a fresh virtual machine (runner). Jobs run in parallel by default, though you can configure dependencies between them. Each job starts with a clean environment, which ensures consistency but requires explicit artifact passing between jobs.
Steps are individual tasks within a job. Steps can run shell commands, use pre-built actions from the marketplace, or execute custom scripts. Steps within a job share the same runner and filesystem.
Actions are reusable units of code that perform specific tasks. You can use actions from the GitHub Marketplace, create your own, or use composite actions that combine multiple steps.
Runners are the servers that execute your workflows. GitHub provides hosted runners (Ubuntu, Windows, macOS) or you can configure self-hosted runners for specific requirements.
Workflow Syntax Deep Dive
Every workflow file follows the same basic structure. Let's examine each section:
name: Workflow Display Name
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
jobs:
job-name:
runs-on: ubuntu-latest
steps:
- name: Step description
run: echo "Hello, GitHub Actions!"
The name field appears in the Actions tab of your repository. Choose descriptive names that indicate what the workflow accomplishes.
The on section defines trigger events. This is where you specify when GitHub should execute your workflow.
The env section sets environment variables available to all jobs and steps in the workflow.
The jobs section contains the actual work. Each job has a unique identifier and configuration.
Building Your First Workflow
Let's create a complete CI workflow that runs on every push and pull request. This workflow will install dependencies, run linting, execute tests, and generate a coverage report.
Basic CI Workflow
Create the file .github/workflows/ci.yml:
name: Continuous Integration
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
CI: true
jobs:
lint:
name: Code Linting
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm test -- --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
name: Build Application
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build application
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
Let's break down the key concepts in this workflow:
Job Dependencies: The needs keyword creates dependencies between jobs. The test job waits for lint to complete, and build waits for both lint and test. This ensures code quality checks pass before attempting builds.
Checkout Action: actions/checkout@v4 clones your repository into the runner. Without this step, your code isn't available for subsequent steps.
Node Setup Action: actions/setup-node@v4 installs Node.js and optionally caches dependencies. The cache: 'npm' option automatically caches the npm cache directory based on your package-lock.json hash.
npm ci vs npm install: We use npm ci (clean install) instead of npm install because it's faster, more reliable for CI, and ensures exact versions from package-lock.json.
Artifacts: actions/upload-artifact@v4 preserves files between jobs or for later download. Artifacts are useful for coverage reports, build outputs, and debugging.
Trigger Events Deep Dive
Understanding trigger events gives you precise control over when workflows execute. Let's explore the most useful event configurations.
Push and Pull Request Events
The most common triggers respond to code changes:
on:
push:
branches:
- main
- develop
- 'release/**'
paths:
- 'src/**'
- 'package.json'
paths-ignore:
- '**.md'
- 'docs/**'
tags:
- 'v*'
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
Branch filtering lets you restrict workflow execution to specific branches. Glob patterns like 'release/**' match any branch starting with release/.
Path filtering with paths runs workflows only when specified files change. This prevents unnecessary CI runs when only documentation changes. The paths-ignore option excludes specific paths.
Tag triggers enable release automation. The pattern 'v*' matches tags like v1.0.0 or v2.1.3.
Pull request types control which PR events trigger workflows. The default includes opened, synchronize (new commits), and reopened.
Scheduled and Manual Triggers
For maintenance tasks, use scheduled or manual triggers:
on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2 AM UTC
- cron: '0 6 * * *' # Daily at 6 AM UTC
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
dry_run:
description: 'Perform dry run without deployment'
required: false
default: false
type: boolean
Cron schedules use standard cron syntax with five fields: minute, hour, day of month, month, day of week. All times are UTC.
Manual triggers with workflow_dispatch add a "Run workflow" button in the Actions tab. Input parameters let you customize each run.
Workflow Call Triggers
For reusable workflows called by other workflows:
on:
workflow_call:
inputs:
node_version:
required: true
type: string
secrets:
npm_token:
required: false
This pattern enables DRY workflow configurations across multiple repositories.
Advanced Workflow Patterns
Now let's implement patterns that handle real-world complexity.
Matrix Testing Strategy
Matrix builds run the same job across multiple configurations simultaneously:
name: Matrix Testing
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test-matrix:
name: Test on Node ${{ matrix.node }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
exclude:
- node: 18
os: macos-latest
include:
- node: 20
os: ubuntu-latest
coverage: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Run coverage
if: matrix.coverage
run: npm test -- --coverage
Matrix configuration creates a job for each combination. With 3 Node versions and 3 operating systems, this creates 9 parallel jobs (minus exclusions).
Fail-fast disabled means all matrix jobs run to completion even if one fails. This helps identify which specific configurations have issues.
Exclude and include refine the matrix. Exclude removes specific combinations, while include adds additional configurations or variables.
Conditional steps with if: matrix.coverage run only for specific matrix configurations.
Caching for Performance
Effective caching dramatically reduces build times:
name: Optimized CI with Caching
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache node_modules
uses: actions/cache@v4
id: cache-modules
with:
path: node_modules
key:
${{ runner.os }}-node-modules-${{ hashFiles('**/package-lock.json')
}}
restore-keys: |
${{ runner.os }}-node-modules-
- name: Install dependencies
if: steps.cache-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Cache build output
uses: actions/cache@v4
with:
path: |
.next/cache
dist
key: ${{ runner.os }}-build-${{ hashFiles('src/**') }}
restore-keys: |
${{ runner.os }}-build-
- name: Build application
run: npm run build
Cache keys should include variables that invalidate the cache when dependencies change. The hashFiles() function generates a hash from file contents.
Restore keys provide fallback options when exact matches aren't found. This enables partial cache hits.
Conditional installation skips npm ci when dependencies are fully cached, saving significant time on repeated builds.
Docker Image Building
Containerized deployments require building and pushing Docker images:
name: Docker Build and Push
on:
push:
branches: [main]
tags: ['v*']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Docker Buildx enables advanced build features including multi-platform images and improved caching.
GitHub Container Registry (ghcr.io) integrates seamlessly with GitHub Actions using the built-in GITHUB_TOKEN.
Metadata action automatically generates appropriate tags based on Git refs, semantic versioning, and commit SHAs.
Layer caching with cache-from and cache-to dramatically speeds up subsequent builds by reusing unchanged layers.
Deployment Workflows
Automating deployments requires careful consideration of environments, approvals, and rollback strategies.
Staging Deployment
name: Deploy to Staging
on:
push:
branches: [develop]
env:
STAGING_URL: https://staging.example.com
jobs:
deploy-staging:
name: Deploy to Staging Environment
runs-on: ubuntu-latest
environment:
name: staging
url: ${{ env.STAGING_URL }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build for staging
run: npm run build
env:
NODE_ENV: staging
API_URL: ${{ secrets.STAGING_API_URL }}
- name: Deploy to staging server
run: |
echo "Deploying to staging environment..."
# Add your deployment commands here
# Examples:
# - rsync to server
# - AWS S3 sync
# - Cloudflare Pages deploy
# - Kubernetes apply
env:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
- name: Run smoke tests
run: |
echo "Running smoke tests against staging..."
curl -f ${{ env.STAGING_URL }}/health || exit 1
- name: Notify deployment
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
echo "Staging deployment successful!"
else
echo "Staging deployment failed!"
fi
Environments provide deployment protection rules, required reviewers, and deployment history. Configure environments in repository Settings and reference them in workflows.
Environment secrets are separate from repository secrets, enabling different credentials per environment.
Smoke tests verify the deployment succeeded by checking critical endpoints.
Production Deployment with Approvals
name: Deploy to Production
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to deploy'
required: true
env:
PRODUCTION_URL: https://example.com
jobs:
validate:
name: Pre-deployment Validation
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run full test suite
run: npm test
- name: Security audit
run: npm audit --audit-level=high
- name: Build verification
run: npm run build
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: validate
environment:
name: production
url: ${{ env.PRODUCTION_URL }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build for production
run: npm run build
env:
NODE_ENV: production
API_URL: ${{ secrets.PRODUCTION_API_URL }}
- name: Create deployment record
id: deployment
run: |
echo "version=${{ github.event.release.tag_name || github.event.inputs.version }}" >> $GITHUB_OUTPUT
echo "timestamp=$(date -u +%Y%m%d%H%M%S)" >> $GITHUB_OUTPUT
- name: Deploy to production
run: |
echo "Deploying version ${{ steps.deployment.outputs.version }}..."
# Production deployment commands
env:
DEPLOY_TOKEN: ${{ secrets.PRODUCTION_DEPLOY_TOKEN }}
- name: Verify deployment health
run: |
echo "Verifying production deployment..."
for i in {1..5}; do
if curl -sf ${{ env.PRODUCTION_URL }}/health; then
echo "Health check passed!"
exit 0
fi
echo "Attempt $i failed, retrying in 10s..."
sleep 10
done
echo "Health check failed after 5 attempts"
exit 1
- name: Create deployment summary
run: |
echo "## Production Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Version**: ${{ steps.deployment.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Timestamp**: ${{ steps.deployment.outputs.timestamp }}" >> $GITHUB_STEP_SUMMARY
echo "- **Status**: Success โ
" >> $GITHUB_STEP_SUMMARY
echo "- **URL**: ${{ env.PRODUCTION_URL }}" >> $GITHUB_STEP_SUMMARY
notify:
name: Post-deployment Notifications
runs-on: ubuntu-latest
needs: deploy-production
if: always()
steps:
- name: Send success notification
if: needs.deploy-production.result == 'success'
run: |
echo "Production deployment completed successfully!"
# Add Slack, Discord, or email notification here
- name: Send failure notification
if: needs.deploy-production.result == 'failure'
run: |
echo "Production deployment failed!"
# Add failure notification here
Release triggers automate production deployments when you publish GitHub releases.
Pre-deployment validation ensures code quality before production deployment.
Health checks with retry handle transient failures during deployment.
Job summaries with $GITHUB_STEP_SUMMARY create rich deployment reports visible in the Actions UI.
Secrets and Security
Proper secrets management is critical for secure CI/CD pipelines.
Secrets Best Practices
name: Secure Workflow
on:
push:
branches: [main]
jobs:
secure-job:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- name: Use secrets safely
run: |
# Never echo secrets directly
# Use environment variables instead
npm run deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
- name: Mask sensitive output
run: |
TOKEN=$(get-temporary-token)
echo "::add-mask::$TOKEN"
echo "Token retrieved successfully"
Minimal permissions with the permissions key restrict what the workflow can access. Always specify only the permissions you need.
OIDC authentication (OpenID Connect) for cloud providers eliminates long-lived credentials. The id-token: write permission enables this.
Environment variables prevent secrets from appearing in logs. Never interpolate secrets directly into shell commands.
Masking with ::add-mask:: hides dynamically generated sensitive values from logs.
Repository and Environment Secrets
Configure secrets at appropriate levels:
Repository secrets apply to all workflows in the repository. Use for API keys, deployment tokens, and credentials shared across environments.
Environment secrets apply only to jobs referencing that environment. Use for environment-specific credentials like staging vs production database URLs.
Organization secrets apply across multiple repositories. Use for shared credentials like container registry access.
Reusable Workflows
Reusable workflows reduce duplication across repositories and enforce consistent patterns.
Creating a Reusable Workflow
Create .github/workflows/reusable-ci.yml:
name: Reusable CI Workflow
on:
workflow_call:
inputs:
node_version:
description: 'Node.js version'
required: false
type: string
default: '20'
run_coverage:
description: 'Generate coverage report'
required: false
type: boolean
default: false
working_directory:
description: 'Working directory for npm commands'
required: false
type: string
default: '.'
secrets:
npm_token:
description: 'NPM authentication token'
required: false
outputs:
test_result:
description: 'Test execution result'
value: ${{ jobs.test.outputs.result }}
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working_directory }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
cache: 'npm'
cache-dependency-path:
${{ inputs.working_directory }}/package-lock.json
- name: Install dependencies
run: npm ci
env:
NPM_TOKEN: ${{ secrets.npm_token }}
- name: Run linting
run: npm run lint
test:
name: Test
runs-on: ubuntu-latest
needs: lint
outputs:
result: ${{ steps.test.outputs.result }}
defaults:
run:
working-directory: ${{ inputs.working_directory }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node_version }}
cache: 'npm'
cache-dependency-path:
${{ inputs.working_directory }}/package-lock.json
- name: Install dependencies
run: npm ci
env:
NPM_TOKEN: ${{ secrets.npm_token }}
- name: Run tests
id: test
run: |
if npm test; then
echo "result=success" >> $GITHUB_OUTPUT
else
echo "result=failure" >> $GITHUB_OUTPUT
exit 1
fi
- name: Generate coverage
if: inputs.run_coverage
run: npm test -- --coverage
- name: Upload coverage
if: inputs.run_coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: ${{ inputs.working_directory }}/coverage/
Calling a Reusable Workflow
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
uses: ./.github/workflows/reusable-ci.yml
with:
node_version: '20'
run_coverage: true
secrets:
npm_token: ${{ secrets.NPM_TOKEN }}
post-ci:
needs: ci
runs-on: ubuntu-latest
steps:
- name: Check CI result
run: |
echo "CI test result: ${{ needs.ci.outputs.test_result }}"
Workflow inputs define configurable parameters. Callers can override defaults.
Secrets inheritance requires explicit passing unless you use secrets: inherit.
Outputs enable passing data from reusable workflows back to callers.
Conditional Execution Patterns
Control when jobs and steps execute with conditional expressions.
Common Conditional Patterns
name: Conditional Workflow
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
conditional-job:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Only on main branch
if: github.ref == 'refs/heads/main'
run: echo "Running on main branch"
- name: Only on pull requests
if: github.event_name == 'pull_request'
run: echo "This is a pull request"
- name: Only when specific files changed
if: contains(github.event.head_commit.modified, 'src/')
run: echo "Source files were modified"
- name: Only on success
if: success()
run: echo "All previous steps succeeded"
- name: Only on failure
if: failure()
run: echo "A previous step failed"
- name: Always run (cleanup)
if: always()
run: echo "This always runs"
- name: Check for specific label
if: contains(github.event.pull_request.labels.*.name, 'deploy')
run: echo "PR has deploy label"
- name: Skip for bot commits
if: "!contains(github.event.head_commit.author.name, '[bot]')"
run: echo "Not a bot commit"
skip-ci:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, '[skip ci]')"
steps:
- run: echo "CI not skipped"
deployment-gate:
runs-on: ubuntu-latest
if: |
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
!contains(github.event.head_commit.message, '[no-deploy]')
steps:
- run: echo "Deployment conditions met"
Branch conditions control environment-specific behavior.
Event type checks differentiate between pushes, PRs, and other triggers.
Status functions (success(), failure(), always(), cancelled()) respond to workflow state.
Commit message parsing enables skip patterns and deployment flags.
Multi-line conditions with YAML pipe syntax improve readability for complex logic.
Debugging and Troubleshooting
When workflows fail, these techniques help identify and resolve issues.
Enable Debug Logging
Set these repository secrets to enable verbose output:
- ACTIONS_RUNNER_DEBUG: true - Enables runner diagnostic logging
- ACTIONS_STEP_DEBUG: true - Enables step debug logging
Debugging Steps
name: Debug Workflow
on:
workflow_dispatch:
jobs:
debug:
runs-on: ubuntu-latest
steps:
- name: Dump GitHub context
run: echo '${{ toJSON(github) }}'
- name: Dump job context
run: echo '${{ toJSON(job) }}'
- name: Dump steps context
run: echo '${{ toJSON(steps) }}'
- name: Dump runner context
run: echo '${{ toJSON(runner) }}'
- name: Dump environment variables
run: env | sort
- name: Check filesystem
run: |
pwd
ls -la
df -h
- name: Interactive debugging
if: failure()
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
timeout-minutes: 15
Context dumps reveal available variables and their values.
tmate action provides SSH access to a failed runner for interactive debugging. Use sparingly and always set timeouts.
Common Issues and Solutions
Permission denied errors: Check repository settings for Actions permissions. Ensure GITHUB_TOKEN has required scopes.
Cache misses: Verify cache keys include all variables that should invalidate the cache. Check key patterns match expected format.
Timeout errors: Long-running jobs may need increased timeouts. Add timeout-minutes to job or step definitions.
Concurrent execution issues: Use concurrency groups to prevent overlapping runs:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Complete Example Application
The tutorial repository includes a complete Node.js application demonstrating all workflow patterns.
Package Configuration
{
"name": "crashbytes-tutorial-github-actions",
"version": "1.0.0",
"description": "GitHub Actions CI/CD Tutorial",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"build": "npm run lint && npm test",
"test": "jest --passWithNoTests",
"test:coverage": "jest --coverage",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix"
},
"devDependencies": {
"eslint": "^8.57.0",
"jest": "^29.7.0",
"nodemon": "^3.1.0"
}
}
Application Code
// src/index.js
const { greet, calculateSum, validateEmail } = require('./utils')
const { healthCheck, getStatus } = require('./api')
const PORT = process.env.PORT || 3000
function main() {
console.log(greet('GitHub Actions'))
console.log('Sum of 1-10:', calculateSum([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))
console.log(
'Valid email test@example.com:',
validateEmail('test@example.com')
)
console.log('Health check:', healthCheck())
console.log('Status:', getStatus())
}
if (require.main === module) {
main()
}
module.exports = { main }
// src/utils.js
function greet(name) {
return `Hello, ${name}!`
}
function calculateSum(numbers) {
return numbers.reduce((sum, num) => sum + num, 0)
}
function validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
module.exports = { greet, calculateSum, validateEmail }
// src/api.js
function healthCheck() {
return { status: 'healthy', timestamp: new Date().toISOString() }
}
function getStatus() {
return {
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development',
uptime: process.uptime(),
}
}
module.exports = { healthCheck, getStatus }
Test Suite
// tests/index.test.js
const { greet, calculateSum, validateEmail } = require('../src/utils')
const { healthCheck, getStatus } = require('../src/api')
describe('Utils', () => {
describe('greet', () => {
it('should return greeting with name', () => {
expect(greet('World')).toBe('Hello, World!')
})
it('should handle empty string', () => {
expect(greet('')).toBe('Hello, !')
})
})
describe('calculateSum', () => {
it('should sum array of numbers', () => {
expect(calculateSum([1, 2, 3])).toBe(6)
})
it('should return 0 for empty array', () => {
expect(calculateSum([])).toBe(0)
})
it('should handle negative numbers', () => {
expect(calculateSum([-1, 1])).toBe(0)
})
})
describe('validateEmail', () => {
it('should validate correct email', () => {
expect(validateEmail('test@example.com')).toBe(true)
})
it('should reject invalid email', () => {
expect(validateEmail('invalid-email')).toBe(false)
})
it('should reject email without domain', () => {
expect(validateEmail('test@')).toBe(false)
})
})
})
describe('API', () => {
describe('healthCheck', () => {
it('should return healthy status', () => {
const result = healthCheck()
expect(result.status).toBe('healthy')
expect(result.timestamp).toBeDefined()
})
})
describe('getStatus', () => {
it('should return status object', () => {
const result = getStatus()
expect(result.version).toBeDefined()
expect(result.environment).toBeDefined()
expect(result.uptime).toBeGreaterThanOrEqual(0)
})
})
})
Dockerfile
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --only=production COPY src/ ./src/ USER node EXPOSE 3000 CMD ["node", "src/index.js"]
Workflow Optimization Tips
Maximize efficiency and minimize costs with these optimization strategies.
Reduce Workflow Duration
Parallelize independent jobs: Jobs without dependencies run simultaneously. Structure your workflow to maximize parallelism.
Use appropriate runners: ubuntu-latest is fastest for most workloads. Use macos-latest or windows-latest only when necessary.
Skip unnecessary work: Use path filters to avoid running workflows when irrelevant files change.
Cache aggressively: Cache dependencies, build outputs, and anything expensive to recreate.
Minimize Costs
GitHub Actions charges based on minutes consumed. These strategies reduce costs:
Use concurrency groups: Cancel redundant runs when new commits arrive.
Implement early termination: Fail fast on critical checks before expensive operations.
Optimize Docker builds: Use multi-stage builds and layer caching.
Review billing regularly: Monitor Actions usage in repository settings.
Summary and Best Practices
GitHub Actions provides powerful CI/CD automation that scales from simple workflows to complex enterprise pipelines.
Key Takeaways
Start simple: Begin with basic workflows and add complexity as needed. A working simple workflow beats a broken complex one.
Use caching: Proper caching can reduce build times by 50 percent or more. Always cache dependencies and consider caching build outputs.
Secure your secrets: Never expose secrets in logs. Use OIDC where possible to eliminate long-lived credentials.
Embrace reusability: Extract common patterns into reusable workflows. This reduces maintenance and ensures consistency.
Monitor and optimize: Review workflow run times regularly. Identify bottlenecks and address them systematically.
Document your workflows: Future you will appreciate comments explaining non-obvious configurations.
Next Steps
- Clone the tutorial repository: Get hands-on with the example workflows
- Adapt workflows to your project: Start with the basic CI workflow and customize
- Implement deployment automation: Add staging and production deployments
- Explore the marketplace: Find actions that solve common problems
- Share reusable workflows: Create organization-wide workflow templates
The complete source code and all workflow files are available at github.com/CrashBytes/ByteSizedExamples/tree/main/crashbytes-tutorial-github-actions-ci-cd.
GitHub Actions transforms how teams ship software. With the patterns in this tutorial, you're equipped to build CI/CD pipelines that are fast, reliable, and maintainable. Start with the basics, iterate based on your team's needs, and continuously improve your automation.
