Build CI/CD with GitHub Actions
Build a tested student roster that deploys to GitHub Pages with CI/CD.
Introduction
30 Second Summary
A web page can seem to work while quietly saving a broken record. Without an automatic check, that mistake can reach everyone who opens the live version.
In this project, you will build ShipSafe Student Roster with a real CI/CD workflow. You will use GitHub Actions to test every change before GitHub Pages publishes the roster.
What You'll Build
You will demo a live roster that adds and searches fictional student records while its workflow history proves broken changes stay offline.
By the end of this project, you'll have:
- A responsive student roster that displays three fictional records. You can add students or search by ID, name, or program.
- A visible automated quality gate that catches duplicate student IDs. You can show the failed run that exposed the defect and the successful run after your fix.
- A live deployment pipeline that publishes to GitHub Pages only after all tests pass. New pushes to main update the public roster automatically.
- Secret Mission: Add an academic-program filter and protect it with a fourth automated test.
Are there any prerequisites?
No prior CI/CD experience is required. Step 1 helps you create or sign in to a free GitHub account and prepare Git, Node.js 24.20.0, and Visual Studio Code on macOS or Windows.
Before We Start
Before We Start...
Before any hands-on work begins, this is your moment to commit to the demo you are building. You are also choosing the quality rule that must protect every deployment.
Set Up the Repository
A repeatable CI/CD pipeline needs a stable starting point before application code arrives. A public remote repository gives every future workflow the same source.
GitHub will store that repository. Git will connect it to your Mac.
Node.js will run the tests later. Visual Studio Code will hold the workspace.
Safari will show the roster in the next step.
In this step, get ready to:
- Create a public repository initialized with a README.
- Prepare the local workspace inside a Visual Studio Code clone.
- Verify the required development tools.
Create the repository on GitHub
The remote repository becomes the shared home for your roster. Initializing it with a README makes it ready to clone immediately.
Keep Public Data Fictional
A public repository makes every committed file visible. Use only fictional student records in this project.
This public setting gives the project its verified $0 path for GitHub Pages plus standard hosted GitHub Actions usage.
- Visit the GitHub website in your browser.
- Sign in to your GitHub account.
- Select New repository.
- Enter student-roster-cicd in the repository name field.
- Choose Public for the repository visibility.
- Enable the README option.
- Select Create repository.
You should see the student-roster-cicd repository page with a README file and public visibility. That gives your future pipeline a remote home.
Clone and prepare the workspace
A clone gives you a local copy that stays connected to GitHub. You will organize the site files separately from its tests and workflow.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Visual Studio Code and press Enter.
Authorization May Open Your Browser
The first GitHub clone may send you to your browser for authorization. Complete the authorization before returning to Visual Studio Code.
- Press Shift+Cmd+P on macOS or Ctrl+Shift+P on Windows to open the Command Palette.
- Enter Git: Clone in the Command Palette.
- Select the repository named student-roster-cicd.
- Choose your Desktop as the clone destination.
- Select Open when Visual Studio Code finishes cloning.
You should see student-roster-cicd at the top of the Explorer sidebar. Its README file confirms that you opened the clone.
Asked to Trust the Folder?
Visual Studio Code may ask whether you trust the cloned folder. Approve the prompt because this repository belongs to you.
If the repository does not open after approval, help me open my cloned repository.
- Click the New Folder control under the repository heading in the Explorer sidebar.
- Enter site and press Enter.
- Click the New Folder control again.
- Enter tests and press Enter.
You should now see separate site and tests folders in the Explorer sidebar.
- Click the New Folder control under the repository heading.
- Enter .github and press Enter.
- Select the .github folder in the Explorer sidebar.
- Click the New Folder control.
- Enter workflows and press Enter.
Workspace Checkpoint
The Explorer sidebar should now contain site, tests, and .github/workflows.
Visual Studio Code may display the nested GitHub folders together on one line. They still represent two separate folders.
- Click the New File control under the repository heading.
- Enter site/index.html and press Enter.
- Click the New File control under the repository heading.
- Enter site/styles.css and press Enter.
You should see two empty files inside site: index.html plus styles.css.
- Click the New File control under the repository heading.
- Enter site/student-utils.js and press Enter.
- Click the New File control under the repository heading.
- Enter site/app.js and press Enter.
Application File Checkpoint
The site folder should now contain index.html, styles.css, student-utils.js, and app.js.
Each file should be completely empty at this point.
- Click the New File control under the repository heading.
- Enter tests/student-utils.test.js and press Enter.
- Click the New File control under the repository heading.
- Enter .github/workflows/ci-cd.yml and press Enter.
You should see student-utils.test.js inside tests. You should also see ci-cd.yml inside .github/workflows.
✔️ Awesome, I've got everything!
Your empty project files are in place. The workspace structure is ready for the roster code.
ⓧ I'd like to double check the full code
Compare your Explorer sidebar with this exact empty workspace state:
- The site folder contains empty index.html, styles.css, student-utils.js, and app.js files.
- The tests folder contains an empty student-utils.test.js file.
- The .github/workflows folder contains an empty ci-cd.yml file.
All six project files must contain zero text.
Workspace Structure Looks Different?
- Check every file name for the exact spelling shown in the comparison tab.
- Confirm that workflows sits inside .github.
- Move any misplaced file into its named folder using the Explorer sidebar.
If a path still looks wrong, help me fix my student roster workspace structure.
Verify the confirmed tools
The folder structure is ready. The last check confirms that your Mac can track changes and run the tests used by the pipeline.
- Select Terminal from the Visual Studio Code menu bar.
- Select New Terminal.
- Verify Git by running this command:
git --version
What Does This Check Prove?
The version check asks Git to print information about the installed release. A line beginning with git version confirms that the terminal can use Git.
🙋♀️ Git Version Missing?
Confirm that you ran the check inside the Visual Studio Code terminal. Reopen the terminal panel if it closed before printing a result.
If Git still does not print a version, help me diagnose my Git installation.
Before you check Node.js, which version do you expect the terminal to print?
- Check the installed Node.js version by running this command:
node --version
What Does This Check Prove?
The version check asks Node.js to print its installed release. This project requires v24.20.0 so local tests match the pipeline runtime.
✔️ I see v24.20.0
Node.js is ready. Your local test runner now matches the version used by the future workflow.
ⓧ I see an older version
Your installed Node.js version is below the project pin. Install 24.20.0 before continuing.
- Open the official Node.js release page for version 24.20.0.
- Download node-v24.20.0.pkg.
- Open the Downloads folder in Finder.
- Double-click node-v24.20.0.pkg.
- Complete the macOS installer prompts.
- Return to the Visual Studio Code terminal from earlier.
- Recheck the installed version by running:
node --version
What Does This Recheck Prove?
The recheck asks the refreshed terminal to print the newly installed Node.js version. The setup is complete when you see v24.20.0.
ⓧ Command not found
The terminal cannot find Node.js yet. Install the official macOS package for version 24.20.0.
- Open the official Node.js release page for version 24.20.0.
- Download node-v24.20.0.pkg.
- Open the Downloads folder in Finder.
- Double-click node-v24.20.0.pkg.
- Complete the macOS installer prompts.
- Return to the Visual Studio Code terminal from earlier.
- Check the installation by running:
node --version
What Does This Check Prove?
The check confirms that the terminal can now find Node.js. You should see v24.20.0.
Still Seeing the Wrong Node.js Version?
Close the existing terminal panel after the installer finishes. Open a fresh terminal so Visual Studio Code reloads the available commands.
If the version still differs, help me fix the Node.js version used by Visual Studio Code.
Safari needs no extension or extra service for this project. Opening it now confirms that your browser is ready for the local roster.
- Press Cmd+Space to open Spotlight on your Mac.
- Type Safari and press Enter.
You should see a Safari window open successfully. Your browser is ready for the first roster preview.
- Return to the Visual Studio Code terminal from earlier.
Before the final check, what two version lines do you expect the terminal to print?
- Confirm both command-line tools together by running these commands:
git --version
node --version
What Does the Final Check Prove?
These checks print the installed Git and Node.js versions without changing your files. Together they confirm that the local workspace can support the later test workflow.
You should see Git print its installed version. Node.js should print v24.20.0.
- Confirm the folder structure by checking the Visual Studio Code Explorer sidebar.
You should see the README file plus site, tests, and .github/workflows with all six empty project files.
Your public repository is connected to a verified local workspace. Next up, you will turn those empty files into a student roster that exposes its first data-integrity gap.
Build the Student Roster
Your repository is ready in Visual Studio Code. Now it needs a working application that your pipeline can protect.
A polished browser interface can still hide a data-quality gap. You will build a responsive student roster with HTML, CSS, and JavaScript. Then you will test its behavior with a duplicate student ID.
In this step, get ready to:
- Build a responsive roster with a form and searchable table.
- Add browser behavior for displaying and filtering fictional students.
- Try a duplicate student ID to reveal the data-quality gap.
Build the roster structure
The page structure gives every control and record a predictable place. You will start with the page header before adding the form and roster table.
- Select the empty site/index.html file in the VS Code Explorer sidebar.
- Add the page metadata and header by pasting this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ShipSafe Student Roster</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="hero">
<p class="eyebrow">CI/CD protected demo</p>
<h1>ShipSafe Student Roster</h1>
<p>Add and search fictional student records. Automated tests protect every deployment.</p>
</header>
<script src="student-utils.js"></script>
<script src="app.js"></script>
</body>
</html>
What Does This Structure Do?
- The page metadata helps the roster scale correctly on narrow screens.
- The hero header introduces the demo and its quality goal.
- The two script elements load the student helpers before the browser behavior.
- Save site/index.html.
- Return to Safari from the previous step.
- Click File in the menu bar.
- Select Open File.
- Choose site/index.html from the student-roster-cicd folder.
You will see the ShipSafe Student Roster heading with its description on a plain page.
Header Missing in Safari?
Confirm that Safari opened site/index.html from the cloned repository. An unsaved editor tab can also leave the browser showing an older version.
Ask for help checking the page structure: Why is my ShipSafe Student Roster header missing in Safari?
- Return to site/index.html in VS Code.
- Insert the student form directly above the first <script> element by pasting this code:
<main class="layout">
<section class="card">
<h2>Add a student</h2>
<form id="student-form">
<label>
Student ID
<input id="student-id" name="studentId" placeholder="S004" required>
</label>
<label>
Full name
<input id="student-name" name="studentName" placeholder="Jordan Lee" required>
</label>
<label>
Program
<select id="student-program" name="studentProgram" required>
<option value="">Choose a program</option>
<option>Computer Science</option>
<option>Data Science</option>
<option>Business</option>
</select>
</label>
<button type="submit">Add student</button>
<p id="form-message" class="status" role="status"></p>
</form>
</section>
</main>
How Does the Form Work?
- The form collects a student ID, full name, and academic program.
- The required attributes make every field mandatory in the browser.
- The form-message element provides a place for success and error messages.
- Save site/index.html.
- Refresh the page in Safari.
You will see an Add a student form beneath the header with fields for ID, name, and program.
Form Not Appearing?
Check that the new <main> block sits after </header> and before the first script element. Confirm that site/index.html is saved before refreshing.
Ask for help locating the misplaced markup: Why is my add-student form missing from site/index.html?
- Find the closing </main> element in site/index.html.
- Insert the roster table directly above that element by pasting this code:
<section class="card roster-card">
<div class="roster-heading">
<div>
<p class="eyebrow">Current records</p>
<h2>Student roster</h2>
</div>
<p id="student-count" class="count"></p>
</div>
<div class="filters">
<label>
Search students
<input id="search-input" type="search" placeholder="Search by ID, name, or program">
</label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Name</th>
<th scope="col">Program</th>
</tr>
</thead>
<tbody id="student-table-body"></tbody>
</table>
</div>
</section>
What Does the Roster Section Provide?
- The heading area holds the roster title and live student count.
- The search input lets the browser filter records by ID, name, or program.
- The empty table body gives JavaScript a destination for generated student rows.
- Save site/index.html.
- Refresh the page in Safari.
You will see the roster heading, search control, and three table column headings beneath the form.
Roster Table Missing?
Confirm that the roster section sits inside <main class="layout">. Check that every opening section and div has its matching closing element.
Ask for help comparing the nesting: Help me find the HTML nesting error in my student roster table.
✔️ Awesome, I've got everything!
Your complete page structure is saved. The form and roster table are ready for styling.
ⓧ I'd like to double check the full code
Compare your site/index.html file with this complete version.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ShipSafe Student Roster</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="hero">
<p class="eyebrow">CI/CD protected demo</p>
<h1>ShipSafe Student Roster</h1>
<p>Add and search fictional student records. Automated tests protect every deployment.</p>
</header>
<main class="layout">
<section class="card">
<h2>Add a student</h2>
<form id="student-form">
<label>
Student ID
<input id="student-id" name="studentId" placeholder="S004" required>
</label>
<label>
Full name
<input id="student-name" name="studentName" placeholder="Jordan Lee" required>
</label>
<label>
Program
<select id="student-program" name="studentProgram" required>
<option value="">Choose a program</option>
<option>Computer Science</option>
<option>Data Science</option>
<option>Business</option>
</select>
</label>
<button type="submit">Add student</button>
<p id="form-message" class="status" role="status"></p>
</form>
</section>
<section class="card roster-card">
<div class="roster-heading">
<div>
<p class="eyebrow">Current records</p>
<h2>Student roster</h2>
</div>
<p id="student-count" class="count"></p>
</div>
<div class="filters">
<label>
Search students
<input id="search-input" type="search" placeholder="Search by ID, name, or program">
</label>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Name</th>
<th scope="col">Program</th>
</tr>
</thead>
<tbody id="student-table-body"></tbody>
</table>
</div>
</section>
</main>
<script src="student-utils.js"></script>
<script src="app.js"></script>
</body>
</html>
The structure is visible, but spacing and layout still depend on browser defaults. The stylesheet turns those raw controls into a responsive two-card interface.
- Select the empty site/styles.css file in the VS Code Explorer sidebar.
- Add the page foundation and hero background by pasting this code:
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #f3f6fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
.hero {
padding: 40px 20px 80px;
color: white;
text-align: center;
background: linear-gradient(135deg, #163a6b, #2563eb);
}
What Does the Page Foundation Control?
- The root rules establish the page font and color palette.
- The universal box-sizing rule keeps element dimensions predictable.
- The hero rules create the blue header with centered white text.
- Save site/styles.css.
- Refresh the page in Safari.
You will see a blue gradient header across the page with centered white text.
Styles Not Loading?
Check that styles.css sits inside the same site folder as index.html. Confirm that the stylesheet link still uses href="styles.css".
Ask for help checking the connection: Why is site/styles.css not changing my roster page?
- Append the hero text and eyebrow styles to site/styles.css by pasting this code:
.hero h1 {
margin: 4px 0 10px;
font-size: clamp(2rem, 5vw, 3.5rem);
}
.hero p:last-child {
max-width: 650px;
margin: 0 auto;
color: #dbeafe;
}
.eyebrow {
margin: 0;
color: #2563eb;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero .eyebrow {
color: #bfdbfe;
}
How Does the Hero Gain Hierarchy?
- The heading scales with the browser width while keeping readable limits.
- The description stays centered inside a maximum width.
- The eyebrow style turns the small label into a visual category marker.
- Save site/styles.css.
- Refresh the page in Safari.
You will see a larger responsive title with a compact uppercase label above it.
Hero Text Still Looks Plain?
Confirm that the selectors include the leading period for .hero and .eyebrow. A missing brace can also prevent the remaining rules from loading.
Ask for help checking the selectors: Help me find why my hero text styles are not applying.
- Append the responsive page layout and card styles by pasting this code:
.layout {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
gap: 24px;
width: min(1100px, calc(100% - 32px));
margin: -48px auto 48px;
align-items: start;
}
.card {
padding: 24px;
border: 1px solid #dbe3ef;
border-radius: 18px;
background: white;
box-shadow: 0 16px 40px rgb(23 32 51 / 10%);
}
h2 {
margin: 0 0 18px;
}
What Creates the Card Layout?
- The layout uses a grid with a narrow form column and a flexible roster column.
- The negative top margin pulls both cards over the lower edge of the hero.
- The card styles add space, rounded corners, and a soft shadow.
- Save site/styles.css.
- Refresh the page in Safari.
You will see the form and roster inside two white cards that overlap the bottom of the header.
Cards Stacking Too Early?
Confirm that the layout selector is .layout and that the HTML uses class="layout". Check the grid-template-columns value for missing parentheses.
Ask for help comparing the layout rules: Why are my roster cards not using the two-column layout?
- Append the form and label layout rules by pasting this code:
form,
label {
display: grid;
gap: 8px;
}
form {
gap: 16px;
}
label {
color: #334155;
font-size: 0.9rem;
font-weight: 700;
}
Why Use Grid for the Form?
Grid places each label above its control with consistent spacing. The larger form gap separates each field group so the form remains easy to scan.
- Save site/styles.css.
- Refresh the page in Safari.
You will see even spacing between the form fields with each label positioned above its control.
Form Spacing Uneven?
Confirm that form, and label share the first rule. Check that the separate form rule uses a 16px gap.
Ask for help checking the form rules: Why is the spacing in my add-student form uneven?
- Append the input and focus styles by pasting this code:
input,
select,
button {
width: 100%;
min-height: 44px;
border-radius: 10px;
font: inherit;
}
input,
select {
border: 1px solid #cbd5e1;
padding: 10px 12px;
color: #172033;
background: white;
}
input:focus,
select:focus {
border-color: #2563eb;
outline: 3px solid #dbeafe;
}
What Makes the Controls Consistent?
- The shared dimensions give inputs, selects, and buttons matching shapes.
- The field styles add readable padding and a visible border.
- The focus rules make the active control easy to identify.
- Save site/styles.css.
- Refresh the page in Safari.
- Select the Student ID field.
You will see rounded controls with a blue outline around the selected field.
Focus Outline Missing?
Check that both focus selectors include a colon before focus. Confirm that no closing brace is missing from the input and select rule.
Ask for help checking the focus styles: Why does my selected form field have no blue outline?
- Append the button and status-message styles by pasting this code:
button {
border: 0;
padding: 10px 16px;
color: white;
font-weight: 800;
cursor: pointer;
background: #2563eb;
}
button:hover {
background: #1d4ed8;
}
.status {
min-height: 1.25rem;
margin: 0;
font-size: 0.9rem;
}
.status.success {
color: #087443;
}
.status.error {
color: #b42318;
}
How Will the Form Show Feedback?
- The button uses a strong blue background for the form's primary action.
- The status area reserves space so messages do not shift the card.
- The success and error classes give each message a distinct color.
- Save site/styles.css.
- Refresh the page in Safari.
- Move the pointer over the Add student button.
You will see a blue button that darkens while the pointer is over it.
Button Still Looks Plain?
Confirm that the selector is the element name button without a leading period. Check that the background color remains inside the button rule.
Ask for help checking the button selector: Why are my button styles not applying?
- Append the roster heading and filter layout styles by pasting this code:
.roster-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
}
.roster-heading h2 {
margin: 4px 0 0;
}
.count {
margin: 0;
color: #475569;
font-weight: 700;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin: 22px 0 16px;
}
.table-wrap {
overflow-x: auto;
}
What Keeps the Roster Organized?
- The heading uses flex layout to separate the title from the record count.
- The filter grid can accommodate more controls as the roster grows.
- The table wrapper allows horizontal scrolling on a narrow screen.
- Save site/styles.css.
- Refresh the page in Safari.
You will see the Current records label aligned with the count area and the search field positioned above the table.
Roster Heading Misaligned?
Confirm that the HTML uses class="roster-heading" on the heading container. Check that the CSS selector uses the same spelling.
Ask for help matching the class: Why is my roster heading not aligned with the count area?
- Append the table styles by pasting this code:
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
th {
color: #475569;
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.empty-state {
padding: 28px 12px;
color: #64748b;
text-align: center;
}
How Does the Table Stay Readable?
- Collapsed borders keep the row dividers clean.
- Cell padding creates enough space around every value.
- Uppercase column headings separate labels from student data.
- Save site/styles.css.
- Refresh the page in Safari.
You will see spaced table headings with a divider beneath each column label.
Table Headings Crowded?
Confirm that the shared rule contains both th and td selectors. Check that a comma separates them.
Ask for help checking the table rules: Why are my roster table headings crowded together?
- Append the narrow-screen layout rules by pasting this code:
@media (max-width: 760px) {
.layout {
grid-template-columns: 1fr;
}
.hero {
padding-bottom: 72px;
}
}
What Makes the Page Responsive?
The media query changes the layout to one column when the browser becomes narrower than 760px. The cards remain readable without squeezing the form beside the table.
- Save site/styles.css.
- Refresh the page in Safari.
- Narrow the Safari window until the cards stack.
You will see the roster card move beneath the form card on the narrow page.
Cards Not Stacking?
Confirm that the media query contains max-width: 760px and closes with two braces. Make the Safari window narrower to activate the rule.
Ask for help checking the responsive rule: Why are my roster cards not stacking on a narrow screen?
✔️ Awesome, I've got everything!
Your roster now has a responsive layout with styled forms, cards, and table headings.
ⓧ I'd like to double check the full code
Compare your site/styles.css file with this complete version.
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #f3f6fb;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
}
.hero {
padding: 40px 20px 80px;
color: white;
text-align: center;
background: linear-gradient(135deg, #163a6b, #2563eb);
}
.hero h1 {
margin: 4px 0 10px;
font-size: clamp(2rem, 5vw, 3.5rem);
}
.hero p:last-child {
max-width: 650px;
margin: 0 auto;
color: #dbeafe;
}
.eyebrow {
margin: 0;
color: #2563eb;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.hero .eyebrow {
color: #bfdbfe;
}
.layout {
display: grid;
grid-template-columns: minmax(260px, 360px) minmax(0, 1fr);
gap: 24px;
width: min(1100px, calc(100% - 32px));
margin: -48px auto 48px;
align-items: start;
}
.card {
padding: 24px;
border: 1px solid #dbe3ef;
border-radius: 18px;
background: white;
box-shadow: 0 16px 40px rgb(23 32 51 / 10%);
}
h2 {
margin: 0 0 18px;
}
form,
label {
display: grid;
gap: 8px;
}
form {
gap: 16px;
}
label {
color: #334155;
font-size: 0.9rem;
font-weight: 700;
}
input,
select,
button {
width: 100%;
min-height: 44px;
border-radius: 10px;
font: inherit;
}
input,
select {
border: 1px solid #cbd5e1;
padding: 10px 12px;
color: #172033;
background: white;
}
input:focus,
select:focus {
border-color: #2563eb;
outline: 3px solid #dbeafe;
}
button {
border: 0;
padding: 10px 16px;
color: white;
font-weight: 800;
cursor: pointer;
background: #2563eb;
}
button:hover {
background: #1d4ed8;
}
.status {
min-height: 1.25rem;
margin: 0;
font-size: 0.9rem;
}
.status.success {
color: #087443;
}
.status.error {
color: #b42318;
}
.roster-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: end;
}
.roster-heading h2 {
margin: 4px 0 0;
}
.count {
margin: 0;
color: #475569;
font-weight: 700;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin: 22px 0 16px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #e2e8f0;
text-align: left;
}
th {
color: #475569;
font-size: 0.78rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.empty-state {
padding: 28px 12px;
color: #64748b;
text-align: center;
}
@media (max-width: 760px) {
.layout {
grid-template-columns: 1fr;
}
.hero {
padding-bottom: 72px;
}
}
Add the browser behavior
The interface needs reusable data rules before it can display records. The helper file normalizes student input and filters the current roster without touching the page directly.
The helper is wrapped so the same functions can later run in both the browser and automated tests. You will add the connected halves before saving the file.
- Select the empty site/student-utils.js file in the VS Code Explorer sidebar.
- Add the input normalization and student-creation logic by pasting this first half:
(function exposeStudentUtils(global) {
"use strict";
function normalizeStudent(student) {
return {
id: String(student.id).trim().toUpperCase(),
name: String(student.name).trim(),
program: String(student.program).trim(),
};
}
function addStudent(students, student) {
const normalized = normalizeStudent(student);
if (!normalized.id || !normalized.name || !normalized.program) {
throw new Error("All student fields are required.");
}
return [...students, normalized];
}
What Does This Logic Do?
- The normalizeStudent() helper trims every field and converts IDs to uppercase.
- The addStudent() function checks that every normalized field has a value.
- A valid student is returned inside a new array so the existing input array remains unchanged.
- Add the filtering and export logic directly below the first half by pasting this code:
function filterStudents(students, query) {
const term = String(query).trim().toLowerCase();
if (!term) {
return [...students];
}
return students.filter((student) =>
[student.id, student.name, student.program].some((value) =>
String(value).toLowerCase().includes(term),
),
);
}
const api = { addStudent, filterStudents };
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
}
global.StudentUtils = api;
})(typeof globalThis !== "undefined" ? globalThis : this);
How Does Filtering Stay Reusable?
- The search term is trimmed and converted to lowercase before matching.
- Each record can match through its ID, name, or program.
- The API exposes addStudent and filterStudents to the browser.
- Save site/student-utils.js.
- Refresh the page in Safari.
You will see the styled roster load without a browser error page. This confirms that the completed helper file can load through the existing script element.
Page Stops Loading?
Confirm that both code sections are present in site/student-utils.js. The final line must close the wrapper that begins on the first line.
Ask for help checking the wrapper: Help me find the syntax error in site/student-utils.js.
✔️ Awesome, I've got everything!
The student helpers are complete and available to the browser.
ⓧ I'd like to double check the full code
Compare your site/student-utils.js file with this complete version.
(function exposeStudentUtils(global) {
"use strict";
function normalizeStudent(student) {
return {
id: String(student.id).trim().toUpperCase(),
name: String(student.name).trim(),
program: String(student.program).trim(),
};
}
function addStudent(students, student) {
const normalized = normalizeStudent(student);
if (!normalized.id || !normalized.name || !normalized.program) {
throw new Error("All student fields are required.");
}
return [...students, normalized];
}
function filterStudents(students, query) {
const term = String(query).trim().toLowerCase();
if (!term) {
return [...students];
}
return students.filter((student) =>
[student.id, student.name, student.program].some((value) =>
String(value).toLowerCase().includes(term),
),
);
}
const api = { addStudent, filterStudents };
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
}
global.StudentUtils = api;
})(typeof globalThis !== "undefined" ? globalThis : this);
The page controller connects those helpers to the form, search field, table, and record count. It also starts the roster with three fictional students.
- Select the empty site/app.js file in the VS Code Explorer sidebar.
- Add the sample data and page references by pasting this code:
"use strict";
const { addStudent, filterStudents } = window.StudentUtils;
let students = [
{ id: "S001", name: "Maya Chen", program: "Computer Science" },
{ id: "S002", name: "Noah Williams", program: "Business" },
{ id: "S003", name: "Amina Patel", program: "Data Science" },
];
const form = document.querySelector("#student-form");
const idInput = document.querySelector("#student-id");
const nameInput = document.querySelector("#student-name");
const programInput = document.querySelector("#student-program");
const searchInput = document.querySelector("#search-input");
const tableBody = document.querySelector("#student-table-body");
const count = document.querySelector("#student-count");
const message = document.querySelector("#form-message");
What Does the Controller Track?
- The students array starts with three fictional records.
- The document queries connect each JavaScript reference to an exact element ID in the HTML.
- The helper functions are read from window.StudentUtils after the helper script loads.
- Save site/app.js.
- Refresh the page in Safari.
You will see the styled form and empty table load normally. This confirms that all eight page references match elements in site/index.html.
Page Turns Blank?
Check each ID in document.querySelector() against the matching ID in site/index.html. Confirm that student-utils.js loads before app.js.
Ask for help matching the references: Help me find the mismatched DOM reference in site/app.js.
- Append the table-rendering function to site/app.js by pasting this code:
function renderStudents(records) {
tableBody.textContent = "";
count.textContent = `${records.length} student${records.length === 1 ? "" : "s"}`;
if (records.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 3;
cell.className = "empty-state";
cell.textContent = "No students match this search.";
row.append(cell);
tableBody.append(row);
return;
}
records.forEach((student) => {
const row = document.createElement("tr");
[student.id, student.name, student.program].forEach((value) => {
const cell = document.createElement("td");
cell.textContent = value;
row.append(cell);
});
tableBody.append(row);
});
}
How Are Records Rendered?
- The function clears the previous table rows before each render.
- The count uses singular or plural text based on the number of visible records.
- Each student becomes one table row with cells for ID, name, and program.
- Append the current-view and event behavior directly below renderStudents() by pasting this code:
function renderCurrentStudents() {
renderStudents(filterStudents(students, searchInput.value));
}
form.addEventListener("submit", (event) => {
event.preventDefault();
try {
students = addStudent(students, {
id: idInput.value,
name: nameInput.value,
program: programInput.value,
});
form.reset();
message.textContent = "Student added.";
message.className = "status success";
renderCurrentStudents();
} catch (error) {
message.textContent = error.message;
message.className = "status error";
}
});
searchInput.addEventListener("input", renderCurrentStudents);
renderCurrentStudents();
How Does the Page React to Input?
- The current-view function filters the latest student array with the search value.
- The submit handler sends the three form values through addStudent() before rendering the result.
- The input listener rerenders the table whenever the search text changes.
- The final function call displays the sample records as soon as the page loads.
- Save site/app.js.
- Refresh the page in Safari.
You will see three rows for Maya Chen, Noah Williams, and Amina Patel. The count will show 3 students.
- Enter Data Science in the Search students field.
You will see only Amina Patel in the table. The count will change to 1 student.
- Clear the Search students field.
All three fictional records will return to the table.
Records Not Appearing?
Confirm that renderCurrentStudents(); is the final line in site/app.js. Check that the sample array and both rendering functions use the exact names shown above.
Ask for help tracing the render: Why are my three sample students missing from the roster?
✔️ Awesome, I've got everything!
Your roster displays three fictional students and filters them as you type.
ⓧ I'd like to double check the full code
Compare your site/app.js file with this complete version.
"use strict";
const { addStudent, filterStudents } = window.StudentUtils;
let students = [
{ id: "S001", name: "Maya Chen", program: "Computer Science" },
{ id: "S002", name: "Noah Williams", program: "Business" },
{ id: "S003", name: "Amina Patel", program: "Data Science" },
];
const form = document.querySelector("#student-form");
const idInput = document.querySelector("#student-id");
const nameInput = document.querySelector("#student-name");
const programInput = document.querySelector("#student-program");
const searchInput = document.querySelector("#search-input");
const tableBody = document.querySelector("#student-table-body");
const count = document.querySelector("#student-count");
const message = document.querySelector("#form-message");
function renderStudents(records) {
tableBody.textContent = "";
count.textContent = `${records.length} student${records.length === 1 ? "" : "s"}`;
if (records.length === 0) {
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.colSpan = 3;
cell.className = "empty-state";
cell.textContent = "No students match this search.";
row.append(cell);
tableBody.append(row);
return;
}
records.forEach((student) => {
const row = document.createElement("tr");
[student.id, student.name, student.program].forEach((value) => {
const cell = document.createElement("td");
cell.textContent = value;
row.append(cell);
});
tableBody.append(row);
});
}
function renderCurrentStudents() {
renderStudents(filterStudents(students, searchInput.value));
}
form.addEventListener("submit", (event) => {
event.preventDefault();
try {
students = addStudent(students, {
id: idInput.value,
name: nameInput.value,
program: programInput.value,
});
form.reset();
message.textContent = "Student added.";
message.className = "status success";
renderCurrentStudents();
} catch (error) {
message.textContent = error.message;
message.className = "status error";
}
});
searchInput.addEventListener("input", renderCurrentStudents);
renderCurrentStudents();
Experience the duplicate-ID gap
The roster looks complete because adding and searching both work. A direct behavior check reveals whether the data rule is as strong as the interface.
Keep the Demo Fictional
- Use fictional names and student records throughout this public project.
- Keep private information and credentials outside the repository.
Before you submit this record, do you think the roster will protect the existing S001 ID?
- Enter S001 in the Student ID field.
- Enter Jordan Lee in the Full name field.
- Select Data Science from the Program menu.
- Click Add student.
You will see a fourth row with the ID S001. The count will change to 4 students.
The Duplicate Is Intentional
You have exposed the data-quality gap hidden behind the working form. The interface accepts a duplicate ID because addStudent() currently checks required fields without comparing IDs.
🙋♀️ Duplicate Row Not Appearing?
Clear the search field before submitting the record. Confirm that the ID is S001 and that all three form fields contain values.
Ask for help tracing the form submission: Why does my duplicate S001 record not appear in the roster?
You found the exact flaw that the quality gate needs to catch. Next, you will turn this duplicate-ID behavior into an automated test and watch GitHub Actions report the failure.
Let CI Catch the Duplicate-ID Bug
Your roster now works in Safari. However, the duplicate row proved that a successful browser interaction can still leave invalid data behind.
An automated test gives the duplicate-ID rule a repeatable pass-or-fail signal. The Node.js test runner checks that rule locally.
GitHub Actions repeats the same check after pushes or pull requests to main. This turns continuous integration into a visible quality gate for your roster.
In this step, get ready to:
- Write automated checks for normalization, text search, and duplicate-ID rejection.
- Create a GitHub Actions job that runs the checks on pushes and pull requests.
- Push the roster code and inspect the workflow result.
Write the quality checks
Each test gives one behavior a small input and compares the result with a clear expectation. Running these checks separately makes it easier to see which rule creates the release blocker.
- Switch back to tests/student-utils.test.js in Visual Studio Code.
- Populate the file with the test imports plus normalization check by pasting this code:
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { addStudent, filterStudents } = require("../site/student-utils.js");
test("normalizes a student before adding it", () => {
const result = addStudent([], {
id: " s004 ",
name: " Jordan Lee ",
program: " Data Science ",
});
assert.deepEqual(result, [
{ id: "S004", name: "Jordan Lee", program: "Data Science" },
]);
});
What does this test do?
- The node:test import provides the test function that defines each check.
- The node:assert/strict import provides strict comparisons for actual and expected results.
- The test passes padded input to addStudent. It uses assert.deepEqual to confirm that the saved record has normalized values.
- Save tests/student-utils.test.js by pressing Cmd+S.
- Check the normalization rule in the VS Code terminal by running this command:
node --test
What does this command check?
The node --test command discovers student-utils.test.js automatically. It runs every saved test in the file.
You'll see one passing test with no failures. Your normalization behavior now has a repeatable check.
Is the first test failing?
- Confirm that tests/student-utils.test.js is saved inside the tests folder.
- Check that the import path points to ../site/student-utils.js.
Help me troubleshoot the normalization test.
The first check protects how records are stored. The next check protects how people find those records.
- Add the text-search test below the normalization test by pasting this code:
test("filters students by ID, name, or program", () => {
const records = [
{ id: "S001", name: "Maya Chen", program: "Computer Science" },
{ id: "S002", name: "Amina Patel", program: "Data Science" },
];
assert.deepEqual(filterStudents(records, "data"), [records[1]]);
});
How does the search test work?
- The test creates two sample records that use different academic programs.
- The query data should match the Data Science record through its program value.
- The assert.deepEqual check confirms that filterStudents returns only the matching record.
- Save tests/student-utils.test.js by pressing Cmd+S.
- Check both saved behaviors by running this command:
node --test
Why run the same command again?
The test runner reloads the saved file on every run. It now evaluates both independent behaviors in one test session.
You'll see two passing tests with no failures. Normalization and text search are both protected.
Is the search test failing?
- Confirm that the search query is lowercase data.
- Check that the expected result contains records[1].
Help me troubleshoot the text-search test.
Two behaviors now have passing checks. The final test writes the data-integrity rule that the browser could not enforce.
- Add the duplicate-ID test below the search test by pasting this code:
test("rejects duplicate student IDs", () => {
const records = [
{ id: "S001", name: "Maya Chen", program: "Computer Science" },
];
assert.throws(
() => addStudent(records, {
id: " s001 ",
name: "Another Student",
program: "Business",
}),
/Student ID already exists\./,
);
});
What rule does this test define?
- The existing record uses S001 as its student ID.
- The new record uses a padded lowercase version of the same ID. This confirms that duplicate detection must compare normalized values.
- The assert.throws check expects addStudent to reject the record with Student ID already exists.
- Save tests/student-utils.test.js by pressing Cmd+S.
Before you run all three tests, do you expect the duplicate-ID rule to behave like the first two rules?
- Reveal how the current roster handles the new rule by running this command:
node --test
Why does this run matter?
The command evaluates all three expectations against the same utility code used by the browser. Any failed test gives the process a failing exit code.
You'll see two passing tests and one failing test. The duplicate-ID check fails because addStudent returns another record instead of throwing an error.
This Failure Is Intentional
The test expected addStudent to throw an error with the message Student ID already exists. The current function accepts the duplicate record.
This failing check turns the browser's hidden data problem into a release blocker that automation can detect.
🙋♀️ Seeing a different test result?
- Compare the first two tests with the complete reference below if more than one test fails.
- Inspect addStudent in site/student-utils.js if all three tests pass. The current version should only validate required fields before returning the new array.
Help me reproduce the intended duplicate-ID failure.
✔️ I have all three tests
Your test file contains all three quality checks. Keep the duplicate-ID failure in place for this step.
ⓧ I'd like to double check the full code
Compare your saved test file with this complete reference.
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { addStudent, filterStudents } = require("../site/student-utils.js");
test("normalizes a student before adding it", () => {
const result = addStudent([], {
id: " s004 ",
name: " Jordan Lee ",
program: " Data Science ",
});
assert.deepEqual(result, [
{ id: "S004", name: "Jordan Lee", program: "Data Science" },
]);
});
test("filters students by ID, name, or program", () => {
const records = [
{ id: "S001", name: "Maya Chen", program: "Computer Science" },
{ id: "S002", name: "Amina Patel", program: "Data Science" },
];
assert.deepEqual(filterStudents(records, "data"), [records[1]]);
});
test("rejects duplicate student IDs", () => {
const records = [
{ id: "S001", name: "Maya Chen", program: "Computer Science" },
];
assert.throws(
() => addStudent(records, {
id: " s001 ",
name: "Another Student",
program: "Business",
}),
/Student ID already exists\./,
);
});
How to use this reference
This reference shows the exact test order and spacing for the complete file. The final test should remain failing during this step.
Create the CI test job
A local check only protects the roster when someone remembers to run it. A workflow makes the same test automatic whenever code reaches main or targets it through a pull request.
- Switch back to the empty .github/workflows/ci-cd.yml file in VS Code.
- Define the workflow triggers plus test job by pasting this code:
name: Student Roster CI/CD
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
test:
name: Test student logic
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24.20.0'
- name: Run tests
run: node --test
How does this workflow create a gate?
- The push and pull_request triggers limit automatic runs to changes involving main.
- The test job uses the ubuntu-latest hosted runner.
- The actions/checkout@v6 step copies the repository files onto the runner.
- The actions/setup-node@v7 step installs Node.js 24.20.0 for a consistent test environment.
- The final step runs node --test. A failed test gives the process exit code 1. GitHub Actions uses that result to mark the job as failed.
- Save .github/workflows/ci-cd.yml by pressing Cmd+S.
- Select the Source Control icon in the left Activity Bar.
- Confirm that ci-cd.yml appears under Changes.
Workflow file missing from Source Control?
- Confirm that the file is saved at .github/workflows/ci-cd.yml inside student-roster-cicd.
- Check that the filename ends in .yml.
Help me locate my GitHub Actions workflow file.
✔️ My test workflow is ready
Your workflow now has the triggers and test job required for the quality gate.
ⓧ I'd like to double check the full code
Compare your saved workflow with this complete CI-only version.
name: Student Roster CI/CD
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
test:
name: Test student logic
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24.20.0'
- name: Run tests
run: node --test
What should match?
This reference contains the complete workflow for this step. It includes one job named Test student logic.
Push the known failure
The workflow runs on GitHub after your files reach the remote repository. Syncing the current code lets the hosted runner judge the same duplicate-ID rule you tested locally.
- Return to the Source Control sidebar in VS Code.
- Click the plus control beside Changes to stage all files.
- Enter Add roster and failing duplicate test in the commit message field.
- Select Commit.
- Select Sync Changes.
Having trouble syncing?
- Confirm that VS Code shows the commit in the Source Control history.
- Complete the GitHub authorization prompt if VS Code requests access to the remote repository.
Help me sync this commit from VS Code.
Before you inspect the remote run, do you expect GitHub Actions to reach a different conclusion from your local test runner?
- Open your student-roster-cicd repository on GitHub.
- Select the Actions tab.
- Select the latest Student Roster CI/CD workflow run.
- Select the Test student logic job.
- Expand the Run tests step.
You'll see two passing tests and one failing duplicate-ID test. The workflow run is red because the test job received the same failing result as your local run.
Why Is Red Progress?
That red run has done its job. Your pipeline detected invalid roster behavior before any deployment could happen.
The browser accepted the duplicate record. The automated gate produced durable evidence of the defect.
🙋♀️ No workflow run showing?
- Confirm that .github/workflows/ci-cd.yml appears in the remote repository.
- Confirm that the synced commit reached the main branch.
- Check that the workflow file remains inside .github/workflows.
Help me find my missing workflow run.
Your CI gate now exposes the duplicate-ID blocker locally and on GitHub. Next, you'll update addStudent and watch the same pipeline turn green.
Fix the Bug and Turn the Pipeline Green
Your red GitHub Actions run has done its job. It proved that the current roster breaks the duplicate-ID rule.
The failing quality gate has identified the exact release blocker.
You will compare normalized IDs before accepting a record. The same test must pass locally and on GitHub before the pipeline turns green.
In this step, get ready to:
- Add normalized duplicate-ID protection to addStudent().
- Prove the fix with automated tests and the browser roster.
- Commit the fix and confirm the remote test job turns green.
Add duplicate-ID validation
A duplicate check needs one consistent ID format before it compares records. Normalization removes extra spaces and standardizes letter casing.
- Select site/student-utils.js in the Explorer sidebar of Visual Studio Code.
- Inside addStudent(), find the block that throws All student fields are required..
- Paste the following code directly below that block:
const duplicateExists = students.some(
(existing) => String(existing.id).trim().toUpperCase() === normalized.id,
);
if (duplicateExists) {
throw new Error("Student ID already exists.");
}
What does this code do?
- The duplicateExists value records whether the roster already contains the incoming ID.
- The comparison converts each existing ID to the same trimmed uppercase format as the incoming ID.
- The error stops addStudent() before the duplicate record reaches the returned array.
- Save site/student-utils.js.
Before you run the tests, predict whether all three checks pass with this validation in place.
- Test the updated validation in the VS Code terminal from earlier by running this command:
node --test
What does this test prove?
The built-in test runner in Node.js checks normalization and search behavior. It also confirms that duplicate IDs now cause an error.
A zero-failure result proves the release blocker is fixed in the shared student logic.
You'll see all three tests pass with zero failures.
Still Seeing a Failed Test?
- Confirm the duplicate check sits above the return line in addStudent().
- Check that the comparison uses normalized.id for the incoming record.
- Check that the thrown message matches Student ID already exists. exactly.
Help me diagnose the remaining failed test.
✔️ Awesome, I've got everything!
Great. Your saved utility now rejects normalized duplicate IDs.
ⓧ I'd like to double check the full code
Compare your site/student-utils.js file with this completed version:
(function exposeStudentUtils(global) {
"use strict";
function normalizeStudent(student) {
return {
id: String(student.id).trim().toUpperCase(),
name: String(student.name).trim(),
program: String(student.program).trim(),
};
}
function addStudent(students, student) {
const normalized = normalizeStudent(student);
if (!normalized.id || !normalized.name || !normalized.program) {
throw new Error("All student fields are required.");
}
const duplicateExists = students.some(
(existing) => String(existing.id).trim().toUpperCase() === normalized.id,
);
if (duplicateExists) {
throw new Error("Student ID already exists.");
}
return [...students, normalized];
}
function filterStudents(students, query) {
const term = String(query).trim().toLowerCase();
if (!term) {
return [...students];
}
return students.filter((student) =>
[student.id, student.name, student.program].some((value) =>
String(value).toLowerCase().includes(term),
),
);
}
const api = { addStudent, filterStudents };
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
}
global.StudentUtils = api;
})(typeof globalThis !== "undefined" ? globalThis : this);
Prove the fix in Safari and GitHub Actions
The automated tests prove that the shared function rejects a duplicate. Safari shows whether the form turns that rule into visible protection.
- Switch back to Safari from earlier.
- Click File in Safari's menu bar.
- Select Open File.
- Select site/index.html inside the student-roster-cicd folder.
- Enter S001 in the Student ID field.
- Enter Another Student in the Full name field.
- Select Business in the Program field.
Before you submit the form, predict whether the roster count changes this time.
- Click Add student.
You'll still see three students. The status message displays Student ID already exists..
Still Seeing a Fourth Row?
- Confirm Safari reopened the saved site/index.html file from your current repository.
- Confirm site/student-utils.js contains the duplicateExists check above its return line.
Help me find why Safari still accepts the duplicate record.
The local fix now passes both the automated check and the browser check. Your remote quality gate needs the same change.
- Select the Source Control icon in VS Code's left sidebar.
- Stage site/student-utils.js with the plus control beside Changes.
- Enter Reject duplicate student IDs in the commit message box.
- Select Commit.
- Select Sync Changes.
Before you inspect the newest run, predict whether the same test job stays red or turns green.
- Return to the student-roster-cicd repository on GitHub.
- Select the Actions tab.
- Open the newest Student Roster CI/CD workflow run.
- Select the Test student logic job.
You'll see the newest workflow run in green. The Test student logic job shows three passing tests.
That red gate is now green. Your pipeline has proof that duplicate student IDs cannot pass its automated check.
Is the Newest Workflow Still Red?
- Open the failed test step to confirm which test did not pass.
- Return to Source Control in VS Code to confirm no change remains unsynced.
- Compare the remote site/student-utils.js file with the completed version above.
Help me troubleshoot the red workflow after syncing the duplicate-ID fix.
Your tests now protect the roster on your Mac and in the remote workflow. Next, you will make a GitHub Pages deployment wait for that green test job.
Deploy Only After Tests Pass
Your duplicate-ID fix has turned the GitHub Actions test job green. The roster now has a quality gate that can protect every release.
Green tests can now control what reaches the public site. You will connect GitHub Pages to a deployment job that waits for the test job to succeed.
In this step, get ready to:
- Enable GitHub Pages as the repository's publishing destination.
- Add a deployment job that depends on the passing test job.
- Publish the tested roster through the completed workflow.
Enable GitHub Pages
GitHub Pages needs to accept deployments from your workflow. Setting the publishing source connects the repository to that release path.
- Switch back to the public student-roster-cicd repository in GitHub.
- Select Settings in the repository navigation.
- Select Pages under Code and automation in the sidebar.
- Select GitHub Actions for Source.
- Confirm that Source now shows GitHub Actions.
You have connected the publishing destination. GitHub Pages can now accept the tested site from your workflow.
Add the gated deployment job
A job dependency turns your passing tests into a release gate. The needs: test setting prevents deployment from starting until the test job succeeds.
The push condition limits deployment to repository pushes. Pull requests still run the tests without publishing the site.
- Return to Visual Studio Code from earlier.
- Select .github/workflows/ci-cd.yml from the repository file list.
- Find the final run: node --test line in the existing test job.
- Place the deployment job below the existing test job by adding this code:
deploy:
name: Deploy to GitHub Pages
if: github.event_name == 'push'
needs: test
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: './site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
How does the release gate work?
- The if: github.event_name == 'push' condition keeps deployment limited to push events.
- The needs: test dependency makes a successful test job mandatory.
- The permissions block gives the job authority to publish Pages content. It also lets GitHub verify the deployment identity.
- The upload step packages ./site as a deployment artifact.
- The final action publishes that artifact to the github-pages environment.
- Save .github/workflows/ci-cd.yml.
- Select Source Control in the Visual Studio Code sidebar.
You will see .github/workflows/ci-cd.yml listed as modified. This confirms the deployment job is saved locally.
Workflow file showing a problem?
- Check that deploy: aligns with test: beneath jobs:.
- Keep needs: test inside the deployment job.
- Confirm the artifact path remains ./site.
Help me check the indentation in my deployment job.
✔️ Awesome, I've got everything!
Your workflow now contains the passing test job and its dependent deployment job.
ⓧ I'd like to double check the full code
The complete workflow should match this file:
name: Student Roster CI/CD
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
test:
name: Test student logic
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24.20.0'
- name: Run tests
run: node --test
deploy:
name: Deploy to GitHub Pages
if: github.event_name == 'push'
needs: test
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v4
with:
path: './site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Publish and inspect the live roster
The completed workflow turns your next push into a release event. GitHub Actions runs the quality checks before it publishes the roster.
- Select the plus control beside .github/workflows/ci-cd.yml in Source Control to stage the file.
- Enter Deploy tested roster to GitHub Pages in the commit message field.
- Select Commit.
- Select Sync Changes.
The first Pages deployment may spend a little while preparing the site. Give the workflow time to complete if the deployment job is still running.
Before you inspect the run, which job do you expect to finish before deployment can start?
- Switch back to the student-roster-cicd repository in GitHub.
- Select the Actions tab.
- Open the newest Student Roster CI/CD workflow run.
You will see Test student logic finish successfully before Deploy to GitHub Pages starts. Both jobs will finish green.
- Select Deploy to GitHub Pages in the workflow graph.
- Select the deployment URL shown by the successful job.
The URL follows https://YOUR-USERNAME.github.io/student-roster-cicd/. You will see the tested roster with its three fictional student records.
You have shipped the complete release gate. Every push to main now has to pass the test job before the roster reaches the live site.
Deployment not reaching the live site?
- Confirm that Source still shows GitHub Actions on the Pages settings page.
- Check that Test student logic completed successfully in the newest workflow run.
- Confirm that the upload step still uses ./site as its artifact path.
Help me diagnose my GitHub Pages deployment.
Secret mission
Add a Program Filter Quality Gate
Add an academic-program filter that works alongside search. Protect the new behavior with a fourth automated test before the live roster updates.
Clean Up Your Resources
Clean Up Your Resources
Your public GitHub project costs $0 on the planned GitHub Free path while the repository remains public. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Keep Public Data Fictional
Your repository and project site are public. Keep every student record fictional.
Never add real student records, private information, or credentials.
Resources you used:
- A public GitHub repository named student-roster-cicd.
- A GitHub Actions workflow using standard GitHub-hosted runners.
- A public GitHub Pages project site.
Keep everything running
No action is needed. Choose this option if you want the complete project available for future demos.
- Leave the public student-roster-cicd repository active.
- Keep the Student Roster CI/CD workflow enabled.
- Keep the GitHub Pages site published.
The planned path remains at $0 while the repository remains public.
Pause - I'll come back to this later
This option removes the live deployment while preserving the repository. It also stops new workflow runs until you return.
- Return to GitHub.
- Select the public student-roster-cicd repository.
- Select Settings in the repository navigation.
- Select Pages under Code and automation.
- Select Unpublish site.
- Select the repository's Actions tab.
- Select the Student Roster CI/CD workflow.
- Select Disable workflow.
The repository remains available with its files intact. The current Pages deployment is removed.
A later successful workflow run can publish the site again after you select Enable workflow.
Delete - I don't want to use this again
This option removes all project resources from GitHub. The deletion is permanent.
Remove the Live Deployment
- Return to GitHub.
- Select the public student-roster-cicd repository.
- Select Settings in the repository navigation.
- Select Pages under Code and automation.
- Select Unpublish site.
Disable the Workflow
- Select the repository's Actions tab.
- Select the Student Roster CI/CD workflow.
- Select Disable workflow.
Delete the Repository
Deleting the repository is permanent, so take a moment before continuing. GitHub asks you to confirm the repository before removing it.
- Select Settings in the repository navigation.
- Select General.
- Scroll to Danger Zone.
- Select Delete this repository.
- Complete GitHub's confirmation prompts.
The public repository, workflow, and Pages site are now removed from GitHub.
Nice Work!
Nice Work!
You did it! ShipSafe Student Roster now uses automated tests in GitHub Actions to protect every GitHub Pages release.
You've learned how to:
- Build a responsive student roster that adds fictional records. Its search finds students by ID, name, or program. Duplicate-ID protection keeps each record unique.
- Turn a duplicate-ID defect into an automated test. You watched the CI/CD pipeline fail on purpose. After your fix, the same gate passed locally and on GitHub.
- Create a two-job workflow where deployment depends on the test job. Successful pushes now publish the tested roster to its live site.
- Complete the optional Secret Mission by adding an academic-program filter. A fourth automated test now protects that feature before deployment.
Ready to quiz yourself?