Hands-on: worked through with sharifndlovu123/github-actions-course, the companion repo for the DevOps Directive "GitHub Actions: Beginner to Pro" course. The basics are in 2. GitHub Actions; this note goes deeper.
What each topic is for
| Topic | Question it answers |
|---|---|
| Core features | How do workflows, jobs, steps, events and variables fit together? |
| Runners | Where does the job run, and on what hardware? |
| Artifacts and caching | How do I pass files between jobs and avoid redoing work? |
| Permissions and OIDC | How does a workflow get access safely? |
| Matrix and conditionals | How do I run many variants and skip what is not needed? |
| Marketplace actions | Which reusable actions can I trust? |
| Common workflows | What do most pipelines actually contain? |
| Best practices | How do I keep pipelines fast, maintainable and secure? |
Authoring your own actions and testing workflows locally: Authoring and Testing GitHub Actions.
Core Features
flowchart TB
E[Event: push, pull_request, schedule, workflow_dispatch] --> W[Workflow: YAML file]
W --> J1[Job A]
W --> J2[Job B needs: A]
J1 --> S1[Step: run shell]
J1 --> S2[Step: uses action]
- A workflow reacts to events; it contains jobs that run in parallel unless
needs:orders them - A step is either a shell command (
run:, bash by default on Linux, also Python and others viashell:) or an action (uses:) - Each job gets a fresh runner, so nothing carries over between jobs unless you pass it explicitly
Triggers and filters
on:
push:
branches: [main]
paths: ["src/**", "!src/**/*.md"] # only run when matching files change
tags: ["v*"]
pull_request:
paths-ignore: ["docs/**"]
workflow_dispatch: # manual run button
inputs:
env: { type: choice, options: [staging, production] }
Path and branch filters are the cheapest way to do less: a docs-only change should not run the full test suite.
Variables and scope
Variables can be set at three levels, and the closest one wins:
env: # workflow level
LEVEL: workflow
jobs:
demo:
runs-on: ubuntu-latest
env:
LEVEL: job # job level
steps:
- run: echo "$LEVEL" # job
- env:
LEVEL: step # step level
run: echo "$LEVEL" # step
Use vars.NAME for non-sensitive configuration and secrets.NAME for sensitive values. Both can be defined on the repository or on an environment (which can also require reviewers).
Passing data
| Between | How |
|---|---|
| Steps in a job | Write to $GITHUB_ENV (env var) or $GITHUB_OUTPUT (step output) |
| Jobs | Declare job outputs: and read them with needs.<job>.outputs.<name> |
| Jobs (files) | Upload and download an artifact |
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
steps:
- id: v
run: echo "version=1.4.2" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ needs.build.outputs.version }}"
Runners
| Runner | Notes |
|---|---|
| GitHub-hosted | ubuntu-latest, windows-latest, macos-latest; clean VM each job; free minutes depend on plan and repo visibility |
| Larger / ARM runners | More CPU and RAM, native ARM, billed per minute |
| Container jobs | container: image: runs the steps inside an image you choose; services: starts databases or Redis next to the job |
| Self-hosted | Your own machine, full control, but you secure and maintain it |
| Third-party runners | Faster or cheaper hosted runners (for example Namespace, Blacksmith, Actuated) that drop in with a different runs-on: |
Avoid emulation (QEMU) where you can; use native ARM and x86 runners for each architecture. Never attach self-hosted runners to public repositories that accept fork pull requests.
Artifacts and Caching
| Artifacts | Cache | |
|---|---|---|
| Purpose | Hand outputs to later jobs or keep them for download (build files, test reports) | Reuse dependencies between runs |
| Actions | actions/upload-artifact / download-artifact |
actions/cache, or cache: inside setup-node, setup-python, setup-go |
| Scope | One workflow run (with a retention period) | Shared across runs, keyed |
- uses: actions/cache@<sha> # pin to a commit SHA
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- The key should change when the dependencies change (hash the lock file)
restore-keysallow a partial hit from an older cache- Measure cache hit rate and job time before and after; a cache that never hits only adds work
- Prune unused caches and artifacts; set
retention-days
Permissions and Cloud Authentication
The automatic GITHUB_TOKEN is scoped per job. Pull requests from forks get read-only tokens by default. Always declare what you need:
permissions:
contents: read # default to the least
jobs:
release:
permissions:
contents: write # raise only where needed
Static keys vs OIDC
| Static credentials | OIDC (recommended) | |
|---|---|---|
| Stored as | Long-lived secrets in the repo | Nothing stored |
| Lifetime | Until you rotate them | Minutes, per job |
| Risk | Leak means long-lived access | Trust policy limits repo, branch, environment |
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@<sha>
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: af-south-1
Equivalent login actions exist for Azure (azure/login) and Google Cloud (google-github-actions/auth). Cloud-side setup: 2. AWS IAM and Security, 5. Azure Identity and Security.
Matrix and Conditionals
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
include:
- os: ubuntu-latest
node: 24
experimental: true
exclude:
- os: windows-latest
node: 20
- A matrix creates one job per combination;
includeandexcludefine-tune it fail-fast: falselets the other jobs finish when one fails;max-parallelcaps concurrency
Dynamic matrix
Compute the matrix in a first job, then fan out:
jobs:
plan:
runs-on: ubuntu-latest
outputs:
services: ${{ steps.find.outputs.services }}
steps:
- uses: actions/checkout@<sha>
- id: find
run: echo "services=$(ls services | jq -R -s -c 'split("\n")[:-1]')" >> "$GITHUB_OUTPUT"
test:
needs: plan
runs-on: ubuntu-latest
strategy:
matrix:
service: ${{ fromJSON(needs.plan.outputs.services) }}
steps:
- run: echo "Testing ${{ matrix.service }}"
Useful in a monorepo: test only the services that changed.
Conditionals
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
- if: failure() # runs only when an earlier step failed
run: ./notify.sh
- if: always() # runs regardless
run: ./cleanup.sh
Status functions: success() (default), failure(), always(), cancelled().
Workflow Commands
Specially formatted output lets a step talk to the runner:
| Command | Effect |
|---|---|
::notice::, ::warning::, ::error file=app.js,line=5::msg |
Annotations on the run and the diff |
::group::Title ... ::endgroup:: |
Collapsible log sections |
::add-mask::value |
Hide a value in logs |
echo "K=V" >> $GITHUB_ENV |
Set an env var for later steps |
echo "k=v" >> $GITHUB_OUTPUT |
Set a step output |
echo "text" >> $GITHUB_STEP_SUMMARY |
Markdown shown on the run summary page |
echo "/my/bin" >> $GITHUB_PATH |
Add to PATH for later steps |
The older ::set-output and ::set-env commands are deprecated; use the files above.
Marketplace Actions
Thousands of actions exist, so treat each as a third-party dependency:
- Does it do exactly what you need, and nothing more?
- Signals: verified publisher, stars, recent commits, open issues, a readable source
- Read the code if it touches secrets or deploys
- Pin to a full commit SHA, not a tag or branch:
# bad: moves under you
- uses: actions/checkout@v4
# good: immutable, with the version as a comment
- uses: actions/checkout@<full-40-character-commit-sha> # v4.x.y
Tags can be re-pointed. In March 2025 a widely used action (tj-actions/changed-files, CVE-2025-30066) was compromised and leaked secrets from repositories that referenced it by tag, which is why SHA pinning matters. Automate the updates with Dependabot or Renovate.
| Group | Examples |
|---|---|
| Official | checkout, cache, upload-artifact, download-artifact, github-script |
| Runtimes | setup-node, setup-python, setup-go, setup-java |
| Cloud auth | aws-actions/configure-aws-credentials, azure/login, google-github-actions/auth |
| Containers | docker/setup-buildx-action, docker/login-action, docker/build-push-action |
| Quality | Super-Linter, CodeQL |
Common Workflows
| Stage | Typical jobs |
|---|---|
| Validate | Lint, unit / integration / end-to-end tests, static analysis, dependency and secret scanning, coverage gates |
| Build | Compile, package, build and push a container image or machine image |
| Deploy (push) | The pipeline runs kubectl apply or terraform apply |
| Deploy (pull / GitOps) | Commit the desired state; an agent in the cluster applies it (Upgrades, CI-CD and GitOps) |
| Automation | Release notes and tagging, stale issue cleanup, dependency update PRs |
Pipeline concepts: 1. CI-CD Pipelines. Image build steps: 3. Container Images and Build Tools.
Best Practices
Performance
- Measure first: baseline duration, queue time and cache hit rate
- Wait less: enough runner capacity, fail fast with quick lint and type checks first
- Do less:
pathsfilters,if:conditions, caching, Docker layer caching (cache-from: type=gha) - Use resources well: parallel jobs and matrix, use every CPU core in tests, avoid emulation
- Prevent regressions: alert on job-time creep, clean old caches and artifacts
Maintainability
- Give each repo a "CI API": a single task runner (Task, Make, Bazel) with standard commands (
install,test,build,dev) so the workflow just callstask testand a developer can run the same thing locally - Avoid copy-paste: composite actions and reusable workflows, versioned like libraries
- Iterate locally before pushing (Authoring and Testing GitHub Actions)
- Decide up front how logic is shared across a mono-repo or many repos
Security
- Least-privilege
permissions:; preferGITHUB_TOKENto personal access tokens - OIDC instead of stored cloud keys
- Pin actions by SHA
- No self-hosted runners for fork pull requests
- Environments with required reviewers for production deploys
- More: 5. Secure Coding and Supply Chain
Capstone Pattern
The course ends with a project that ties it together: pull request checks (lint, test), a build job that creates and pushes an image, and a deploy job gated by an environment, all calling shared tasks so the logic can be run locally with act. The repo layout and diagrams are in the capstone folder of the companion repo.