GitHub Actions: A Complete Guide to CI/CD Workflows
GitHub Actions is the CI/CD platform built into every GitHub repository. It lets you automate builds, tests, deployments, and virtually any workflow — triggered by pushes, pull requests, schedules, or manual dispatch. Since its public release in 2019, it has become the default CI/CD choice for open-source projects and a major player in enterprise DevOps. This guide covers everything you need to write production-quality workflows.
How GitHub Actions Works
At its core, GitHub Actions runs workflows — YAML files stored in the .github/workflows/ directory of your repository. When a trigger event occurs (like a push to main), GitHub spins up a virtual machine, clones your code, and executes the steps you defined. Here's the hierarchy:
Workflow (.yml file)
└── Job (runs on a VM)
└── Step (individual command or action)
└── Action (reusable unit, e.g. actions/checkout@v4) A workflow can have multiple jobs. By default, jobs run in parallel. Steps within a job run sequentially. Each job gets a fresh virtual machine — they don't share filesystems unless you use artifacts or caching.
Your First Workflow
Create .github/workflows/ci.yml in your repository:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test This workflow runs on every push to main and every pull request targeting main. It checks out the code, installs Node.js 20 with npm caching, installs dependencies, and runs tests. Let's break down each section.
Triggers: The on Key
The on key defines when your workflow runs. GitHub Actions supports dozens of event triggers:
| Trigger | When It Fires | Common Use |
|---|---|---|
push | Code pushed to branch | CI builds, deploys |
pull_request | PR opened, updated, or synchronized | PR checks, previews |
workflow_dispatch | Manual button in GitHub UI | On-demand deploys, maintenance |
schedule | Cron schedule (UTC) | Nightly builds, cleanup |
release | Release published | NPM publish, Docker push |
workflow_call | Called by another workflow | Reusable workflows |
You can filter triggers by branch, path, or tag:
on:
push:
branches: [main, 'release/**']
paths:
- 'src/**'
- 'package.json'
tags:
- 'v*'
pull_request:
types: [opened, synchronize, reopened] The paths filter is powerful for monorepos — you can run a workflow only when relevant files change, saving CI minutes.
Scheduled Workflows
Use cron expressions to run workflows on a schedule:
on:
schedule:
- cron: '0 6 * * 1-5' # 6 AM UTC, Monday through Friday Important caveats: scheduled workflows only run on the default branch (usually main), they use UTC timezone, and during high-load periods, GitHub may delay or skip scheduled runs. Don't rely on them for time-critical operations.
Jobs and Runners
Each job runs on a runner — a virtual machine hosted by GitHub. The runs-on key specifies the operating system:
| Runner | OS | Specs |
|---|---|---|
ubuntu-latest | Ubuntu 24.04 | 4 vCPUs, 16 GB RAM, 14 GB SSD |
ubuntu-22.04 | Ubuntu 22.04 | 4 vCPUs, 16 GB RAM, 14 GB SSD |
windows-latest | Windows Server 2022 | 4 vCPUs, 16 GB RAM, 14 GB SSD |
macos-latest | macOS 14 (Sonoma) | 3 or 4 vCPUs, 7 or 14 GB RAM |
Linux runners are fastest to provision and cheapest (free for public repos, $0.008/minute for private). macOS runners cost 10× more. Use Linux unless you specifically need macOS or Windows for testing.
Job Dependencies
Use needs to create dependencies between jobs:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: npm run deploy The deploy job only runs after test succeeds, and only on the main branch.
Steps: uses vs run
Each step either runs a shell command (run) or uses a published action (uses):
steps:
# Use a published action from the marketplace
- uses: actions/checkout@v4
# Use an action with configuration
- uses: actions/setup-node@v4
with:
node-version: 20
# Run a shell command
- run: echo "Hello from CI"
# Run a multi-line script
- name: Build and test
run: |
npm ci
npm run build
npm test Always pin actions to a specific version (@v4) or commit SHA (@a5ac7e51b41094c92402da3b24376905380afc29) rather than using @main. This prevents supply chain attacks where a compromised action could steal your secrets.
Matrix Builds
Matrix strategies run the same job with different configurations — perfect for testing across multiple Node.js versions, operating systems, or Python versions:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test This creates 9 jobs (3 OS × 3 Node versions). The fail-fast: false setting ensures all combinations run even if one fails — useful for seeing the full compatibility picture.
Secrets and Environment Variables
Never hardcode credentials in workflow files. Use GitHub's encrypted secrets:
steps:
- name: Deploy to production
env:
API_KEY: ${{ secrets.API_KEY }}
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.sh Add secrets in your repository settings under Settings → Secrets and variables → Actions. Secrets are masked in logs — GitHub will replace them with *** if they appear in output. For organization-wide secrets, use organization-level secrets with repository access policies.
Environment Protection Rules
For production deployments, use environments with protection rules:
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- run: ./deploy.sh Configure the production environment in repository settings to require manual approval, restrict to specific branches, or add wait timers. This prevents accidental deployments from feature branches.
Caching Dependencies
Caching dramatically speeds up workflows. Most setup actions have built-in caching:
# Node.js with npm caching
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
# Python with pip caching
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
# Manual cache for custom paths
- uses: actions/cache@v4
with:
path: ~/.cache/my-tool
key: my-tool-${{ hashFiles('config.lock') }} Cache hits typically save 30–60 seconds per workflow. Caches are scoped to the branch — a feature branch can read caches from its base branch but not vice versa.
Artifacts: Sharing Files Between Jobs
Since each job runs on a separate VM, use artifacts to pass files between jobs:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: ./deploy.sh dist/ Artifacts are also downloadable from the GitHub Actions UI — useful for build outputs, test reports, and coverage data.
Conditional Execution
Use if expressions to control when jobs or steps run:
# Only run on main branch
if: github.ref == 'refs/heads/main'
# Only run when PR is merged (not just closed)
if: github.event.pull_request.merged == true
# Run even if previous steps failed
if: always()
# Skip if commit message contains [skip ci]
if: "!contains(github.event.head_commit.message, '[skip ci]')"
# Only run for specific actor
if: github.actor == 'dependabot[bot]' Common Workflow Patterns
CI for a Node.js Project
name: CI
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test -- --coverage
- run: npm run build Publish to NPM on Release
name: Publish
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} Deploy to Cloudflare Pages
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: cloudflare/wrangler-action@v3
with:
command: pages deploy dist --project-name=my-site
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} Debugging Failed Workflows
When a workflow fails, start with these techniques:
- Read the logs — click the failed step in the Actions tab to see full output. Most failures have clear error messages.
- Enable debug logging — add the secret
ACTIONS_STEP_DEBUGwith valuetrueto get verbose output from actions. - Use
tmatefor SSH access — themxschmitt/action-tmate@v3action gives you an SSH session into the runner for interactive debugging. - Check runner status — githubstatus.com shows if there are platform-wide issues.
- Validate your YAML — use our GitHub Actions Validator to catch syntax errors before pushing.
Performance Tips
- Cache aggressively —
npm ciwith cache takes 5 seconds instead of 30+ - Use
pathsfilters — don't run the full CI pipeline when only docs changed - Parallelize jobs — split test, lint, and build into separate jobs that run simultaneously
- Use
concurrency— cancel in-progress runs when a new push arrives to the same branch - Pin to specific Ubuntu versions —
ubuntu-22.04instead ofubuntu-latestavoids surprise breakages when the latest label rolls forward
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true Security Best Practices
- Pin action versions — use commit SHAs (
actions/checkout@a5ac7e51...) for critical workflows instead of mutable tags - Minimize permissions — use the
permissionskey to grant only what's needed - Protect secrets — never echo secrets, use environment-level secrets for production
- Review third-party actions — check the source code before trusting actions with your secrets
- Use
CODEOWNERS— require approval for changes to.github/workflows/
Free Tier Limits
GitHub Actions is free for public repositories with unlimited minutes. For private repositories on the free plan, you get 2,000 minutes per month. Linux runners consume 1× minutes, Windows 2×, and macOS 10×. A typical Node.js CI workflow takes 1–3 minutes, so 2,000 minutes covers roughly 700–2,000 CI runs per month — more than enough for most teams.
Next Steps
Start with a simple CI workflow that runs your tests on every push and pull request. Once that's working, add linting, type checking, and build verification. Then layer on deployment workflows with environment protection rules and manual approvals. Use our GitHub Actions Validator to catch YAML syntax errors before you commit, and the YAML Formatter to keep your workflow files clean and consistent.