GitHub Actions automates your build, test, and deploy pipelines directly in your repository. This reference covers the full workflow syntax with copy-ready examples.
Quick reference
| Concept | Syntax |
|---|---|
| Trigger on push | on: push |
| Trigger on PR | on: pull_request |
| Scheduled run | on: schedule: - cron: '0 9 * * 1' |
| Manual trigger | on: workflow_dispatch |
| Use an action | uses: actions/checkout@v4 |
| Run a command | run: npm test |
| Set env var | env: NODE_ENV: test |
| Use a secret | ${{ secrets.MY_SECRET }} |
| Job output | ${{ needs.build.outputs.version }} |
| Matrix build | strategy: matrix: node: [18, 20, 22] |
| Upload artifact | uses: actions/upload-artifact@v4 |
| Cache deps | uses: actions/cache@v4 |
Workflow file anatomy
Every workflow is a YAML file in .github/workflows/.
name: CI # Workflow name (shown in UI)
on: # What triggers the workflow
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs: # One or more jobs (run in parallel by default)
test: # Job ID (your name)
runs-on: ubuntu-latest # Runner OS
steps: # Ordered list of steps
- uses: actions/checkout@v4 # Check out source code
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
Triggers (on)
Common triggers
on:
push:
branches: [main]
paths:
- 'src/**' # Only trigger when src/ changes
- '!src/**/*.md' # Ignore markdown changes
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
workflow_dispatch: # Manual trigger with optional inputs
inputs:
environment:
description: 'Deploy target'
required: true
default: 'staging'
type: choice
options: [staging, production]
workflow_call: # Called by another workflow (reusable)
inputs:
version:
type: string
required: true
Trigger shorthand
on: [push, pull_request] # Multiple events, simple form
Jobs
Sequential jobs with needs
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "Building..."
test:
runs-on: ubuntu-latest
needs: build # Runs after build completes
steps:
- run: echo "Testing..."
deploy:
runs-on: ubuntu-latest
needs: [build, test] # Runs after both complete
if: github.ref == 'refs/heads/main' # Conditional job
steps:
- run: echo "Deploying..."
Job outputs
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get-version.outputs.value }}
steps:
- id: get-version
run: echo "value=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ needs.build.outputs.version }}"
Contexts and expressions
# Contexts available in expressions ${{ }}
${{ github.event_name }} # Event that triggered the workflow
${{ github.ref }} # Branch/tag ref (refs/heads/main)
${{ github.sha }} # Commit SHA
${{ github.actor }} # User who triggered the workflow
${{ github.repository }} # owner/repo
${{ github.workspace }} # Path to checked-out repo
${{ runner.os }} # Linux, macOS, Windows
${{ env.MY_VAR }} # Environment variable
${{ secrets.MY_SECRET }} # Secret value
${{ vars.MY_VAR }} # Repository variable (non-secret)
${{ job.status }} # success, failure, cancelled
${{ steps.STEP_ID.outputs.VALUE }} # Step output
${{ needs.JOB_ID.outputs.VALUE }} # Job output
Conditionals
steps:
- name: Deploy to production
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
- name: Notify on failure
if: failure() # Built-ins: success(), failure(), always(), cancelled()
run: ./notify.sh
- name: Skip draft PRs
if: github.event.pull_request.draft == false
run: npm test
Secrets and variables
# Repository secrets (Settings → Secrets and variables → Actions)
steps:
- name: Deploy
env:
API_KEY: ${{ secrets.API_KEY }}
DB_URL: ${{ secrets.DATABASE_URL }}
run: ./deploy.sh
# Repository variables (non-secret, visible in logs)
- name: Set environment
env:
APP_URL: ${{ vars.APP_URL }}
run: echo "Deploying to $APP_URL"
# Environment secrets (scoped to deployment environments)
- name: Production deploy
environment: production # Environment must be configured in repo settings
env:
PROD_KEY: ${{ secrets.PROD_KEY }}
run: ./deploy-prod.sh
# Set an env var for subsequent steps
- name: Set version
run: echo "VERSION=1.2.3" >> $GITHUB_ENV
- name: Use version
run: echo "Building $VERSION" # Available in next steps
Matrix builds
Run the same job across multiple configurations:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false # Don't cancel others if one fails
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: [18, 20, 22]
exclude:
- os: macos-latest
node: 18 # Skip this combination
include:
- os: ubuntu-latest
node: 22
experimental: true # Add extra data to specific combo
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
Caching dependencies
# Node.js — cache node_modules
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
# Python — cache pip
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
# Setup actions have built-in caching (preferred)
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm' # Automatically caches npm
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
Artifacts
Share files between jobs or download after a run:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist-files
path: dist/
retention-days: 7 # Default: 90 days
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist-files
path: dist/
- run: ./deploy.sh dist/
Common workflow recipes
Node.js CI
name: Node.js CI
on: [push, pull_request]
jobs:
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 run build --if-present
- run: npm test
Python CI
name: Python CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install -r requirements.txt
- run: pip install pytest
- run: pytest
Docker build and push
name: Docker Build
on:
push:
branches: [main]
tags: ['v*']
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: myuser/myapp
tags: |
type=semver,pattern={{version}}
type=sha
- uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Deploy only on main
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
concurrency:
group: production
cancel-in-progress: false # Never cancel a deploy in progress
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
Reusable workflows
Define once, call from multiple workflows:
# .github/workflows/reusable-test.yml
on:
workflow_call:
inputs:
node-version:
type: string
default: '20'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci && npm test
# .github/workflows/ci.yml — calls the reusable workflow
jobs:
call-test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '22'
secrets: inherit # Pass all secrets through
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
uses: actions/checkout (no version) |
Breaking changes with @main |
Always pin: @v4 |
Storing secrets in env at top level |
Secrets leak into all jobs/steps | Scope env to the step that needs it |
run: npm install instead of npm ci |
Non-reproducible installs | Use npm ci in CI |
No fail-fast: false in matrix |
One failure cancels all matrix jobs | Add fail-fast: false |
if: always() on deploy step |
Deploys even when tests fail | Use if: success() or omit (default) |
| Hardcoded branch names in conditions | Breaks on branch renames | Use github.event.repository.default_branch |
Large artifacts without retention-days |
Storage costs accumulate | Set retention-days: 7 for temp files |
FAQ
How do I skip a workflow run?
Include [skip ci] or [ci skip] in your commit message. GitHub also has native skip: add skip-checks: true to the commit.
How do I run a step only on failure?
Use if: failure(). Steps run with success() by default. For cleanup that always runs: if: always().
Can I pass secrets to reusable workflows?
Yes — add secrets: inherit to pass all secrets, or list them individually under secrets:.
How do I debug a failing workflow?
Enable debug logging by setting secret ACTIONS_STEP_DEBUG to true in your repository. Also try tmate for an interactive SSH session.
What is $GITHUB_OUTPUT vs set-output?set-output (old) was deprecated. Use echo "key=value" >> $GITHUB_OUTPUT instead. Same for env vars: echo "VAR=value" >> $GITHUB_ENV.
How do I run a workflow manually with parameters?
Use on: workflow_dispatch with inputs. A "Run workflow" button appears in the Actions tab. Parameters are available via ${{ github.event.inputs.PARAM_NAME }}.