GitHub Actions
The default CI/CD platform for GitHub-hosted repos. Workflows in YAML, runners in the cloud (free for public repos, billed by minute for private), tight integration with PRs and releases. Lots of nuance below the surface — interview questions probe how deeply you’ve actually used it.
Anatomy
.github/workflows/ci.yml
├── name (display name)
├── on (triggers)
├── env (workflow-level env vars)
├── concurrency (cancel-in-progress, group)
├── permissions (GITHUB_TOKEN scopes)
└── jobs
└── <job_id>
├── runs-on (runner)
├── needs (job dependencies)
├── if (conditional)
├── strategy.matrix (parallel variants)
├── outputs (pass to dependent jobs)
├── services (sidecar containers)
└── steps
└── - name / uses / run / with / env / if
Minimal workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.14"
- run: pip install -r requirements.txt
- run: pytest -v
Drop in .github/workflows/, push to GitHub, it runs.
Triggers
| Trigger | When |
|---|---|
push |
on push to specified branches / paths |
pull_request |
PR opened/updated/closed |
pull_request_target |
PR but runs in the base repo context — for forks, careful with secrets |
schedule |
cron ('0 0 * * *') |
workflow_dispatch |
manual button in UI |
workflow_call |
invoked by another workflow (reusable workflows) |
repository_dispatch |
external webhook |
release |
when a release is published |
issues, issue_comment |
issue events |
pull_request_review |
review submitted |
Filter triggers:
on:
push:
branches: [main, "release/*"]
paths:
- "src/**"
- "tests/**"
paths-ignore:
- "**.md"
paths-ignore saves CI minutes on doc-only commits.
Jobs and dependencies
jobs:
test:
runs-on: ubuntu-latest
steps: [...]
build:
needs: test # only runs if test succeeds
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: [test, build]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps: [...]
Jobs default to running in parallel; needs: creates a DAG. Independent jobs run concurrently.
Job outputs
Pass data from one job to the next:
jobs:
build:
outputs:
version: ${{ steps.set-version.outputs.version }}
steps:
- id: set-version
run: echo "version=1.2.3" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying ${{ needs.build.outputs.version }}"
$GITHUB_OUTPUT is the file mechanism for setting outputs (the old set-output command is deprecated).
Matrix builds
Test against multiple versions / OSes in parallel:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.10", "3.11", "3.12"]
exclude:
- os: windows-latest
python: "3.10"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "${{ matrix.python }}" }
- run: pytest
9 OS × Python combinations, 1 excluded → 8 parallel runs.
fail-fast: false — let all combinations finish so you see all failures (default cancels remaining on first failure).
Reusable workflows
# .github/workflows/reusable-test.yml
on:
workflow_call:
inputs:
python-version:
required: true
type: string
secrets:
PYPI_TOKEN:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "${{ inputs.python-version }}" }
- run: pytest
Called from another workflow:
jobs:
test-and-publish:
uses: ./.github/workflows/reusable-test.yml
with:
python-version: "3.14"
secrets:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
Or call across repos: uses: org/repo/.github/workflows/file.yml@v1.
Centralize common patterns once; many repos use the same workflow.
Composite actions
Smaller-grained reuse:
# .github/actions/setup-python-and-deps/action.yml
name: Setup Python and Deps
inputs:
python-version:
required: true
runs:
using: composite
steps:
- uses: actions/setup-python@v5
with: { python-version: "${{ inputs.python-version }}" }
- run: pip install -r requirements.txt
shell: bash
Then:
- uses: ./.github/actions/setup-python-and-deps
with: { python-version: "3.14" }
Use composites for “the 4 steps every job needs”; use reusable workflows for “the whole test pipeline.”
Secrets and variables
env:
CI: true
jobs:
deploy:
env:
ENVIRONMENT: production
steps:
- env:
API_KEY: ${{ secrets.PROD_API_KEY }}
run: deploy.sh
| Scope | Where |
|---|---|
| Repository secrets | Settings → Secrets and variables → Actions |
| Repository variables | Same; non-sensitive |
| Environment secrets / vars | Settings → Environments → |
| Organization secrets | Org-level — shared across repos |
${{ secrets.NAME }} is masked in logs. Environment secrets only available to jobs targeting that environment:
jobs:
deploy:
environment: production # requires approval if configured
steps:
- run: echo "Using prod credentials"
The environment can have required reviewers; the job waits for approval before running.
OIDC for cloud auth — the modern way
Instead of long-lived AWS access keys in secrets:
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::1234:role/github-actions
aws-region: us-east-1
- run: aws s3 ls
GitHub issues a short-lived JWT to AWS via OIDC. AWS verifies and assumes a role. No long-lived secrets. Same for GCP, Azure, HashiCorp Vault. Modern best practice. See 06_secrets_and_supply_chain.md.
Caching
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
.venv
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
key is the exact cache; restore-keys falls back to partial matches.
Many setup-* actions have built-in caching:
- uses: actions/setup-python@v5
with:
python-version: "3.14"
cache: pip # auto-caches based on requirements.txt
Use built-in when available — fewer moving parts.
Artifacts — sharing files between jobs
jobs:
build:
steps:
- run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/*.whl
test:
needs: build
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: pip install dist/*.whl && pytest
Use artifacts for build outputs, test reports, coverage data. Default retention is 90 days; configure with retention-days.
For container images, push to a registry (GHCR, Docker Hub, ECR) instead of artifacts.
Services — DB / Redis sidecars
jobs:
test:
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
options: --health-cmd pg_isready --health-interval 10s
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- run: psql -h localhost -U postgres -c "select 1;"
env: { PGPASSWORD: test }
Sidecars run for the lifetime of the job; networked at localhost. Faster than Docker Compose for simple cases.
Permissions and GITHUB_TOKEN
permissions:
contents: read
pull-requests: write
id-token: write
The default GITHUB_TOKEN has broad permissions. Restrict per workflow / job. Principle of least privilege.
Common scopes: contents (repo files), issues, pull-requests, id-token (OIDC), packages (GHCR).
Concurrency control
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
Only one workflow with this group runs at a time. Useful for deploys (don’t double-deploy) and PR CI (cancel the previous run when new commits arrive).
Conditional steps
- name: Deploy to prod
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: deploy.sh
- name: Slack notify
if: failure()
run: notify.sh
Built-in conditions: success() (default), failure(), always(), cancelled(). Use to run cleanup or notification steps regardless of upstream result.
Self-hosted runners
runs-on: self-hosted
# Or with labels:
runs-on: [self-hosted, linux, gpu]
For workloads cloud runners can’t handle: GPUs, large memory, internal-network access, custom OS. See 04_self_hosted_runners.md.
Cloud-hosted runner sizes
| Runner | Cores / RAM (approx) | $/min (private repo) |
|---|---|---|
| ubuntu-latest | 2 / 7 GB | free tier; $0.008/min beyond |
| ubuntu-latest (larger 4-core) | 4 / 16 GB | $0.016/min |
| ubuntu-latest (larger 8-core) | 8 / 32 GB | $0.032/min |
| windows-latest | 2 / 7 GB | $0.016/min (2× Linux) |
| macos-latest | 3 / 14 GB | $0.08/min (10× Linux) |
macOS runners are expensive. Avoid for routine work. For iOS / macOS builds, weigh self-hosted Mac infrastructure vs the cloud bill.
Common patterns
PR validation + main deploy
on:
pull_request: {}
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps: [...]
Conditional release on tag
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- run: python -m build
- uses: softprops/action-gh-release@v2
with:
files: dist/*
Dependent monorepo paths
on:
push:
paths: ["services/api/**"]
Trigger only when relevant paths change. For complex monorepos, use dorny/paths-filter to compute affected packages dynamically. See 07_pipeline_design_patterns.md.
Common pitfalls
- Pinning actions by tag (
@v4) — tags can move. For high-security pipelines, pin to a SHA:uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29. - Echoing secrets —
run: echo "Using $SECRET". GitHub masks but log analytics tools may strip masks. Don’t echo. - No timeout on long-running jobs — defaults to 6h. Set
timeout-minutesto fail fast. - Sharing secrets with forks via
pull_request— secrets are excluded from PR-from-fork runs (good). Don’t switch topull_request_targetto “fix” this without understanding the security implications. - No
concurrencyon deploy jobs — two deploys racing. - Caching the wrong key — using
${{ github.sha }}as part of the cache key means every commit has a fresh cache. UsehashFiles('requirements.txt')so cache reuses when deps are unchanged. pull_request_targetrunning untrusted code — checking out PR head with this trigger lets fork code run with your secrets. Classic CI security hole.
Marketplace and third-party actions
The Marketplace has tens of thousands of community actions. Most are fine; some are malicious or unmaintained. Vetting checklist:
- Official actions (
actions/*) — safe. - Vendor actions from known orgs (
aws-actions/*,google-github-actions/*,docker/*) — safe. - Verified creators — generally fine.
- Random one-person actions — read the source. Pin to SHA.
Security: a malicious action runs in your repo’s context with GITHUB_TOKEN permissions. Treat third-party actions like third-party dependencies — review and pin.
Interview angle
- “How is a GitHub Actions workflow structured?” — YAML in
.github/workflows/*.yml. Top-level: name, triggers (on), permissions, concurrency. Jobs run on runners; each job has steps. Steps use eitherrun(shell) oruses(action). Jobs default to parallel;needs:creates dependencies. - “Difference between an action and a workflow?” — an action is a reusable unit (
uses: actions/checkout@v4). A workflow is a YAML file orchestrating jobs and steps. Composite actions = small bundle of steps. Reusable workflows = a whole workflow callable from others. - “How do you parallelize tests across Python versions?” —
strategy.matrix.python: ["3.10", "3.11", "3.12"]. Each value runs as a parallel job. Combine withosfor the full grid. - “How do you handle secrets?” — repository / environment / organization secrets via Settings UI. Inject as
${{ secrets.NAME }}(masked in logs). For cloud (AWS/GCP/Azure), prefer OIDC + role assumption over long-lived keys. - “What’s OIDC in GitHub Actions and why use it?” — GitHub issues a short-lived JWT identifying the workflow run. The cloud provider verifies and grants temporary credentials. No long-lived access keys in repo secrets. Modern best practice for cloud deploys.
- “How do you cache dependencies?” —
actions/cache@v4with a key based on lock-file hash. Or built-in caching insetup-python,setup-node, etc. (preferred when available). - “What’s the difference between
pull_requestandpull_request_target?” —pull_requestruns in the fork’s context, with secrets excluded (safe default).pull_request_targetruns in the base repo’s context with full secrets — needed for some workflows but a security risk if you check out PR code without care. - “How do you prevent two deploys from running concurrently?” —
concurrency: { group: deploy-${{ github.ref }}, cancel-in-progress: false }at the workflow or job level.