Every time you push code, someone has to build it, test it, and deploy it. Do that manually and you waste hours — or worse, introduce human error. GitHub Actions automates all of it. It is a CI/CD platform built directly into GitHub, so there is nothing extra to install or configure. You write a YAML file, push it, and GitHub runs your pipeline on every commit.
This tutorial takes you from zero to a production-grade CI/CD pipeline with caching for 3x faster builds, matrix builds for multi-version testing, deployment pipelines with approval gates, and secrets management — all using the companion project at shazforiot/github-actions-demo.
What Is GitHub Actions?
GitHub Actions is a CI/CD platform built into GitHub. It automates running tests on every push, building and deploying your application, scheduled tasks like backups, and virtually any workflow you can define. With over 20,000 actions in the GitHub Marketplace, you can assemble pipelines without writing custom scripts.
Core Concepts
Understand these four terms and everything else clicks:
| Concept | What It Is | Example |
|---|---|---|
| Workflow | The entire automation, defined in a .yml file |
ci.yml, deploy.yml |
| Trigger | What starts the workflow | Push, pull request, schedule, manual |
| Job | A group of steps running on the same machine | Test job, build job, deploy job |
| Step | An individual command or pre-built action | npm install, actions/checkout@v4 |
Data flows like this: Trigger → Workflow → Jobs → Steps. Each job runs on a fresh virtual machine, and jobs can run in sequence (using needs:) or in parallel.
Step 1 — Your First CI Pipeline
Create a file at .github/workflows/ci.yml. This is the most common workflow — run tests and build on every push:
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test
build:
name: Build Project
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-files
path: dist/
Line-by-Line Breakdown
name— Display name in the GitHub Actions UI.on— Triggers. This runs on pushes and pull requests tomain.runs-on— The operating system.ubuntu-latestis the most common choice.uses: actions/checkout@v4— A pre-built action that clones your repository. Every workflow needs this as the first step.uses: actions/setup-node@v4— Installs Node.js. Thewith:block passes parameters.run:— Executes a shell command directly.needs: test— The build job only runs after the test job passes.actions/upload-artifact@v4— Saves build output so downstream jobs (like deployment) can use it.
Step 2 — Add Caching for 3x Faster Builds
Without caching, every workflow run downloads all dependencies from scratch. For a Node.js project, that can take 30–60 seconds per run. Caching stores node_modules between runs, cutting install time to near zero on cache hits.
- name: Cache node modules
uses: actions/cache@v4
id: cache-npm
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Cache status
run: |
if [ "${{ steps.cache-npm.outputs.cache-hit }}" == "true" ]; then
echo "Cache HIT - using cached dependencies"
else
echo "Cache MISS - will cache after install"
fi
- name: Install dependencies
run: npm install
How the Cache Key Works
The key includes a hash of package-lock.json. When dependencies change, the hash changes, the cache misses, and a new cache is saved. The restore-keys fallback tries any cache starting with runner.os-npm-, so even a partial match speeds up the install.
Result: First run has a cache miss (normal). Second run gets a cache hit and installs in seconds instead of minutes.
Step 3 — Matrix Builds (Multi-Version Testing)
Does your code work on Node 18? Node 20? Node 22? Windows? Linux? Instead of writing separate workflows, use a matrix strategy to test all combinations in parallel:
jobs:
test-matrix:
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
This configuration creates 6 parallel jobs (3 Node versions × 2 operating systems). The fail-fast: false setting ensures all jobs complete even if one fails — useful for seeing the full compatibility picture.
Step 4 — Deployment Pipeline with Staging and Production
A real deployment pipeline has stages: Test → Build → Staging → Production. Each stage depends on the previous one, and production can require manual approval.
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deployment
cancel-in-progress: false
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install
- run: npm test
build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: production-build
path: dist/
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: production-build
path: dist/
- run: echo "Deploying to staging..."
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: deploy-staging
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: production-build
path: dist/
- run: echo "Deploying to production..."
Key Features
concurrency— Prevents multiple deployments from running simultaneously. If a new commit pushes while a deployment is in progress, it waits rather than creating conflicts.needs:— Enforces the stage order. Staging only runs after build passes. Production only runs after staging.environment: staging/environment: production— GitHub Environments let you require manual approval. Go to Settings → Environments → production → Required reviewers to add approval gates.upload-artifact/download-artifact— Pass build output between jobs since each job runs on a separate machine.
Step 5 — Secrets and Environment Variables
Never hardcode sensitive data in your workflows. Use GitHub Secrets for API keys, database URLs, and deployment credentials.
Adding Secrets
- Go to your repository Settings → Secrets and variables → Actions.
- Click New repository secret.
- Add the name (e.g.,
API_KEY) and value. - Reference it in workflows as
${{ secrets.API_KEY }}.
Using Secrets and Variables in Workflows
name: Secrets Demo
on:
workflow_dispatch:
env:
APP_NAME: github-actions-demo
NODE_ENV: production
jobs:
demo:
runs-on: ubuntu-latest
env:
LOG_LEVEL: info
steps:
- uses: actions/checkout@v4
- name: Show GitHub context
run: |
echo "Repository: ${{ github.repository }}"
echo "Branch: ${{ github.ref_name }}"
echo "Commit: ${{ github.sha }}"
echo "Actor: ${{ github.actor }}"
- name: Use secrets safely
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
echo "API Key is set: $([[ -n "$API_KEY" ]] && echo 'Yes' || echo 'No')"
- name: Generate output
id: generate
run: echo "random_id=$(date +%s)" >> $GITHUB_OUTPUT
- name: Use generated value
run: echo "Generated ID: ${{ steps.generate.outputs.random_id }}"
Secrets are automatically masked in logs — you will see *** instead of the actual value. The GITHUB_OUTPUT environment variable lets you pass data between steps within the same job.
Step 6 — Scheduled Jobs (Cron Workflows)
Run workflows on a schedule for health checks, dependency updates, or weekly reports:
name: Scheduled Tasks
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9 AM UTC
workflow_dispatch:
inputs:
task:
description: 'Which task to run'
type: choice
options:
- health-check
- dependency-update
- report
jobs:
health-check:
if: github.event.inputs.task == 'health-check' || github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- run: echo "Running health checks..."
dependency-check:
if: github.event.inputs.task == 'dependency-update' || github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm outdated || true
weekly-report:
if: github.event.inputs.task == 'report' || github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: |
echo "=== Weekly Report ==="
git log --oneline --since="7 days ago" | head -20
git shortlog -sn --since="7 days ago"
Cron Syntax Reference
| Expression | Meaning |
|---|---|
0 0 * * * |
Every day at midnight UTC |
0 */6 * * * |
Every 6 hours |
0 9 * * 1-5 |
Weekdays at 9 AM UTC |
0 9 * * 1 |
Every Monday at 9 AM UTC |
Use crontab.guru to generate and verify cron expressions.
The Demo Project — Calculator API
The companion repository uses a simple Node.js Calculator API with endpoints for add, subtract, multiply, and divide. It includes unit tests using Node’s built-in test runner:
// src/index.js (simplified)
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }
function divide(a, b) {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
module.exports = { add, subtract, multiply, divide };
// src/index.test.js (simplified)
const { test, describe } = require('node:test');
const assert = require('node:assert');
const { add, divide } = require('./index.js');
describe('Calculator', () => {
test('adds two numbers', () => {
assert.strictEqual(add(2, 3), 5);
});
test('throws on division by zero', () => {
assert.throws(() => divide(10, 0), { message: 'Cannot divide by zero' });
});
});
Run it locally: npm install && npm test.
Try It Yourself
- Fork or clone the repository:
git clone https://github.com/shazforiot/github-actions-demo - Push to your own GitHub account.
- Go to the Actions tab in your repository and watch the workflows run.
- Make a change — edit a file, commit, and push. See the CI pipeline trigger automatically.
- Create a pull request — see the PR checks run.
- Set up a secret in Settings → Secrets and trigger the secrets-demo workflow manually.
Common Issues and Fixes
Workflow not running?
- Check that the
on:triggers match your action (pushing tomain? creating a PR?). - Ensure the
.ymlfile is in.github/workflows/. - Check for YAML indentation errors — spaces only, no tabs.
Tests failing in CI but passing locally?
- Add
- run: npm test -- --verbosefor more detailed output. - Check the logs in the Actions tab for the exact error.
- Ensure your Node version matches between local and CI.
Cache not working?
- Verify the cache key matches — it depends on
package-lock.jsonexisting and being committed. - Check the “Cache status” step output for HIT or MISS.
Pro Tips
- Use
workflow_dispatchto manually trigger workflows for testing without pushing code. - Add status badges to your README:
 - Use environments for deployment approvals — staging can be automatic, production requires sign-off.
- Set up branch protection to require passing CI checks before merging PRs.
- Monitor your usage in Settings → Billing → Actions to stay within free limits.
Resources
- Full source code and all workflows: github.com/shazforiot/github-actions-demo
- GitHub Actions Documentation: docs.github.com/en/actions
- Workflow Syntax Reference: Workflow Syntax
- Actions Marketplace: github.com/marketplace?type=actions
- Cron Expression Generator: crontab.guru
Frequently Asked Questions
Is GitHub Actions free?
Yes. GitHub offers 2,000 free minutes per month for public and private repositories on the free plan. Public repositories have unlimited minutes. For most small projects and learning, the free tier is more than enough. Paid plans offer higher minute allowances.
What is the difference between a workflow, a job, and a step?
A workflow is the entire automation defined in a .yml file inside .github/workflows/. A job is a group of steps that run on the same virtual machine. A step is an individual command or action within a job. The hierarchy is: Workflow → Job → Step.
How do I speed up my GitHub Actions builds?
The most effective method is dependency caching. Use actions/cache@v4 to store node_modules, pip packages, or other dependencies between runs. A cache hit can reduce build times from minutes to seconds. Other strategies include using smaller base images, running jobs in parallel instead of sequentially, and only running workflows on relevant file changes.
Can I require manual approval before deploying to production?
Yes. Use GitHub Environments with required reviewers. In your deploy.yml, set environment: production on the production job. Then go to repo Settings → Environments → production → Required reviewers and add the team members who must approve before the deployment proceeds.
How do I use secrets in GitHub Actions?
Go to your repository Settings → Secrets and variables → Actions → New repository secret. Add the name (e.g., API_KEY) and value. In your workflow, reference it as ${{ secrets.API_KEY }}. Secrets are automatically masked in logs so they never appear in plain text.
Video Chapters — Quick Navigation
- 0:00 — Introduction
- 0:30 — What is GitHub Actions?
- 2:00 — Creating your first CI pipeline
- 6:40 — Adding caching for faster builds
- 9:40 — Matrix builds (multi-version testing)
- 12:15 — Deployment pipelines
- 16:00 — Try it yourself
- 19:45 — Recap