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, notactions/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
concurrencyto 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.