Workflow Basics, Triggers, Jobs & Steps, Runners, Caching, Secrets, Matrix Builds, Reusable Workflows — CI/CD mastery.
# ── Basic CI Workflow ──
name: CI Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# Environment variables at workflow level
env:
NODE_VERSION: "20"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# Permissions
permissions:
contents: read
packages: write
# Concurrency — cancel in-progress runs
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
timeout-minutes: 30
# Job-level environment
environment: staging
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full git history
lfs: true # Git LFS support
submodules: recursive
- 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 linting
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
run: npm test -- --coverage
- name: Build
run: npm run build
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7| Key | Required | Description |
|---|---|---|
| name | No | Workflow display name |
| on / triggers | Yes | Events that trigger the workflow |
| env | No | Environment variables for all jobs |
| permissions | No | Token permissions (contents, packages, etc.) |
| concurrency | No | Control concurrent runs |
| defaults | No | Default settings for all jobs |
| run-name | No | Custom name for workflow run in list |
| jobs | Yes | Map of jobs to run |
| Event | When it Fires |
|---|---|
| push | Git push to any branch |
| pull_request | Open, synchronize, reopen PR |
| pull_request_target | PR from fork (runs in base context) |
| workflow_dispatch | Manual trigger from UI |
| workflow_call | Called by another workflow |
| schedule | Cron schedule (UTC) |
| release | Publish/unpublish a release |
| issues | Issue opened, closed, labeled, etc. |
| issue_comment | Comment created, edited, deleted on issue |
| page_build | GitHub Pages build |
| repository_dispatch | Triggered via REST API |
| registry_package | Package published/updated |
# ── Push Triggers ──
on:
push:
branches:
- main
- 'releases/**' # Glob pattern
- '!dev-branch' # Exclude
tags:
- 'v*' # All version tags
paths:
- 'src/**'
- 'package.json'
- '.github/workflows/**'
- '!README.md' # Exclude
# ── Pull Request Triggers ──
on:
pull_request:
types: [opened, synchronize, reopened, closed]
branches: [main]
paths:
- 'src/**.ts'
# ── Schedule (Cron) ──
on:
schedule:
- cron: '0 6 * * 1-5' # 6 AM UTC, weekdays only
- cron: '0 0 1 * *' # Monthly on 1st
# ── Workflow Dispatch (manual trigger) ──
on:
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
default: 'staging'
type: choice
options:
- staging
- production
debug:
description: 'Enable debug mode'
required: false
type: boolean
default: false
version:
description: 'Version to deploy'
required: false
type: string
# ── Multiple Triggers ──
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: # Manual trigger as well
schedule:
- cron: '0 6 * * *' # Plus nightly build| Type | Description |
|---|---|
| opened | PR opened for the first time |
| synchronize | New commits pushed to PR branch |
| reopened | Closed PR reopened |
| closed | PR closed (merged or not) |
| assigned | PR assigned to a user |
| unassigned | Assignment removed |
| labeled | Label added |
| unlabeled | Label removed |
| ready_for_review | Draft PR marked ready |
| converted_to_draft | PR converted to draft |
| edited | PR title or body edited |
| Expression | Description |
|---|---|
| github.ref | Full ref (refs/heads/main, refs/tags/v1) |
| github.ref_name | Short ref name (main, v1) |
| github.sha | Commit SHA that triggered the workflow |
| github.event_name | Event name (push, pull_request) |
| github.actor | Username who triggered the event |
| github.base_ref | Base branch (PR) |
| github.head_ref | Head branch (PR) |
| github.event.pull_request.merged | Boolean: was PR merged? |
# ── Job Configuration ──
jobs:
# Job 1: Build
build:
runs-on: ubuntu-latest
timeout-minutes: 15
needs: [lint] # Run after lint job
if: github.event_name == 'push' # Conditional execution
outputs:
artifact-name: ${{ steps.meta.outputs.name }} # Job outputs
environment: staging # Deployment environment
continue-on-error: false # Don't fail workflow if this fails
concurrency:
group: deploy-staging
cancel-in-progress: true
steps:
# Checkout
- uses: actions/checkout@v4
# Run shell commands
- name: Install deps
run: npm ci
- name: Run multi-line script
run: |
echo "Building project..."
npm run build
echo "Build complete!"
ls -la dist/
# Step with environment
- name: Set env vars for step
env:
DATABASE_URL: postgres://localhost/mydb
NODE_ENV: test
run: npm test
# Conditional step
- name: Deploy
if: github.ref == 'refs/heads/main'
run: npm run deploy
# Step that produces output
- name: Set metadata
id: meta
run: echo "name=production-build-${{ github.sha }}" >> $GITHUB_OUTPUT
# Step that always runs (even on failure)
- name: Cleanup
if: always()
run: rm -rf node_modules
# Job 2: Deploy (depends on build)
deploy:
needs: build
runs-on: ubuntu-latest
if: needs.build.result == 'success'
steps:
- name: Deploy to production
run: echo "Deploying artifact: ${{ needs.build.outputs.artifact-name }}"| Expression | Description |
|---|---|
| github.event_name == 'push' | Only on push events |
| github.ref == 'refs/heads/main' | Only on main branch |
| github.event.pull_request.draft == false | Skip draft PRs |
| success() | All dependency jobs succeeded |
| failure() | Any dependency job failed |
| always() | Always runs (even after cancellation) |
| cancelled() | Workflow was cancelled |
| needs.build.result == 'success' | Specific job succeeded |
| contains(fromJSON(steps.changed.outputs.files), 'src/') | Custom logic |
| Type | Syntax | Description |
|---|---|---|
| Shell | run: echo hello | Default shell (bash/sh) |
| PowerShell | shell: pwsh | PowerShell Core |
| Bash | shell: bash | Explicit bash |
| Python | shell: python | Run Python script |
| Multi-line | run: | | Preserve line breaks |
# ── GitHub-Hosted Runners ──
jobs:
ubuntu:
runs-on: ubuntu-latest # Ubuntu 22.04
# ubuntu-22.04, ubuntu-20.04
windows:
runs-on: windows-latest # Windows Server 2022
# windows-2022, windows-2019
macos:
runs-on: macos-latest # macOS 14 (M1)
# macos-14, macos-13, macos-12
# Specific version
specific:
runs-on: ubuntu-22.04
# ── Self-Hosted Runner (requires setup) ──
self-hosted:
runs-on: [self-hosted, linux, x64]
# Labels: self-hosted, linux, macos, windows,
# x64, arm64, gpu
# ── Larger Runner (more resources) ──
large:
runs-on: ubuntu-latest-4-cores # 4 cores, 16 GB RAM
# ubuntu-latest-8-cores, ubuntu-latest-16-cores| Runner | Cores | RAM | Disk | Cost |
|---|---|---|---|---|
| ubuntu-latest | 2 | 7 GB | 14 GB SSD | Free |
| ubuntu-4-cores | 4 | 16 GB | 14 GB SSD | Free |
| ubuntu-8-cores | 8 | 32 GB | 14 GB SSD | Free |
| windows-latest | 2 | 7 GB | 14 GB SSD | Free |
| macos-latest | 3 (M1) | 7 GB | 14 GB SSD | 10x min |
| macos-13 | 3 (Intel) | 7 GB | 14 GB SSD | 10x min |
| self-hosted | Custom | Custom | Custom | Infra cost |
| Label | Description |
|---|---|
| self-hosted | Any self-hosted runner |
| linux / macos / windows | Operating system |
| x64 / arm64 | Architecture |
| gpu | GPU-enabled runner |
| custom-label | Your own labels (tags) |
actions/runner-images for the full list. For GPU workloads, use ubuntu-latest-gpu (4-core, 16GB RAM, 2x Tesla T4).# ── Cache Dependencies ──
steps:
# Auto-caching with setup-node
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Automatically caches npm
# Manual cache
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-
# Cache pip (Python)
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('requirements.txt') }}
# Cache Docker layers
- uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: buildx-${{ github.sha }}
restore-keys: buildx-
# ── Artifacts (share data between jobs) ──
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 5
if-no-files-found: error
compression-level: 6
- name: Download artifact in another job
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
github-token: ${{ secrets.GITHUB_TOKEN }}| Strategy | Key Pattern | Best For |
|---|---|---|
| Exact match | hashFiles("**/lock") | Lock files (package-lock.json) |
| Fallback | restore-keys: prefix- | Branch-based fallbacks |
| Versioned | cache-v1-{hash} | Cache versioning/busting |
| Resource | Limit |
|---|---|
| Total cache per repo | 10 GB |
| Individual cache | 10 GB |
| Cache entries | Unlimited (LRU eviction) |
| Retention | 7 days (no access) |
| Artifact size | 2 GB per artifact |
| Total artifacts per run | No limit (but 10 GB total) |
# ── Using Secrets ──
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# Access repository secrets
- name: Deploy with secret
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
run: aws s3 sync dist/ s3://my-bucket/
# Access organization secrets
- name: Use org secret
env:
API_KEY: ${{ secrets.ORG_API_KEY }}
run: make deploy
# Access environment secrets (higher priority)
- name: Use environment secret
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} # From environment
run: echo "Using deploy key"
# Pass secret to action
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
# ── Secrets Scopes ──
# Repository secrets: available in all workflows of the repo
# Environment secrets: available only when targeting that environment
# Organization secrets: available in all repos of the org
# Variables (non-secret): available via vars context| Context | Scope |
|---|---|
| secrets.GITHUB_TOKEN | Auto-provided, repo-scoped (60 min expiry) |
| secrets.MY_SECRET | Repository secret |
| vars.MY_VAR | Repository variable (not secret) |
uses: action@v4, use uses: action@abc123def456... (full SHA). This prevents supply chain attacks if an action is compromised. Use dependabot.yml to auto-update pinned actions.# ── Basic Matrix ──
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
# ── Multi-Dimension Matrix ──
test-full:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
os: [ubuntu-latest, windows-latest, macos-latest]
# Exclude specific combinations
exclude:
- os: windows-latest
node-version: 18
# Include additional combinations
include:
- os: ubuntu-latest
node-version: 22
experimental: true
# ── Matrix with Failure Strategy ──
test-resilient:
runs-on: ubuntu-latest
strategy:
fail-fast: false # Continue all jobs even if some fail
max-parallel: 4 # Limit parallel jobs
matrix:
version: [v1, v2, v3]
steps:
- run: npm test
# ── Matrix Output Aggregation ──
build:
runs-on: ubuntu-latest
outputs:
results: ${{ toJSON(steps.result.outputs) }}
strategy:
matrix:
platform: [linux, mac, win]
steps:
- id: result
run: echo "result=${{ matrix.platform }}-success" >> $GITHUB_OUTPUT| Option | Default | Description |
|---|---|---|
| fail-fast | true | Cancel all jobs on first failure |
| max-parallel | Infinity | Max simultaneous matrix jobs |
| matrix | (required) | The matrix variables to expand |
| Variable | Description |
|---|---|
| matrix.node-version | Current matrix value |
| matrix.os | Current matrix value for os dimension |
| matrix.include[n].key | Access include values by index |
| strategy.job-index | Current job index (0-based) |
| strategy.job-total | Total number of jobs in matrix |
# ── Reusable Workflow (called by others) ──
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
required: true
type: string
description: 'Target environment'
version:
required: false
type: string
default: 'latest'
outputs:
url:
description: 'Deployment URL'
value: ${{ jobs.deploy.outputs.url }}
secrets:
deploy-key:
required: true
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
- run: echo "Deploying version ${{ inputs.version }} to ${{ inputs.environment }}"
- run: echo "url=https://${{ inputs.environment }}.app.example.com" >> $GITHUB_OUTPUT
id: deploy# ── Calling a Reusable Workflow ──
name: Deploy Pipeline
on:
push:
branches: [main]
jobs:
# Call reusable workflow
deploy-staging:
uses: ./.github/workflows/called.yml
with:
environment: staging
version: ${{ github.sha }}
secrets: inherit # Pass all secrets
deploy-production:
needs: deploy-staging
uses: ./.github/workflows/called.yml
with:
environment: production
version: ${{ github.sha }}
secrets:
deploy-key: ${{ secrets.PROD_DEPLOY_KEY }} # Pass specific secret
# You can also use: secrets: inherit
# Call workflow from another repo
external:
uses: my-org/shared-actions/.github/workflows/deploy.yml@main
with:
environment: staging
secrets: inherit| Feature | Description |
|---|---|
| inputs | Typed parameters (string, boolean, number, choice) |
| outputs | Return values from called workflow |
| secrets | Pass secrets explicitly or inherit |
| secrets: inherit | Pass all caller secrets to called workflow |
| uses | Reference local or external workflow file |
| Type | Scope | Runs On | Use For |
|---|---|---|---|
| Workflow | Repo-level | Runner VM | Full CI/CD pipelines |
| Action (JS) | Step-level | Runner VM | Single purpose (checkout, deploy) |
| Action (Composite) | Step-level | Runner VM | Multi-step reusable sequences |
| Reusable Workflow | Job-level | Runner VM | Shared job patterns across repos |
GitHub Actions is YAML-based, tightly integrated with GitHub (PR checks, branch protection, deployment environments), and uses a marketplace of reusable actions. Jenkins is self-hosted, Groovy-based, highly customizable but complex to maintain. GitLab CI is also YAML-based and built into GitLab. GitHub Actions advantages: native GitHub integration, generous free tier (2000 min/month for public repos), OIDC federation for cloud auth, and matrix builds.
Free tier: 2000 minutes/month for public repos, 500 MB artifact storage, 10 GB cache. Pro/team: 3000/3000 minutes, 2 GB artifacts, 10 GB cache. Per workflow: 72-hour timeout, 100 concurrent jobs, 100 total jobs, 256 MB memory per step on GitHub-hosted runners. Artifacts: 2 GB per file, 90-day retention. Secrets: 1000 repo secrets, 100 KB each.
1) Use secrets context for sensitive data (auto-masked in logs). 2) Use OIDC federation to get short-lived cloud credentials without long-lived keys. 3) Use environment protection rules with required reviewers. 4) Pin actions to SHA, not tags. 5) Use permissions to grant minimum token scopes. 6) Never use echo $SECRET — secrets are masked but can leak indirectly through encoded formats.
GITHUB_TOKEN is automatically provided to every workflow. Its permissions are scoped by the permissions key. Default: read-only for public repos, read/write for private repos. It can access the GitHub API (clone repos, create issues, upload releases). It expires when the job completes. You can also create Personal Access Tokens (PAT) as secrets for more permissions.
1) Cache dependencies (npm, pip, Docker layers). 2) Parallel jobs — split independent tasks. 3) Matrix builds — run tests in parallel across versions. 4) Incremental builds — use paths filters to skip irrelevant changes. 5) Docker layer caching with BuildKit. 6) Avoid heavy setup — use actions/setup-node with built-in cache. 7) Use artifacts wisely — avoid large unnecessary uploads. 8) Use GitHub-hosted runners for standard workloads (no provisioning time).