Skip to content
DevOps AI ToolKit
Newsletter
Core Guide · Delivery & Automation

GitHub Actions

A complete, production-focused guide to GitHub Actions — how workflows, jobs, runners, and events actually fit together, plus real CI/CD examples: matrices, caching, reusable workflows, container builds, and keyless cloud deploys with OIDC.

Last reviewed August 2026 Guide · Production examples · 28 min read

Technically validated: Examples use current action major versions (actions/checkout@v4, actions/setup-node@v4, actions/cache@v4) and the GitHub-hosted ubuntu-latest runner. Pin to commit SHAs in production — see Security.

On this page

GitHub Actions is where most teams’ CI/CD lives, and it rewards understanding the model rather than copy-pasting YAML. The difference between a workflow that’s fast, secure, and maintainable and one that’s slow, leaky, and flaky is almost entirely in the details this guide covers: least-privilege permissions, keyless cloud auth with OIDC, caching that actually hits, and reusable workflows that stop you copying the same 80 lines into every repo.

The model: events → workflows → jobs → steps

A workflow is a YAML file in .github/workflows/. An event (a push, a PR, a schedule, a manual dispatch) triggers it. A workflow contains jobs, which run in parallel by default on runners (fresh VMs). Each job contains steps, which run sequentially and are either a shell command (run:) or an action (uses: — a reusable unit of logic).

event (push / pull_request / schedule / workflow_dispatch)
  └─ workflow  (.github/workflows/ci.yml)
       └─ job(s)   ── run in parallel on separate runners
            └─ step(s)  ── run in order; `run:` a command or `uses:` an action

Jobs are isolated: they don’t share a filesystem unless you pass data between them with artifacts or outputs. That isolation is what lets them parallelize — and what surprises people who expect job B to see the files job A built.

A complete, correct CI workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

# Least privilege by default: this workflow can only READ the repo.
permissions:
  contents: read

# Cancel superseded runs on the same ref (saves minutes on rapid pushes).
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'            # built-in dependency cache
      - run: npm ci
      - run: npm test

Every piece of that is deliberate: permissions: contents: read means a compromised dependency in this run can’t push code or open releases; concurrency cancels stale runs; setup-node’s cache: npm restores node_modules deps across runs.

Events and triggers

on:
  push:
    branches: [main]
    paths: ['src/**', 'package.json']      # only when these change
  pull_request:
    types: [opened, synchronize, reopened]
  schedule:
    - cron: '0 6 * * *'                     # daily 06:00 UTC
  workflow_dispatch:                        # manual "Run workflow" button
    inputs:
      environment:
        type: choice
        options: [staging, production]

Runners

  • GitHub-hosted (runs-on: ubuntu-latest / windows-latest / macos-latest) — ephemeral, patched VMs. Zero maintenance; ideal default.
  • Self-hosted — your own machines, for special hardware, private-network access, or cost at scale.

Secrets, environment variables, and GITHUB_TOKEN

    steps:
      - run: ./deploy.sh
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}   # from repo/org/environment secrets
          LOG_LEVEL: info                        # plain (non-secret) config

Every run gets an automatic GITHUB_TOKEN whose scope is exactly your permissions: block. Grant only what a job needs, per job:

jobs:
  release:
    permissions:
      contents: write        # create a release/tag
      packages: write        # push to GitHub Packages
    runs-on: ubuntu-latest

Matrices

Run the same job across combinations — versions, OSes, targets — in parallel.

jobs:
  test:
    strategy:
      fail-fast: false                 # don't cancel siblings when one fails
      matrix:
        node: ['18', '20', '22']
        os: [ubuntu-latest, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '${{ matrix.node }}' }
      - run: npm ci && npm test

That’s six parallel jobs from ten lines. fail-fast: false lets you see all failures instead of just the first.

Caching and artifacts

Caching speeds up dependency restoration; artifacts pass build output between jobs or out to you.

      # Explicit cache (setup-* actions wrap this for common ecosystems)
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-${{ hashFiles('requirements.txt') }}
          restore-keys: pip-

      # Publish build output as an artifact
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

In a later job, actions/download-artifact@v4 retrieves it. This is the correct way to hand a build from a build job to a deploy job — remember, jobs don’t share a filesystem.

Reusable workflows and composite actions

Stop copy-pasting. A reusable workflow is a whole workflow other workflows call; a composite action bundles steps into a single uses:.

# .github/workflows/deploy.yml  (reusable)
on:
  workflow_call:
    inputs:
      environment: { required: true, type: string }
    secrets:
      token: { required: true }
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - run: ./deploy.sh --env "${{ inputs.environment }}"
# caller
jobs:
  prod:
    uses: ./.github/workflows/deploy.yml
    with: { environment: production }
    secrets: { token: ${{ secrets.DEPLOY_TOKEN }} }

Environments and approvals

