GitHub Actions in Depth
Workflows, matrix builds, reusable actions, OIDC, and the patterns that scale to large orgs.
Workflows, Jobs, Steps
Workflows live in .github/workflows/*.yml. Each file is a separate workflow.
Mental model: GitHub Actions runs WORKFLOWS triggered by events. A workflow contains JOBS that run on RUNNERS. Each job is a sequence of STEPS, each of which is a shell command or an ACTION (a reusable unit of work).
Basic structure:
name: My Workflow
on: # what triggers this workflow
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 2 * * *' # daily at 2 AM UTC
workflow_dispatch: # manual trigger
inputs:
environment:
type: choice
options: [staging, production]
jobs:
my-job:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Hello"
Triggers:
• push — code pushed to a branch
• pull_request — PR opened, updated, closed
• schedule — cron-based
• workflow_dispatch — manual trigger
• release — when you publish a release
• pull_request_target — PR event, but with permissions of the BASE branch (security-sensitive)
Filtering by branch and path:
on:
push:
branches: [main, 'releases/**']
paths:
- 'src/**'
- 'package.json'
paths-ignore:
- '**.md'
Critical: pull_request from forks has NO secret access (security). If you need secrets to test PRs from forks, use pull_request_target carefully — review forked code before running it.
Jobs, Sequencing, and Concurrency
Jobs run in parallel by default. Use needs: for sequencing:
jobs:
test:
runs-on: ubuntu-latest
steps: [...]
lint:
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: [test, lint] # waits for both
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps: [...]
Runners:
• ubuntu-latest — most common. Includes git, Node, Python, Docker, AWS CLI, gcloud, kubectl
• windows-latest, macos-latest
• ubuntu-22.04 — pinned versions
• Larger / GPU runners — paid feature for big builds
• Self-hosted runners — for private network access, specialized hardware, compliance
Concurrency control — prevent overlapping runs:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true # cancel old runs when a new one starts
For deploys:
concurrency:
group: deploy-production
cancel-in-progress: false # don't cancel — just queue
Outputs from one job to another:
jobs:
setup:
outputs:
version: ${{ steps.get-version.outputs.version }}
steps:
- id: get-version
run: echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT
deploy:
needs: setup
steps:
- run: echo "Deploying ${{ needs.setup.outputs.version }}"
Actions — The Reusable Units
An action is a packaged step you can use in any workflow. You reference them by owner/repo@version.
Common actions:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history (default is shallow)
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- uses: actions/cache@v4
with:
path: ~/.cargo
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# Download in another job
- uses: actions/download-artifact@v4
with:
name: dist
Pinning actions:
• @v4 — latest in major version 4 (gets minor/patch updates)
• @v4.1.7 — exact version
• @<commit-sha> — pin to commit (most secure)
Best practice: pin third-party actions by SHA. For first-party (actions/*, github/*) and trusted vendors (aws-actions/*, docker/*), @vN is fine.
Composite actions (in-team reuse):
# .github/actions/setup-app/action.yml
name: Setup App
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- shell: bash
run: npm ci
Use it in any workflow:
- uses: ./.github/actions/setup-app
Matrix Builds
Matrix lets one job definition expand into many parallel runs:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm test
Produces 9 parallel jobs (3 OS × 3 Node versions).
Exclude specific combinations:
matrix:
os: [ubuntu, macos, windows]
node: [18, 20]
exclude:
- os: windows
node: 18
include:
- os: ubuntu
node: 22
Matrix is powerful for libraries that must work on many platforms. Use sparingly for apps.
Secrets, Variables & OIDC
NEVER commit secrets. Store them in:
• Repo settings → Secrets and variables → Actions
• Organization-level secrets (shared across repos)
• Environment-level secrets (production-only)
Using them:
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Secrets are masked in logs (***). Not available to PRs from forks.
Environments — group secrets and add protections:
jobs:
deploy:
environment: production # links to GitHub Environment
runs-on: ubuntu-latest
steps:
- run: echo $DB_URL
env:
DB_URL: ${{ secrets.DB_URL }}
Environments support required reviewers, wait timers, branch restrictions.
OIDC for cloud — the right way to deploy
Long-lived AWS keys in GitHub Secrets are a security risk. Modern setup: GitHub Actions authenticates via short-lived OIDC tokens.
permissions:
id-token: write # request OIDC token
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-deploy
aws-region: us-east-1
- run: aws s3 cp file.txt s3://my-bucket/
No AWS keys anywhere. The role can be scoped to exact branch/environment. Compromised secrets become impossible because there are no long-lived secrets.
GCP and Azure have equivalent OIDC integrations. This is the 2026 standard.
Reusable Workflows
As your CI grows, copy-pasting YAML across workflows becomes painful.
Reusable workflow:
# .github/workflows/test-template.yml
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
secrets:
NPM_TOKEN:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test
Calling it:
jobs:
test:
uses: ./.github/workflows/test-template.yml
with:
node-version: '20'
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
Reusable workflows can be in another repo:
uses: my-org/shared-workflows/.github/workflows/test.yml@v1
This is how a Platform Engineering team shares CI logic across many repos. Define once; teams call them.
Composite action vs reusable workflow:
• Composite — used as a step inside a job. Lighter weight.
• Reusable workflow — replaces an entire job. Can have its own runs-on, secrets, environments.
Use composite for shared steps. Use reusable for shared pipelines.
Production Patterns
A grab-bag of patterns from real production setups:
1. Required status checks — branch protection requires specific jobs to pass before merge.
2. Deploy on tag:
on:
push:
tags: ['v*']
3. Auto-merge dependabot PRs that pass CI — use Dependabot's config plus a workflow.
4. Cost control:
expensive-job:
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'expensive')
5. PR concurrency cancellation:
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
6. Notify on failure:
- name: Notify Slack
if: failure() && github.ref == 'refs/heads/main'
uses: slackapi/slack-github-action@v1.27.0
with:
payload: |
{"text": "Main branch CI failed: ${{ github.sha }}"}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
7. Caching beyond setup-node:
- uses: actions/cache@v4
with:
path: |
~/.cargo
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
The next lesson covers build optimization — once CI/CD runs constantly, making it FAST becomes the next leverage point.
⁂ Back to all modules