GitHub Actions lets you automate builds, tests, and deployments directly from your repository. No external CI server needed. This tutorial walks you through everything from your first workflow file to advanced patterns like matrix builds, scheduled jobs, and API integrations.
If you've ever pushed code and wished something would automatically run your tests, lint your files, or deploy to production — that's exactly what GitHub Actions does. It's free for public repositories and comes with 2,000 minutes per month on private repos.
By the end of this guide, you'll understand the core concepts, be able to write your own workflows from scratch, and know the patterns that experienced developers use every day.
GitHub Actions is a CI/CD (Continuous Integration / Continuous Deployment) platform built into GitHub. You define automation workflows using YAML files stored in your repository under .github/workflows/. When a specified event happens — a push, a pull request, a schedule — GitHub spins up a virtual machine and runs your instructions.
The key concepts you need to understand:
Create a file at .github/workflows/ci.yml in your repository. Here's the simplest useful workflow — it runs your tests on every push:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
Let's break down each section:
name — A human-readable label that appears in the Actions tab on GitHub.on — The trigger. This workflow runs on pushes to main and on pull requests targeting main.jobs — Contains one or more jobs. Here we have a single job called test.runs-on — Specifies the runner environment. ubuntu-latest is the most common choice.steps — The ordered list of tasks. Each step either uses an action or runs a shell command with run.The on section is where you define when your workflow runs. GitHub supports dozens of event types. Here are the ones you'll use most often:
The most common pattern runs tests on pushes and PRs. You can filter by branch or by file path:
on:
push:
branches: [main, develop]
paths:
- 'src/**'
- '*.js'
- '!docs/**'
pull_request:
branches: [main]
The paths filter is powerful — your workflow only runs when matching files are changed. The ! prefix excludes paths. This keeps CI fast by skipping workflows when only documentation changes. If you're building complex path filter patterns with wildcards, a tool like the helloandy Regex Generator can help you think through the matching logic — glob patterns and regex share similar wildcard concepts.
You can run workflows on a schedule using cron syntax. This is useful for nightly builds, periodic security scans, or data collection tasks:
on:
schedule:
# Run at 06:00 UTC every Monday
- cron: '0 6 * * 1'
# Run at midnight on the 1st of each month
- cron: '0 0 1 * *'
Cron syntax uses five fields: minute, hour, day-of-month, month, day-of-week. If you don't write cron expressions regularly, it's easy to get the fields mixed up. The helloandy Cron Generator lets you build the expression visually and shows you exactly when it will next run — it even exports in GitHub Actions format directly.
Tip: GitHub Actions cron schedules run in UTC. Scheduled workflows can be delayed during periods of high demand. Don't rely on exact-minute precision — if you need something to run at exactly 9:00 AM, consider a dedicated scheduler.
The workflow_dispatch event adds a "Run workflow" button in the GitHub UI. You can define custom inputs:
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy target'
required: true
default: 'staging'
type: choice
options:
- staging
- production
dry_run:
description: 'Simulate without deploying'
type: boolean
default: true
Access input values in your steps with ${{ github.event.inputs.environment }}.
Jobs run in parallel by default. If you need one job to finish before another starts, use the needs keyword:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
deploy:
runs-on: ubuntu-latest
needs: [lint, test]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- run: echo "Deploying to production..."
In this example, lint and test run simultaneously. The deploy job waits for both to pass and only runs on the main branch.
You can set environment variables at the workflow, job, or step level. Sensitive values like API keys should use GitHub Secrets:
env:
NODE_ENV: production
jobs:
deploy:
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
steps:
- uses: actions/checkout@v4
- run: echo "Deploying to $NODE_ENV"
- name: Call deployment API
run: |
curl -X POST https://api.example.com/deploy \
-H "Authorization: Bearer ${{ secrets.DEPLOY_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"version": "${{ github.sha }}"}'
Secrets are configured in your repository settings under Settings > Secrets and variables > Actions. They're masked in logs automatically — GitHub will never print the actual value.
Matrix strategies let you run the same job across multiple configurations — different Node versions, operating systems, or any combination of variables:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-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 9 parallel jobs (3 operating systems x 3 Node versions). The fail-fast: false option ensures all combinations run even if one fails — useful for identifying platform-specific bugs.
Many workflows need to interact with external services — sending Slack notifications, updating deployment trackers, or triggering downstream pipelines. You can use curl directly or use community actions:
steps:
- name: Notify Slack on failure
if: failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK_URL }} \
-H "Content-Type: application/json" \
-d '{
"text": "Build failed on ${{ github.repository }}",
"blocks": [{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Build Failed* in `${{ github.repository }}`\nBranch: `${{ github.ref_name }}`\nCommit: `${{ github.sha }}`"
}
}]
}'
- name: Update status page
if: success()
run: |
curl -X PATCH https://api.statuspage.io/v1/components/$COMPONENT_ID \
-H "Authorization: OAuth ${{ secrets.STATUSPAGE_TOKEN }}" \
-d '{"component": {"status": "operational"}}'
When you're building these API calls, getting the JSON payload structure right matters. You can prototype your requests with the helloandy API Tester first to verify the endpoint works and see what the response looks like, then translate the working request into your workflow YAML.
Caching dependencies dramatically speeds up your workflows. Without caching, every run downloads all packages from scratch:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test
The cache: 'npm' option on setup-node automatically caches the npm global cache directory. First run downloads everything; subsequent runs restore from cache and only download changed packages.
For build outputs you want to download or pass between jobs, use artifacts:
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
Here's a complete workflow that combines everything — linting, testing across Node versions, building, and deploying only from main:
name: CI/CD Pipeline
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
branches: [main]
schedule:
- cron: '0 6 * * 1'
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
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to server
run: |
echo "Deploying build artifacts..."
# Your deployment commands here
This workflow runs a weekly health check on Mondays at 6 AM UTC (the Cron Generator can help you customize that schedule), skips CI for documentation-only changes, tests across three Node versions, and only deploys from main branch pushes.
actions/checkout without @v4 — Always pin action versions. Using @main or @latest can break your workflow without warning.${{ secrets.NAME }}.paths filters and branch restrictions to avoid wasting CI minutes on irrelevant changes.fail-fast in matrix builds — The default is true, which cancels all jobs if one fails. Set it to false when you need to see all results.ubuntu-latest without understanding it changes — ubuntu-latest points to the newest LTS version. If your build depends on specific system packages, pin to ubuntu-22.04 or ubuntu-24.04.You now have the foundation to automate your development workflow with GitHub Actions. Here's where to go from here:
.The best way to learn is to start small. Add the basic CI workflow from this guide to one of your projects, watch it run, then gradually add complexity as your needs grow.
Build cron schedules for your GitHub Actions workflows visually. Export in GitHub Actions format with one click.
Cron Generator API Tester