backend / cicd / 02_github_actions.md

GitHub Actions

8 interview angles 9 min read source

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 → — gated by approval
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 secretsrun: 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-minutes to fail fast.
  • Sharing secrets with forks via pull_request — secrets are excluded from PR-from-fork runs (good). Don’t switch to pull_request_target to “fix” this without understanding the security implications.
  • No concurrency on 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. Use hashFiles('requirements.txt') so cache reuses when deps are unchanged.
  • pull_request_target running 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 either run (shell) or uses (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 with os for 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@v4 with a key based on lock-file hash. Or built-in caching in setup-python, setup-node, etc. (preferred when available).
  • “What’s the difference between pull_request and pull_request_target?”pull_request runs in the fork’s context, with secrets excluded (safe default). pull_request_target runs 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.