Build a CI/CD Pipeline with GitHub Actions
Automate testing and deployment of a Node.js app with GitHub Actions.
Introduction
30 Second Summary
Every team that ships software faces the same challenge: how do you make sure your code still works after every change? Manually checking everything is slow, error-prone, and doesn't scale.
In this project, you will build an automated CI/CD pipeline using GitHub Actions. Every time you push code, your pipeline will lint it, test it, and deploy it to a live website.
What You'll Build
A multi-job pipeline powered by GitHub Actions that automatically lints, tests, builds, and deploys a Node.js web application on every code push.
By the end of this project, you'll have:
- A GitHub repository with a Node.js application and automated tests.
- A multi-job CI/CD workflow file that lints, tests, builds, and deploys your code automatically.
- A live website deployed to GitHub Pages through your pipeline.
- Secret Mission: Add matrix testing across multiple Node.js versions and dependency caching for faster pipeline runs.
Are there any prerequisites?
You'll need a GitHub account, Git installed on your Windows PC, and basic comfort with the command line. Step 1 covers installing everything else you need.
Set Up Your Environment
Before you can build a pipeline, you need the tools that your pipeline will automate. Your CI/CD pipeline will run Node.js commands like testing and linting every time you push code.
In this step, you will install Node.js on your machine, create a GitHub repository for your project, and set up the application files that your pipeline will eventually test and deploy.
In this step, get ready to:
- Install Node.js v24 LTS and verify it works.
- Create and clone a GitHub repository for your project.
- Set up application files, tests, and a lint script.
Install Node.js
Node.js is the JavaScript runtime your project needs. It also ships with npm, the package manager you will use to run your test and lint scripts.
- Open your browser and navigate to https://nodejs.org/en/download.
- Click the LTS tab (this selects the stable, long-term support version).
- Download the Windows installer (the file is named node-v24.18.0-x64.msi).
- Run the installer and accept all the default options.
- When the installer finishes, open a new Command Prompt by pressing the Windows key, typing Command Prompt, and pressing Enter.
Verify the installation by running this command:
node -v
Then check that npm is available by running:
npm -v
✔️ I see version 24 or higher
If node -v shows v24.x.x (like v24.18.0) and npm -v shows a version number, you are all set.
ⓧ I see an older version
If node -v shows a version lower than 24, you have an older Node.js installed.
- Go to https://nodejs.org/en/download and download the LTS installer (node-v24.18.0-x64.msi).
- Run the installer. It will upgrade your existing Node.js to v24.
- Close your current Command Prompt and open a brand new one.
- Run node -v again. You should now see v24.x.x.
ⓧ Command not found
If your terminal says the command is not recognized, Node.js did not install correctly or the terminal was not restarted.
- Close Command Prompt completely and open a new one (the installer updates your system PATH, but only new terminal windows pick it up).
- Try node -v again.
- If it still fails, re-run the installer from https://nodejs.org/en/download and make sure you accept all defaults (including the PATH checkbox).
Still not working?
Make sure you opened a new Command Prompt window after installing. Existing windows do not pick up PATH changes.
help me troubleshoot my Node.js installation on Windows
Create the project repository
Your pipeline needs a Git repository on GitHub to live in. GitHub Actions (the CI/CD tool you will use) reads workflow files directly from your repository.
- Open GitHub in your browser and sign in.
- Click the + icon in the top-right corner of any GitHub page.
- Select New repository.
- Enter ci-cd-pipeline-demo as the repository name.
- Set the visibility to Public.
- Leave all other options at their defaults (no README, no .gitignore).
- Click Create repository.
Why a public repository?
GitHub Actions provides unlimited free minutes for workflows running on public repositories. This means your pipeline will run at no cost.
Now clone the repository to your machine and initialise the project.
- In your Command Prompt, move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
- Clone your new repository by running this command:
git clone https://github.com/[[YOUR_USERNAME="your GitHub username"]]/ci-cd-pipeline-demo.git
- Navigate into the project folder by running:
cd ci-cd-pipeline-demo
- Initialise a Node.js project by running:
npm init -y
What does npm init -y do?
This command creates a package.json file with default values. The -y flag automatically accepts all defaults so you skip the interactive prompts.
You will update this file later with your custom test and lint scripts.
You should see a package.json file in the output. Confirm by running:
dir
You should see package.json listed in the directory output.
Don't see package.json?
Make sure you ran cd ci-cd-pipeline-demo before running npm init -y. You must be inside the cloned repository folder.
help me fix my npm init issue in my ci-cd-pipeline-demo folder
Create the application and test files
Your pipeline will need actual code to test and lint. You will create a simple math utility module, an automated test file, a custom lint script, and an HTML page for deployment.
- Create the project directories by running these commands:
mkdir src
mkdir tests
mkdir scripts
mkdir public
- Open the project in VS Code by running:
code .
What does code . do?
The code command launches VS Code and the . tells it to open the current folder. You will see your src, tests, scripts, and public folders in the Explorer sidebar.
Now create each file. Start with the application code.
- In VS Code's Explorer sidebar, right-click on the src folder.
- Click New File.
- Type index.js and press Enter.
- Paste the following code into the file:
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
function isEven(n) {
return n % 2 === 0;
}
module.exports = { add, subtract, multiply, isEven };
What does this code do?
This module exports four simple math functions: add, subtract, multiply, and isEven.
The functions themselves are intentionally simple. The focus of this project is the pipeline that tests them, not the application logic.
- Right-click on the tests folder in the Explorer sidebar.
- Click New File and name it index.test.js.
- Paste the following code into the file:
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { add, subtract, multiply, isEven } = require('../src/index.js');
describe('Math utilities', () => {
it('should add two numbers correctly', () => {
assert.strictEqual(add(2, 3), 5);
assert.strictEqual(add(-1, 1), 0);
assert.strictEqual(add(0, 0), 0);
});
it('should subtract two numbers correctly', () => {
assert.strictEqual(subtract(5, 3), 2);
assert.strictEqual(subtract(0, 5), -5);
});
it('should multiply two numbers correctly', () => {
assert.strictEqual(multiply(3, 4), 12);
assert.strictEqual(multiply(-2, 3), -6);
assert.strictEqual(multiply(0, 100), 0);
});
it('should check if a number is even', () => {
assert.strictEqual(isEven(4), true);
assert.strictEqual(isEven(7), false);
assert.strictEqual(isEven(0), true);
});
});
What does this test file do?
This file uses Node.js's built-in test runner (node:test) and assertion library (node:assert). No external testing framework like Jest or Mocha is needed.
Each it() block checks that a function returns the expected result. If any assertion fails, Node.js reports the failure and exits with a non-zero code, which tells the pipeline something broke.
- Right-click on the scripts folder in the Explorer sidebar.
- Click New File and name it lint.js.
- Paste the following code into the file:
const fs = require('fs');
const path = require('path');
const srcDir = path.join(__dirname, '..', 'src');
const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.js'));
let errors = 0;
files.forEach(file => {
const content = fs.readFileSync(path.join(srcDir, file), 'utf8');
const lines = content.split('\n');
lines.forEach((line, i) => {
if (line.includes('\t')) {
console.error(`${file}:${i + 1} - No tabs allowed, use spaces`);
errors++;
}
if (line.length > 120) {
console.error(`${file}:${i + 1} - Line exceeds 120 characters`);
errors++;
}
});
});
if (errors > 0) {
console.error(`\nFound ${errors} linting error(s)`);
process.exit(1);
} else {
console.log('All files pass linting checks');
}
What does this lint script do?
This custom linter scans every .js file in the src directory for two code style rules: no tab characters and no lines exceeding 120 characters.
If it finds violations, it exits with code 1, which signals a failure to the pipeline. If all files pass, it prints a success message and exits with code 0.
- Right-click on the public folder in the Explorer sidebar.
- Click New File and name it index.html.
- Paste the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline Demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
text-align: center;
}
h1 { color: #24292e; }
.status {
background: #28a745;
color: white;
padding: 10px 20px;
border-radius: 6px;
display: inline-block;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>CI/CD Pipeline Demo</h1>
<p>This site was deployed automatically by GitHub Actions.</p>
<p>Every push to the main branch triggers the pipeline:</p>
<p>Lint -> Test -> Build -> Deploy</p>
<div class="status">Deployed Successfully</div>
</body>
</html>
What is this HTML file for?
This is the page your pipeline will deploy to GitHub Pages in a later step. It is a simple static page that confirms your deployment is working.
Finally, update package.json with the correct scripts and metadata.
- In VS Code's Explorer sidebar, click on package.json (in the project root) to open it.
- Select all the existing content (Ctrl+A) and replace it with the following:
{
"name": "ci-cd-pipeline-demo",
"version": "1.0.0",
"description": "A demo project for learning CI/CD with GitHub Actions",
"main": "src/index.js",
"scripts": {
"test": "node --test tests/",
"lint": "node scripts/lint.js"
},
"keywords": [],
"author": "",
"license": "MIT"
}
What changed in package.json?
The test script runs node --test tests/, which tells Node.js to execute all test files in the tests/ directory using its built-in test runner.
The lint script runs node scripts/lint.js, which executes your custom linter. Your pipeline will call these same scripts in the cloud.
- Save all open files by pressing Ctrl+K then S (or File then Save All).
Now verify everything works locally.
- Switch back to your Command Prompt (it should still be in the ci-cd-pipeline-demo directory).
- Run the tests by executing:
npm test
You should see output showing all four test cases passing with green checkmarks.
- Run the linter by executing:
npm run lint
You should see the message All files pass linting checks.
Tests failing or lint errors?
If npm test fails, make sure the contents of src/index.js and tests/index.test.js match exactly what was shown above. A small typo in a function name or require path will cause failures.
If npm run lint reports errors, check that src/index.js uses spaces (not tabs) for indentation and has no lines longer than 120 characters.
help me debug my failing tests or lint errors in ci-cd-pipeline-demo
Now commit and push all files to GitHub.
- Stage, commit, and push your code by running these commands:
git add .
git commit -m "Initial project setup with tests and lint"
git push origin main
What do these commands do?
The git add . command stages all new files. The git commit command saves a snapshot locally. The git push origin main command uploads everything to your GitHub repository.
Refresh your repository page on GitHub. You should see all your files (src/, tests/, scripts/, public/, package.json) listed there.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and pushed to GitHub.
ⓧ I'd like to double check the full code
Here is each file as it should look at this point.
{
"name": "ci-cd-pipeline-demo",
"version": "1.0.0",
"description": "A demo project for learning CI/CD with GitHub Actions",
"main": "src/index.js",
"scripts": {
"test": "node --test tests/",
"lint": "node scripts/lint.js"
},
"keywords": [],
"author": "",
"license": "MIT"
}
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
function multiply(a, b) {
return a * b;
}
function isEven(n) {
return n % 2 === 0;
}
module.exports = { add, subtract, multiply, isEven };
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { add, subtract, multiply, isEven } = require('../src/index.js');
describe('Math utilities', () => {
it('should add two numbers correctly', () => {
assert.strictEqual(add(2, 3), 5);
assert.strictEqual(add(-1, 1), 0);
assert.strictEqual(add(0, 0), 0);
});
it('should subtract two numbers correctly', () => {
assert.strictEqual(subtract(5, 3), 2);
assert.strictEqual(subtract(0, 5), -5);
});
it('should multiply two numbers correctly', () => {
assert.strictEqual(multiply(3, 4), 12);
assert.strictEqual(multiply(-2, 3), -6);
assert.strictEqual(multiply(0, 100), 0);
});
it('should check if a number is even', () => {
assert.strictEqual(isEven(4), true);
assert.strictEqual(isEven(7), false);
assert.strictEqual(isEven(0), true);
});
});
const fs = require('fs');
const path = require('path');
const srcDir = path.join(__dirname, '..', 'src');
const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.js'));
let errors = 0;
files.forEach(file => {
const content = fs.readFileSync(path.join(srcDir, file), 'utf8');
const lines = content.split('\n');
lines.forEach((line, i) => {
if (line.includes('\t')) {
console.error(`${file}:${i + 1} - No tabs allowed, use spaces`);
errors++;
}
if (line.length > 120) {
console.error(`${file}:${i + 1} - Line exceeds 120 characters`);
errors++;
}
});
});
if (errors > 0) {
console.error(`\nFound ${errors} linting error(s)`);
process.exit(1);
} else {
console.log('All files pass linting checks');
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline Demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
text-align: center;
}
h1 { color: #24292e; }
.status {
background: #28a745;
color: white;
padding: 10px 20px;
border-radius: 6px;
display: inline-block;
margin-top: 20px;
}
</style>
</head>
<body>
<h1>CI/CD Pipeline Demo</h1>
<p>This site was deployed automatically by GitHub Actions.</p>
<p>Every push to the main branch triggers the pipeline:</p>
<p>Lint -> Test -> Build -> Deploy</p>
<div class="status">Deployed Successfully</div>
</body>
</html>
Your environment is ready and your code is on GitHub. Next up, you will write a workflow file that tells GitHub Actions to run these tests automatically every time you push.
Write Your First CI Workflow
Your local environment is fully set up. Tests pass, lint passes, and your code is on GitHub.
Now it is time to automate everything. Instead of manually running npm test and npm run lint every time you make a change, you will create a workflow that runs them automatically on every push. This is the core of Continuous Integration.
In this step, get ready to:
- Create a GitHub Actions workflow file that automates linting and testing.
- Push the workflow to GitHub and watch it execute in real time.
- Read the pipeline logs to confirm every step passed.
Create the workflow file
GitHub Actions looks for workflow files inside a special folder called .github/workflows. Any .yml file you place there becomes an automated pipeline.
- Create the workflows directory by running this command:
Windows
mkdir .github\workflows
macOS
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github
You should see workflows listed in the output.
Not seeing the workflows folder?
Make sure you ran the command from inside your ci-cd-pipeline-demo folder. Run cd to check your current directory in the terminal.
help me figure out why my .github/workflows directory was not created
Now you need to create the actual workflow file. This is a YAML file that tells GitHub Actions exactly what to do when you push code.
- Create a new file called ci.yml inside the .github/workflows folder and paste the following code into it:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm ci
- run: npm run lint
- run: npm test
What does this workflow do?
- name: gives your workflow a display name in the GitHub Actions tab.
- on: defines when the workflow triggers. This one runs on every push to main and on pull requests targeting main.
- runs-on: ubuntu-latest tells GitHub to run your pipeline on a fresh Linux virtual machine.
- actions/checkout@v6 copies your repository code into the runner.
- actions/setup-node@v4 installs Node.js version 24 on the runner.
- npm ci installs your project dependencies from the lock file. This is faster and more reliable than npm install in CI environments.
- The final two steps run your lint check and test suite automatically.
- Save .github/workflows/ci.yml.
Seeing a YAML parsing error later?
YAML uses spaces, not tabs, to define structure. Each level of nesting is two spaces. If your indentation is off by even one space, the workflow will fail to parse.
Double-check that steps: is indented under ci:, and each - uses: or - run: is aligned at the same level.
help me fix YAML indentation in my workflow file
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete ci.yml file:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm ci
- run: npm run lint
- run: npm test
Push and observe the pipeline run
Your workflow file is ready locally. The moment you push it to GitHub, the pipeline will trigger automatically.
- Commit and push the workflow file by running these commands:
git add .github/workflows/ci.yml
git commit -m "Add CI workflow"
git push
What happens when you push?
The on: push trigger in your workflow file tells GitHub to start the pipeline immediately. GitHub spins up a fresh virtual machine, clones your code, and runs every step in order.
- Open your browser and go to your ci-cd-pipeline-demo repository on GitHub.
- Click the Actions tab at the top of your repository page.
You should see your workflow running or already completed. A yellow dot means it is in progress. A green checkmark means it passed.
- Click on the workflow run with the commit message "Add CI workflow".
- Click the ci job to expand its step-by-step log.
You can see timing, output, and status for every action in your pipeline.
Nothing showing in the Actions tab?
Make sure you pushed to the main branch. Run git branch to check which branch you are on. If you are on a different branch, your workflow's branches: [main] filter will not match.
Also check that your file is in exactly .github/workflows/ci.yml. A typo in the path means GitHub will not detect it as a workflow.
help me figure out why my GitHub Actions workflow is not running
Understand what happened
Take a moment to read the logs. This is the same skill you will use to diagnose failures in the next step.
- Look at the workflow run summary at the top. A green checkmark next to the job name means all steps passed.
- Click into the ci job if you have not already.
- Expand individual steps to see their full output.
What context does GitHub provide?
Each workflow run shows you the runner OS, the Node.js version installed, and the event that triggered it. This metadata helps you debug environment-specific issues later.
Notice that each step shows its duration. This becomes useful when you want to find bottlenecks and speed up your pipeline.
Your code is now automatically tested on every push. Next up, you will intentionally break the pipeline to learn how to read error logs and fix failures.
Break and Fix the Pipeline
Your pipeline is green and running smoothly. That is exactly the state you want in production, but it is not where you learn the most.
The real skill of CI/CD is not building pipelines. It is reading failure logs, diagnosing what went wrong, and fixing it fast. In this step, you will deliberately break your pipeline in two different ways and practice the troubleshooting cycle that professional DevOps engineers use every day.
In this step, get ready to:
- Introduce a failing test and read the error output in the Actions log.
- Introduce a YAML syntax error and observe how GitHub reports it differently.
- Fix both issues and confirm the pipeline recovers to green.
Introduce a test failure
A failing test is the most common reason a CI pipeline goes red. You are going to change one assertion so it expects the wrong answer, then push and watch how GitHub Actions reports the failure.
- In your editor, open tests/index.test.js.
- Find this line inside the first test:
assert.strictEqual(add(2, 3), 5);
- Change 5 to 6, so the line now looks like this:
assert.strictEqual(add(2, 3), 6);
- Save tests/index.test.js.
- Commit and push this deliberate failure by running these commands:
git add -A
git commit -m "Break: intentional test failure"
git push
- Switch to your browser and open the **Actions** tab on your ci-cd-pipeline-demo repository.
- Wait for the workflow run to complete. You will see a red X icon instead of a green checkmark.
- Click into the failed workflow run, then click the **ci** job to expand its logs.
- Click the **Run npm test** step to see the error output.
What should I see in the logs?
The test runner output shows which test failed, what value it expected, and what value it actually received. You will see the assertion error pointing to add(2, 3) returning 5 when the test expected 6.
The step exits with a non-zero exit code, which tells GitHub Actions the job failed. Every step after the failing one is skipped.
Why practice breaking things on purpose?
In a real project, tests fail unexpectedly from code changes, dependency updates, or merge conflicts. Seeing a controlled failure first means you will recognize the log format when a real one happens later.
The troubleshooting cycle is always the same: push, read logs, identify the root cause, fix, push again.
Introduce a YAML syntax error
Not all pipeline failures come from your application code. A misplaced space in your workflow file can prevent the entire pipeline from starting. This type of error looks completely different in the Actions tab.
- First, fix the test you just broke. In tests/index.test.js, change 6 back to 5:
assert.strictEqual(add(2, 3), 5);
- Save tests/index.test.js.
- Now open .github/workflows/ci.yml.
- Find this line:
- run: npm run lint
- Add extra spaces before the dash so it is misaligned with the other steps. Make it look like this:
- run: npm run lint
What does this break?
YAML uses indentation to define structure. Every sequence item (the lines starting with -) under steps: must be at the same indentation level. Adding extra spaces makes the YAML parser unable to interpret the file, so the workflow cannot even start.
- Save ci.yml.
- Commit and push by running these commands:
git add -A
git commit -m "Break: intentional YAML syntax error"
git push
- Switch back to the **Actions** tab in your browser.
- Look at the workflow run. This time the workflow does not start at all. GitHub shows the run as failed immediately with an annotation about invalid workflow file syntax.
How is this different from the test failure?
With the test failure, the workflow started, ran several steps, and failed at the test step. You could see exactly which step failed and read the test output.
With a YAML syntax error, GitHub cannot even parse the workflow file. The job never starts. You will see the error reported at the workflow level, not inside a specific step. This is an important distinction when troubleshooting in the real world.
Fix both issues and confirm pipeline recovery
The test is already fixed from the previous substep. Now you need to fix the YAML indentation to restore the pipeline to a fully green state.
- In .github/workflows/ci.yml, find the misaligned line:
- run: npm run lint
- Remove the extra spaces so it aligns with the other steps again:
- run: npm run lint
- Save ci.yml.
- Commit and push the fix by running these commands:
git add -A
git commit -m "Fix: restore correct YAML indentation"
git push
- Switch back to the **Actions** tab in your browser.
- Wait for the workflow run to complete. You should see a green checkmark again, confirming all steps (checkout, setup-node, install, lint, test) pass.
The push-read-fix-push cycle
You have now practiced the most common troubleshooting pattern in CI/CD: push code, read the failure logs, identify the root cause, fix it, and push again. This cycle is what DevOps engineers do daily.
Pipeline still showing red?
Make sure you saved both files before committing. Check that the - run: npm run lint line starts at exactly 6 spaces of indentation (aligned with the other - run: and - uses: lines).
Also confirm the test assertion in tests/index.test.js is back to assert.strictEqual(add(2, 3), 5).
help me figure out why my GitHub Actions pipeline is still failing after I fixed both errors
✔️ Awesome, I've got everything!
Great. Double check you have saved both tests/index.test.js and .github/workflows/ci.yml before moving on.
ⓧ I'd like to double check the full code
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm ci
- run: npm run lint
- run: npm test
const { describe, it } = require('node:test');
const assert = require('node:assert');
const { add, subtract, multiply, isEven } = require('../src/index.js');
describe('Math utilities', () => {
it('should add two numbers correctly', () => {
assert.strictEqual(add(2, 3), 5);
assert.strictEqual(add(-1, 1), 0);
assert.strictEqual(add(0, 0), 0);
});
it('should subtract two numbers correctly', () => {
assert.strictEqual(subtract(5, 3), 2);
assert.strictEqual(subtract(0, 5), -5);
});
it('should multiply two numbers correctly', () => {
assert.strictEqual(multiply(3, 4), 12);
assert.strictEqual(multiply(-2, 3), -6);
assert.strictEqual(multiply(0, 100), 0);
});
it('should check if a number is even', () => {
assert.strictEqual(isEven(4), true);
assert.strictEqual(isEven(7), false);
assert.strictEqual(isEven(0), true);
});
});
You now know how to read failure logs and recover from two of the most common pipeline problems. Next up, you will split this single-job workflow into a professional multi-job pipeline with separate stages for linting, testing, building, and deploying.
Build a Multi-Job Workflow
You've broken your pipeline, read the error logs, and fixed things back to green. That troubleshooting loop is the daily reality of CI/CD work.
Right now, your workflow runs everything in a single job. Real teams split their pipeline into separate jobs so that linting, testing, and deploying run independently. This gives you clearer logs, parallel execution, and granular control over what triggers each stage.
In this step, get ready to:
- Split the workflow into separate lint, test, and build jobs.
- Add a deploy job that publishes your site to GitHub Pages.
- Enable Pages and watch all four jobs run with dependency arrows.
Split into separate jobs
A single-job workflow forces every task to run in sequence. If the lint step takes a while, tests have to wait. By splitting into separate jobs, lint and test can run in parallel, and you get a clear signal about exactly which stage failed.
- In VS Code, open .github/workflows/ci.yml and replace the entire file content with the following:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: './public'
What does this code do?
- Three separate jobs (lint, test, build) each run on their own fresh ubuntu-latest runner. Lint and test start at the same time because neither depends on the other.
- needs: [lint, test] on the build job means it waits for both lint and test to pass before running. If either fails, build never starts.
- cache: 'npm' tells actions/setup-node to cache your npm dependencies between runs, making subsequent pipeline runs faster.
- The build job uses actions/configure-pages@v5 and actions/upload-pages-artifact@v4 to package your public/ folder as a deployment artifact for GitHub Pages.
- Save ci.yml.
You won't push yet. The build job needs GitHub Pages enabled in your repository settings, which you'll configure after adding the deploy job.
Add the deployment job
With lint, test, and build separated, the last piece is a deploy job that takes the uploaded artifact and publishes it to a live URL. The deploy job also needs special permissions to write to GitHub Pages.
- In ci.yml, find the on: trigger block. Add the following permissions and concurrency blocks directly below it (before jobs:):
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
What do these blocks do?
The permissions block grants the workflow the minimum access it needs. pages: write allows deploying to Pages. id-token: write enables secure authentication with GitHub's deployment system.
The concurrency block prevents multiple deployments from running at the same time. If two pushes happen quickly, the second waits for the first to finish.
- Scroll to the bottom of the file, after the build job. Add the following deploy job:
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
What does the deploy job do?
- needs: build means deploy only runs after a successful build. This creates the chain: lint + test (parallel) then build then deploy.
- The if condition ensures deployment only triggers on direct pushes to main. Pull requests trigger lint and test but skip deployment.
- The environment block links this job to the github-pages environment, which tracks deployment history and provides the live URL.
- actions/deploy-pages@v4 takes the artifact uploaded by the build job and publishes it to your GitHub Pages site.
- Save ci.yml.
You won't push yet. First, you need to enable GitHub Pages in your repository settings so the deploy job has somewhere to publish.
✔️ 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.yml file with all four jobs:
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm test
build:
runs-on: ubuntu-latest
needs: [lint, test]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: './public'
deploy:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Enable GitHub Pages in repository settings
The deploy job needs GitHub Pages turned on in your repository. Without this, the actions/configure-pages step will fail with a 404 error.
- Open your ci-cd-pipeline-demo repository on GitHub.com.
- Click Settings in the top navigation bar.
- In the left sidebar, click Pages.
- Under Build and deployment, find the Source dropdown.
- Select GitHub Actions from the dropdown.
Why GitHub Actions as the source?
GitHub Pages can deploy from a branch or from a GitHub Actions workflow. By choosing GitHub Actions, you control exactly what gets deployed through your ci.yml pipeline instead of GitHub auto-deploying from a branch.
- Return to your terminal in VS Code. Commit and push the updated workflow by running these commands:
git add .github/workflows/ci.yml
git commit -m "Refactor into multi-job workflow with deployment"
git push
- On GitHub.com, click the Actions tab in your repository.
- Click on the latest workflow run to open it.
You should see four jobs displayed with dependency arrows: lint and test run in parallel, then build, then deploy. All four should complete with green checkmarks.
- Once the deploy job finishes, visit your live site at https://YOUR-USERNAME.github.io/ci-cd-pipeline-demo/ (replace with your actual GitHub username).
You should see your CI/CD Pipeline Demo page with the green "Deployed Successfully" badge.
Pipeline failing on the deploy job?
- Make sure you selected GitHub Actions (not a branch) as the Source in your Pages settings. Without this, the configure-pages step fails with a 404 error.
- Check that the permissions block is at the top level of the YAML file (aligned with on: and jobs:), not indented under a job.
- Verify the deploy job's indentation matches the other jobs. YAML is whitespace-sensitive, and a single misaligned space breaks the structure.
Still stuck? Help me fix my GitHub Pages deployment.
Your pipeline now mirrors a real production workflow. Every push gets linted and tested in parallel, built into an artifact, and deployed to a live URL. Next up, you'll take this even further with matrix testing and performance optimizations in the Secret Mission.
Secret mission
Matrix Testing and Caching
Your pipeline works, but professional teams need to guarantee compatibility across multiple runtime versions and keep build times fast. In this secret mission, you'll add a matrix strategy to test across Node.js 22.x and 24.x simultaneously, observe how npm caching speeds up repeat runs, and add a live status badge to your README.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on GitHub's free tier and your local machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- GitHub repository ci-cd-pipeline-demo (includes your GitHub Pages site).
- Local project folder ci-cd-pipeline-demo on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your pipeline running for future experiments.
- Your repository and GitHub Pages site are completely free for public repositories. They will stay live indefinitely.
- Your local project folder stays on your Desktop for future edits.
Pause - I'll come back to this later
There are no running services to pause. Both your GitHub repository and GitHub Pages site are free and static, so they do not consume resources while idle.
- If you want to stop deployments without deleting the repository, go to your repository on GitHub, click Settings, then click Pages in the sidebar. Under Build and deployment, change the source branch to None and click Save.
- This unpublishes your Pages site but keeps all your code and workflow files intact for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to clean up everything.
- Delete the GitHub repository (this also removes your GitHub Pages site):
- Open your ci-cd-pipeline-demo repository on GitHub.
- Click Settings under the repository name.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository, then click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
Delete the local project folder by running this command in your terminal:
cd ~/Desktop
rmdir /s /q ci-cd-pipeline-demo
macOS or Linux users
Use this command instead:
cd ~/Desktop
rm -rf ci-cd-pipeline-demo
Nice Work!
Nice Work!
Nice work! You just built a production-style CI/CD pipeline from scratch using GitHub Actions and deployed a live website through it.
You've learned how to:
- Write YAML workflow files that automatically lint, test, build, and deploy your code on every push to GitHub.
- Diagnose and fix pipeline failures by reading GitHub Actions logs to identify test failures and YAML syntax errors.
- Create a multi-job workflow with proper job dependencies between lint, test, build, and deploy stages that deploys a live website to GitHub Pages.
- Secret Mission: Added matrix testing across multiple Node.js versions, npm caching for faster builds, and a live pipeline status badge on your README.
Ready to quiz yourself?