Build a CI/CD Pipeline with GitHub Actions

Automate testing and deployment of a Node.js site with GitHub Actions.

Introduction

30 Second Summary

Imagine pushing a code change and having it automatically tested, packaged, and deployed to a live website within minutes. That's the power of CI/CD.

In this project, you will build a complete CI/CD pipeline using GitHub Actions. Your pipeline will automatically test, build, and deploy a portfolio website every time you push code.

What You'll Build

A multi-stage GitHub Actions pipeline that runs automated tests, builds your site, and deploys it live to GitHub Pages on every push.

By the end of this project, you'll have:

  • A Node.js project with a simple HTML portfolio site and an automated Jest test suite.
  • A multi-job GitHub Actions workflow with test, build, and deploy stages connected by job dependencies.
  • Repository secrets and environment variables configured to manage sensitive data securely.
  • A live website on GitHub Pages that auto-deploys on every push to main.
  • Secret Mission: Add a pull request workflow with branch protection to block broken code from merging.

Are there any prerequisites?

This project assumes basic familiarity with Git commands (clone, add, commit, push) and basic JavaScript knowledge. No prior CI/CD experience is needed.

Create a GitHub Repository and Initialize Your Node.js Project

Every CI/CD pipeline needs a codebase to work with. Your pipeline will live inside a GitHub repository, where GitHub Actions can detect changes and trigger automated workflows.

In this step, you'll create a fresh GitHub repository, clone it to your local machine, and initialize a Node.js project with a testing framework.

In this step, get ready to:
  • Create a public GitHub repository and clone it to your local machine.
  • Initialize a Node.js project and install Jest as a testing framework.
  • Set up the folder structure with src/ and tests/ directories.
Create a new GitHub repository

The repository needs to be public because GitHub Pages is free for public repositories. Later in this project, you'll deploy your site to GitHub Pages directly from your pipeline.

  • Open your browser and go to github.com.
  • Click the + icon in the top-right corner of the page.
  • Click New repository.
  • Enter cicd-portfolio in the Repository name field.
  • Select Public.
  • Leave all checkboxes unchecked. Do not add a README, .gitignore, or license file.
  • Click Create repository.

GitHub displays a quick setup page with your new repository's URL. Keep this page open.

Now you'll clone this repository to your local machine so you can work on it.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • On the GitHub quick setup page, click the clipboard icon next to the HTTPS URL to copy it.
  • Clone your repository and move into the project folder by running these commands:
git clone [[REPO_URL="your repository HTTPS URL from GitHub"]]
cd cicd-portfolio

You should see Cloning into 'cicd-portfolio'... in your terminal output.

Clone failed or repository not found?

