GitHub Actions
GitHub Actions in depth - workflow syntax, matrix builds, OIDC, reusable workflows, environments, self-hosted runners
GitHub Actions
GitHub Actions is GitHub's native CI/CD — workflows defined as YAML in .github/workflows/, executed by runners on demand. Tightly integrated with PRs, releases, issues, and the GitHub API.
Anatomy of a Workflow
name: CI # human-readable name shown in UI
on: # triggers
push:
branches: [main]
pull_request:
env: # workflow-wide env vars
NODE_ENV: test
jobs:
test: # job ID
runs-on: ubuntu-latest # which runner
steps:
- uses: actions/checkout@v4 # an "action" — reusable step
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm test # raw shell step| Construct | What it is |
|---|---|
| Workflow | A YAML file in .github/workflows/ |
| Event | Something that triggers the workflow (push, pull_request, workflow_dispatch...) |
| Job | A unit of work; runs on one runner |
| Step | A command or an action call inside a job |
| Action | A reusable, packaged step (in a separate repo or built-in) |
| Runner | The machine executing the job |
Jobs run in parallel by default. Add needs: to express dependencies.
Matrix Builds
Test across many versions / OSes / configurations with a single job definition:
jobs:
test:
strategy:
fail-fast: false # don't cancel others when one fails
matrix:
node: [18, 20, 22]
os: [ubuntu-latest, macos-latest, windows-latest]
include:
- { node: 20, os: ubuntu-latest, coverage: true }
exclude:
- { node: 18, os: macos-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
- if: matrix.coverage
uses: codecov/codecov-action@v4That creates 3 × 3 = 9 jobs (minus 1 exclude), each independent.
Reusable Workflows
Two patterns for reuse: composite actions and reusable workflows.
Composite Action (in your repo)
# .github/actions/setup-and-install/action.yml
name: Setup and install
description: Set up Node + install dependencies with caching
inputs:
node-version:
default: '20'
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- run: npm ci
shell: bashCall it:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-and-install
with: { node-version: '22' }Reusable Workflow (workflow_call)
# .github/workflows/build-image.yml
name: Build Image
on:
workflow_call:
inputs:
image-name: { required: true, type: string }
outputs:
image-digest:
value: ${{ jobs.build.outputs.digest }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- id: push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ inputs.image-name }}Call from another workflow:
jobs:
build:
uses: ./.github/workflows/build-image.yml
with:
image-name: ghcr.io/${{ github.repository }}:${{ github.sha }}Reusable workflows reduce duplication across many repos when stored in a shared .github repo.
OIDC: Short-Lived Cloud Credentials
The standout security feature. Instead of storing long-lived AWS / GCP / Azure keys, GitHub mints an OIDC token per workflow run that cloud providers trust:
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/gha-deploy
aws-region: us-east-1
- run: aws s3 lsThe IAM role's trust policy restricts which repo, branch, and environment can assume it:
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123: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:myorg/myrepo:ref:refs/heads/main"
}
}
}Now main of myorg/myrepo can assume the role. PRs from forks can't. A PR from a malicious fork has no credentials to steal — there are none.
Environments
Environments add gates and per-environment secrets:
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://api.example.com
steps:
- run: ./deploy.shConfigure in Settings → Environments:
- Required reviewers — humans must approve before the job runs.
- Wait timer — delay before allowing (e.g. 10 min canary window).
- Deployment branches — only
main(or tags matchingv*) can deploy. - Environment secrets — different values for
stagingandproduction.
Combined with OIDC, you get: only this branch, only after this reviewer's approval, can assume the production IAM role.
Concurrency Control
Stop a deploy from running twice at once, or cancel old PR runs:
# Cancel previous runs on the same PR
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Or queue (don't cancel) for production deploys
concurrency:
group: deploy-production
cancel-in-progress: falseCaching
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
# For Docker builds — GHA's build cache backend
- uses: docker/build-push-action@v6
with:
cache-from: type=gha
cache-to: type=gha,mode=maxsetup-node, setup-python, setup-go, and setup-java all have built-in cache: 'npm' / 'pip' / etc — use that first.
Self-Hosted Runners
For bigger machines, private network access, or cost control:
runs-on: [self-hosted, linux, x64, gpu] # match by labelsRun the Actions Runner Controller (ARC) on Kubernetes for autoscaled, ephemeral runners — best ops story for self-hosted.
Don't run self-hosted runners on public repos without strict precautions. A PR from a fork can execute arbitrary code on your runner — at minimum, restrict who can trigger workflows from PRs, and use ephemeral / sandboxed runners.
Workflow Patterns
Conditional path-based triggers
on:
push:
paths: ['frontend/**'] # only when frontend changes
paths-ignore: ['**/*.md']Job outputs
jobs:
build:
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- id: meta
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ needs.build.outputs.version }}"Matrix with reusable workflow
jobs:
deploy:
strategy:
matrix:
env: [staging, production]
uses: ./.github/workflows/deploy.yml
with: { environment: ${{ matrix.env }} }
secrets: inheritManual deploy button
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
tag:
type: string
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- run: ./deploy.sh ${{ inputs.tag }}What's Next
You know the Actions API surface well enough to ship. See:
- GitLab CI — the same concepts in a different model
- Best Practices — speed, security, pipeline design across platforms