GitHub Actions CI/CD
How to build and deploy with GitHub Actions using workflows, matrices, caching, and secrets.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
GitHub Actions is a CI/CD platform built into GitHub. It automates software workflows from testing to deployment using YAML-defined pipelines triggered by Git events (push, PR, release).
Before CI/CD became mainstream, teams relied on manual steps to verify code: running tests locally, building artifacts on a developer machine, and deploying via SSH scripts. This was error-prone, inconsistent, and impossible to scale. GitHub Actions solves this by turning every Git event into an automated, reproducible workflow running in isolated environments.
By defining your pipeline as code inside your repository, you get version control, peer review, and auditability for your entire delivery process. A green checkmark on a pull request becomes a trust signal that the code compiles, tests pass, and style guidelines are met before anyone merges.
When to Use
Use this recipe when:
- Running tests on every pull request to catch regressions before they reach production. See Unit Testing for test automation.
- Building and pushing Docker images on release tags for immutable deployments. See Docker Basics for image building.
- Deploying to staging or production environments with gated approvals. See Environment Variables for managing deployment config.
- Linting and formatting checks before merge to maintain consistent code style. See Pre-Commit Hooks for local code quality.
- Running scheduled tasks (cron-based workflows) such as dependency audits or nightly backups. See Scheduled Jobs for cron patterns.
- Generating and publishing documentation automatically when source files change
- Scanning for security vulnerabilities in dependencies on every commit. See Secret Management for credential security.
Solution
Basic Workflow (Node.js CI)
This workflow runs on every push and pull request targeting main. It checks out the code, sets up Node.js, installs dependencies, and runs the full validation pipeline.
# .github/workflows/ci.yml
name: CI
# Trigger: run on push and PR to main branch
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Check out the repository code
- uses: actions/checkout@v4
# Set up Node.js with built-in npm caching
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
# Install dependencies using lock file for reproducibility
- run: npm ci
# Run linting to catch style issues early
- run: npm run lint
# Execute the test suite
- run: npm run test
# Build the production artifact
- run: npm run build
Build Matrix
A matrix strategy runs the same job across multiple combinations of OS and runtime versions. This catches platform-specific bugs and ensures compatibility without duplicating workflow code.
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
Deploy with Secrets
This job only runs after the test job succeeds and only on the main branch. Secrets are injected at runtime from the repository settings and are never exposed in logs.
jobs:
deploy:
runs-on: ubuntu-latest
needs: test # wait for test job to pass
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying with token"
curl -X POST https://api.deploy.com/release \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}"
Caching Dependencies
Caching stores downloaded dependencies between workflow runs. The cache key includes the OS and a hash of the lock file so it invalidates automatically when dependencies change.
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
Reusable Workflow
When multiple repositories need the same pipeline logic, extract it into a reusable workflow. Callers pass inputs and receive outputs, keeping the shared logic in one place.
# .github/workflows/reusable-lint.yml
name: Reusable Lint
on:
workflow_call:
inputs:
node-version:
required: true
type: string
jobs:
lint:
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 run lint
Call it from another repository:
jobs:
call-lint:
uses: org/shared-workflows/.github/workflows/reusable-lint.yml@main
with:
node-version: '20'
What Works
- Pin action versions using full commit SHAs for supply-chain security. While tags like
v4are convenient, a compromised action maintainer could repoint the tag to malicious code. Pinning to a SHA ensures the exact code runs every time. - Use
npm ciinstead ofnpm installin CI for reproducible builds.npm cistrictly respectspackage-lock.json, eliminating drift between local and CI environments. - Split jobs: Test, build, and deploy as separate jobs with explicit dependencies (
needs). This makes failures isolated, allows parallel execution, and produces clearer logs. - Use concurrency groups to cancel outdated runs when new commits are pushed. This saves CI minutes and prevents stale deployments from overwriting newer ones.
- Store secrets in GitHub Secrets, never hardcode tokens in workflow files. Secrets are encrypted at rest and masked in logs. Even private repositories can leak through fork PRs.
- Use reusable workflows for shared logic across multiple repositories. Extract common steps into a central repository so fixes propagate automatically to every consumer.
- Fail fast with strict permissions: set
permissions: read-allat the workflow level and grant write only where needed. This limits the blast radius if an action is compromised. - Monitor your Actions bill: set spending limits and review usage monthly. Matrix builds across 3 OSes and 3 Node versions create 9 jobs per commit.
Common Mistakes
- Using
actions/checkoutwithout specifying a ref, causing detached HEAD issues or checking out the wrong commit when triggered by a tag. Always useref: ${{ github.ref }}explicitly when needed. - Running deployment on every push instead of filtering by branch or tag. This deploys unfinished feature branches to production and bypasses your release process entirely.
- Not caching dependencies, causing builds that download the internet on every run. A well-tuned cache can cut build times from minutes to seconds.
- Using
ubuntu-latestwhen you actually need a specific OS for native compilation. Thelatestlabel migrates over time; pin to a specific version likeubuntu-22.04for reproducibility. - Forgetting to set
if: failure()for notification steps on error. Without this condition, Slack or email alerts only fire on success, which is the opposite of what you want. - Hardcoding environment values in workflow files instead of using repository variables. This forces you to edit YAML every time a URL or version changes, creating unnecessary commits.
- Granting overly broad permissions like
permissions: write-all. If a third-party action is compromised, it could rewrite your code, create releases, or modify secrets. Use the principle of least privilege.
Frequently Asked Questions
Q: How do I run a workflow manually?
A: Add workflow_dispatch: to the on: block. You can then trigger it from the Actions tab, optionally providing input parameters that your workflow can consume.
Q: Can I reuse workflows across repositories?
A: Yes. Create a workflow with on: workflow_call in a central repository, then reference it from others using uses: org/shared-workflows/.github/workflows/reusable.yml@main. This keeps shared logic updated in one place.
Q: How do I debug a failing workflow?
A: Use the tmate action to spawn an interactive SSH session inside the runner, or add set -x in shell steps for verbose output. You can also enable step debugging by setting the secret ACTIONS_STEP_DEBUG to true.
Q: How do I prevent workflow runs from forks?
A: Use if: github.event.pull_request.head.repo.full_name == github.repository to skip workflows triggered by external forks. For sensitive workflows, disable fork pull request triggers entirely and require manual approval instead.
Q: What is the difference between workflow_dispatch and repository_dispatch?
A: workflow_dispatch is triggered manually from the GitHub UI or API. repository_dispatch is triggered externally via the GitHub API, useful for integrating with third-party services that need to trigger builds outside of Git events.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Reusable Workflow with Matrix Strategy
# .github/workflows/test-matrix.yml
name: Test Matrix
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: "20"
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel other jobs on failure
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18 # Skip old Node on macOS
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.node == 20
Environment Protection Rules
# .github/workflows/deploy.yml
name: Deploy
on:
push:
tags: ['v*']
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.example.com
# Required reviewers must approve before deployment
# Configured in repo Settings > Environments
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying ${{ github.ref_name }} to production"
./deploy.sh --env prod --version ${{ github.ref_name }}
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
Composite Action
# .github/actions/setup-project/action.yml
name: Setup Project
description: Install dependencies and cache them
inputs:
node-version:
required: false
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Install dependencies
shell: bash
run: npm ci
- name: Build
shell: bash
run: npm run build
# Usage in a workflow
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
with:
node-version: '22'
- run: npm test
Conditional Jobs and Steps
jobs:
lint:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- run: npm run lint
deploy:
needs: [lint, test]
if: github.ref == 'refs/heads/main' && success()
runs-on: ubuntu-latest
steps:
- run: echo "Deploying only on main after lint+test pass"
Job Summary for PR Comments
- name: Generate summary
if: always()
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "| Suite | Status | Duration |" >> $GITHUB_STEP_SUMMARY
echo "|-------|--------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| Unit | ${{ job.status }} | 30s |" >> $GITHUB_STEP_SUMMARY
echo "| E2E | ${{ job.status }} | 2m |" >> $GITHUB_STEP_SUMMARY
Additional Best Practices
- Pin action versions to SHA. Tags can be re-tagged; SHA pins are immutable:
# Bad: tag can change
- uses: actions/checkout@v4
# Better: SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- Use
concurrencyto cancel stale runs. Don’t waste minutes on outdated pushes:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
- Set job timeouts. Prevent hung jobs from consuming minutes:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # Kill after 15 minutes
Additional Common Mistakes
- Not using
concurrencygroups. Without it, every push to a PR triggers a new run, wasting minutes:
# Add this to cancel previous runs
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
- Using
needswithoutif: always(). Downstream jobs skip by default if upstream fails:
notify:
needs: [test, lint, deploy]
if: always() # Run regardless of upstream status
runs-on: ubuntu-latest
steps:
- run: ./notify.sh ${{ job.status }}
FAQ
How do I share secrets across environments?
Use repository-level secrets for non-sensitive values. Use environment-level secrets for production credentials. Reference them with ${{ secrets.SECRET_NAME }}:
steps:
- run: deploy.sh
env:
API_KEY: ${{ secrets.PROD_API_KEY }} # Environment secret
LOG_LEVEL: ${{ vars.LOG_LEVEL }} # Repository variable
How do I run jobs sequentially vs in parallel?
By default, jobs run in parallel. Use needs to create dependencies:
jobs:
build: # Runs first
runs-on: ubuntu-latest
test: # Runs after build
needs: build
runs-on: ubuntu-latest
deploy: # Runs after test
needs: test
runs-on: ubuntu-latest
How do I pass artifacts between jobs?
Use actions/upload-artifact and actions/download-artifact:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh dist/
Performance Tips
- Cache dependencies aggressively. Cache npm, pip, Maven, and Gradle:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- Use self-hosted runners for heavy builds. Skip the queue and use more CPU:
runs-on: self-hosted # Your own hardware
- Split test suites across runners. Use matrix to parallelize:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/4
- Use Docker layer caching. Speed up image builds:
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max Related Resources
Unit Testing
How to write fast, deterministic unit tests with mocks and assertions in Python, JavaScript, and Java.
RecipeDocker Basics
How to containerize an application, write a Dockerfile, and run containers with Docker Compose.
RecipeEnvironment Variables
How to read, set, and manage environment variables securely across Python, JavaScript, and Java.
RecipeCI/CD Pipeline Setup
Set up automated CI/CD pipelines for testing, building, and deploying applications with GitHub Actions and what works.
RecipeGit Workflow
A practical branching strategy for teams: feature branches, pull requests, and clean commit history.
RecipeSetup CI with GitLab Pipelines
How to configure GitLab CI/CD pipelines for testing, building, and deploying applications using .gitlab-ci.yml with stages, jobs, caching, and runners.
DocRunbook Template
A reusable template for operational runbooks: incident response, deployment procedures, and routine tasks.