Build a CI/CD Pipeline with GitHub Actions
Automate testing and deployment of a static site using GitHub Actions.
Introduction
30 Second Summary
Every time a developer changes code, they need to make sure the change didn't break anything. Running tests and deploying updates by hand is slow, repetitive, and easy to forget.
In this project, you will build a CI/CD pipeline that automatically tests your code every time you push changes and deploys your site live to the internet when tests pass.
What You'll Build
You'll create a GitHub Actions workflow that runs automated tests on every push and deploys a static site to GitHub Pages when code merges to the main branch.
By the end of this project, you'll have:
- A Node.js static site generator that builds an HTML page from data, with automated tests that validate the output.
- A GitHub Actions workflow with test and deploy jobs that triggers on every push and pull request.
- A live website deployed to GitHub Pages that updates automatically when you merge to main.
- Secret Mission: Add a scheduled daily test run and a status badge to your README.
Are there any prerequisites?
You should have basic familiarity with Git (clone, commit, push) and basic JavaScript. No prior CI/CD experience is needed.
Set Up Your Project
A CI/CD pipeline needs something to build, test, and deploy. Before you can automate anything, you need a project with code, a test suite, and a GitHub repository for the pipeline to watch.
In this step, you'll create a public GitHub repository, clone it to your machine, and initialize a Node.js project with the folder structure your build scripts and tests will live in.
In this step, get ready to:
- Create a public GitHub repository and clone it locally.
- Initialize the Node.js project with a package.json file.
- Set up the folder structure for your source code and tests.
Create a new public repository on GitHub
Your GitHub repository is where your code lives and where GitHub Actions will detect changes to trigger your pipeline.
- Sign in to GitHub.
- Click the + icon in the upper-right corner of the page.
- Select New repository from the dropdown menu.
- In the Repository name field, type ci-cd-pipeline-project.
- Select Public for the repository visibility.
- Leave all initialization options unchecked (no README, no .gitignore, no license).
- Click Create repository.
Why leave initialization options unchecked?
Skipping the README and license keeps the repository completely empty. This avoids merge conflicts when you push your local project files for the first time.
GitHub will show you a setup page with your repository URL. Keep this page open for the next step.
Clone the repository and open it in VS Code
Cloning copies the empty repository to your computer so you can start adding project files locally.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your repository by running this command:
git clone [[REPO_URL="https://github.com/your-username/ci-cd-pipeline-project.git"]]
Where do I find my repository URL?
On the repository setup page GitHub showed you after creation, look for the HTTPS URL. It follows the pattern https://github.com/your-username/ci-cd-pipeline-project.git. Copy it from that page.
- Move into the project folder by running:
cd ci-cd-pipeline-project
- Confirm you're inside the project folder by running:
ls -a
You should see a .git folder in the output. This confirms the clone worked and Git is tracking this folder.
- Open the project in VS Code. In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the ci-cd-pipeline-project folder on your Desktop, then click Open.
Don't see the .git folder?
If ls -a shows nothing or an error, double-check that your git clone command used the correct repository URL. Make sure you ran cd ci-cd-pipeline-project to move into the cloned folder.
help me troubleshoot why git clone didn't work or why I can't see the .git folder
Initialize the Node.js project and create the folder structure
Every Node.js project starts with a package.json file that stores the project's name, version, and scripts. You'll create one now using npm init.
- Open VS Code's integrated terminal by pressing Ctrl+` (macOS and Windows).
- Initialize your Node.js project by running this command:
npm init
The command will ask a series of questions about your project. Enter the following values when prompted:
- For package name, press Enter to accept the default (ci-cd-pipeline-project).
- For version, press Enter to accept 1.0.0.
- For description, type A static site generator with CI/CD pipeline and press Enter.
- For entry point, press Enter to skip.
- For test command, press Enter to skip.
- For git repository, press Enter to skip.
- For keywords, press Enter to skip.
- For author, press Enter to skip.
- For license, type MIT and press Enter.
- When it shows you the preview and asks Is this OK?, press Enter to confirm.
What does npm init do?
This command creates a package.json file in your project folder. Think of it as an identity card for your project. It stores metadata like the project name and version, and later you'll add scripts here that your CI/CD pipeline will run.
Now open package.json in VS Code's Explorer sidebar. Your file may have a few extra fields that npm init added automatically. Replace the entire contents with this clean version:
{
"name": "ci-cd-pipeline-project",
"version": "1.0.0",
"description": "A static site generator with CI/CD pipeline",
"keywords": [],
"author": "",
"license": "MIT"
}
Why clean up the package.json?
Depending on your version of npm, the generated file might include extra fields like main or a default test script. Removing them now keeps the file minimal. You'll add the scripts you actually need in the next step.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Next, create the two directories your source code and tests will live in.
- Create the src/ and tests/ directories by running these commands in your terminal:
mkdir src
mkdir tests
- Confirm the directories exist by running:
ls
You should see package.json, src, and tests in the output.
What are these folders for?
The src/ folder will hold your build script that generates an HTML page. The tests/ folder will hold automated tests that your CI pipeline runs on every push.
Seeing 'command not found' for mkdir?
Make sure you're running the commands in VS Code's integrated terminal (open with Ctrl+`). Confirm the terminal is in the ci-cd-pipeline-project folder by checking the path shown in the prompt.
help me fix my terminal so I can create directories in my project
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json and that both src/ and tests/ appear in your Explorer sidebar.
ⓧ I'd like to double check the full code
Here is exactly what package.json should contain at this point:
{
"name": "ci-cd-pipeline-project",
"version": "1.0.0",
"description": "A static site generator with CI/CD pipeline",
"keywords": [],
"author": "",
"license": "MIT"
}
Your project folder should also contain a src/ directory and a tests/ directory (both empty for now).
Your project skeleton is ready. Next up, you'll build the static site generator and write tests that your pipeline will run automatically.
Build a Static Site Generator with Tests
Your project folder is structured and ready for code. A CI/CD pipeline only becomes useful when there's real logic to build and real tests to run.
In this step, you'll create a static site generator that turns data into a complete HTML page. You'll also write automated tests that verify the output is correct. These two pieces give your future pipeline something meaningful to work with.
In this step, get ready to:
- Write a build script that generates an HTML page from a data object.
- Create automated tests using the Node.js built-in test runner.
- Add npm scripts and verify everything runs locally.
Create the static site generator
Your build script will take a JavaScript data object and produce a full HTML page from it. This is the core logic that your CI/CD pipeline will eventually run on every push.
- In VS Code, open the src folder in the Explorer sidebar.
- Create a new file inside src called build.js.
- Add the imports and site data by pasting this code into src/build.js:
const fs = require("fs");
const path = require("path");
const siteData = {
title: "My CI/CD Project",
heading: "Deployed with GitHub Actions",
items: [
"Automated testing on every push",
"Continuous deployment to GitHub Pages",
"Pull request checks for code quality"
]
};
What does this code do?
The fs and path imports are built-in Node.js modules for file system operations and path handling.
The siteData object holds everything that will appear on your generated HTML page. The build script will turn this data into a real webpage.
You won't see a change yet. The next chunk adds the function that uses this data.
- Add the generateHTML function directly below the siteData object in src/build.js:
function generateHTML(data) {
const listItems = data.items.map((item) => ` <li>${item}</li>`).join("\n");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${data.title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 { color: #24292e; }
ul { line-height: 1.8; }
.footer { margin-top: 40px; color: #666; font-size: 0.9em; }
</style>
</head>
<body>
<h1>${data.heading}</h1>
<ul>
${listItems}
</ul>
<p class="footer">Last built: ${new Date().toISOString()}</p>
</body>
</html>`;
}
What does this function do?
The generateHTML function takes a data object and returns a complete HTML string. It maps the items array into <li> tags and injects the title, heading, and list into an HTML template.
The template includes basic CSS styling and a footer showing when the page was last built. This timestamp changes on every build, which makes it easy to confirm a fresh deployment later.
You won't see a change yet. The next chunk adds the function that writes the HTML to a file.
- Add the build function, its invocation, and the module export below the generateHTML function in src/build.js:
function build() {
const outputDir = path.join(__dirname, "..", "dist");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const html = generateHTML(siteData);
fs.writeFileSync(path.join(outputDir, "index.html"), html);
console.log("Build complete: dist/index.html");
}
build();
module.exports = { generateHTML };
What does this code do?
The build function creates a dist folder (if it doesn't exist) and writes the generated HTML into dist/index.html.
Calling build() immediately runs the function whenever the script executes. The module.exports line makes generateHTML available to other files. Your tests will import it this way.
- Save src/build.js.
- Verify the build script works by running this command in the terminal:
node src/build.js
You should see Build complete: dist/index.html printed in the terminal.
Seeing an error instead?
- If you see Cannot find module, make sure your terminal is in the project root folder (ci-cd-pipeline-project), not inside src/.
- If you see a syntax error, double check that all backticks and curly braces in build.js are correctly placed. Template literals use backticks (`), not single quotes.
Still stuck? Help me debug my build.js script
✔️ Awesome, I've got everything!
Great. Double check you've saved src/build.js before moving on.
ⓧ I'd like to double check the full code
const fs = require("fs");
const path = require("path");
const siteData = {
title: "My CI/CD Project",
heading: "Deployed with GitHub Actions",
items: [
"Automated testing on every push",
"Continuous deployment to GitHub Pages",
"Pull request checks for code quality"
]
};
function generateHTML(data) {
const listItems = data.items.map((item) => ` <li>${item}</li>`).join("\n");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${data.title}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 600px;
margin: 50px auto;
padding: 20px;
background-color: #f5f5f5;
}
h1 { color: #24292e; }
ul { line-height: 1.8; }
.footer { margin-top: 40px; color: #666; font-size: 0.9em; }
</style>
</head>
<body>
<h1>${data.heading}</h1>
<ul>
${listItems}
</ul>
<p class="footer">Last built: ${new Date().toISOString()}</p>
</body>
</html>`;
}
function build() {
const outputDir = path.join(__dirname, "..", "dist");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const html = generateHTML(siteData);
fs.writeFileSync(path.join(outputDir, "index.html"), html);
console.log("Build complete: dist/index.html");
}
build();
module.exports = { generateHTML };
Write automated tests for the build script
Automated tests verify that your code works correctly every time it runs. Your CI/CD pipeline will run these tests on every push, catching bugs before they reach production.
What is a test runner?
A test runner is a tool that executes your test code and reports which tests pass or fail. Node.js 22 includes a built-in test runner, so you don't need to install any extra packages like Jest or Mocha.
- In VS Code, open the tests folder in the Explorer sidebar.
- Create a new file inside tests called build.test.js.
- Add the full test suite by pasting this code into tests/build.test.js:
const { describe, it } = require("node:test");
const assert = require("node:assert");
const { generateHTML } = require("../src/build.js");
describe("generateHTML", () => {
const testData = {
title: "Test Site",
heading: "Hello World",
items: ["Item one", "Item two", "Item three"]
};
it("should include the title in the head", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<title>Test Site</title>"));
});
it("should include the heading in an h1 tag", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<h1>Hello World</h1>"));
});
it("should include all items as list items", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<li>Item one</li>"));
assert.ok(html.includes("<li>Item two</li>"));
assert.ok(html.includes("<li>Item three</li>"));
});
it("should produce valid HTML structure", () => {
const html = generateHTML(testData);
assert.ok(html.startsWith("<!DOCTYPE html>"));
assert.ok(html.includes("<html lang=\"en\">"));
assert.ok(html.includes("</html>"));
});
});
What do these tests check?
- The describe block groups all tests for the generateHTML function together.
- Each it() block tests one specific behavior: that the title appears in the <head>, the heading appears in an <h1>, all items render as <li> elements, and the HTML structure is valid.
- The tests use a separate testData object so they don't depend on the real site data. This keeps them predictable.
- Save tests/build.test.js.
You'll verify the tests work in the next substep after wiring up the npm scripts.
✔️ Awesome, I've got everything!
Great. Double check you've saved tests/build.test.js before moving on.
ⓧ I'd like to double check the full code
const { describe, it } = require("node:test");
const assert = require("node:assert");
const { generateHTML } = require("../src/build.js");
describe("generateHTML", () => {
const testData = {
title: "Test Site",
heading: "Hello World",
items: ["Item one", "Item two", "Item three"]
};
it("should include the title in the head", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<title>Test Site</title>"));
});
it("should include the heading in an h1 tag", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<h1>Hello World</h1>"));
});
it("should include all items as list items", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<li>Item one</li>"));
assert.ok(html.includes("<li>Item two</li>"));
assert.ok(html.includes("<li>Item three</li>"));
});
it("should produce valid HTML structure", () => {
const html = generateHTML(testData);
assert.ok(html.startsWith("<!DOCTYPE html>"));
assert.ok(html.includes("<html lang=\"en\">"));
assert.ok(html.includes("</html>"));
});
});
Add build and test scripts to package.json
npm scripts give you short, memorable commands to run your build and tests. Your CI/CD pipeline will use these same scripts later.
- In VS Code, open package.json from the Explorer sidebar.
- Find this line:
"description": "A static site generator with CI/CD pipeline",
- Add a scripts block directly below the description line, so the file now looks like this:
{
"name": "ci-cd-pipeline-project",
"version": "1.0.0",
"description": "A static site generator with CI/CD pipeline",
"scripts": {
"build": "node src/build.js",
"test": "node --test tests/build.test.js"
},
"keywords": [],
"author": "",
"license": "MIT"
}
What do these scripts do?
- The build script runs your static site generator and produces dist/index.html.
- The test script runs your test file using Node.js built-in test runner with the --test flag.
- These names are important. npm test is a standard convention that CI/CD tools recognize automatically.
- Save package.json.
- Verify the build script works through npm by running:
npm run build
You should see Build complete: dist/index.html in the terminal output.
- Verify the test suite passes by running:
npm test
You should see output showing all 4 tests passing with a summary line like # tests 4 and # pass 4.
Tests failing or command not found?
- If you see a JSON parse error, check that package.json has a comma after every line except the last item in each block. JSON is strict about trailing commas.
- If tests fail with Cannot find module, make sure tests/build.test.js uses ../src/build.js in the require path (two dots, going up one folder).
- If you see ERR_MODULE_NOT_FOUND, confirm your Node.js version is 22 or higher by running node --version.
Still stuck? Help me debug why my npm test or npm run build is failing
✔️ Awesome, I've got everything!
Great. Double check you've saved package.json before moving on.
ⓧ I'd like to double check the full code
{
"name": "ci-cd-pipeline-project",
"version": "1.0.0",
"description": "A static site generator with CI/CD pipeline",
"scripts": {
"build": "node src/build.js",
"test": "node --test tests/build.test.js"
},
"keywords": [],
"author": "",
"license": "MIT"
}
Your static site generator is working and fully tested. Next up, you'll create the GitHub Actions workflow that runs these same tests automatically every time you push code.
Create the CI/CD Workflow
Your static site generator builds HTML and your tests verify it works. Right now, both only run when you manually type the commands.
In this step, you will create a GitHub Actions workflow that automates the entire process. Every time you push code, GitHub will run your tests and deploy your site without you lifting a finger.
In this step, get ready to:
- Create the workflow file with trigger and permissions configuration.
- Add a test job that runs your test suite automatically.
- Add a deploy job that builds and publishes your site to GitHub Pages.
Create the workflow file with trigger configuration
GitHub Actions looks for workflow files in a specific folder: .github/workflows/. Any YAML file you place there becomes an automated workflow.
- In the VS Code Explorer sidebar, hover over your project name and click the New File icon (the page icon with a plus sign).
- Type .github/workflows/ci-deploy.yml and press Enter.
Why type the full path?
VS Code automatically creates any intermediate folders that don't exist yet. Typing the full path .github/workflows/ci-deploy.yml creates both the .github folder and the workflows folder inside it.
The file opens empty in the editor. The first section of a workflow defines when it should run and what permissions it needs.
- Add the following trigger and permissions configuration to ci-deploy.yml:
name: CI and Deploy
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
What does this configuration do?
- name gives the workflow a human-readable label that appears in the GitHub Actions tab.
- on defines the triggers. This workflow runs on every push to main and on every pull request targeting main.
- permissions grants the workflow access to read your code and write to GitHub Pages.
- concurrency prevents multiple deployments from running at the same time.
- Save ci-deploy.yml.
- In the Explorer sidebar, confirm you see the .github folder with a workflows subfolder containing ci-deploy.yml.
Don't see the .github folder?
Make sure you typed the path with a leading dot: .github not github. The dot makes it a hidden folder, but VS Code still shows it in the Explorer.
Also check that the file extension is .yml and not .yaml. Both extensions work for GitHub Actions, but your file should match the exact name used in this project.
help me find my workflow file in VS Code
Add the test job
A workflow contains one or more jobs. Each job runs on its own virtual machine (called a runner) and executes a series of steps.
- In ci-deploy.yml, add the following code below the concurrency block:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
What does this job do?
- runs-on: ubuntu-latest tells GitHub to run this job on a Linux virtual machine hosted by GitHub.
- actions/checkout@v6 copies your repository code onto the runner.
- actions/setup-node@v4 installs Node.js 22 on the runner.
- npm ci installs dependencies from your lockfile. It is faster and more deterministic than npm install in CI environments.
- npm test runs the same test command you ran locally in Step 2.
- Save ci-deploy.yml.
You won't see a visible change yet. The next chunk adds the deploy job that completes the workflow.
Add the deploy job
The deploy job builds your site and publishes it to GitHub Pages. It should only run after tests pass and only on pushes to main (not on pull requests).
- In ci-deploy.yml, add the following code directly below the last line of the test job (below run: npm test):
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: "./dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
What does this job do?
- needs: test makes this job wait for the test job to pass before it starts.
- The if condition ensures deployment only happens on pushes to main. Pull requests trigger tests but not deployments.
- npm run build generates your HTML into the dist/ folder.
- The three Pages actions (configure-pages, upload-pages-artifact, deploy-pages) are GitHub's official actions for publishing to GitHub Pages.
Why two separate jobs instead of one?
Splitting test and deploy into separate jobs gives you clear feedback. If tests fail, the deploy job never starts. You can see exactly where the pipeline broke without digging through logs.
- Save ci-deploy.yml.
- Check that your file has no red squiggly lines in VS Code. YAML is sensitive to indentation, so every level must use exactly 2 spaces.
Seeing red underlines or indentation errors?
YAML uses spaces for structure (not tabs). Make sure every nested level is indented by exactly 2 spaces from its parent.
Check that deploy is at the same indentation level as test (both indented 2 spaces under jobs). Use the double-check tab below to compare your file against the full version.
help me fix YAML indentation in my workflow file
✔️ Awesome, I've got everything!
Great. Double check you have saved ci-deploy.yml and that there are no red squiggly lines in the editor.
ⓧ I'd like to double check the full code
Here is the complete ci-deploy.yml file. Compare it against yours line by line:
name: CI and Deploy
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: "./dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Your CI/CD workflow is ready to go. Next up, you will push this to GitHub and watch the pipeline execute in real time.
Push and Watch Your Pipeline Run
Your code is ready. Your workflow is written. Now it's time to see everything come together.
When you push your code to GitHub, the workflow file in .github/workflows/ tells GitHub Actions to spring into action. It will spin up a cloud server, install your project, and run your tests automatically.
In this step, get ready to:
- Push all project files to GitHub and trigger the CI/CD workflow.
- Watch the test job pass in the Actions tab.
- Enable GitHub Pages and verify your site is live on the internet.
Commit all files and push to GitHub
Before pushing, you need a package-lock.json file. Your workflow uses npm ci to install dependencies, and that command requires a lockfile to exist.
- Generate the lockfile by running this command in your terminal:
npm install
Why npm install here?
Even though this project has zero npm dependencies, running npm install creates a package-lock.json file. The workflow's npm ci command needs this lockfile to verify the project state is consistent.
- Stage all your project files by running:
git add .
- Commit your files with a descriptive message by running:
git commit -m "Add static site generator with CI/CD workflow"
- Push your commit to the main branch on GitHub by running:
git push origin main
You should see output confirming the push to your remote repository.
Push rejected or authentication error?
If you see a permission or authentication error, make sure you're logged into Git with credentials that have push access to the repository. You may need to set up a personal access token or SSH key in your GitHub account settings.
If the push is rejected because the remote has changes you don't have locally, run git pull origin main --rebase first, then try the push again.
Help me fix a git push authentication or rejection error.
Watch the workflow execute in the Actions tab
The moment you pushed, GitHub detected the workflow file and started running it. You can watch it happen live.
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
- Click on the workflow run labeled with your commit message (Add static site generator with CI/CD workflow).
- Click on the test job to see its steps executing in real time.
You should see each step light up green as it completes: checking out code, setting up Node.js, installing dependencies, and running your tests.
What is happening on the runner?
GitHub Actions spun up a fresh Ubuntu virtual machine for your workflow. This runner checked out your code, installed Node.js 22, ran npm ci, and executed npm test.
The deploy job will likely show as failed on this first run. That's expected. GitHub Pages needs to be enabled in your repository settings before the deploy action can publish your site.
Don't see any workflow runs?
Make sure your workflow file is located at exactly .github/workflows/ci-deploy.yml in your repository. The folder names must be exact, including the dot prefix on .github.
Also confirm the file was included in your commit. Run git log --stat locally to verify ci-deploy.yml appears in the committed files.
Why isn't my GitHub Actions workflow running after I pushed?
Enable GitHub Pages and verify your live site
The test job passed, which means your CI pipeline is working. Now you need to configure GitHub Pages so the deploy job has a destination to publish to.
- In your repository on GitHub, 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 set the source to GitHub Actions?
This tells GitHub that your workflow controls deployments. Without this setting, the deploy-pages action in your workflow has no target to publish to and will fail.
Now you need to re-run the failed deploy job so it can complete with Pages enabled.
- Click Actions in the top navigation bar to return to your workflow runs.
- Click on your workflow run (it should show with a red X indicating the deploy failure).
- In the upper-right corner, select the Re-run jobs dropdown menu and click Re-run failed jobs.
- Click Re-run jobs to confirm.
Wait for the deploy job to complete. You should see both jobs turn green with checkmarks.
- Once the deploy job passes, go back to Settings then Pages. You should see a message with your live site URL.
- Click the URL or visit it in a new tab. Your site is at https://your-github-username.github.io/ci-cd-pipeline-project.
You should see a styled page with the heading "Deployed with GitHub Actions" and a list of three bullet points describing your pipeline features.
Seeing a 404 after deploying?
GitHub Pages can take a minute or two to become available after the first deploy. Wait 30 seconds and refresh the page.
If it still shows 404, verify the deploy job completed successfully in the Actions tab. Make sure the Pages source is set to GitHub Actions (not a branch) in your repository settings.
My GitHub Pages site is showing a 404 error after deployment. How do I fix this?
Your pipeline is fully operational. Code pushed, tests passed, site deployed. Next up, you'll learn what happens when things go wrong and how pull requests protect your code.
Break It, Fix It with a Pull Request
Your pipeline is running smoothly. Every push to main triggers tests and deploys your site. But a pipeline that only ever passes does not prove much.
In this step, you will intentionally break a test to see your pipeline catch the problem. You will use the pull request workflow to propose, verify, and merge a fix. This is the real-world cycle that every development team relies on.
In this step, get ready to:
- Create a branch and intentionally break a test to trigger a failing CI check.
- Open a pull request and observe the failing status check.
- Fix the test, merge to main, and confirm a new deployment.
Create a branch and break a test
Feature branches let you propose changes without affecting main directly. You will create a branch, change an assertion in your test file so it expects the wrong value, and push it to GitHub.
- Create a new branch called break-test by running this command:
git checkout -b break-test
- In tests/build.test.js, find this line inside the second test:
assert.ok(html.includes("<h1>Hello World</h1>"));
- Replace Hello World with Wrong Heading so the line now looks like this:
assert.ok(html.includes("<h1>Wrong Heading</h1>"));
- Save tests/build.test.js.
- Confirm the test fails locally by running:
npm test
You should see a failing test result in your terminal. The assertion expected <h1>Wrong Heading</h1> but the HTML actually contains <h1>Hello World</h1>.
Why break a test on purpose?
Seeing a real failure proves that your pipeline catches bugs before they reach production. A pipeline that never fails might not be running meaningful tests at all.
- Commit and push the broken test to GitHub by running these commands:
git add .
git commit -m "Break a test to see CI fail"
git push origin break-test
What do these commands do?
- git add . stages all changed files for commit.
- git commit -m saves the staged changes with a descriptive message.
- git push origin break-test uploads your new branch to GitHub so the workflow can pick it up.
Push rejected or authentication error?
Make sure you are signed into Git with credentials that have push access to your repository. If you see a permission error, re-authenticate with git using your GitHub credentials or a personal access token.
Help me fix my Git authentication
Open a pull request and see the failing check
Now you will open a pull request on GitHub. Because your workflow triggers on pull_request events, pushing the branch already started the CI run. The PR page shows whether the checks passed or failed.
- Open your repository on GitHub in your browser.
- You should see a banner saying your break-test branch had recent pushes. Click Compare & pull request.
- Leave the base branch as main and the compare branch as break-test.
- Enter a title like Test breaking CI for the pull request.
- Click Create pull request.
After a moment, scroll down on the PR page. You should see the status checks section showing a failing test job with a red X icon.
What is a status check?
Status checks are external processes (like your CI workflow) that run for each push to a pull request. GitHub displays passing, pending, or failing states directly on the PR so reviewers know whether the code is safe to merge.
- Click Details next to the failing check to open the workflow run log.
- Expand the Run tests step in the log. You should see the assertion error showing the expected value did not match.
Don't see the status check on the PR?
Checks can take 30-60 seconds to start. Refresh the pull request page if you do not see any status section yet.
If the check never appears, confirm your workflow file triggers on pull_request events targeting the main branch. Check the Actions tab for any queued or failed workflow runs.
Help me debug my missing status checks
Fix the test, merge, and confirm deployment
You have proven that the pipeline catches failures. Now fix the test, push the correction, and watch the checks turn green. Then merge the PR to trigger a fresh deployment.
- Back in VS Code, open tests/build.test.js and find the broken line:
assert.ok(html.includes("<h1>Wrong Heading</h1>"));
- Change Wrong Heading back to Hello World so it reads:
assert.ok(html.includes("<h1>Hello World</h1>"));
- Save tests/build.test.js.
- Confirm the test passes locally by running:
npm test
You should see all tests passing in your terminal output.
- Commit and push the fix by running these commands:
git add .
git commit -m "Fix the broken test"
git push origin break-test
What happens when you push to the same branch?
Pushing new commits to a branch that already has an open pull request automatically triggers the workflow again. GitHub re-runs the checks against your latest commit so the PR status updates without you doing anything extra.
- Go back to your pull request page on GitHub.
- Wait for the new workflow run to complete. You should see the status checks turn green with a checkmark.
- Click Merge pull request.
- Click Confirm merge.
Merging to main triggers your full CI and Deploy workflow. The test job runs first, and once it passes, the deploy job builds and publishes your site to GitHub Pages.
- Click the Actions tab in your repository.
- Watch the new workflow run triggered by the merge. Both the test and deploy jobs should complete successfully.
- Visit your live site to confirm it is still accessible at the following URL:
https://[[YOUR_GITHUB_USERNAME="your GitHub username"]].github.io/ci-cd-pipeline-project
Checks still failing after the fix?
Make sure you saved tests/build.test.js before committing. Run npm test locally to confirm the test passes on your machine before pushing.
If the deploy job fails after tests pass, check that GitHub Pages is still set to source GitHub Actions in your repository Settings under Pages.
Help me fix my still-failing PR checks
✔️ Awesome, I've got everything!
Great. Double check that your pull request is merged and the latest workflow run on main shows both jobs green.
ⓧ I'd like to double check the full code
Here is the final state of tests/build.test.js after the fix is merged back to main:
const { describe, it } = require("node:test");
const assert = require("node:assert");
const { generateHTML } = require("../src/build.js");
describe("generateHTML", () => {
const testData = {
title: "Test Site",
heading: "Hello World",
items: ["Item one", "Item two", "Item three"]
};
it("should include the title in the head", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<title>Test Site</title>"));
});
it("should include the heading in an h1 tag", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<h1>Hello World</h1>"));
});
it("should include all items as list items", () => {
const html = generateHTML(testData);
assert.ok(html.includes("<li>Item one</li>"));
assert.ok(html.includes("<li>Item two</li>"));
assert.ok(html.includes("<li>Item three</li>"));
});
it("should produce valid HTML structure", () => {
const html = generateHTML(testData);
assert.ok(html.startsWith("<!DOCTYPE html>"));
assert.ok(html.includes("<html lang=\"en\">"));
assert.ok(html.includes("</html>"));
});
});
Secret mission
Add a Scheduled Workflow and Status Badge
Your pipeline runs on pushes and pull requests. But what about catching issues when nobody is pushing code? Add a daily scheduled test run and a status badge so anyone visiting your repo can see your pipeline's health at a glance.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on GitHub's free tier for public repositories, 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 (includes your Actions workflows and GitHub Pages site).
- Local project folder on your Desktop.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep your live GitHub Pages site and CI/CD pipeline running.
- Your GitHub Pages site stays live at its URL.
- GitHub Actions continues to run on every push and pull request.
- Public repositories have unlimited free Actions minutes, so nothing here costs money.
Pause - I'll come back to this later
Disable the live site and workflows while keeping your code intact. You can re-enable everything later.
- On GitHub, navigate to your repository.
- Click Settings.
- In the left sidebar, click Pages.
- Under Build and deployment, change the Source to None to unpublish your Pages site.
- Your code, workflows, and commit history remain in the repository for when you return.
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
- Delete the GitHub repository:
- On GitHub, navigate to the main page of your repository.
- Click Settings.
- 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.
This removes your GitHub Pages site, Actions workflows, and all commit history.
- Delete your local project folder on macOS/Linux by running:
rm -rf ~/Desktop/ci-cd-pipeline-project
- On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\ci-cd-pipeline-project
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully automated CI/CD pipeline from scratch using GitHub Actions.
Every professional development team uses pipelines like this to ship code safely. You now have hands-on experience with the same workflow.
You've learned how to:
- Build a GitHub Actions workflow that automatically runs tests on every push and deploys on merge to main.
- Deploy a live website to GitHub Pages through an automated pipeline triggered by code changes.
- Experience a failed CI build and resolve it through a pull request workflow with status checks.
- Added a scheduled workflow that runs tests daily and a status badge to your README.
Ready to quiz yourself?