Steven's Knowledge

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
ConstructWhat it is
WorkflowA YAML file in .github/workflows/
EventSomething that triggers the workflow (push, pull_request, workflow_dispatch...)
JobA unit of work; runs on one runner
StepA command or an action call inside a job
ActionA reusable, packaged step (in a separate repo or built-in)
RunnerThe 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@v4

That 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: bash

Call 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 ls

The 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.sh

Configure 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 matching v*) can deploy.
  • Environment secrets — different values for staging and production.

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: false

Caching

- 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=max

setup-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 labels

Run 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: inherit

Manual 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

On this page