Build a CI/CD Pipeline with GitHub Actions
Automate testing, gating, and deployment for a Node.js app.
Introduction
30 Second Summary
Every time you use an app or visit a website, the code behind it went through a pipeline of automated checks before it reached you. Companies like Meta push hundreds of code changes per day, and every single one passes through linting, testing, and staged deployment without a human manually approving each step.
In this project, you will build a complete CI/CD pipeline using GitHub Actions that automatically checks code quality, runs tests, blocks bad merges, and deploys passing code to a live website.
What You'll Build
A fully automated pipeline for a Node.js web application that lints, tests, gates, and deploys your code on every change.
By the end of this project, you'll have:
- A CI workflow that automatically runs ESLint and Jest tests on every pull request.
- A CD workflow that deploys passing code to a live GitHub Pages website automatically.
- Branch protection rules that enforce trunk-based development by blocking merges unless all checks pass.
- Secret Mission: Add a code coverage gate that blocks PRs dropping below 80% test coverage.
Are there any prerequisites?
You should have basic familiarity with Git (commit, push, pull) and JavaScript. You will also need a GitHub account, Node.js, VS Code, and Git installed on your machine.
Create the Repository and Project Structure
Every CI/CD pipeline needs a codebase to protect. In this step, you'll create the repository and project skeleton that your pipeline will build around.
You'll set up a public GitHub repository, initialize a Node.js project, and install the linting and testing tools your pipeline will run automatically.
In this step, get ready to:
- Create a new public GitHub repository and clone it locally.
- Initialize a Node.js project with ESLint and Jest as dev dependencies.
- Create the folder structure for source code, tests, and workflows.
Create a new GitHub repository and clone it locally
Your pipeline needs a remote repository to watch for changes and trigger automated workflows. You'll create one on GitHub and clone it to your machine.
- Open your browser and navigate to github.com.
- Sign in to your GitHub account.
- Click the + icon in the top-right corner of the page.
- Select New repository.
- Enter enterprise-cicd-pipeline in the Repository name field.
- Select Public for the repository visibility.
- Leave all checkboxes unchecked (no README, no .gitignore, no license).
- Click Create repository.
GitHub shows a Quick Setup page with your repository URL. Keep this page open for the next step.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS) or Ctrl+` (Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your new repository by running this command:
git clone [[GITHUB_REPO_URL="https://github.com/your-username/enterprise-cicd-pipeline.git"]]
Where do I find my repository URL?
On the Quick Setup page that appeared after you created the repository, copy the HTTPS URL. It looks like https://github.com/your-username/enterprise-cicd-pipeline.git with your actual GitHub username.
- Move into the cloned repository by running:
cd enterprise-cicd-pipeline
- Confirm you're in the right folder by running:
ls -a
You should see a .git folder in the output. This confirms the repository was cloned successfully.
Don't see a .git folder?
Make sure you ran cd enterprise-cicd-pipeline after cloning. The .git folder lives inside the cloned directory, not on your Desktop.
If git clone failed, double-check that you copied the full HTTPS URL from the GitHub Quick Setup page.
Help me troubleshoot my git clone
Initialize the project and install dependencies
Your pipeline will run ESLint for code quality checks and Jest for automated tests. You need to set up the Node.js project and install both as dev dependencies.
- Initialize a new Node.js project by running:
npm init -y
You should see a package.json file created with default values printed in the terminal.
- Install the three dev dependencies by running:
npm install --save-dev eslint @eslint/js jest
What did this install?
This command installs three packages as development dependencies (tools used during development, not shipped to production):
- eslint catches code quality issues like unused variables and missing semicolons.
- @eslint/js provides the recommended rule set for JavaScript.
- jest runs your automated tests and reports which pass or fail.
- Confirm the install succeeded by running:
ls node_modules/.package-lock.json
You should see node_modules/ and package-lock.json listed. These confirm that npm downloaded and recorded your dependencies.
Seeing permission errors or EACCES?
Permission errors usually mean npm is trying to write to a system directory. Make sure your terminal is inside the enterprise-cicd-pipeline folder (run pwd to check).
If the install hangs or fails with a network error, check your internet connection and try again.
Help me fix my npm install error
Now you need to update package.json to set the correct project description and add the scripts your pipeline will call.
- In the VS Code Explorer sidebar, click on package.json to open it.
- Select all the existing content and replace it with the following:
{
"name": "enterprise-cicd-pipeline",
"version": "1.0.0",
"description": "Enterprise CI/CD pipeline demo with GitHub Actions",
"scripts": {
"test": "jest",
"lint": "eslint src/"
},
"devDependencies": {
"eslint": "^10.3.0",
"@eslint/js": "^10.3.0",
"jest": "^30.4.2"
}
}
What does this file do?
The scripts section defines two commands your CI pipeline will run automatically:
- "test": "jest" means running npm test executes Jest to check all your tests.
- "lint": "eslint src/" means running npm run lint scans everything in src/ for code quality issues.
The devDependencies section records the exact packages and version ranges your project needs. Anyone who clones this repo can run npm install to get the same tools.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing red squiggles in package.json?
Make sure you replaced the entire file contents, not just parts of it. The file should start with { and end with } with no extra commas or brackets.
If the version numbers in your devDependencies look different from the code above, that is fine. The ^ prefix allows minor version updates, so npm install may have resolved slightly newer versions.
Help me fix my package.json
✔️ Awesome, I've got everything!
Great. Make sure you've saved package.json before moving on.
ⓧ I'd like to double check the full code
Here is the complete package.json at this point:
{
"name": "enterprise-cicd-pipeline",
"version": "1.0.0",
"description": "Enterprise CI/CD pipeline demo with GitHub Actions",
"scripts": {
"test": "jest",
"lint": "eslint src/"
},
"devDependencies": {
"eslint": "^10.3.0",
"@eslint/js": "^10.3.0",
"jest": "^30.4.2"
}
}
Create the project folder structure
Your project needs separate directories for source code, tests, and GitHub Actions workflow files. GitHub Actions looks for workflows in a specific location.
- Create all three directories by running this command in your terminal:
mkdir -p src tests .github/workflows
What are these folders for?
- src/ holds your application code (the JavaScript utilities and HTML page).
- tests/ holds your Jest test files.
- .github/workflows/ is where GitHub Actions looks for workflow YAML files. This path is required by GitHub.
- Confirm the directories were created by running:
ls -a
You should see src, tests, and .github listed alongside package.json and node_modules.
Don't see .github in the output?
Folders starting with a dot are hidden by default. The -a flag in ls -a shows hidden files. If you ran ls without -a, you won't see it.
Help me find my .github folder
Your project skeleton is ready. Next up, you'll write the application code and tests that your pipeline will automatically validate on every pull request.
Build the Application and Tests
Your project structure is ready, but the pipeline has nothing to protect yet. A CI/CD pipeline only makes sense when there is real code to lint, test, and deploy.
In this step, you will create a utility module with functions, a webpage that your pipeline will eventually deploy, and a test suite that validates everything works. Once these pieces are in place, you will run them locally to confirm everything passes before automating them in a workflow.
In this step, get ready to:
- Create the utility module and webpage that the pipeline will deploy.
- Write automated tests that validate the utility functions.
- Configure the linter and test runner, then verify everything passes locally.
Create the utility module and HTML page
Every pipeline needs application code to operate on. You will create two files: a JavaScript module with utility functions, and an HTML page that will become your live deployed site.
- In VS Code, open src/utils.js and add the following code:
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
export function isEven(n) {
return n % 2 === 0;
}
What does this code do?
This module exports four simple math functions: add, subtract, multiply, and isEven.
Each function is exported so other files (like your tests and HTML page) can import and use them. These functions are deliberately simple so you can focus on the pipeline mechanics, not complex application logic.
- Save src/utils.js.
- You won't see a visible change yet. The tests you write next will import and validate these functions.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/utils.js before moving on.
ⓧ I'd like to double check the full code
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
export function isEven(n) {
return n % 2 === 0;
}
- Now open src/index.html and add the following code:
<!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: 800px;
margin: 0 auto;
padding: 2rem;
background: #0d1117;
color: #c9d1d9;
}
h1 { color: #58a6ff; }
.status {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.875rem;
font-weight: 600;
}
.badge-green { background: #238636; color: #fff; }
</style>
</head>
<body>
<h1>Enterprise CI/CD Pipeline</h1>
<p>This site is automatically deployed via GitHub Actions whenever code passes all checks on <code>main</code>.</p>
<div class="status">
<h2>Pipeline Status</h2>
<p><span class="badge badge-green">Deployed</span> All checks passed</p>
<ul>
<li>ESLint: Code quality verified</li>
<li>Jest: All tests passing</li>
<li>Deploy: Auto-deployed to GitHub Pages</li>
</ul>
</div>
<div class="status">
<h2>How It Works</h2>
<ol>
<li>Developer creates a short-lived branch</li>
<li>Opens a Pull Request to <code>main</code></li>
<li>CI automatically runs linting and tests</li>
<li>Branch protection blocks merge if checks fail</li>
<li>On merge, CD deploys to this live site</li>
</ol>
</div>
</body>
</html>
What does this page do?
This is the webpage your pipeline will deploy to GitHub Pages later. It displays a pipeline status dashboard explaining how the CI/CD process works.
The dark theme with status badges mimics a real engineering dashboard. The How It Works section describes the exact flow you are building in this project.
- Save src/index.html.
- Open src/index.html in your browser by double-clicking the file in Finder (macOS) or File Explorer (Windows).
- You should see a dark-themed page with the heading Enterprise CI/CD Pipeline and two status boxes listing the pipeline steps.
Don't see the page styled correctly?
Make sure you saved src/index.html and opened the correct file. If you see raw HTML code instead of a rendered page, make sure you opened the file in a browser, not a text editor.
help me debug my index.html page not rendering correctly
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/index.html before moving on.
ⓧ 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>CI/CD Pipeline Demo</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: #0d1117;
color: #c9d1d9;
}
h1 { color: #58a6ff; }
.status {
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 3px;
font-size: 0.875rem;
font-weight: 600;
}
.badge-green { background: #238636; color: #fff; }
</style>
</head>
<body>
<h1>Enterprise CI/CD Pipeline</h1>
<p>This site is automatically deployed via GitHub Actions whenever code passes all checks on <code>main</code>.</p>
<div class="status">
<h2>Pipeline Status</h2>
<p><span class="badge badge-green">Deployed</span> All checks passed</p>
<ul>
<li>ESLint: Code quality verified</li>
<li>Jest: All tests passing</li>
<li>Deploy: Auto-deployed to GitHub Pages</li>
</ul>
</div>
<div class="status">
<h2>How It Works</h2>
<ol>
<li>Developer creates a short-lived branch</li>
<li>Opens a Pull Request to <code>main</code></li>
<li>CI automatically runs linting and tests</li>
<li>Branch protection blocks merge if checks fail</li>
<li>On merge, CD deploys to this live site</li>
</ol>
</div>
</body>
</html>
Write the test suite
Tests are the backbone of any CI pipeline. Without automated tests, there is no way for the pipeline to know whether code changes break existing functionality.
- Open tests/utils.test.js and add the following test suite:
import { add, subtract, multiply, isEven } from "../src/utils.js";
describe("add", () => {
test("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("adds negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
});
test("adds zero", () => {
expect(add(5, 0)).toBe(5);
});
});
describe("subtract", () => {
test("subtracts two numbers", () => {
expect(subtract(10, 4)).toBe(6);
});
test("returns negative when second is larger", () => {
expect(subtract(3, 7)).toBe(-4);
});
});
describe("multiply", () => {
test("multiplies two numbers", () => {
expect(multiply(3, 4)).toBe(12);
});
test("multiplies by zero", () => {
expect(multiply(5, 0)).toBe(0);
});
});
describe("isEven", () => {
test("returns true for even numbers", () => {
expect(isEven(4)).toBe(true);
});
test("returns false for odd numbers", () => {
expect(isEven(3)).toBe(false);
});
});
What does this test file do?
Each describe block groups tests for one function. Each test block checks a specific behavior using expect(...).toBe(...) to compare the actual result to the expected value.
The first line imports all four functions from src/utils.js. Jest runs every test block and reports which passed or failed. This is exactly what your CI workflow will automate on every pull request.
- Save tests/utils.test.js.
- You won't see a change yet. The next substep runs these tests to confirm they pass.
✔️ Awesome, I've got everything!
Great. Make sure you have saved tests/utils.test.js before moving on.
ⓧ I'd like to double check the full code
import { add, subtract, multiply, isEven } from "../src/utils.js";
describe("add", () => {
test("adds two positive numbers", () => {
expect(add(2, 3)).toBe(5);
});
test("adds negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
});
test("adds zero", () => {
expect(add(5, 0)).toBe(5);
});
});
describe("subtract", () => {
test("subtracts two numbers", () => {
expect(subtract(10, 4)).toBe(6);
});
test("returns negative when second is larger", () => {
expect(subtract(3, 7)).toBe(-4);
});
});
describe("multiply", () => {
test("multiplies two numbers", () => {
expect(multiply(3, 4)).toBe(12);
});
test("multiplies by zero", () => {
expect(multiply(5, 0)).toBe(0);
});
});
describe("isEven", () => {
test("returns true for even numbers", () => {
expect(isEven(4)).toBe(true);
});
test("returns false for odd numbers", () => {
expect(isEven(3)).toBe(false);
});
});
Configure the linter and test runner
Your package.json already has test and lint scripts from the previous step. Now you need to tell each tool how to behave by creating configuration files.
- Create a new file called eslint.config.js in your project root (the enterprise-cicd-pipeline folder) and add the following code:
import js from "@eslint/js";
export default [
js.configs.recommended,
{
rules: {
"no-unused-vars": "warn",
"no-console": "off"
}
}
];
What does this config do?
ESLint 10 uses a flat config format. The js.configs.recommended entry loads a set of standard rules that catch common JavaScript mistakes like undeclared variables and unreachable code.
The rules object customizes two behaviors: unused variables produce a warning instead of an error, and console.log statements are allowed. This is a practical setup for a development project.
- Save eslint.config.js.
- Create another file called jest.config.js in the same project root folder and add the following:
export default {
testMatch: ["**/tests/**/*.test.js"]
};
What does this config do?
The testMatch pattern tells Jest to look for any file ending in .test.js inside the tests/ directory. Without this, Jest would search the entire project for test files, which could pick up files you did not intend to test.
- Save jest.config.js.
✔️ Awesome, I've got everything!
Great. Make sure both config files are saved.
ⓧ I'd like to double check the full code
import js from "@eslint/js";
export default [
js.configs.recommended,
{
rules: {
"no-unused-vars": "warn",
"no-console": "off"
}
}
];
export default {
testMatch: ["**/tests/**/*.test.js"]
};
Now verify that both tools work correctly with your code.
- Run the linter by entering this command in your terminal:
npm run lint
- If ESLint finds no issues, it produces no output and returns you to the command prompt. No news is good news.
- Run the tests by entering this command:
npm test
- You should see Jest report that all 10 tests passed across the four describe blocks.
Seeing test failures or errors?
If you see import errors, make sure the import path in tests/utils.test.js matches the exact file location: "../src/utils.js". The path is relative to the test file.
If ESLint reports errors about missing configuration, make sure eslint.config.js is in the project root (not inside src/).
help me fix errors when running npm test or npm run lint
Your application code, tests, and tool configuration are all in place and passing locally. Next up, you will create a CI workflow that runs these same checks automatically on every pull request.
Create the CI Workflow
Your linter and tests pass locally, which is great for catching issues on your own machine. But when a team of developers pushes code, nobody should have to remember to run checks manually.
In this step, you'll create a GitHub Actions workflow that automatically runs ESLint and Jest every time someone opens a pull request. This is the Continuous Integration piece of your pipeline.
In this step, get ready to:
- Write a CI workflow file that runs on every pull request.
- Push your project to GitHub and open a test pull request.
- Watch the CI pipeline run automatically in the Actions tab.
Write the CI workflow file
GitHub Actions reads YAML workflow files from your .github/workflows/ directory. Each file in this folder defines a separate automated pipeline.
- Create a file called ci.yml inside your .github/workflows/ folder, then paste the following code:
name: CI Pipeline
on:
pull_request:
branches: [main]
jobs:
lint-and-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 ESLint
run: npm run lint
- name: Run tests
run: npm test
What does this workflow do?
- on: pull_request: branches: [main] triggers the workflow whenever a PR targets the main branch.
- The lint-and-test job runs on a fresh Ubuntu virtual machine hosted by GitHub.
- actions/checkout@v6 clones your repository code into the runner.
- actions/setup-node@v4 installs Node.js 22 so your project can run.
- npm ci installs dependencies from your lockfile. It is faster and more reliable than npm install in CI environments because it does a clean install every time.
- npm run lint and npm test run your ESLint and Jest checks, the same ones you ran locally.
Why GitHub Actions over Jenkins?
Jenkins requires a separate server to run your pipelines. GitHub Actions is built directly into GitHub, runs for free on public repositories, and integrates natively with pull requests and branch protection.
You get 2,000 free minutes per month on private repos too. For most teams, that means zero infrastructure to manage and zero cost to get started.
- Save ci.yml.
- You won't see a change locally. The workflow only activates on GitHub when a pull request is opened. The next substep pushes this file to GitHub.
YAML not looking right in your editor?
- Make sure you're using spaces, not tabs. YAML requires 2-space indentation and rejects tab characters.
- Confirm the file is at .github/workflows/ci.yml and not in a different folder.
- Help me fix indentation issues in my ci.yml file.
✔️ Awesome, I've got everything!
Great. Make sure you've saved ci.yml before moving on.
ⓧ I'd like to double check the full code
name: CI Pipeline
on:
pull_request:
branches: [main]
jobs:
lint-and-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 ESLint
run: npm run lint
- name: Run tests
run: npm test
Push your code and open a test pull request
Your workflow file exists locally, but GitHub can't see it yet. You need to push all your project files to the main branch first. Then you'll open a pull request from a feature branch to trigger the workflow.
- Push all your project files to main by running these commands:
git add .
git commit -m "Add CI workflow and project files"
git push origin main
What does this do?
- git add . stages all files in the project for commit.
- git commit saves a snapshot of those files locally.
- git push origin main uploads the snapshot to your GitHub repository on the main branch.
Push rejected or authentication error?
- If GitHub asks for credentials, you may need to set up a personal access token or SSH key. GitHub no longer accepts passwords for command-line pushes.
- If the push is rejected because the remote has commits you don't have (like an initial README), run git pull origin main --rebase first, then push again.
- Help me fix a git push authentication or rejection error.
Now that main has all your files (including ci.yml), the workflow is armed. It will fire the moment a PR targets main. The workflow file must exist on the target branch for the pull_request trigger to work.
- Create a new feature branch by running:
git checkout -b feature/test-ci
- Add a README file and push the branch by running these commands:
echo "# Enterprise CI/CD Pipeline" > README.md
git add README.md
git commit -m "Add README"
git push origin feature/test-ci
Why create a feature branch?
Your CI workflow triggers on pull requests to main. A pull request compares a feature branch against main, so you need a separate branch with at least one new commit to open one.
- Open your repository on GitHub in your browser.
- You should see a yellow banner near the top suggesting you compare and open a pull request. Click Compare & pull request.
- Leave the title and description as-is, then click Create pull request.
Watch the CI workflow run
The moment you created that pull request, GitHub detected your ci.yml file and started executing it. You can watch it in real time.
- Click the Actions tab at the top of your repository page.
- You should see the CI Pipeline workflow listed with a yellow dot (in progress) or green checkmark (passed).
- Click into the workflow run to see each step (checkout, setup, lint, test) execute in sequence.
What's happening on GitHub right now?
GitHub spun up a fresh Ubuntu virtual machine, cloned your repo, installed Node.js 22, ran npm ci, linted your code, and ran your tests. All automatically, triggered by the pull request you just opened.
If you go back to the pull request page, you'll see the check status at the bottom. A green checkmark means all steps passed.
Don't see the workflow running?
- Make sure your PR targets the main branch. The workflow only triggers on pull_request events to main.
- Confirm that ci.yml was pushed to main (not just on the feature branch). The workflow file must exist on the target branch for the trigger to work.
- Check the Actions tab for a failed run. Click into it to read the error logs.
- Help me figure out why my GitHub Actions CI workflow isn't running.
Your CI pipeline is live. Every pull request to main will now be automatically checked before anyone can merge it. Next up, you'll add a CD workflow that deploys your site whenever code passes all gates and lands on main.
Create the CD Workflow
Your CI pipeline now catches bad code before it reaches main. The next piece of the puzzle is automatic deployment.
In this step, you'll create a Continuous Deployment workflow that deploys your site to GitHub Pages every time code lands on main. The result is a live URL that always reflects the latest passing code.
In this step, get ready to:
- Create a CD workflow that triggers on pushes to main.
- Enable GitHub Pages in your repository settings.
- Push the workflow and verify your site is live.
Create the deploy workflow file
Your CI workflow runs on pull requests. The CD workflow needs a different trigger: it should deploy whenever code is pushed directly to main (which happens after a PR merge).
- In VS Code, create a new file called deploy.yml inside the .github/workflows/ directory.
- Add the following workflow configuration to deploy.yml:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: "src"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
What does this workflow do?
- on: push: branches: [main] triggers the workflow every time code is pushed to main (including PR merges).
- permissions grants the workflow token access to read your code and write to GitHub Pages.
- concurrency ensures only one deployment runs at a time. If you push twice quickly, the second waits for the first to finish.
- actions/upload-pages-artifact@v4 packages your src/ folder into a deployable artifact.
- actions/deploy-pages@v4 publishes that artifact to your live GitHub Pages URL.
- Save deploy.yml.
Why does the workflow need id-token: write?
GitHub Pages deployments use OpenID Connect tokens to verify the deployment request is legitimate. The id-token: write permission allows the workflow to request this token from GitHub's OIDC provider.
✔️ 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
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: "src"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Enable GitHub Pages in repository settings
Before the deploy workflow can publish your site, you need to tell GitHub to accept deployments from GitHub Actions instead of a branch.
- Open your enterprise-cicd-pipeline repository on GitHub.com in your browser.
- Click Settings in the repository navigation bar.
- In the left sidebar under "Code and automation", click Pages.
- Under "Build and deployment", find the Source dropdown.
- Select GitHub Actions from the dropdown.
What is GitHub Pages?
GitHub Pages is a free static hosting service built into GitHub. It serves HTML, CSS, and JavaScript files directly from your repository. By selecting GitHub Actions as the source, you're telling Pages to deploy whatever artifact your workflow uploads.
Don't see the Pages option?
Make sure your repository is set to Public. GitHub Pages is free for public repositories. If your repository is private, you'll need a paid plan to use Pages.
help me find the GitHub Pages settings in my repository
Push the workflow and verify your live site
With Pages enabled, pushing deploy.yml to main will trigger the CD workflow and deploy your site.
- Stage, commit, and push the new deploy workflow by running these commands:
git add .github/workflows/deploy.yml
git commit -m "Add CD workflow for GitHub Pages deployment"
git push origin main
- Go to your repository on GitHub.com.
- Click the Actions tab.
- You should see a workflow run named Deploy to GitHub Pages in progress or completed with a green checkmark.
- Once the workflow finishes, go back to Settings then Pages.
- Click Visit site to see your live deployment.
You should see your "Enterprise CI/CD Pipeline" page live at a URL like https://your-username.github.io/enterprise-cicd-pipeline.
Deployment can take a minute
The first GitHub Pages deployment often takes 1-2 minutes to complete. If the Actions tab shows the workflow is still running, wait for it to finish before visiting the URL.
Seeing a 404 page?
Check that you selected GitHub Actions (not "Deploy from a branch") as the Source in your Pages settings.
Verify the path: "src" in your deploy.yml matches the folder where index.html lives. Your HTML file is at src/index.html, so the path must be src.
my GitHub Pages deployment is showing a 404
Your site is live and auto-deploys whenever code hits main. Next up, you'll lock down main with branch protection so nothing can merge without passing CI first.
Set Up Branch Protection
Your CI and CD workflows are running, but nothing stops someone from merging broken code directly into main.
In this step, you will configure branch protection rules that enforce your pipeline. Once protection is active, every change to main must go through a pull request and pass all CI checks before it can be merged.
In this step, get ready to:
- Create a branch protection rule for main.
- Require pull requests and passing status checks before merging.
- Test the protection by pushing a failing change.
Navigate to branch protection settings
Branch protection rules live in your repository's settings on GitHub.
- Open your enterprise-cicd-pipeline repository on GitHub.
- Click Settings in the top navigation bar of your repository.
- In the left sidebar, under the Code and automation section, click Branches.
- Click Add rule next to Branch protection rules.
- Under Branch name pattern, type main.
What is branch protection?
A branch protection rule restricts what can happen to a specific branch. It can require pull requests, enforce passing CI checks, and even block force pushes.
This is how enterprise teams prevent broken code from reaching production. The rule acts as a gate that only opens when all your automated checks pass.
Enable required pull requests and status checks
Now you will configure the two protections that form the core of trunk-based development.
- Scroll down to the Protect matching branches section.
- Check the box for Require a pull request before merging.
- Check the box for Require status checks to pass before merging.
- Check the box for Require branches to be up to date before merging.
- In the search field that appears, type lint-and-test and select it from the dropdown.
- Scroll to the bottom and click Create.
Why lint-and-test?
The status check name matches the job name in your ci.yml workflow file. GitHub uses this name to identify which check must pass before a PR can be merged.
If you had multiple jobs in your workflow, each would appear as a separate selectable check.
Check must have run recently
The lint-and-test check only appears in the search if it has completed successfully within the past seven days. If you do not see it, make sure the CI workflow ran at least once on a pull request in the previous step.
Don't see the lint-and-test check?
If the check does not appear in the search field, go to the Actions tab and confirm that the CI Pipeline workflow has completed at least one run. You may need to create a quick PR to trigger it first.
help me find my CI status check in branch protection settings
Test branch protection with a failing change
The best way to verify your protection works is to open a PR with code that deliberately fails the CI check.
- Create a new branch locally by running this command:
git checkout -b test-protection
- Open src/utils.js and add this broken function at the bottom of the file:
export function divide(a, b) {
// This uses an undeclared variable to trigger a lint error
return undefinedVariable;
}
What does this code do?
The function references undefinedVariable which does not exist. ESLint's no-undef rule (part of the recommended config) catches this as an error, causing the lint step to fail.
- Commit and push the branch by running these commands:
git add src/utils.js
git commit -m "Test branch protection with lint error"
git push origin test-protection
- Open your repository on GitHub.
- You should see a banner saying your branch had recent pushes. Click Compare & pull request.
- Click Create pull request.
Wait a minute or two for the CI workflow to run. You should see the lint-and-test check fail with a red X, and the merge button will be blocked.
What should I see?
On the PR page, the Checks section shows lint-and-test with a red X and a message indicating the required check failed. The Merge pull request button is grayed out and cannot be clicked.
This is your pipeline doing its job. Broken code cannot reach main.
Merge button still active?
If you are the repository admin, GitHub may show a warning but still allow you to merge by bypassing protection. This is normal for admins. The important thing is that the check shows as failed and the standard merge path is blocked.
help me verify my branch protection is working
- Close the pull request without merging by clicking Close pull request at the bottom of the page.
- Switch back to main locally by running this command:
git checkout main
Your pipeline is now fully enforced. Every change to main must pass through a pull request and survive automated linting and testing before it can be merged.
Secret mission
Add Code Coverage Gating
Your pipeline catches lint errors and test failures. But what about code that ships without tests at all? Add a coverage gate that automatically blocks PRs dropping below 80% test coverage.
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:
- A public GitHub repository (free, no cost).
- A GitHub Pages site (free for public repos).
- GitHub Actions workflow runs (free for public repos, 2,000 minutes/month on the free plan for private repos).
- A local project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want your site to stay live.
- Your GitHub Pages site remains live at no cost.
- GitHub Actions workflows only run when you push code or open a pull request. There is no idle cost.
- Your local project folder stays on your Desktop.
Pause - I'll come back to this later
There is nothing to pause. GitHub Actions workflows only run when triggered by a push or pull request, so they do not consume resources while idle.
- Your GitHub Pages site stays live at no cost while you are away.
- No processes are running locally. Your project folder stays on your Desktop until you return.
Delete - I don't want to use this again
Remove all project resources to start fresh. This is permanent.
- Delete the GitHub repository (this also removes your GitHub Pages site and all workflow history):
- Navigate to your repository on GitHub and 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.
- Delete the local project folder. On macOS or Linux, run:
rm -rf ~/Desktop/enterprise-cicd-pipeline
- On Windows, run:
Remove-Item -Recurse -Force ~\Desktop\enterprise-cicd-pipeline
Nice Work!
Nice Work!
You just built a production-grade CI/CD pipeline using GitHub Actions. Every pull request is now automatically tested, gated, and deployed without manual intervention.
You learned how to:
- Created a CI workflow that runs ESLint and Jest on every pull request to catch issues before they reach production.
- Created a CD workflow that auto-deploys passing code to a live GitHub Pages website.
- Configured branch protection rules to enforce trunk-based development discipline and block bad merges.
- Added code coverage gating to maintain test quality standards across the project.
Ready to quiz yourself?