A complete technical dissection of how GitHub Actions works under the hood: event routing, workflow execution, runner infrastructure, secrets management, and production deployment patterns.
██████╗ ██╗██████╗ ███████╗██╗ ██╗███╗ ██╗███████╗ ██╔══██╗██║██╔══██╗██╔════╝██║ ██║████╗ ██║██╔════╝ ██████╔╝██║██████╔╝█████╗ ██║ ██║██╔██╗ ██║█████╗ ██╔═══╝ ██║██╔═══╝ ██╔══╝ ██║ ██║██║╚██╗██║██╔══╝ ██║ ██║██║ ███████╗███████╗██║██║ ╚████║███████╗ ╚═╝ ╚═╝╚═╝ ╚══════╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝
Most developers have used CI/CD. Far fewer understand what it actually does. The phrase gets conflated with "running tests automatically" but that is only a small corner of what a mature pipeline does. Before dissecting GitHub Actions, it is worth aligning on the real definition.
CI/CD is not about automation for its own sake. It is about compressing the feedback loop between writing code and knowing whether that code is correct and deployable. The closer that loop gets to zero, the faster teams can move safely. GitHub Actions is the orchestration engine that enforces this loop as infrastructure.
Everything in GitHub Actions begins with an event. A workflow does not run unless an event fires. Understanding the event model is the foundation for understanding the whole system.
When something happens in your GitHub repository — a push, a pull request, a comment, a release tag — GitHub's platform emits an event payload as a JSON object. This payload is routed to the Actions engine, which scans all .github/workflows/*.yml files in your repository, evaluates each workflow's on: trigger block, and dispatches those that match.
The event payload is then made available inside the workflow as the github.event context object, allowing steps to inspect exactly what triggered the run.
push, pull_request, pull_request_review, create (branch/tag), delete, release, fork, star. These are the most common triggers for CI pipelines.schedule. Runs workflows on a fixed schedule regardless of code activity. Used for nightly builds, dependency audits, database maintenance, and health checks.workflow_dispatch allows humans to trigger a workflow via the GitHub UI or API with custom inputs. repository_dispatch allows external systems to trigger workflows via HTTP API calls.workflow_run fires when another workflow completes. workflow_call allows a workflow to be invoked as a subroutine by other workflows. These enable modular pipeline composition.# Trigger on pushes to main or release branches
on:
push:
branches:
- main
- 'release/**'
paths-ignore:
- 'docs/**'
- '*.md'
# Only run on PR open/reopen/sync to main
pull_request:
types: [opened, synchronize, reopened]
branches:
- main
# Scheduled nightly audit at 2am UTC
schedule:
- cron: '0 2 * * *'
# Manual trigger with environment selection
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
required: true
type: choice
options:
- staging
- production
A critical performance optimization: use paths and paths-ignore filters on push triggers. Without them, every documentation commit triggers your full build pipeline. For a monorepo with 10 services, that means 10x wasted runner minutes. GitHub evaluates path filters before dispatching to runners, so filtering is essentially free.
A GitHub Actions workflow is a YAML file that defines a state machine. Each workflow file is a declaration of: what events trigger it, what jobs to run, in what order, on what machines, under what conditions. The YAML is parsed into an internal execution graph.
Within a job, steps execute sequentially on the same runner. Each step is either an action (a reusable unit of automation, referenced with uses:) or a shell command (raw script, referenced with run:). Steps share the same filesystem and environment within a job, which is how files produced in one step are available in the next.
jobs:
build-and-test:
runs-on: ubuntu-latest # pin e.g. ubuntu-24.04 for reproducibility
timeout-minutes: 20
steps:
# 1. Checkout source code
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for semantic versioning
# 2. Restore dependency cache
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
# 3. Install dependencies
- name: Install
run: npm ci # clean install, never modifies lockfile
# 4. Run linting and tests in parallel via npm scripts
- name: Lint
run: npm run lint
- name: Test
run: npm test -- --coverage
# 5. Upload coverage for next job to consume
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./coverage/
YAML values in GitHub Actions can use expressions: dynamic values computed at runtime, wrapped in ${{ }}. Expressions have access to a set of context objects that expose data about the run, the repository, the runner, and more.
needs:Runners are the machines where your jobs actually execute. Understanding runner architecture is critical because it explains performance characteristics, security boundaries, and cost structure.
When a job is dispatched to a GitHub-hosted runner, GitHub provisions a brand new virtual machine from its runner fleet. The VM is pre-configured with a large set of tools (Node.js, Python, Go, Docker, AWS CLI, etc.) corresponding to the runner image. Your job runs, the VM is destroyed. No state persists between runs unless you explicitly use the cache or artifact APIs.
This ephemeral model is the key to security and reproducibility: you are guaranteed a clean environment every single time. There are no "it worked yesterday" mysteries caused by stale global state.
| Runner Type | OS | vCPU | RAM | Billed? | Use Case |
|---|---|---|---|---|---|
| ubuntu-latest | Ubuntu 24.04 | 4 | 16 GB | Free tier | Most CI workloads |
| windows-latest | Windows Server 2022 | 4 | 16 GB | 2x multiplier | .NET, Windows builds |
| macos-latest | macOS 14 (M1) | 3 | 7 GB | 10x multiplier | iOS/macOS apps |
| ubuntu-latest (4-core) | Ubuntu 24.04 | 4 | 16 GB | Paid | Larger CI builds |
| Self-hosted | Any | You control | You control | Free GitHub side | GPU, custom tools, on-prem |
A self-hosted runner is a process (the actions-runner agent) that you install on your own machine or VM. It opens a persistent long-polling connection to GitHub's API and receives jobs assigned to it. Key architectural differences from hosted runners:
Never run self-hosted runners on public repositories. A fork PR could trigger a workflow that executes arbitrary code on your runner, with access to your network, your environment variables, and potentially your cloud credentials. If you must use self-hosted runners on public repos, isolate them in ephemeral containers with no persistent secrets and no network access to sensitive resources.
Jobs within a workflow execute in parallel by default. Dependency ordering is declared explicitly using needs:. This creates a directed acyclic graph (DAG) that the Actions scheduler uses to determine what to run when.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: tag
run: echo "tag=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- run: docker build -t myapp:${{ steps.tag.outputs.tag }} .
unit-test:
needs: [lint, build] # waits for BOTH to succeed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test
deploy:
needs: [unit-test]
if: github.ref == 'refs/heads/main' # only on main branch
runs-on: ubuntu-latest
steps:
- run: echo "Deploying image ${{ needs.build.outputs.image-tag }}"
Jobs run on different runners. They cannot share memory or local files. The only way to pass data from one job to a downstream job is via job outputs. A step writes a key-value pair to the GITHUB_OUTPUT environment file. The job declares which step outputs to expose. Downstream jobs read them via needs.job-id.outputs.key. This is how a build job passes an image SHA to a deploy job without a shared filesystem.
Both jobs and individual steps support an if: field that evaluates a boolean expression at runtime. If the expression is false, the job or step is skipped. Common patterns:
if: github.ref == 'refs/heads/main' — only on main branchif: github.event_name == 'push' — only on direct pushes, not PRsif: failure() — only run if a previous step failed (for cleanup/notifications)if: always() — run even if previous steps failed (for reporting)if: contains(github.event.pull_request.labels.*.name, 'deploy') — only if PR has a labelSecrets are the most security-sensitive part of any pipeline. GitHub Actions has a layered variable system with different scopes, precedence rules, and security properties.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # activates env secrets + approval gate
steps:
# Using repository secret
- name: Configure AWS Credentials
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
# Using GITHUB_TOKEN to push to packages registry
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
GitHub Actions automatically masks secret values in log output. Any output line that contains a string matching a registered secret value is replaced with ***. This is a post-processing step on the runner agent level. It is not foolproof: if a secret is base64-encoded or split across lines, masking may not catch it. The architectural lesson: treat logs as potentially public, never log secrets even indirectly.
Jobs on GitHub Actions are ephemeral by design. This creates a problem: how does a build job pass a compiled binary to a deploy job? How do you avoid reinstalling 500MB of npm packages on every run? The answer is two separate systems with different semantics.
Files uploaded from a workflow run and stored by GitHub for a configurable retention period (default 90 days). Artifacts are associated with a specific workflow run. They are the right tool for:
API: actions/upload-artifact and actions/download-artifact.
A key-value store for files that are expensive to recreate and safe to reuse across runs. Cache entries are keyed by an explicit string (typically a hash of your dependency lockfile). The right tool for:
API: actions/cache.
- name: Cache npm dependencies
uses: actions/cache@v4
with:
path: ~/.npm
# Primary key: exact lockfile hash (cache hit if nothing changed)
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
# Fallback keys: partial restore if primary misses
restore-keys: |
${{ runner.os }}-npm-
${{ runner.os }}-
Cache hit rate directly translates to pipeline speed and cost. A cold npm install on a typical Node app takes 45-90 seconds. A cache restore takes 3-5 seconds. For a team running 200 CI jobs per day, the difference is 2-3 hours of runner time saved daily. Track your cache hit rates. A drop in hit rate often indicates someone modified the lockfile or the cache key strategy is wrong.
This is where CI ends and CD begins. Getting code from a passing build to a running production system involves environment management, approval gates, rollback strategies, and deployment verification. GitHub Actions has native primitives for all of these.
A GitHub Environment is a named deployment target (staging, production, etc.) configured in repository settings. Environments can have:
jobs:
deploy-staging:
needs: [test]
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.myapp.com
steps:
- name: Deploy to Staging
run: ./scripts/deploy.sh staging
env:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
deploy-production:
needs: [deploy-staging]
runs-on: ubuntu-latest
environment:
name: production # required_reviewers blocks here until approval
url: https://myapp.com
steps:
- name: Deploy to Production
run: ./scripts/deploy.sh production
env:
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
on: push: tags: ['v*.*.*']. This separates "merge to main" from "release to production" with an explicit human gesture: creating a release tag.The single biggest source of CI/CD debt in large organizations is duplicated workflow YAML across dozens of repositories. Every team copies a CI template, makes local modifications, and three months later no one knows which version of the security scan step they are running. Reusable workflows solve this.
# .github/workflows/reusable-docker-build.yml
on:
workflow_call: # This makes it callable by other workflows
inputs:
image-name:
required: true
type: string
registry:
required: false
type: string
default: ghcr.io
secrets:
REGISTRY_TOKEN:
required: true
outputs:
image-tag:
description: "Built image tag"
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v5
id: meta
with:
push: true
tags: ${{ inputs.registry }}/${{ inputs.image-name }}:${{ github.sha }}
jobs:
build:
uses: my-org/shared-workflows/.github/workflows/reusable-docker-build.yml@main
with:
image-name: my-service
secrets:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
deploy:
needs: [build]
steps:
- run: echo "Deploy ${{ needs.build.outputs.image-tag }}"
The strategy.matrix feature creates a grid of job variants from a set of variable combinations. GitHub Actions generates one job per combination and runs them all in parallel. This is the canonical way to test across multiple language versions, operating systems, or configuration dimensions without duplicating YAML.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: ['20', '22', '24']
exclude:
# Skip this specific combination (too expensive)
- os: macos-latest
node-version: '22'
fail-fast: false # don't cancel all on first failure
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm test
Beyond OS and version combinations, matrix builds can be used to shard a large test suite across parallel runners. Define a matrix like shard: [1, 2, 3, 4] and pass --shard=${{ matrix.shard }}/4 to your test runner. A 12-minute test suite becomes a 3-minute pipeline. This is how large engineering teams keep CI fast at scale without throwing more compute at individual runners.
On new personal repositories, GitHub’s default workflow permissions for GITHUB_TOKEN are restricted to read for contents and packages (organization or repository settings can still choose a more permissive default). Older repos may still use broader defaults—so never assume write. Always set an explicit permissions: block for least privilege. As of 2025–2026, GitHub is moving the Actions runtime from Node.js 20 toward Node.js 24 (Node 20 Actions runtime deprecation); keep first-party actions (checkout, setup-node, upload-artifact) on current major versions.
permissions:
contents: read # read code only
packages: write # push to container registry
pull-requests: write # post PR comments
id-token: write # required for OIDC (see below)
# all other permissions default to none
The traditional approach to deploying from CI: store a long-lived cloud credential (AWS key, GCP service account JSON) as a repository secret. This works but has serious problems: credentials rotate infrequently (or never), if the secret leaks it can be used anytime from anywhere, and there is no automatic expiry.
OIDC (OpenID Connect) solves this. The workflow requests a short-lived identity token from GitHub's OIDC provider. It presents this token to your cloud provider (AWS, GCP, Azure all support this). The cloud provider verifies the token's signature against GitHub's public JWKS endpoint and checks that the claims match your configured trust policy (repo, branch, environment). If the checks pass, it grants a temporary credential valid for minutes, not years.
The credential is never stored anywhere. It cannot be used outside the workflow run. There is nothing to rotate or leak.
permissions:
id-token: write # required: allows workflow to request OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Configure AWS via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
# The ARN of the IAM role to assume. Configured in AWS to trust
# tokens from this repo/branch combination.
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
aws-region: us-east-1
# No access-key-id or secret-access-key. OIDC handles it.
- run: aws s3 sync ./dist s3://my-bucket/
Pipeline speed is a developer productivity metric. Research consistently shows that CI runs longer than 10 minutes destroy flow state and increase the temptation to skip the process. Here is how to engineer a fast pipeline systematically.
paths: on push triggers. Documentation commits should not rebuild your Docker image. A paths filter is evaluated before a runner is provisioned, making it free in terms of compute cost.concurrency: to cancel in-progress runs when a new commit pushes to the same PR. No point waiting for a stale run to complete when the new commit supersedes it. Saves queue time for the whole team.docker/build-push-action with cache-from: type=gha. Docker builds can reuse layers from previous runs, turning a 5-minute image build into a 30-second cache hit when only application code changes.concurrency:
# Group by workflow + PR number (or branch for non-PR runs)
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true # cancel the old run, start the new one
A CI/CD pipeline is not a DevOps concern bolted on after the fact. It is architecture. The decisions you make in your workflow files encode your team's deployment philosophy: how much risk you accept, what quality gates are non-negotiable, how you handle production access, and how fast you can safely ship. Treat your .github/workflows/ directory with the same architectural rigor you apply to your application code.
You now have a complete mental model of GitHub Actions as a platform: the event routing engine that triggers workflows, the YAML-defined DAG that orchestrates jobs, the ephemeral runner fleet that executes them, the layered secrets system that provides credentials, the artifact and cache APIs that persist state, the environment and approval system that controls production access, the OIDC integration that eliminates stored credentials, and the performance engineering techniques that keep pipelines fast at scale. The next time you write a workflow file, you are no longer filling in a template. You are making deliberate architectural decisions.