GitHub Actions Tutorial 2026 – CI/CD Pipeline from Scratch

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 to main.
  • runs-on — The operating system. ubuntu-latest is 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. The with: 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

  1. Go to your repository Settings → Secrets and variables → Actions.
  2. Click New repository secret.
  3. Add the name (e.g., API_KEY) and value.
  4. 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

  1. Fork or clone the repository: git clone https://github.com/shazforiot/github-actions-demo
  2. Push to your own GitHub account.
  3. Go to the Actions tab in your repository and watch the workflows run.
  4. Make a change — edit a file, commit, and push. See the CI pipeline trigger automatically.
  5. Create a pull request — see the PR checks run.
  6. 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 to main? creating a PR?).
  • Ensure the .yml file is in .github/workflows/.
  • Check for YAML indentation errors — spaces only, no tabs.

Tests failing in CI but passing locally?

  • Add - run: npm test -- --verbose for 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.json existing and being committed.
  • Check the “Cache status” step output for HIT or MISS.

Pro Tips

  • Use workflow_dispatch to manually trigger workflows for testing without pushing code.
  • Add status badges to your README: ![CI](https://github.com/user/repo/actions/workflows/ci.yml/badge.svg)
  • 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

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