environment: gates a job behind protection rules — required reviewers, wait timers, and environment-scoped secrets. This is how you make production deploys require a human click.

  deploy-prod:
    environment:
      name: production
      url: https://app.example.com
    runs-on: ubuntu-latest
    steps: [ ... ]

Keyless cloud deploys with OIDC

The most important security upgrade for CI/CD: stop storing long-lived cloud keys as secrets. With OIDC, GitHub issues a short-lived token that your cloud trusts, scoped to a specific repo/branch — nothing to leak, nothing to rotate.

jobs:
  deploy:
    permissions:
      id-token: write        # REQUIRED to request the OIDC token
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://my-bucket

You configure a trust policy on the cloud side (an IAM role for AWS, a workload-identity federation for GCP, a federated credential for Azure) that permits only your repo and branch to assume it. Azure and GCP have equivalent azure/login and google-github-actions/auth flows.

Building and pushing container images

  image:
    permissions:
      contents: read
      packages: write        # push to ghcr.io
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

cache-from/to: type=gha uses the Actions cache for Docker layers — often the biggest win for image-build times. Tagging by ${{ github.sha }} gives every build an immutable, traceable tag.

Conditional execution and job dependencies

jobs:
  build:
    runs-on: ubuntu-latest
    steps: [ ... ]
  deploy:
    needs: build                                    # wait for build to succeed
    if: github.ref == 'refs/heads/main'             # only on main
    runs-on: ubuntu-latest
    steps: [ ... ]

needs: builds a dependency graph; if: gates a job or step on an expression. Together they express “build always; deploy only from main after build passes.”

Security and supply-chain hardening

  • Pin actions to a full commit SHA, not a tag — uses: actions/checkout@<sha>. Tags are mutable; a compromised popular action pushed to an existing tag has caused real incidents. (Dependabot can update the pins.)
  • Least-privilege permissions: on every workflow, widened per job.
  • OIDC instead of stored cloud keys.
  • Guard pull_request_target and never run untrusted PR code with secrets.
  • Ephemeral, private-only self-hosted runners.
  • Review third-party actions before adding them; each is code running with your token.

Debugging and optimization

  • Re-run with debug logging: set repo secrets ACTIONS_STEP_DEBUG: true and re-run to get verbose step logs.
  • workflow_dispatch lets you trigger a run manually with inputs while iterating.
  • Speed: precise cache keys, concurrency cancellation, paths: filters to skip irrelevant changes, split slow suites across a matrix, and use needs: so independent jobs run in parallel.
  • Flakiness: pin action versions, avoid time/network-dependent tests in CI, and set sensible timeout-minutes: so a hung job doesn’t burn an hour.

Production checklist

  • permissions: set to least privilege at the workflow level, widened per job.
  • Cloud deploys use OIDC; no long-lived provider keys in secrets.
  • Third-party actions pinned to commit SHAs; Dependabot keeps them current.
  • concurrency with cancel-in-progress on CI to save minutes.
  • Cache keys hash the lockfile; artifacts pass build output between jobs.
  • Production jobs gated by an environment: with required reviewers.
  • timeout-minutes: on every job; fail-fast: false where you want full matrix results.
  • Image builds scan for vulnerabilities before push.

Frequently asked questions

Why don’t my jobs share files? Each job runs on a fresh, isolated runner. Pass data between jobs with actions/upload-artifact / download-artifact or job outputs — not the filesystem.

How do I deploy to AWS/Azure/GCP without storing keys? Use OIDC: grant the job id-token: write, use the provider’s official login action (configure-aws-credentials, azure/login, google-github-actions/auth), and configure a cloud-side trust policy scoped to your repo/branch. The credentials are short-lived and never stored.

What’s the difference between a reusable workflow and a composite action? A reusable workflow (on: workflow_call) is an entire workflow with its own jobs that another workflow calls. A composite action bundles multiple steps into one uses: you drop inside a job. Use reusable workflows for whole pipelines, composite actions for repeated step sequences.

How do I make CI faster? Precise dependency cache keys, concurrency cancellation of stale runs, paths: filters, parallelism via matrices and independent jobs, and Docker layer caching (type=gha) for image builds.

Is it safe to use pull_request_target? Only with care. It runs with secrets and a writable token but involves untrusted fork code. Never check out and execute the PR’s code in that context; prefer plain pull_request (read-only, no secrets) whenever possible.

Why pin actions to a SHA instead of a tag? Tags are mutable — an attacker who compromises a popular action can move a tag to malicious code that then runs with your token. A full commit SHA is immutable; Dependabot can still bump it safely.

Continue learning

Related Core Guides that build on this one.

Written by James Joyner IV, Sr. Systems Software Engineer — for engineers who run what they build.

Last reviewed August 2026. Found an error or an out-of-date command? Tell us — accuracy is the point of a Core Guide.