| name | ci-cd |
| description | Use when setting up or debugging GitHub Actions pipelines — adding quality gates, configuring OIDC cloud auth, building matrix test runs, publishing artifacts to GHCR/PyPI/npm, or promoting builds from staging to production. |
CI/CD with GitHub Actions
A complete reference for building robust, secure, and efficient CI/CD pipelines using GitHub Actions, covering everything from workflow anatomy to multi-environment deployment promotion.
When to Activate
- Creating or modifying a GitHub Actions workflow
- Adding a quality gate (coverage, lint, security scan) to a pipeline
- Setting up CD with environment promotion from staging to production
- Optimizing a slow CI pipeline with caching or parallelism
- Publishing a Docker image, Python package, or npm package from CI
- Configuring secrets and OIDC-based cloud authentication
Workflow Anatomy
Every GitHub Actions workflow is defined in .github/workflows/*.yml. Understanding the core building blocks is essential before composing pipelines.
Triggers
| Trigger | Use Case |
|---|
push | Run on commits to specified branches or tags |
pull_request | Run on PR open, sync, or reopen |
workflow_dispatch | Manual trigger with optional input parameters |
schedule | Cron-based triggers (e.g., nightly security scans) |
workflow_call | Reusable workflow called by another workflow |
Jobs
- Jobs run in parallel by default unless
needs: is specified.
- Use
needs: [job-a, job-b] to declare dependencies; a job waits for all listed jobs to succeed.
- Each job runs in a fresh virtual machine (or container).
Steps
uses: — invokes a reusable action from the marketplace or a local path.
run: — executes a shell command directly on the runner.
- Steps within a job share the same filesystem and runner environment.
Key Contexts
| Context | What It Provides |
|---|
github | Repo name, SHA, ref, event name, actor, run ID |
secrets | Encrypted secret values; never printed in logs |
env | Environment variables set at workflow, job, or step level |
matrix | Current values from the strategy matrix |
job | Current job status, container info |
Expression Syntax
All dynamic values use ${{ expression }} syntax:
if: github.ref == 'refs/heads/main'
run: echo "SHA is ${{ github.sha }}"
env:
BRANCH: ${{ github.ref_name }}
Minimal Workflow Skeleton
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: make test
Standard Pipeline Structure
The canonical stage sequence moves from fast feedback (lint) to slower gates (security), then publishing.
lint → test → build → security-scan → publish
Job Dependency Graph
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint
run: make lint
test:
needs: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Test
run: make test
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: make build
security-scan:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Security scan
run: make scan
publish:
needs: [build, security-scan]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish
run: make publish
fail-fast Behavior
| Setting | When to Use |
|---|
fail-fast: true (default) | Sequential stage pipelines — stop early on first failure |
fail-fast: false | Matrix builds — collect all results before deciding |
Use fail-fast: false in matrix strategies so you see failures across all OS/version combinations rather than stopping at the first one.
Dependency Caching
Caching package manager dependencies is the single highest-impact optimization for CI speed. The key strategy: hash the lockfile so the cache automatically invalidates when dependencies change.
Cache Key Strategy
cache-key = runner-os + lockfile-hash
restore-key = runner-os (fallback to most recent cache)
Python
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
For more control with pyproject.toml:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml', '**/requirements*.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Node.js
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
For pnpm or yarn, set cache: 'pnpm' or cache: 'yarn' respectively.
Go
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
Cache Hit Rate Tips
- Always commit lockfiles (
package-lock.json, poetry.lock, go.sum) to the repository.
- Use
hashFiles('**/package-lock.json') to scope cache keys to the exact dependency set.
- Restore keys act as fallbacks: a partial hit is better than no cache.
Matrix Builds
Matrix builds let a single job definition run across multiple configurations (language versions, operating systems) in parallel.
Matrix Strategy
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13']
os: [ubuntu-latest, windows-latest]
include:
- python-version: '3.12'
os: ubuntu-latest
publish: true
exclude:
- python-version: '3.11'
os: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip
- run: pytest
- name: Publish (only from designated matrix entry)
if: matrix.publish == true
run: make publish
Matrix Reference
| Feature | Syntax | Purpose |
|---|
| Access value | ${{ matrix.python-version }} | Use current dimension value in steps |
include | Adds extra key-value pairs to specific entries | Inject flags or override runner for one combo |
exclude | Removes specific combinations | Skip known broken or unnecessary combos |
fail-fast | false on matrix | See all failures, not just first |
Quality Gates
Coverage Threshold
Fail the build if coverage drops below the required threshold.
- name: Run tests with coverage
run: pytest --cov=src --cov-report=xml --cov-fail-under=80
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
fail_ci_if_error: true
For Node.js with Jest:
- name: Run tests with coverage
run: jest --coverage --coverageThreshold='{"global":{"lines":80}}'
Inline Lint Annotations with reviewdog
reviewdog posts lint results as PR review comments, making issues visible without reading raw logs.
- uses: reviewdog/action-flake8@v3
with:
reporter: github-pr-review
level: warning
- uses: reviewdog/action-eslint@v1
with:
reporter: github-pr-review
eslint_flags: '--ext .js,.ts src/'
Branch Protection Rules
Configure these in GitHub Settings → Branches, not in workflow YAML:
- Required status checks: list every CI job name that must pass before merge.
- Required approvals: minimum 1 reviewer for
main.
- Dismiss stale approvals: re-approval required after new commits are pushed.
- Require branches to be up to date: prevents merging outdated branches.
Security Scanning
- name: Python dependency audit
run: pip-audit
- name: Node audit
run: npm audit --audit-level=high
- name: Go vulnerability check
run: govulncheck ./...
For container scanning, integrate Trivy:
- uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
severity: 'CRITICAL,HIGH'
exit-code: '1'
Secrets and Environment Variables
Contexts
env:
APP_ENV: production
DB_HOST: ${{ vars.DB_HOST }}
API_KEY: ${{ secrets.API_KEY }}
Variable Scoping
| Scope | Set In | Available To |
|---|
| Workflow | Top-level env: block | All jobs and steps |
| Job | env: under a specific job | All steps in that job |
| Step | env: under a specific step | That step only |
| Repository | GitHub Settings → Secrets and Variables | All workflows in the repo |
| Environment | GitHub Settings → Environments | Jobs using that environment |
$GITHUB_TOKEN Permissions
Declare only what the job requires (principle of least privilege):
permissions:
contents: read
packages: write
id-token: write
pull-requests: write
checks: write
Set permissions: {} at the workflow level, then grant specific permissions per job to minimize blast radius.
OIDC for Cloud Auth (No Long-Lived Credentials)
OIDC eliminates the need to store cloud credentials as GitHub secrets. The cloud provider verifies the workflow's identity token and grants temporary credentials.
AWS:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
Requires an IAM role with a trust policy that allows token.actions.githubusercontent.com as an OIDC provider.
Google Cloud:
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/my-pool/providers/github
service_account: deployer@my-project.iam.gserviceaccount.com
Requires a Workload Identity Pool configured in GCP with a GitHub OIDC provider binding.
Azure:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
Uses federated credentials on the Azure App Registration — no client secret stored.
CD and Environment Promotion
The standard promotion pattern: build once, deploy to staging automatically, then gate production behind a manual approval.
Staging → Production Pipeline
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build artifact
run: make build
- uses: actions/upload-artifact@v4
with:
name: app-artifact
path: dist/
deploy-staging:
environment: staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/download-artifact@v4
with:
name: app-artifact
path: dist/
- name: Deploy to staging
run: ./scripts/deploy.sh staging
smoke-test:
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- name: Run smoke tests against staging
run: ./scripts/smoke-test.sh https://staging.example.com
deploy-production:
environment: production
runs-on: ubuntu-latest
needs: smoke-test
steps:
- uses: actions/download-artifact@v4
with:
name: app-artifact
path: dist/
- name: Deploy to production
run: ./scripts/deploy.sh production
- name: Notify on failure
if: failure()
run: ./scripts/notify-failure.sh "${{ job.status }}"
Environment Protection Configuration
Set these in GitHub Settings → Environments, not in YAML:
| Setting | Recommended Value |
|---|
| Required reviewers | 1–2 senior engineers for production |
| Wait timer | Optional: 5–10 min buffer before deployment |
| Deployment branches | Limit to main branch only |
| Environment secrets | Prod credentials scoped here, not at repo level |
Deployment Status
Use job.status in post-deploy notifications:
- name: Notify Slack
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Deploy to production: ${{ job.status }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
Artifact and Package Publishing
Docker Image to GHCR
jobs:
publish-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=,suffix=,format=short
type=ref,event=branch
type=semver,pattern={{version}}
- uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Always tag images with the commit SHA. Never use latest as the only tag in production.
PyPI with Trusted Publishing (No Token Needed)
Configure a Trusted Publisher in PyPI project settings pointing to this repository and workflow, then:
jobs:
publish-pypi:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install build
- run: python -m build
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://upload.pypi.org/legacy/
npm Publish
jobs:
publish-npm:
runs-on: ubuntu-latest
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 run build
- run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Complete Workflow Examples
Python Service
name: Python CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
- run: pip install -e ".[dev]"
- run: ruff check .
- run: mypy src/
- run: pytest --cov=src --cov-report=xml --cov-fail-under=80
- run: pip-audit
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
TypeScript/Node Service
name: Node CI
on:
push:
branches: [main]
pull_request:
branches: [main]
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 audit --audit-level=high
Go Service
name: Go CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- run: go vet ./...
- run: staticcheck ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go tool cover -func=coverage.out
- run: govulncheck ./...
Decision Tables
Which Cache Approach to Use
| Language | Recommended Approach | Lockfile to Hash |
|---|
| Python | setup-python built-in cache: pip | requirements*.txt, pyproject.toml |
| Node.js | setup-node built-in cache: npm/pnpm | package-lock.json, pnpm-lock.yaml |
| Go | setup-go built-in cache: true | go.sum |
| Rust | actions/cache on ~/.cargo | Cargo.lock |
| Java/Maven | actions/cache on ~/.m2 | pom.xml |
When to Use OIDC vs Stored Secrets
| Credential Type | Use OIDC | Use Stored Secret |
|---|
| AWS IAM role | Yes | No — OIDC is the standard |
| GCP service account | Yes | No — Workload Identity preferred |
| Azure managed identity | Yes | No — Federated credentials preferred |
| NPM publish token | No | Yes — npm does not support OIDC |
| PyPI publish token | No (use Trusted Publishing) | Only if Trusted Publishing unavailable |
| Third-party API key | No | Yes |
Trigger Selection Guide
| Scenario | Recommended Trigger |
|---|
| Run CI on every PR | pull_request targeting main |
| Deploy on merge to main | push on main branch |
| Nightly dependency audit | schedule with cron |
| Manual production deploy | workflow_dispatch with input parameters |
| Shared CI logic across repos | workflow_call (reusable workflow) |
| Release on tag push | push with tags: ['v*'] |
Red Flags
- Storing cloud credentials as GitHub Secrets — long-lived access keys can be exfiltrated via PR log injection; use OIDC federated credentials (
aws-actions/configure-aws-credentials) instead
- Hardcoding
runs-on: ubuntu-latest without version pinning — ubuntu-latest can shift to a new OS major version mid-project, silently changing available toolchains; pin to ubuntu-22.04 for stability
- Single monolithic job with 20+ steps — any step failure retries the entire job from scratch; split into separate jobs with
needs: so fast gates (lint) don't block slow ones (security scan)
- Pushing to
main without a required status check — branch protection "Required status checks" must be configured in GitHub Settings, not just in the YAML; YAML alone can be bypassed
- Cache key without a lockfile hash — using
key: ${{ runner.os }}-pip without hashFiles(...) causes stale caches after dependency updates, producing false green builds
- Tagging Docker images with only
latest — latest is overwritten on every build; a failed rollback cannot target a specific previous image; always add a commit SHA tag alongside latest
- Using
pull_request_target without careful filtering — this trigger runs in the context of the base branch and has access to secrets, making it exploitable by a forked PR that modifies the workflow
- Skipping
--cov-fail-under or equivalent threshold — coverage upload without a fail threshold lets coverage silently drop to zero; the gate must actively fail the build
Checklist