GitHub Actions powers CI/CD for millions of projects in 2026 — it is the platform most developers will reach for first for automated testing, linting, and deployment. The YAML syntax is approachable but has enough depth to trip up even experienced engineers. This guide covers the patterns that matter in production.
What changed in 2026
- Actions Runner Controller (ARC) v2 is stable for self-hosted Kubernetes runners, giving teams autoscaling CI without per-seat costs.
- GitHub-hosted runners include 4 vCPUs for public repos and paid tiers;
ubuntu-24.04 is the current default label.
- Larger GitHub-hosted runners (16 vCPUs, 64 GB RAM) are available for orgs that need faster build times.
- Attestations and artifact signing via
actions/attest-build-provenance are now expected for packages published to npm or container registries.
- Merge queues became generally available and integrate with required status checks — the recommended pattern for high-velocity repos.
Anatomy of a workflow file
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm test
Key concepts: on defines triggers, jobs are parallel by default, steps run sequentially within a job.
Pinning action versions securely
# BAD — @v4 tag can be force-pushed to a different commit
- uses: actions/checkout@v4
# GOOD — pinned to a specific commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Pin to full SHAs for actions in security-sensitive workflows. Tools like Dependabot or pin-github-action automate this.
Secrets and environment variables
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production # requires manual approval if configured
env:
NODE_ENV: production
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
API_KEY: ${{ secrets.DEPLOY_API_KEY }}
run: ./scripts/deploy.sh
secrets.* values are masked in logs — they never appear as plain text. vars.* (repository variables) are for non-sensitive config.
Dependency caching
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm' # caches ~/.npm based on package-lock.json hash
For more control:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
Good caching cuts install times from ~2 minutes to ~10 seconds on warm runs.
Build matrix
jobs:
test:
runs-on: ubuntu-24.04
strategy:
matrix:
node-version: ['20', '22', '23']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci && npm test
Workflow comparison
| Pattern |
When to use |
push trigger |
Run on every commit to main |
pull_request trigger |
Run on every PR — block merge on failure |
workflow_dispatch |
Manual run with optional inputs |
schedule (cron) |
Nightly builds, dependency scans |
workflow_call |
Reusable workflow called from another |
release trigger |
Publish artifacts on GitHub release |
Docker build and push example
jobs:
build-push:
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
How to pick between actions approaches
| Need |
Pattern |
| Share steps within one repo |
Composite action in .github/actions/ |
| Share full workflow across repos |
Reusable workflow with workflow_call |
| Call a third-party action |
Marketplace action (pin to SHA) |
| Run a script inline |
run: step |
| Deploy to a cloud provider |
Official action (AWS, GCP, Azure) |
Common mistakes
Not caching dependencies. A cold npm ci or pip install adds 1–3 minutes to every run.
Over-permissioned workflows. The default GITHUB_TOKEN permissions are broad. Set permissions: explicitly in every workflow to the minimum required.
Hardcoded tokens or passwords. Even if rotated later, they appear in git history. Always use secrets.*.
Not using continue-on-error: true for steps that are allowed to fail (e.g., coverage upload) — a failure there should not block the entire job.
Running all steps as one job. Separate fast linting from slow integration tests. Fast checks give quick feedback; slow builds run only on success.
What to skip
- Self-hosted runners for every project — use GitHub-hosted runners unless you need specific hardware, GPU access, or private network access.
- Complex bash inline in workflow YAML — move logic into scripts in the repo; YAML strings are painful to test and debug.
- Third-party actions you have not reviewed — the Actions marketplace has many low-quality or abandoned actions; prefer official or well-maintained ones.
FAQ
How do I pass data between steps in a job?
Use $GITHUB_OUTPUT: echo "my_key=my_value" >> "$GITHUB_OUTPUT" and reference with ${{ steps.step-id.outputs.my_key }}.
How do I trigger a workflow from another workflow?
Use workflow_dispatch with github.rest.actions.createWorkflowDispatch via the API, or use workflow_call for synchronous reusable workflows.
How do I debug a failing workflow?
Enable debug logging by setting the secret ACTIONS_STEP_DEBUG to true. Alternatively, use tmate or the nektos/act local runner for local testing.
What are environments used for?
Environments add protection rules (required reviewers, wait timers) to deployment jobs. Use them for staging and production deploys to require manual approval.
Where to go next