3 min read
On this page

GitHub Actions in Practice

GitHub Actions is the CI/CD system built into GitHub. It runs workflows defined in YAML files inside your repository. If your code lives on GitHub, Actions is the default choice -- no external service to configure, no webhooks to manage, no separate authentication system.

Core Concepts

Workflows, Jobs & Steps

A workflow is a YAML file in .github/workflows/. It contains one or more jobs. Each job contains a sequence of steps. Jobs run in parallel by default; steps run sequentially within a job.

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm test

lint and test run in parallel. If you need test to wait for lint, add needs: lint to the test job.

Triggers

The on key defines when a workflow runs.

on:
  push:
    branches: [main, develop]
    paths:
      - "src/**"
      - "package.json"

  pull_request:
    branches: [main]

  schedule:
    - cron: "0 6 * * 1"  # Every Monday at 6 AM UTC

  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production
  • push: Runs on commits to matching branches. Path filters limit when it triggers.
  • pull_request: Runs on PR events (opened, synchronized, reopened).
  • schedule: Cron-based. Useful for nightly builds, dependency audits, stale test detection.
  • workflow_dispatch: Manual trigger from the GitHub UI. Add inputs for parameters.

Matrix Strategies

Test across multiple versions, operating systems, or configurations in a single job definition.

test:
  runs-on: ${{ matrix.os }}
  strategy:
    matrix:
      os: [ubuntu-latest, macos-latest]
      node-version: [18, 20, 22]
    fail-fast: false
  steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
    - run: npm ci
    - run: npm test

This creates 6 parallel jobs (2 operating systems times 3 Node versions). Setting fail-fast: false ensures one failure does not cancel the others -- you want to see the full picture.

Secrets & Environment Variables

Secrets

Store sensitive values in repository or organization secrets. Reference them in workflows:

steps:
  - name: Deploy
    run: ./deploy.sh
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Secrets are masked in logs automatically. They are not available in workflows triggered by forks (for security).

Environments

Environments add protection rules -- required reviewers, wait timers, branch restrictions.

deploy-production:
  runs-on: ubuntu-latest
  environment:
    name: production
    url: https://myapp.example.com
  steps:
    - run: ./deploy.sh production

Configure the production environment in GitHub to require approval before deployment runs.

Reusable Workflows

Extract common patterns into reusable workflows. Call them from other workflows.

# .github/workflows/reusable-deploy.yml
name: Reusable Deploy

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      image-tag:
        required: true
        type: string
    secrets:
      DEPLOY_TOKEN:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        run: ./deploy.sh ${{ inputs.environment }} ${{ inputs.image-tag }}
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Calling the reusable workflow:

# .github/workflows/release.yml
jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image-tag: ${{ steps.build.outputs.tag }}
    steps:
      - id: build
        run: echo "tag=${{ github.sha }}" >> "$GITHUB_OUTPUT"

  deploy-staging:
    needs: build
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: staging
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

  deploy-production:
    needs: deploy-staging
    uses: ./.github/workflows/reusable-deploy.yml
    with:
      environment: production
      image-tag: ${{ needs.build.outputs.image-tag }}
    secrets:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Self-Hosted Runners

GitHub-hosted runners work for most projects, but you may need self-hosted runners for:

  • Access to private networks or internal resources
  • Specialized hardware (GPUs, ARM processors)
  • Cost control at high volume
  • Compliance requirements around data residency
jobs:
  build:
    runs-on: [self-hosted, linux, x64]
    steps:
      - uses: actions/checkout@v4
      - run: make build

Register runners at the repository, organization, or enterprise level. Use labels to route jobs to specific runners.

Runner Security

Self-hosted runners persist between jobs. This means:

  • Previous job data may remain on disk. Use cleanup steps.
  • Do not use self-hosted runners on public repositories. Anyone who opens a PR can run code on your runner.
  • Use ephemeral runners (auto-scale, destroy after each job) when possible.

Patterns You Will Use Everywhere

Caching

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-

Artifacts

- uses: actions/upload-artifact@v4
  with:
    name: build-output
    path: dist/
    retention-days: 5

# In a later job
- uses: actions/download-artifact@v4
  with:
    name: build-output
    path: dist/

Conditional Steps

- name: Deploy to production
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: ./deploy.sh production

Common Pitfalls

  • Not pinning action versions. Use actions/checkout@v4, not actions/checkout@main. Better yet, pin to a commit SHA for supply chain security.
  • Leaking secrets in logs. Avoid echo ${{ secrets.TOKEN }}. The masking works, but multi-line secrets or indirect exposure can slip through.
  • Ignoring the billing model. GitHub-hosted runner minutes are not free for private repositories. Matrix builds multiply your usage.
  • Overusing marketplace actions. Every third-party action is code running in your pipeline. Audit what you use. Prefer official actions or inline scripts for simple tasks.
  • Not using concurrency to cancel stale runs. When you push twice, the first run is wasted. Cancel it.
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
  • Storing build outputs as artifacts when you should use a registry. Artifacts are for pipeline-internal data. Docker images belong in a container registry.

Key Takeaways

  • Workflows are YAML files in .github/workflows/. Jobs run in parallel by default; steps run sequentially.
  • Use path filters and concurrency groups to avoid unnecessary work.
  • Matrix strategies let you test across versions and platforms with one job definition.
  • Reusable workflows eliminate duplication across repositories.
  • Pin action versions. Audit third-party actions. Treat your pipeline as a security boundary.
  • Self-hosted runners give flexibility but require careful security hygiene.