Make sure you copied the HTTPS URL (starts with https://github.com/), not the SSH URL.

Check that you're signed in to the correct GitHub account by running:

git config --global user.name

help me debug a failed git clone

Initialize your Node.js project and install Jest

Every Node.js project starts with a package.json file. This file tracks your project's name, version, dependencies, and scripts.

  • Initialize your Node.js project by running:
npm init

What does npm init do?

npm init creates a package.json file by walking you through a series of prompts. This file is the foundation of any Node.js project. It tells tools like npm and GitHub Actions how to install dependencies and run your scripts.

The command asks a series of questions. Enter these values at each prompt:

  • For package name, press Enter to accept the default cicd-portfolio.
  • For version, press Enter to accept the default 1.0.0.
  • For description, type A portfolio site with a CI/CD pipeline and press Enter.
  • For entry point, type src/utils.js and press Enter.
  • For test command, type jest and press Enter.
  • For git repository, press Enter to accept the auto-detected URL.
  • For keywords, press Enter to skip.
  • For author, press Enter to skip.
  • For license, type MIT and press Enter.
  • When shown the JSON preview, type yes and press Enter to confirm.

You should see a package.json preview in your terminal followed by a confirmation message.

Now that you have a package.json, you can install your first dependency. Jest is the testing framework your CI/CD pipeline will use to verify your code automatically.

  • Install Jest as a dev dependency by running:
npm install --save-dev jest

What does --save-dev mean?

The --save-dev flag tells npm that Jest is only needed during development and testing. It won't be included in a production build. You'll see it listed under devDependencies in your package.json.

  • Verify the installation succeeded by running:
cat package.json

You should see "jest": "^30.3.0" listed under devDependencies in the output.

npm install failed?

Make sure you ran npm init first. The npm install command needs a package.json to exist in the current folder.

Check that you're inside the cicd-portfolio folder by running pwd. The output should end with cicd-portfolio.

help me fix my npm install error

✔️ Awesome, I've got everything!

Great. Double check that you've saved your package.json and move on to the next substep.

ⓧ I'd like to double check the full code

Your package.json should look like this:

{
  "name": "cicd-portfolio",
  "version": "1.0.0",
  "description": "A portfolio site with a CI/CD pipeline",
  "main": "src/utils.js",
  "scripts": {
    "test": "jest"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "jest": "^30.3.0"
  }
}
Create the project folder structure

Your project needs two folders. The src/ folder holds your website's source code. The tests/ folder holds the automated tests your pipeline will run.

  • Create both directories by running:
mkdir src tests
  • Confirm the folders exist by running:
ls

You should see node_modules, package-lock.json, package.json, src, and tests in the output.

Missing src or tests folder?

Make sure you ran the mkdir command from inside the cicd-portfolio folder. If you're in the wrong folder, run cd ~/Desktop/cicd-portfolio to get back, then try again.

help me find my project folder

Your repository is set up and your project structure is ready. Next up, you'll build the portfolio site and write tests that your pipeline will run automatically.

Build Your Portfolio Site and Write Tests

Your project has a solid foundation from the last step. You have a Node.js project, Jest installed, and the src/ and tests/ folders ready to go.

A CI/CD pipeline needs code to work with. In this step, you'll create a portfolio website, write JavaScript utility functions, and build a test suite to verify everything works.

In this step, get ready to:
  • Create an HTML portfolio page and a JavaScript utility module.
  • Write a Jest test suite for each utility function.
  • Run the tests locally to confirm they all pass.
Create your portfolio page and utility module

Your pipeline will eventually deploy a website and run tests against JavaScript code. You need both of these before you can automate anything.

You'll start by creating the portfolio page that will be deployed, then write utility functions that your test suite will verify.

  • In VS Code, right-click the src folder in the Explorer sidebar and select New File.
  • Name the file index.html.
  • Paste the following code into index.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Portfolio</title>
    <style>
        /* Reset default browser styles */
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background-color: #0d1117;
            color: #c9d1d9;
            line-height: 1.6;
        }
        /* Center content with a max width */
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 2rem;
        }
        /* Header section with name and title */
        header {
            text-align: center;
            padding: 3rem 0;
            border-bottom: 1px solid #21262d;
        }
        header h1 {
            color: #58a6ff;
            font-size: 2.5rem;
            margin-bottom: 0.5rem;
        }
        header p {
            color: #8b949e;
            font-size: 1.2rem;
        }
        /* Projects section */
        .projects {
            padding: 2rem 0;
        }
        .projects h2 {
            color: #58a6ff;
            margin-bottom: 1.5rem;
        }
        /* Individual project cards */
        .project-card {
            background-color: #161b22;
            border: 1px solid #21262d;
            border-radius: 8px;
            padding: 1.5rem;
            margin-bottom: 1rem;
        }
        .project-card h3 { color: #f0f6fc; margin-bottom: 0.5rem; }
        .project-card p { color: #8b949e; }
        /* Green badge for tech labels */
        .badge {
            display: inline-block;
            background-color: #238636;
            color: #ffffff;
            padding: 0.25rem 0.75rem;
            border-radius: 12px;
            font-size: 0.85rem;
            margin-top: 0.5rem;
        }
        /* Footer with bottom border */
        footer {
            text-align: center;
            padding: 2rem 0;
            border-top: 1px solid #21262d;
            color: #8b949e;
        }
        footer span { color: #58a6ff; }
    </style>
</head>
<body>
    <div class="container">
        <!-- Header with your name and role -->
        <header>
            <h1>Your Name</h1>
            <p>Software Developer</p>
        </header>
        <!-- Projects section with two cards -->
        <section class="projects">
            <h2>Projects</h2>
            <div class="project-card">
                <h3>CI/CD Pipeline</h3>
                <p>A fully automated pipeline with testing, building, and deployment using GitHub Actions.</p>
                <span class="badge">GitHub Actions</span>
            </div>
            <div class="project-card">
                <h3>Portfolio Site</h3>
                <p>This very site! Deployed automatically via a CI/CD pipeline.</p>
                <span class="badge">HTML/CSS/JS</span>
            </div>
        </section>
        <!-- Footer crediting the deployment method -->
        <footer>
            <p>Deployed with <span>GitHub Actions</span> | Built by You</p>
        </footer>
    </div>
</body>
</html>

What does this code do?

This is a single-page portfolio site styled to look like a GitHub dark-mode interface.

  • The <header> displays a name and role at the top of the page.
  • The .project-card elements show two project summaries with green tech badges.
  • All styles are embedded in a <style> block so the page works as a standalone file with no external dependencies.
  • Save index.html.
  • Open File Explorer and navigate to the src folder inside cicd-portfolio on your Desktop.
  • Double-click index.html to open it in your browser. You should see a dark-themed page with your name at the top, two project cards in the middle, and a footer at the bottom.

Don't see the dark-themed page?

  • Make sure you saved index.html before opening it in the browser.
  • Check that the file is inside the src/ folder, not the project root. The file path should be cicd-portfolio/src/index.html.
  • If the page appears as raw code instead of a styled page, make sure the file extension is .html and not .txt.

Still stuck? Ask for help.

✔️ Awesome, I've got everything!

Great. Double check you've saved index.html and move on to the next file.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Portfolio</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background-color: #0d1117;
            color: #c9d1d9;
            line-height: 1.6;
        }
        .container {
            max-width: 800px;
            margin: 0 auto;
            padding: 2rem;
        }
        header {
            text-align: center;
            padding: 3rem 0;
            border-bottom: 1px solid #21262d;
        }
        header h1 {
            color: #58a6ff;
            font-size: 2.5rem;
            margin-bottom: 0.5rem;
        }
        header p {
            color: #8b949e;
            font-size: 1.2rem;
        }
        .projects {
            padding: 2rem 0;
        }
        .projects h2 {
            color: #58a6ff;
            margin-bottom: 1.5rem;
        }
        .project-card {
            background-color: #161b22;
            border: 1px solid #21262d;
            border-radius: 8px;
            padding: 1.5rem;
            margin-bottom: 1rem;
        }
        .project-card h3 { color: #f0f6fc; margin-bottom: 0.5rem; }
        .project-card p { color: #8b949e; }
        .badge {
            display: inline-block;
            background-color: #238636;
            color: #ffffff;
            padding: 0.25rem 0.75rem;
            border-radius: 12px;
            font-size: 0.85rem;
            margin-top: 0.5rem;
        }
        footer {
            text-align: center;
            padding: 2rem 0;
            border-top: 1px solid #21262d;
            color: #8b949e;
        }
        footer span { color: #58a6ff; }
    </style>
</head>
<body>
    <div class="container">
        <header>
            <h1>Your Name</h1>
            <p>Software Developer</p>
        </header>
        <section class="projects">
            <h2>Projects</h2>
            <div class="project-card">
                <h3>CI/CD Pipeline</h3>
                <p>A fully automated pipeline with testing, building, and deployment using GitHub Actions.</p>
                <span class="badge">GitHub Actions</span>
            </div>
            <div class="project-card">
                <h3>Portfolio Site</h3>
                <p>This very site! Deployed automatically via a CI/CD pipeline.</p>
                <span class="badge">HTML/CSS/JS</span>
            </div>
        </section>
        <footer>
            <p>Deployed with <span>GitHub Actions</span> | Built by You</p>
        </footer>
    </div>
</body>
</html>

Now you need the JavaScript code that your pipeline will test. You'll create a utility module with three functions that the portfolio page could use.

  • In VS Code, right-click the src folder in the Explorer sidebar and select New File.
  • Name the file utils.js.
  • Paste the following code into utils.js:
// Format a Date object into a human-readable string like "May 3, 2026"
function formatDate(date) {
    if (!(date instanceof Date) || isNaN(date)) {
        throw new Error('Invalid date');
    }
    const options = { year: 'numeric', month: 'long', day: 'numeric' };
    return date.toLocaleDateString('en-US', options);
}

// Convert a text string into a URL-friendly slug like "hello-world"
function slugify(text) {
    if (typeof text !== 'string') {
        throw new Error('Input must be a string');
    }
    return text
        .toLowerCase()
        .trim()
        .replace(/[^a-z0-9\s-]/g, '')
        .replace(/\s+/g, '-')
        .replace(/-+/g, '-');
}

// Estimate reading time in minutes based on a 200 words-per-minute pace
function calculateReadingTime(text) {
    if (typeof text !== 'string') {
        throw new Error('Input must be a string');
    }
    const wordsPerMinute = 200;
    const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
    const minutes = Math.ceil(wordCount / wordsPerMinute);
    return minutes;
}

// Export all three functions so other files (and tests) can use them
module.exports = { formatDate, slugify, calculateReadingTime };

What do these functions do?

  • formatDate(date) takes a JavaScript Date object and returns a readable string like "May 3, 2026". It throws an error if the input is not a valid date.
  • slugify(text) converts a regular string into a URL-friendly slug by lowercasing it, removing special characters, and replacing spaces with hyphens.
  • calculateReadingTime(text) counts the words in a string and divides by 200 (a typical reading pace) to estimate how many minutes it takes to read.
  • module.exports makes all three functions available to other files. Your tests will import them using this export.
  • Save utils.js.

You won't see a visible change yet. The next substep writes tests that exercise these functions.

✔️ Awesome, I've got everything!

Great. Double check you've saved utils.js and move on to writing tests.

ⓧ I'd like to double check the full code

function formatDate(date) {
    if (!(date instanceof Date) || isNaN(date)) {
        throw new Error('Invalid date');
    }
    const options = { year: 'numeric', month: 'long', day: 'numeric' };
    return date.toLocaleDateString('en-US', options);
}

function slugify(text) {
    if (typeof text !== 'string') {
        throw new Error('Input must be a string');
    }
    return text
        .toLowerCase()
        .trim()
        .replace(/[^a-z0-9\s-]/g, '')
        .replace(/\s+/g, '-')
        .replace(/-+/g, '-');
}

function calculateReadingTime(text) {
    if (typeof text !== 'string') {
        throw new Error('Input must be a string');
    }
    const wordsPerMinute = 200;
    const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
    const minutes = Math.ceil(wordCount / wordsPerMinute);
    return minutes;
}

module.exports = { formatDate, slugify, calculateReadingTime };
Write Jest tests for your utility functions

Automated tests are the first line of defence in any CI/CD pipeline. They catch bugs before your code reaches production.

You'll write test cases that check both the happy path (valid inputs) and the error path (invalid inputs) for each function. Jest makes this easy with its describe and test functions.

  • In VS Code, right-click the tests folder in the Explorer sidebar and select New File.
  • Name the file utils.test.js.
  • Paste the following code into utils.test.js:
// Import the three functions from utils.js
const { formatDate, slugify, calculateReadingTime } = require('../src/utils');

// Tests for the formatDate function
describe('formatDate', () => {
    test('formats a valid date correctly', () => {
        const date = new Date('2026-05-03');
        const result = formatDate(date);
        expect(result).toBe('May 3, 2026');
    });

    test('throws an error for invalid date', () => {
        expect(() => formatDate('not-a-date')).toThrow('Invalid date');
    });

    test('throws an error for invalid Date object', () => {
        expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date');
    });
});

// Tests for the slugify function
describe('slugify', () => {
    test('converts text to a URL-friendly slug', () => {
        expect(slugify('Hello World')).toBe('hello-world');
    });

    test('removes special characters', () => {
        expect(slugify('Hello! @World#')).toBe('hello-world');
    });

    test('handles multiple spaces', () => {
        expect(slugify('hello   world')).toBe('hello-world');
    });

    test('trims whitespace', () => {
        expect(slugify('  hello world  ')).toBe('hello-world');
    });

    test('throws an error for non-string input', () => {
        expect(() => slugify(123)).toThrow('Input must be a string');
    });
});

// Tests for the calculateReadingTime function
describe('calculateReadingTime', () => {
    test('returns 1 minute for short text', () => {
        expect(calculateReadingTime('Hello world')).toBe(1);
    });

    test('calculates correct reading time for longer text', () => {
        const longText = 'word '.repeat(400);
        expect(calculateReadingTime(longText)).toBe(2);
    });

    test('returns 1 for empty string', () => {
        expect(calculateReadingTime('')).toBe(0);
    });

    test('throws an error for non-string input', () => {
        expect(() => calculateReadingTime(42)).toThrow('Input must be a string');
    });
});

What do these tests check?

Each describe block groups tests for one function. Each test block checks a single behaviour.

  • The formatDate tests verify it formats a valid date and throws errors for invalid inputs.
  • The slugify tests cover lowercasing, special character removal, multiple spaces, and whitespace trimming.
  • The calculateReadingTime tests check short text, long text, empty strings, and non-string input.
  • expect().toThrow() wraps the function call in an arrow function so Jest can catch the thrown error without crashing the test.
  • Save utils.test.js.

You won't see a visible change yet. The next substep runs these tests to confirm they pass.

✔️ Awesome, I've got everything!

Great. Double check you've saved utils.test.js and move on to running the tests.

ⓧ I'd like to double check the full code

const { formatDate, slugify, calculateReadingTime } = require('../src/utils');

describe('formatDate', () => {
    test('formats a valid date correctly', () => {
        const date = new Date('2026-05-03');
        const result = formatDate(date);
        expect(result).toBe('May 3, 2026');
    });

    test('throws an error for invalid date', () => {
        expect(() => formatDate('not-a-date')).toThrow('Invalid date');
    });

    test('throws an error for invalid Date object', () => {
        expect(() => formatDate(new Date('invalid'))).toThrow('Invalid date');
    });
});

describe('slugify', () => {
    test('converts text to a URL-friendly slug', () => {
        expect(slugify('Hello World')).toBe('hello-world');
    });

    test('removes special characters', () => {
        expect(slugify('Hello! @World#')).toBe('hello-world');
    });

    test('handles multiple spaces', () => {
        expect(slugify('hello   world')).toBe('hello-world');
    });

    test('trims whitespace', () => {
        expect(slugify('  hello world  ')).toBe('hello-world');
    });

    test('throws an error for non-string input', () => {
        expect(() => slugify(123)).toThrow('Input must be a string');
    });
});

describe('calculateReadingTime', () => {
    test('returns 1 minute for short text', () => {
        expect(calculateReadingTime('Hello world')).toBe(1);
    });

    test('calculates correct reading time for longer text', () => {
        const longText = 'word '.repeat(400);
        expect(calculateReadingTime(longText)).toBe(2);
    });

    test('returns 1 for empty string', () => {
        expect(calculateReadingTime('')).toBe(0);
    });

    test('throws an error for non-string input', () => {
        expect(() => calculateReadingTime(42)).toThrow('Input must be a string');
    });
});
Run your tests locally

Before you set up CI, you should always confirm tests pass on your own machine first. This catches problems before they ever hit the pipeline.

  • Open the terminal in VS Code by pressing Ctrl+`.
  • Make sure you are inside the cicd-portfolio folder (the folder containing package.json).
  • Run your test suite by running this command:
npm test

You should see Jest run all 12 tests across 3 test suites. Every test should show a green checkmark with the word PASS at the top.

What should I see?

Jest prints a summary at the bottom of the output. You should see something like:

  • Test Suites: 1 passed, 1 total means your single test file passed.
  • Tests: 12 passed, 12 total means all 12 individual test cases passed.

Seeing test failures?

  • If you see Cannot find module '../src/utils', check that utils.js is saved inside the src/ folder and that the file name is exactly utils.js.
  • If you see SyntaxError, double check that you pasted the full code for both utils.js and utils.test.js. A missing bracket or quote causes syntax errors.
  • If you see jest is not recognized, make sure you ran npm install in the cicd-portfolio folder first.

Help me debug my failing Jest tests.

Your portfolio site is built and every test passes. Next up, you'll create a GitHub Actions workflow that runs these tests automatically every time you push code.

Create Your First GitHub Actions Workflow

Your tests pass locally, which is great. But right now, you have to remember to run npm test before every commit. If you forget, broken code reaches your repository with no warning.

GitHub Actions can automate this for you. You'll create a workflow file that tells GitHub to run your tests automatically every time you push code to the main branch.

In this step, get ready to:
  • Create the workflow directory and YAML file that GitHub Actions reads.
  • Write a test job that checks out code, installs dependencies, and runs your test suite.
  • Push to GitHub and watch the workflow execute in the Actions tab.
Create the workflow directory and file

GitHub Actions looks for workflow files in a specific folder: .github/workflows/. Any YAML file inside this folder becomes a workflow that GitHub can run.

  • In VS Code, look at the Explorer sidebar on the left. Hover over your project name (cicd-portfolio) to reveal the file action icons.
  • Click the New File icon (it looks like a page with a plus sign).
  • Type .github/workflows/ci-cd.yml and press Enter.

VS Code automatically creates the .github and workflows folders for you. You should see the new file open in the editor with the folder structure visible in the sidebar.

Don't see the folders in the sidebar?

Make sure you typed the full path including the leading dot: .github/workflows/ci-cd.yml. The dot at the start is required. If the file was created in the wrong place, delete it and try again.

Help me create the .github/workflows directory in my project

Write the workflow configuration

A workflow file has two main parts: the configuration at the top (when to run, what permissions to use) and the jobs below (the actual work to perform).

  • In the empty ci-cd.yml file, add the workflow configuration by pasting this code:
# Name displayed in the GitHub Actions tab
name: CI/CD Pipeline

# Run on every push to main, or manually via the Actions tab
on:
  push:
    branches:
      - main
  workflow_dispatch:

# Variables available to all jobs in the workflow
env:
  NODE_VERSION: '20'
  SITE_TITLE: 'My Portfolio'

# Permissions needed for GitHub Pages deployment later
permissions:
  contents: read
  pages: write
  id-token: write

# Prevent overlapping deployments
concurrency:
  group: pages
  cancel-in-progress: false

What does this configuration do?

  • name sets the label you'll see in the GitHub Actions tab.
  • on defines the triggers. push fires on every push to main. workflow_dispatch lets you run the workflow manually from the Actions tab.
  • env sets variables that every job can access. You'll reference NODE_VERSION later to keep Node.js consistent across jobs.
  • permissions and concurrency are set up now for the deployment steps you'll add in a later step.

You won't see a visible change yet. The next chunk adds the job that performs the actual work.

  • Below the concurrency block, add the test job by pasting this code:
# Define the jobs that make up the pipeline
jobs:
  test:
    name: Run Tests
    # Use a cloud-hosted Ubuntu machine
    runs-on: ubuntu-latest
    steps:
      # Pull your repository code onto the runner
      - name: Checkout code
        uses: actions/checkout@v6

      # Install the correct Node.js version with npm caching
      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      # Install exact dependency versions from package-lock.json
      - name: Install dependencies
        run: npm ci

      # Run the Jest test suite with CI-specific env vars
      - name: Run tests
        run: npm test
        env:
          APP_ENV: ci
          SECRET_KEY: ${{ secrets.SECRET_KEY }}

What does this job do?

The test job runs on a fresh Ubuntu virtual machine provided by GitHub. Each step runs a single action or command in sequence:

  • actions/checkout@v6 clones your repository onto the runner so the job can access your files.
  • actions/setup-node@v6 installs Node.js 20 (pulled from your NODE_VERSION env var) and caches npm packages for faster future runs.
  • npm ci installs exact dependency versions from package-lock.json. This is preferred over npm install in CI because it's faster and guarantees reproducible builds.
  • npm test runs your Jest test suite. The step-level env sets two environment variables that your tests could use.

Why does the workflow reference a secret that doesn't exist yet?

The line ${{ secrets.SECRET_KEY }} references a repository secret you haven't created yet. GitHub Actions treats missing secrets as empty strings, so the workflow still runs fine. You'll create the actual secret in Step 5.

  • Save ci-cd.yml by pressing Ctrl+S (Windows) or Cmd+S (macOS).

Seeing red squiggly lines in your YAML?

YAML is very sensitive to indentation. Every level of nesting uses exactly 2 spaces. Make sure you haven't mixed tabs and spaces. Check that jobs: is at the very start of the line (no leading spaces) and that test: is indented 2 spaces under it.

Help me fix YAML indentation errors in my GitHub Actions workflow

✔️ Awesome, I've got everything!

Great. Double check that you've saved ci-cd.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete ci-cd.yml file. Make sure your file matches this exactly:

name: CI/CD Pipeline

on:
  push:
    branches:
      - main
  workflow_dispatch:

env:
  NODE_VERSION: '20'
  SITE_TITLE: 'My Portfolio'

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test
        env:
          APP_ENV: ci
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
Push to GitHub and watch the workflow run

Your workflow file is saved locally, but GitHub Actions only reads it once it's pushed to the repository on GitHub. Time to push all your project files and trigger the pipeline for the first time.

  • Open your terminal in VS Code by pressing Ctrl+` (Windows) or Cmd+` (macOS).
  • Make sure you're inside your cicd-portfolio folder. The terminal prompt should show the folder name.
  • Stage, commit, and push all your files by running these commands:
git add .
git commit -m "Add CI/CD workflow with test job"
git push origin main

What do these commands do?

  • git add . stages every new and changed file in your project for the commit.
  • git commit -m saves those staged changes with a descriptive message.
  • git push origin main uploads everything to your GitHub repository on the main branch. This triggers the workflow because you set on: push in your YAML file.
  • Open your browser and go to your cicd-portfolio repository on GitHub.
  • Click the Actions tab at the top of the repository page.

You should see a workflow run labeled Add CI/CD workflow with test job (matching your commit message). Click on it to see the details.

  • Click the Run Tests job to expand its steps.

You should see each step complete with a green checkmark: Checkout code, Setup Node.js, Install dependencies, and Run tests. Expanding the Run tests step shows your Jest test output, just like when you ran npm test locally.

Workflow failed or not appearing?

  • If you don't see the Actions tab, make sure your repository is public. GitHub Actions is free for public repositories.
  • If the workflow shows a red X, click into the failed job and read the error. The most common cause is a YAML indentation mistake.
  • If you see No tests found in the logs, check that your tests/utils.test.js file was pushed. Run git status to see if any files were missed.

Help me debug my failing GitHub Actions workflow

Your first CI/CD pipeline is live. Every time you push to main, GitHub automatically runs your tests. Next up, you'll add build and deploy stages so the pipeline doesn't just test your code, it ships it to a live website.

Add Build and Deploy Stages to Your Pipeline

Your test job is running on GitHub. Every push to main triggers your test suite automatically.

Tests catch bugs, but they don't get your site in front of users. In this step, you'll add build and deploy stages so your pipeline packages the site and publishes it live to GitHub Pages.

In this step, get ready to:
  • Add a build job that packages your site into a deployable artifact.
  • Add a deploy job that publishes your site to GitHub Pages.
  • Configure GitHub Pages settings and verify the full pipeline.
Add the build job to your workflow

Right now your pipeline has one job. A real CI/CD pipeline chains multiple jobs together so each stage runs only when the previous one succeeds.

  • Open ci-cd.yml in your text editor.
  • Scroll to the bottom of the file and find the last line of the test job:
          SECRET_KEY: ${{ secrets.SECRET_KEY }}
  • Add the build job below that line by copying the following code. Make sure build: is indented at the same level as test: (two spaces):
  # Build job - packages site files for deployment
  build:
    name: Build Site
    runs-on: ubuntu-latest
    needs: test  # Only runs if the test job succeeds
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      # Prepare the GitHub Pages environment
      - name: Setup Pages
        uses: actions/configure-pages@v6

      # Package the src folder as a deployable artifact
      - name: Upload artifact
        uses: actions/upload-pages-artifact@v5
        with:
          path: './src'

What does this code do?

  • needs: test creates a dependency. The build job waits for the test job to succeed. If tests fail, the build is skipped entirely.
  • actions/configure-pages@v6 prepares the GitHub Pages environment so it's ready to receive a deployment.
  • actions/upload-pages-artifact@v5 packages your ./src folder into an artifact. An artifact is a file or bundle that one job produces and another job consumes.

You won't see a change yet. The deploy job in the next chunk uses this artifact to publish your site.

Add the deploy job to your workflow

The deploy job is the final stage of your pipeline. It takes the packaged artifact from the build job and publishes it as a live website.

  • Below the build job you just added, copy the following code. Make sure deploy: is indented at the same level as build: (two spaces):
  # Deploy job - publishes the site to GitHub Pages
  deploy:
    name: Deploy to GitHub Pages
    runs-on: ubuntu-latest
    needs: build  # Only runs after the build job succeeds
    environment:
      name: github-pages  # Links to the GitHub Pages environment
      url: ${{ steps.deployment.outputs.page_url }}  # Captures the live URL
    steps:
      - name: Deploy to GitHub Pages
        id: deployment  # Referenced by the url output above
        uses: actions/deploy-pages@v5

What does this code do?

  • needs: build chains this job after the build. Your three jobs now run in sequence: test, then build, then deploy.
  • environment: name: github-pages tells GitHub this deployment targets the GitHub Pages environment. GitHub creates this environment automatically.
  • id: deployment gives the step a name so the url field above can reference its output with steps.deployment.outputs.page_url.
  • actions/deploy-pages@v5 takes the uploaded artifact and publishes it to your GitHub Pages URL.
  • Save ci-cd.yml.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete ci-cd.yml file so far. Compare yours against it line by line.

name: CI/CD Pipeline

on:
  push:
    branches:
      - main
  workflow_dispatch:

env:
  NODE_VERSION: '20'
  SITE_TITLE: 'My Portfolio'

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test
        env:
          APP_ENV: ci
          SECRET_KEY: ${{ secrets.SECRET_KEY }}

  build:
    name: Build Site
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Pages
        uses: actions/configure-pages@v6

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v5
        with:
          path: './src'

  deploy:
    name: Deploy to GitHub Pages
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v5

YAML looking messy?

YAML is very strict about indentation. Every level uses exactly two spaces (not tabs). If your file has misaligned lines, the workflow will fail to parse.

  • Make sure test:, build:, and deploy: all start at the same indentation level (two spaces under jobs:).
  • Check for accidental tab characters. In VS Code, look at the bottom status bar to confirm it says **Spaces: 2**.

Why is my YAML file not parsing correctly?

Configure GitHub Pages and push your changes

Before pushing, you need to tell GitHub to use GitHub Actions as the deployment source for Pages. By default, GitHub Pages looks for a branch to deploy from, but your pipeline handles deployment through the workflow instead.

  • Open your cicd-portfolio repository on GitHub in your browser.
  • Click **Settings** in the top navigation bar of the repository.
  • In the left sidebar, click **Pages**.
  • Under **Build and deployment**, find the **Source** dropdown.
  • Select **GitHub Actions** from the dropdown.

Why change the source?

GitHub Pages has two deployment modes. The default mode deploys from a branch (like gh-pages). The GitHub Actions mode lets your workflow control exactly what gets deployed and when.

Since your pipeline uploads an artifact with upload-pages-artifact and deploys it with deploy-pages, you need the GitHub Actions source selected for these actions to work.

Now push your updated workflow to trigger the full pipeline.

  • Stage, commit, and push your changes by running these commands in your terminal:
git add .github/workflows/ci-cd.yml
git commit -m "Add build and deploy stages to pipeline"
git push
  • Go back to your repository on GitHub.
  • Click the **Actions** tab.
  • Click on the latest workflow run (it should say Add build and deploy stages to pipeline).

You should see three jobs in a chain: **Run Tests**, **Build Site**, and **Deploy to GitHub Pages**. Wait for all three to show green checkmarks.

  • Once the deploy job finishes, click on it to expand the details.
  • Find the URL in the deployment output. It looks like https://your-username.github.io/cicd-portfolio/.
  • Click the URL to open your live portfolio site.

You should see your dark-themed portfolio page with your name, two project cards, and a footer. This site is now live on the internet.

Pipeline failed or site not loading?

  • If the **Build Site** job fails, click on it in the Actions tab to read the error log. The most common cause is a YAML indentation error in ci-cd.yml.
  • If the **Deploy to GitHub Pages** job fails, make sure you set the **Source** to **GitHub Actions** in your repository's **Settings** > **Pages**. Without this, the deploy action has no target.
  • If the URL returns a 404, wait a minute and try again. GitHub Pages deployments can take 30-60 seconds to go live.
  • Make sure your repository is set to **Public**. GitHub Pages is free for public repositories only.

Help me debug my GitHub Actions pipeline

Your pipeline now tests, builds, and deploys automatically on every push. Next up, you'll configure secrets and environment variables to manage sensitive data securely inside your workflow.

Configure Secrets and Environment Variables

Your pipeline is live and deploys your site automatically on every push to main. But real-world projects deal with sensitive data like API keys, tokens, and credentials that should never be visible in your code.

GitHub Actions gives you two tools to handle this. Secrets store sensitive values that get automatically hidden in logs. Environment variables hold non-sensitive configuration that your workflow steps can read.

In this step, get ready to:
  • Add a repository secret to your GitHub project.
  • Explore how environment variables work at different levels in your workflow.
  • Verify that secrets are automatically redacted in workflow logs.
Add a repository secret in GitHub

Your workflow already references a secret called SECRET_KEY in the test job. Right now, that secret doesn't actually exist in your repository yet, so its value is empty. Let's create it.

  • Open your cicd-portfolio repository on GitHub in your browser.
  • Click the Settings tab at the top of the repository page.
  • In the left sidebar, click Secrets and variables.
  • Click Actions.
  • Click the New repository secret button.
  • In the Name field, type SECRET_KEY.
  • In the Secret field, type my-secret-value-123.
  • Click Add secret.

You should see SECRET_KEY listed on the repository secrets page with its last updated date.

What is a repository secret?

A repository secret is an encrypted value stored in your GitHub repository's settings. Your workflow can access it using the ${{ secrets.SECRET_KEY }} syntax, but the actual value is never exposed in logs or to forks.

GitHub automatically replaces the secret's value with *** anywhere it appears in your workflow logs. Even if a script accidentally prints a secret, it stays hidden.

Your workflow already references this secret. Open ci-cd.yml and find the Run tests step:

      - name: Run tests
        run: npm test
        env:
          APP_ENV: ci
          SECRET_KEY: ${{ secrets.SECRET_KEY }}

What does this code do?

The env block under the Run tests step creates environment variables available only during that step.

  • APP_ENV: ci sets a plain-text environment variable with the value ci.
  • SECRET_KEY: ${{ secrets.SECRET_KEY }} pulls the encrypted secret you just created and makes it available as an environment variable during the test run.

Don't see the Settings tab?

The Settings tab only appears if you own the repository. Make sure you're signed in to the GitHub account that created the cicd-portfolio repository.

Help me find the Settings tab in my GitHub repository.

Explore environment variables in your workflow

Your workflow already uses environment variables at two different levels. Each level controls where the variable is accessible.

At the top of your ci-cd.yml file, find the workflow-level env block:

env:
  NODE_VERSION: '20'
  SITE_TITLE: 'My Portfolio'

How does the environment variable hierarchy work?

Workflow-level variables (defined at the top of the file) are available to every job and every step in the entire workflow. The NODE_VERSION variable is used in the Setup Node.js step to control which version of Node.js gets installed.

Step-level variables (defined under a specific step's env block) are only available during that one step. If a step-level variable has the same name as a workflow-level one, the step-level value takes priority.

Now look at the step-level env block you saw earlier on the Run tests step. APP_ENV is a plain environment variable scoped to just that step. SECRET_KEY pulls from the encrypted secret you created in the previous substep.

Why use env instead of hardcoding values?

Environment variables let you change configuration without editing your workflow logic. If you need to upgrade to Node.js 22 later, you update one line at the top of the file instead of hunting through every job.

Secrets go one step further by encrypting the value and hiding it from logs. Use secrets for API keys, tokens, and passwords. Use plain env variables for everything else.

Trigger a workflow run and verify the logs

Your workflow includes a workflow_dispatch trigger, which lets you start a run manually from the GitHub UI. Now that the SECRET_KEY secret exists, let's trigger a fresh run and see how GitHub handles secrets versus environment variables in the logs.

  • In your repository on GitHub, click the Actions tab.
  • In the left sidebar, click CI/CD Pipeline under the workflows list.
  • Click the Run workflow dropdown button on the right side.
  • Make sure the Branch is set to main.
  • Click the green Run workflow button.

Wait for the workflow run to appear in the list, then click on it to see the details.

  • Click the Run Tests job on the left side.
  • Expand the Run tests step to see the log output.

In the logs, you should see APP_ENV and SITE_TITLE displayed as plain text values. The SECRET_KEY value, however, appears as *** instead of my-secret-value-123.

Why are secrets redacted automatically?

GitHub scans every line of your workflow logs for any value that matches a stored secret. If it finds a match, it replaces that value with *** before the log is visible to anyone.

This is a critical security feature. Even if a script accidentally prints a secret to the console, GitHub catches it and masks the output. Plain environment variables like APP_ENV are not redacted because they hold non-sensitive configuration values.

Don't see the Run workflow button?

Make sure you're on the correct workflow page. Click Actions, then select CI/CD Pipeline from the left sidebar. The Run workflow button only appears for workflows that include workflow_dispatch in their triggers.

Help me trigger a manual workflow run in GitHub Actions.

✔️ Awesome, I've got everything!

Great. Make sure your workflow run completed with all three jobs passing (test, build, deploy).

ⓧ I'd like to double check the full code

Here's the complete ci-cd.yml file. Compare it with yours to make sure everything matches.

name: CI/CD Pipeline

on:
  push:
    branches:
      - main
  workflow_dispatch:

env:
  NODE_VERSION: '20'
  SITE_TITLE: 'My Portfolio'

permissions:
  contents: read
  pages: write
  id-token: write

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test
        env:
          APP_ENV: ci
          SECRET_KEY: ${{ secrets.SECRET_KEY }}

  build:
    name: Build Site
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Pages
        uses: actions/configure-pages@v6

      - name: Upload artifact
        uses: actions/upload-pages-artifact@v5
        with:
          path: './src'

  deploy:
    name: Deploy to GitHub Pages
    runs-on: ubuntu-latest
    needs: build
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - name: Deploy to GitHub Pages
        id: deployment
        uses: actions/deploy-pages@v5

You now have a complete, production-style CI/CD pipeline with automated testing, deployment, and secure secrets management. Next up, tackle the Secret Mission to add pull request checks that prevent broken code from ever reaching your main branch.

Secret mission

Add a Pull Request CI Workflow

Real teams never merge code directly to main. They open pull requests, run automated checks, and only merge when everything passes. Can you add this safety net to your pipeline?

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses GitHub Actions and GitHub Pages, both of which are free for public repositories, so there are no ongoing costs.

Resources you used:

  • GitHub repository with your Node.js portfolio project.
  • GitHub Actions workflows (ci-cd.yml and optionally pr-checks.yml from the Secret Mission).
  • GitHub Pages live site deployed from your repository.
  • Repository secret (SECRET_KEY) stored in your GitHub repository settings.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep your live portfolio site running.

  • Your GitHub Actions workflows will continue to run on every push to main.
  • Your GitHub Pages site will stay live and publicly accessible.
  • Everything is free for public repositories, so there is nothing to worry about.

Pause - I'll come back to this later

Disable your workflows and site to stop automated runs, but keep all your code and configuration intact.

  • Go to your repository on GitHub and click the Actions tab.
  • Click on each workflow name (e.g. CI/CD Pipeline), then click the ... menu and select Disable workflow.
  • To disable GitHub Pages, go to Settings, then Pages, and set the source to None.
  • Your code, secrets, and workflow files will remain in the repository. You can re-enable everything when you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your repository, workflows, secrets, and GitHub Pages site.

  • Go to your repository on GitHub.
  • Click Settings and scroll down to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts. This removes the repository, all workflows, secrets, Pages site, and deployment history.
  • Delete your local project folder from your Desktop.

Mission accomplished

That's a Wrap!

You just built a professional CI/CD pipeline from scratch. Every time you push code to your repository, GitHub Actions automatically tests, builds, and deploys your site.

You've learned how to:

  • Created a Node.js project with an automated Jest test suite that validates your code on every push.
  • Written a multi-job GitHub Actions workflow with test, build, and deploy stages connected by the needs keyword.
  • Deployed a live site to GitHub Pages that auto-updates on every push, with secrets and environment variables configured securely.
  • Added PR-based CI checks with branch protection to prevent broken code from merging into production.

Ready to quiz yourself?