GxP Change Portal with GitHub Actions
Deploy a GxP-inspired review portal with GitHub Actions and Terraform checks.
Introduction
30 Second Summary
Important work often ends up as a single line on a resume. Without a live example, your experience stays trapped in bullet points.
In this project, you will create a public GxP-inspired change review portal through GitHub Actions for deployment to GitHub Pages. The same repository will automatically validate a Terraform blueprint for a private Amazon S3 archive.
What You'll Build
At your public URL, selecting CR-1042 reveals a High risk review with Pending QA status beside a traceable audit timeline.
By the end of this project, you'll have:
- An interactive review queue where each synthetic record opens its complete quality review in a detail panel.
- A public portfolio URL that displays the portal through a GitHub-generated Pages workflow.
- A green infrastructure check proving the private archive blueprint passes its complete validation workflow without cloud credentials.
- Secret Mission: Add a status filter that narrows the live review queue to a selected approval state. Prove the source update triggers automatic redeployment.
Are there any prerequisites?
You'll need a Linux device with Git, Visual Studio Code, a GitHub account, plus permission to install Terraform CLI 1.17.1. You do not need an AWS account because this project creates no cloud resources.
Before We Start
Before the hands-on work begins, this checkpoint locks in your commitment to a public portfolio demo that uses synthetic data only. Your explanation will connect GxP-inspired change control with GitHub Actions, GitHub Pages, and Terraform validation.
Set Up Terraform and the Repository
A repeatable Terraform toolchain keeps local differences from turning into confusing automation failures. A clear repository structure also gives each part of your portfolio project a predictable home.
You will install Terraform CLI 1.17.1. You will also prepare a local Git repository in Visual Studio Code for the portal files and its future GitHub Actions workflow.
In this step, get ready to:
- Install Terraform CLI 1.17.1 on your Linux device.
- Create the local repository structure on your Desktop.
- Confirm your Git identity and add the Terraform ignore rules.
Install and verify Terraform
Terraform checks infrastructure configuration before any resources are created. Using the pinned CLI version keeps your local checks aligned with the automated checks you add later.
- Check your installed Terraform version by running this command:
terraform version
What does this command show?
The command prints the installed Terraform CLI version. The first line tells you which setup path to follow.
✔️ I see version 1.17.1
Your Terraform CLI matches the project pin. You can continue to the shared verification below.
ⓧ I see an older version
Your installed Terraform version needs an upgrade to 1.17.1. Your Linux distribution determines which package repository command you use.
These commands request administrator access. Your password remains hidden while you type it.
- Choose your Linux distribution below.
Ubuntu or Debian
- Upgrade Terraform from HashiCorp's package repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
What do these commands do?
- The first command installs HashiCorp's package-signing key.
- The second command adds the HashiCorp package repository for your Linux release.
- The final command refreshes the package index before installing Terraform.
The package refresh can take a few minutes. Let it finish before continuing.
RHEL
- Upgrade Terraform from HashiCorp's RHEL repository by running these commands:
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install terraform
What do these commands do?
- The first command installs the repository-management utilities.
- The second command adds HashiCorp's RHEL repository.
- The final command installs Terraform from that repository.
The package download can take a few minutes. Let it finish before continuing.
Fedora
- Upgrade Terraform from HashiCorp's Fedora repository by running these commands:
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo yum list available | grep hashicorp
sudo dnf -y install terraform
What do these commands do?
- The first command adds HashiCorp's Fedora repository.
- The second command confirms that HashiCorp packages are available.
- The final command installs Terraform with Fedora's package manager.
The package download can take a few minutes. Let it finish before continuing.
Upgrade did not complete?
Confirm that you selected the commands for your Linux distribution. Rerun that distribution's commands in order if an earlier package step stopped.
Still stuck? Help me troubleshoot my Terraform upgrade.
ⓧ Command not found
Terraform is not installed yet. Your Linux distribution determines which package repository command you use.
These commands request administrator access. Your password remains hidden while you type it.
- Choose your Linux distribution below.
Ubuntu or Debian
- Install Terraform from HashiCorp's package repository by running these commands:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
What do these commands do?
- The first command installs HashiCorp's package-signing key.
- The second command adds the HashiCorp package repository for your Linux release.
- The final command refreshes the package index before installing Terraform.
The first installation can take a few minutes. Let it finish before continuing.
RHEL
- Install Terraform from HashiCorp's RHEL repository by running these commands:
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo yum -y install terraform
What do these commands do?
- The first command installs the repository-management utilities.
- The second command adds HashiCorp's RHEL repository.
- The final command installs Terraform from that repository.
The first installation can take a few minutes. Let it finish before continuing.
Fedora
- Install Terraform from HashiCorp's Fedora repository by running these commands:
wget -O- https://rpm.releases.hashicorp.com/fedora/hashicorp.repo | sudo tee /etc/yum.repos.d/hashicorp.repo
sudo yum list available | grep hashicorp
sudo dnf -y install terraform
What do these commands do?
- The first command adds HashiCorp's Fedora repository.
- The second command confirms that HashiCorp packages are available.
- The final command installs Terraform with Fedora's package manager.
The first installation can take a few minutes. Let it finish before continuing.
Installation did not complete?
Confirm that you selected the commands for your Linux distribution. Rerun that distribution's commands in order if an earlier package step stopped.
Still stuck? Help me troubleshoot my Terraform installation.
Before you check again, what version do you expect the first output line to report?
- Verify the installed Terraform version by running this command:
terraform version
What should you see?
The first line reports Terraform v1.17.1. This confirms that the pinned CLI is available from your terminal.
That is the toolchain ready. Your machine can now run the same Terraform version that the later workflow uses.
Create and open the repository structure
The portal files and infrastructure blueprint need separate locations inside one repository. The workflow directory also needs GitHub's expected folder structure before you add automation.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
This changes the terminal's current location to your Desktop. The project folder you create next stays easy to find.
- Create the project folder by running this command:
mkdir gxp-change-review-portal
What does this command do?
This creates the gxp-change-review-portal folder on your Desktop. It becomes the home for every local project file.
- Move into the project folder by running this command:
cd gxp-change-review-portal
What does this command do?
This moves the terminal into gxp-change-review-portal. New files and directories now land inside the project.
- Initialize the repository on the main branch by running this command:
git init -b main
What does this command do?
This initializes a Git repository with main as its first branch. Git can now track changes inside the project folder.
You will see a message confirming that Git initialized an empty repository. Your local change history now has a home.
- Create the infrastructure directory by running this command:
mkdir infra
What does this command do?
This creates the empty infra/ directory. The Terraform blueprint goes there in a later step.
- Create the workflow directory by running this command:
mkdir -p .github/workflows
What does this command do?
This creates .github/ with an empty workflows/ directory inside it. The parent option creates both levels in one command.
- Open the current project folder in Visual Studio Code by running this command:
code .
What does this command do?
The period represents the terminal's current folder. Visual Studio Code opens gxp-change-review-portal as the workspace.
- Check the Explorer sidebar in Visual Studio Code.
You will see infra/ and .github/workflows/ inside the workspace. Both directories are empty.
Visual Studio Code did not open?
- Restart your terminal if Visual Studio Code was installed while the terminal was already open.
- Start Visual Studio Code from your Linux application launcher if the terminal cannot find the command.
- Select File in the top menu.
- Select Open Folder.
- Choose gxp-change-review-portal from your Desktop.
Need another pair of eyes? Help me open my project folder in Visual Studio Code.
Confirm your Git identity and ignore rules
Git stores an author name in every commit. It also stores an author email address.
Checking both values now prevents your first commit from stopping later. The ignore rules also keep local Terraform artifacts out of your public repository.
- Return to the terminal from earlier.
- Check your Git identity by running these commands:
git config user.name
git config user.email
What do these commands show?
The first command prints the author name Git currently uses. The second command prints the author email address.
A blank line means that value has not been configured.
✔️ I see my name and email
Your Git identity is ready. Future commits in this repository can record you as the author.
ⓧ One or both values are empty
The missing identity value needs to be configured before your first commit. Global settings apply to repositories created by your Linux user.
- Replace Your Name with the author name you want attached to your commits.
- Replace you@example.com with the author email address you want attached to your commits.
- Configure both identity values by running these commands:
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
What do these commands do?
These commands save your author identity in Git's global configuration. Git uses these values unless a repository has its own identity settings.
- Confirm the saved identity by running these commands again:
git config user.name
git config user.email
What should you see?
You will see your author name on the first line. You will see your author email address on the second line.
Identity still missing?
Confirm that both configuration commands include the global option. Keep a name containing spaces inside double quotes.
Need help with the result? Help me configure my Git identity.
- Switch back to Visual Studio Code.
- Select the new-file icon beside gxp-change-review-portal in the Explorer sidebar.
- Enter .gitignore as the file name.
You will see .gitignore at the top level of the workspace. The empty editor is ready for the repository rules.
- Add the Terraform ignore rules by copying this code into .gitignore:
.terraform/
*.tfstate
*.tfstate.*
crash.log
What do these rules protect?
- The .terraform/ rule excludes downloaded provider files.
- The two state patterns exclude Terraform state files and their related copies.
- The final rule excludes Terraform crash logs.
These artifacts belong to local Terraform operations. Excluding them keeps the public repository focused on source files.
- Save .gitignore.
- Confirm that .gitignore appears beside infra/ and .github/ in the Explorer sidebar.
You now have the first project file beside both empty directories. The repository is ready for the portal files you create next.
Do not see .gitignore?
Confirm that the file name starts with a period. Make sure you created it directly inside gxp-change-review-portal.
Still missing? Help me fix my .gitignore location.
✔️ Awesome, I've got everything!
Great. Double-check that you saved .gitignore.
The infra/ directory remains empty. The .github/workflows/ directory also remains empty.
ⓧ I'd like to double check the full code
.terraform/
*.tfstate
*.tfstate.*
crash.log
How to compare this file
Compare each line with your saved .gitignore file. The infra/ directory and .github/workflows/ directory should still be empty.
Before the final check, what first line do you expect Terraform to report?
- Verify the Terraform CLI version one final time by running this command:
terraform version
What should you see?
The first line reports Terraform v1.17.1. This proves the pinned CLI is available for the project.
- Return to Visual Studio Code.
- Expand .github/ in the Explorer sidebar.
- Confirm that .github/workflows/ is empty.
- Confirm that infra/ is empty.
- Check that the branch indicator shows main.
You will see both empty directories inside the local repository. The branch indicator confirms that future commits start on main.
Your local toolchain and repository foundation are ready. Next up, you will turn this structure into a working change review portal.
Build the Change Review Portal
Your repository is ready for its first working feature. The next goal is a visible portal that shows how structured change reviews move from a queue into detailed evidence.
This first version proves the project is useful before you add infrastructure or GitHub Actions automation. The working proof stays on your computer for now.
In this step, get ready to:
- Create the dashboard structure for three synthetic change records.
- Style the portal as a responsive review workspace.
- Make each change record update the detail panel.
Create the dashboard structure
HTML gives the portal its visible structure. You will build a valid page shell first so each addition has an immediate browser check.
- Select the gxp-change-review-portal folder in Visual Studio Code's file sidebar.
- Use the new-file control beside the folder to create index.html.
- Create the valid page shell by pasting this code into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="A synthetic GxP change review portal for a GitHub Actions portfolio project.">
<title>GxP Change Review Portal</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="page-shell">
</main>
</body>
</html>
What does this structure do?
- The metadata describes the page as a synthetic portfolio demonstration.
- The linked styles.css file provides the visual design.
- The deferred app.js file runs after the browser parses the page.
- The page-shell element holds every visible portal section.
- Save index.html.
- Right-click index.html in Visual Studio Code's file sidebar.
- Select the option that reveals the file in your Linux file manager.
- Double-click index.html to open it in your default browser.
You should see GxP Change Review Portal in the browser tab. That title confirms the local page is loading from the correct file.
Browser tab missing the title?
- Confirm that index.html sits directly inside gxp-change-review-portal.
- Check that the title element remains inside the head element.
- Save the file before reopening it from your Linux file manager.
Still stuck? Help me diagnose why my local HTML page does not show the expected browser title.
The portal header establishes the public-demo boundary. The summary cards show the review workload before the learner inspects individual records.
- Place your cursor between the opening and closing main tags in index.html.
- Add the portal header and summary cards by pasting this code:
<header class="hero">
<div>
<p class="eyebrow">Quality Systems Portfolio Demo</p>
<h1>GxP Change Review Portal</h1>
<p class="hero-copy">Review synthetic system changes, validation impact, risk, and approval status.</p>
</div>
<span class="demo-badge">Synthetic data only</span>
</header>
<section class="metrics" aria-label="Change review summary">
<article class="metric-card"><span>Open changes</span><strong>3</strong></article>
<article class="metric-card"><span>Pending QA</span><strong>2</strong></article>
<article class="metric-card"><span>High risk</span><strong>1</strong></article>
<article class="metric-card"><span>Approved</span><strong>1</strong></article>
</section>
What does this section show?
- The header names the portal as a quality systems portfolio demonstration.
- The synthetic-data badge makes the demonstration boundary visible.
- The four metrics summarize the queue by workload and review state.
- Save index.html.
- Return to the browser from earlier.
- Reload the local page.
You should see the portal title above four summary values. The page now has its first visible dashboard section.
Header or metrics missing?
- Confirm that the new code sits inside the page-shell element.
- Check that every opening element has the matching closing element shown in the snippet.
Need another pair of eyes? Help me find why the portal header or summary cards are missing from index.html.
The review area connects the summary values to specific change records. Its table body becomes the insertion point for the records that JavaScript renders later.
- Place your cursor after the closing tag for the metrics section.
- Add the review queue and detail panel by pasting this code:
<section class="review-grid">
<article class="panel table-panel">
<div class="section-heading">
<div><p class="eyebrow">Review queue</p><h2>Change requests</h2></div>
<span class="record-count">3 records</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th scope="col">Change</th><th scope="col">System</th><th scope="col">Risk</th><th scope="col">Status</th></tr></thead>
<tbody id="changeTableBody"></tbody>
</table>
</div>
</article>
<aside class="panel detail-panel" aria-live="polite">
<p class="eyebrow">Selected record</p>
<h2 id="detailId">Choose a change</h2>
<p id="detailTitle" class="detail-title">Select a row to inspect its quality review details.</p>
<dl class="detail-list">
<div><dt>Owner</dt><dd id="detailOwner">Not selected</dd></div>
<div><dt>Risk</dt><dd id="detailRisk">Not selected</dd></div>
<div><dt>Status</dt><dd id="detailStatus">Not selected</dd></div>
<div><dt>Validation impact</dt><dd id="detailValidation">Not selected</dd></div>
<div><dt>Evidence</dt><dd id="detailEvidence">Not selected</dd></div>
</dl>
</aside>
</section>
How does the review area work?
- The table separates each record into change, system, risk, and status columns.
- The changeTableBody element gives JavaScript a precise place to render rows.
- The detail IDs give every selected-record field its own update target.
- The live region supports announcements when the selected details change.
- Save index.html.
- Return to the local portal.
- Reload the page.
You should see an empty change table beside a detail panel. The panel should display Choose a change as its heading.
Review area missing?
- Confirm that the review grid sits below the summary metrics.
- Check the nesting of the section, article, and aside elements.
Still seeing only the metrics? Help me find why the review grid is missing from index.html.
An audit timeline gives the queue traceability context. The footer keeps the synthetic demonstration boundary visible at the end of the page.
- Place your cursor after the closing tag for the review-grid section.
- Finish the visible page structure by pasting this code:
<section class="panel audit-panel">
<div class="section-heading"><div><p class="eyebrow">Traceability</p><h2>Recent audit trail</h2></div></div>
<ol class="timeline">
<li><time datetime="2026-11-08">08 Nov</time> QA requested evidence for CR-1042.</li>
<li><time datetime="2026-11-07">07 Nov</time> System owner assessed CR-1041 as medium risk.</li>
<li><time datetime="2026-11-06">06 Nov</time> CR-1038 received final approval.</li>
</ol>
</section>
<footer>Portfolio demonstration only. No production or regulated data is stored.</footer>
Why include an audit timeline?
The timeline shows recent review activity in chronological context. The footer makes the synthetic-data boundary explicit.
- Save index.html.
- Return to the browser.
- Reload the local portal.
You should see three dated audit entries below the review area. You should also see the portfolio-only notice at the bottom.
Audit trail missing?
- Confirm that the audit section sits inside the page-shell element.
- Check that the footer appears before the closing main tag.
Still seeing a blank lower section? Help me inspect the final structure in index.html.
✔️ Awesome, I've got everything!
Your page now contains the complete portal structure. Save 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">
<meta name="description" content="A synthetic GxP change review portal for a GitHub Actions portfolio project.">
<title>GxP Change Review Portal</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="page-shell">
<header class="hero">
<div>
<p class="eyebrow">Quality Systems Portfolio Demo</p>
<h1>GxP Change Review Portal</h1>
<p class="hero-copy">Review synthetic system changes, validation impact, risk, and approval status.</p>
</div>
<span class="demo-badge">Synthetic data only</span>
</header>
<section class="metrics" aria-label="Change review summary">
<article class="metric-card"><span>Open changes</span><strong>3</strong></article>
<article class="metric-card"><span>Pending QA</span><strong>2</strong></article>
<article class="metric-card"><span>High risk</span><strong>1</strong></article>
<article class="metric-card"><span>Approved</span><strong>1</strong></article>
</section>
<section class="review-grid">
<article class="panel table-panel">
<div class="section-heading">
<div><p class="eyebrow">Review queue</p><h2>Change requests</h2></div>
<span class="record-count">3 records</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th scope="col">Change</th><th scope="col">System</th><th scope="col">Risk</th><th scope="col">Status</th></tr></thead>
<tbody id="changeTableBody"></tbody>
</table>
</div>
</article>
<aside class="panel detail-panel" aria-live="polite">
<p class="eyebrow">Selected record</p>
<h2 id="detailId">Choose a change</h2>
<p id="detailTitle" class="detail-title">Select a row to inspect its quality review details.</p>
<dl class="detail-list">
<div><dt>Owner</dt><dd id="detailOwner">Not selected</dd></div>
<div><dt>Risk</dt><dd id="detailRisk">Not selected</dd></div>
<div><dt>Status</dt><dd id="detailStatus">Not selected</dd></div>
<div><dt>Validation impact</dt><dd id="detailValidation">Not selected</dd></div>
<div><dt>Evidence</dt><dd id="detailEvidence">Not selected</dd></div>
</dl>
</aside>
</section>
<section class="panel audit-panel">
<div class="section-heading"><div><p class="eyebrow">Traceability</p><h2>Recent audit trail</h2></div></div>
<ol class="timeline">
<li><time datetime="2026-11-08">08 Nov</time> QA requested evidence for CR-1042.</li>
<li><time datetime="2026-11-07">07 Nov</time> System owner assessed CR-1041 as medium risk.</li>
<li><time datetime="2026-11-06">06 Nov</time> CR-1038 received final approval.</li>
</ol>
</section>
<footer>Portfolio demonstration only. No production or regulated data is stored.</footer>
</main>
</body>
</html>
Style the responsive portal
CSS turns the document structure into a review workspace. The first group establishes the colour system and the dashboard header.
- Use the new-file control in Visual Studio Code to create styles.css inside gxp-change-review-portal.
- Add the foundation and header styles by pasting this code into styles.css:
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #17324d;
background: #eef4f8;
--navy: #17324d;
--blue: #176b87;
--border: #d7e2ea;
--muted: #60758a;
--surface: #ffffff;
--warning: #a65d03;
--danger: #a33a3a;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: linear-gradient(180deg, #e8f1f6 0%, #f7fafc 44%, #eef4f8 100%); }
button, select { font: inherit; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0 28px; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 32px; color: #fff; background: linear-gradient(135deg, #17324d, #176b87); border-radius: 22px; box-shadow: 0 18px 45px rgb(23 50 77 / 18%); }
.eyebrow { margin: 0 0 8px; color: #4c8194; font-size: .76rem; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
.hero .eyebrow { color: #bfe8f3; }
h1, h2, p { margin-top: 0; }
h1 { max-width: 700px; margin-bottom: 12px; font-size: clamp(2rem, 5vw, 3.6rem); line-height: 1.04; }
h2 { margin-bottom: 0; color: var(--navy); font-size: 1.35rem; }
.hero-copy { max-width: 680px; margin-bottom: 0; color: #d7edf4; font-size: 1.05rem; line-height: 1.6; }
What do these styles control?
- The colour variables keep the portal palette consistent.
- The page shell limits the content width across large screens.
- The hero uses contrast and spacing to establish the visual hierarchy.
- The heading rules keep the title readable across different viewport widths.
- Save styles.css.
- Return to the local portal.
- Reload the page.
You should see a pale page background with a navy-to-blue header. The portal title should appear in white inside a rounded panel.
Header still looks unstyled?
- Confirm that styles.css sits beside index.html.
- Check that the style link in index.html uses the exact file name styles.css.
- Reload the page after saving the style sheet.
Need help tracing the link? Help me find why styles.css is not affecting my local portal.
The next group turns summary values into cards. It also gives the review queue readable columns and colour-coded badges.
- Place your cursor below the .hero-copy rule in styles.css.
- Add the cards and review-area styles by pasting this code:
.demo-badge, .record-count { display: inline-flex; align-items: center; min-height: 32px; padding: 7px 11px; border-radius: 999px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
.demo-badge { color: #17324d; background: #d8f3ea; }
.record-count { color: #176b87; background: #e6f2f6; }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin: 20px 0; }
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); box-shadow: 0 12px 30px rgb(23 50 77 / 8%); }
.metric-card { padding: 18px; border-radius: 16px; }
.metric-card span { display: block; color: var(--muted); font-size: .86rem; }
.metric-card strong { display: block; margin-top: 6px; color: var(--navy); font-size: 1.9rem; }
.review-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: 20px; }
.panel { padding: 22px; border-radius: 18px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 13px 10px; text-align: left; border-bottom: 1px solid #e8eef2; }
th { color: var(--muted); font-size: .76rem; letter-spacing: .06em; text-transform: uppercase; }
.change-button { padding: 0; color: var(--blue); font-weight: 800; background: transparent; border: 0; cursor: pointer; }
.change-button:hover, .change-button:focus-visible { color: var(--navy); text-decoration: underline; }
.status-pill, .risk-pill { display: inline-block; padding: 5px 8px; border-radius: 999px; font-size: .75rem; font-weight: 800; }
.status-pending-qa, .risk-medium { color: #7a4807; background: #fff0d3; }
.status-impact-assessment { color: #145a70; background: #dff1f7; }
.status-approved, .risk-low { color: #17624f; background: #dff4eb; }
.risk-high { color: var(--danger); background: #fde7e7; }
How does the hierarchy help?
- The metrics grid makes the four summary values easy to scan.
- The review grid gives the table more space than the detail panel.
- The table rules preserve column alignment.
- The badge classes turn risk and status values into visual signals.
- Save styles.css.
- Return to the local portal.
- Reload the page.
You should see four raised metric cards above a two-column review area. The empty table should sit beside the detail panel.
Cards or columns look wrong?
- Check that the new rules appear after the .hero-copy rule.
- Confirm that each class name matches the corresponding class in index.html.
- Look for a missing closing brace near the first unstyled element.
Still seeing bare text? Help me identify which CSS rule is stopping the cards or review grid from rendering.
The final group separates each detail field and clarifies the audit trail. Responsive rules stack the review panels when the browser becomes narrow.
- Place your cursor below the .risk-high rule.
- Finish the responsive design by pasting this code:
.detail-panel { background: linear-gradient(180deg, #fff, #f5fafb); }
.detail-title { margin: 10px 0 20px; color: var(--muted); line-height: 1.5; }
.detail-list { display: grid; gap: 14px; margin: 0; }
.detail-list div { padding-bottom: 12px; border-bottom: 1px solid #e4ecef; }
.detail-list dt { color: var(--muted); font-size: .75rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
.detail-list dd { margin: 5px 0 0; line-height: 1.45; }
.audit-panel { margin-top: 20px; }
.timeline { display: grid; gap: 12px; margin: 0; padding-left: 22px; }
.timeline li { padding-left: 6px; color: #38526a; line-height: 1.5; }
.timeline time { color: var(--blue); font-weight: 800; }
footer { padding: 24px 4px 0; color: var(--muted); font-size: .84rem; text-align: center; }
@media (max-width: 860px) { .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .review-grid { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .page-shell { width: min(100% - 20px, 1180px); padding-top: 10px; } .hero { flex-direction: column; padding: 24px; border-radius: 16px; } .metrics { grid-template-columns: 1fr 1fr; } .panel { padding: 16px; } }
What changes on smaller screens?
- The review grid becomes one column when the panels need more room.
- The metrics remain in two columns on compact screens.
- The header content stacks vertically on the narrowest layout.
- Reduced panel spacing preserves room for the record details.
- Save styles.css.
- Return to the portal.
- Reload the page.
- Narrow the browser window.
You should see the table and detail panel stack into one column. The audit entries should remain readable below them.
Layout does not respond?
- Confirm that both responsive rules appear at the bottom of styles.css.
- Check that each responsive rule retains its opening brace and closing brace.
- Make the browser narrower than the review grid before checking the stacked layout.
Need help with the responsive rules? Help me diagnose why my review panels do not stack on a narrow screen.
✔️ Awesome, I've got everything!
Your portal now has its complete responsive design. Save styles.css before adding the record behaviour.
ⓧ I'd like to double check the full code
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #17324d;
background: #eef4f8;
--navy: #17324d;
--blue: #176b87;
--border: #d7e2ea;
--muted: #60758a;
--surface: #ffffff;
--warning: #a65d03;
--danger: #a33a3a;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: linear-gradient(180deg, #e8f1f6 0%, #f7fafc 44%, #eef4f8 100%); }
button, select { font: inherit; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0 28px; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; padding: 32px; color: #fff; background: linear-gradient(135deg, #17324d, #176b87); border-radius: 22px; box-shadow: 0 18px 45px rgb(23 50 77 / 18%); }
.eyebrow { margin: 0 0 8px; color: #4c8194; font-size: .76rem; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
.hero .eyebrow { color: #bfe8f3; }
h1, h2, p { margin-top: 0; }
h1 { max-width: 700px; margin-bottom: 12px; font-size: clamp(2rem, 5vw, 3.6rem); line-height: 1.04; }
h2 { margin-bottom: 0; color: var(--navy); font-size: 1.35rem; }
.hero-copy { max-width: 680px; margin-bottom: 0; color: #d7edf4; font-size: 1.05rem; line-height: 1.6; }
.demo-badge, .record-count { display: inline-flex; align-items: center; min-height: 32px; padding: 7px 11px; border-radius: 999px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
.demo-badge { color: #17324d; background: #d8f3ea; }
.record-count { color: #176b87; background: #e6f2f6; }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin: 20px 0; }
.metric-card, .panel { background: var(--surface); border: 1px solid var(--border); box-shadow: 0 12px 30px rgb(23 50 77 / 8%); }
.metric-card { padding: 18px; border-radius: 16px; }
.metric-card span { display: block; color: var(--muted); font-size: .86rem; }
.metric-card strong { display: block; margin-top: 6px; color: var(--navy); font-size: 1.9rem; }
.review-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, .85fr); gap: 20px; }
.panel { padding: 22px; border-radius: 18px; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 13px 10px; text-align: left; border-bottom: 1px solid #e8eef2; }
th { color: var(--muted); font-size: .76rem; letter-spacing: .06em; text-transform: uppercase; }
.change-button { padding: 0; color: var(--blue); font-weight: 800; background: transparent; border: 0; cursor: pointer; }
.change-button:hover, .change-button:focus-visible { color: var(--navy); text-decoration: underline; }
.status-pill, .risk-pill { display: inline-block; padding: 5px 8px; border-radius: 999px; font-size: .75rem; font-weight: 800; }
.status-pending-qa, .risk-medium { color: #7a4807; background: #fff0d3; }
.status-impact-assessment { color: #145a70; background: #dff1f7; }
.status-approved, .risk-low { color: #17624f; background: #dff4eb; }
.risk-high { color: var(--danger); background: #fde7e7; }
.detail-panel { background: linear-gradient(180deg, #fff, #f5fafb); }
.detail-title { margin: 10px 0 20px; color: var(--muted); line-height: 1.5; }
.detail-list { display: grid; gap: 14px; margin: 0; }
.detail-list div { padding-bottom: 12px; border-bottom: 1px solid #e4ecef; }
.detail-list dt { color: var(--muted); font-size: .75rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; }
.detail-list dd { margin: 5px 0 0; line-height: 1.45; }
.audit-panel { margin-top: 20px; }
.timeline { display: grid; gap: 12px; margin: 0; padding-left: 22px; }
.timeline li { padding-left: 6px; color: #38526a; line-height: 1.5; }
.timeline time { color: var(--blue); font-weight: 800; }
footer { padding: 24px 4px 0; color: var(--muted); font-size: .84rem; text-align: center; }
@media (max-width: 860px) { .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .review-grid { grid-template-columns: 1fr; } }
@media (max-width: 560px) { .page-shell { width: min(100% - 20px, 1180px); padding-top: 10px; } .hero { flex-direction: column; padding: 24px; border-radius: 16px; } .metrics { grid-template-columns: 1fr 1fr; } .panel { padding: 16px; } }
Make the records interactive
JavaScript supplies the synthetic records and connects them to the page. The first runnable slice renders all three records into the empty table.
- Use the new-file control in Visual Studio Code to create app.js inside gxp-change-review-portal.
- Add the dataset and table renderer by pasting this code into app.js:
const changes = [
{ id: "CR-1042", title: "Update LIMS stability-study calculation rules", system: "LIMS", owner: "Maya Chen", risk: "High", status: "Pending QA", validation: "Impact assessment and regression evidence required", evidence: "Protocol VAL-220 and revised training matrix" },
{ id: "CR-1041", title: "Add deviation category to the quality event form", system: "eQMS", owner: "Daniel Ortiz", risk: "Medium", status: "Impact Assessment", validation: "Targeted form and workflow testing required", evidence: "Traceability update and approved test script" },
{ id: "CR-1038", title: "Rotate service account used for training reports", system: "Learning Hub", owner: "Priya Shah", risk: "Low", status: "Approved", validation: "No functional change to the validated workflow", evidence: "Access review and approval record" }
];
const tableBody = document.querySelector("#changeTableBody");
const detailId = document.querySelector("#detailId");
const detailTitle = document.querySelector("#detailTitle");
const detailOwner = document.querySelector("#detailOwner");
const detailRisk = document.querySelector("#detailRisk");
const detailStatus = document.querySelector("#detailStatus");
const detailValidation = document.querySelector("#detailValidation");
const detailEvidence = document.querySelector("#detailEvidence");
function toClassName(value) { return value.toLowerCase().replace(/\s+/g, "-"); }
function renderRows() {
tableBody.innerHTML = changes.map((change) => `
<tr>
<td><button class="change-button" data-change-id="${change.id}">${change.id}</button></td>
<td>${change.system}</td>
<td><span class="risk-pill risk-${toClassName(change.risk)}">${change.risk}</span></td>
<td><span class="status-pill status-${toClassName(change.status)}">${change.status}</span></td>
</tr>`).join("");
}
renderRows();
How are the rows generated?
- The changes array holds three synthetic change-control records.
- The document references connect the script to the table and detail fields.
- The toClassName() helper converts risk and status values into matching CSS class names.
- The renderRows() function converts every record into one table row.
- Save app.js.
- Return to the local portal.
- Reload the page.
You should see CR-1042, CR-1041, and CR-1038 in the review queue. Their risk and status values should appear as coloured badges.
Records do not appear?
- Confirm that app.js sits beside index.html.
- Check that changeTableBody uses the same capitalization in both files.
- Look for a missing quote or closing character in the record data.
Still seeing an empty table? Help me debug why renderRows() does not display the three change records.
The table now shows the complete review queue. The final logic fills the detail panel on load and updates it whenever you select another record.
- Delete the temporary final renderRows(); line from app.js.
- Place your cursor below the closing brace for renderRows().
- Add the detail renderer and selection behaviour by pasting this code:
function renderDetails(change) {
detailId.textContent = change.id;
detailTitle.textContent = change.title;
detailOwner.textContent = change.owner;
detailRisk.textContent = change.risk;
detailStatus.textContent = change.status;
detailValidation.textContent = change.validation;
detailEvidence.textContent = change.evidence;
}
tableBody.addEventListener("click", (event) => {
const button = event.target.closest("[data-change-id]");
if (!button) return;
renderDetails(changes.find((change) => change.id === button.dataset.changeId));
});
renderRows();
renderDetails(changes[0]);
How does row selection work?
- The renderDetails() function copies one record into the matching detail fields.
- The table click handler finds the selected record through its change ID.
- The first startup call renders the queue.
- The second startup call displays the first record automatically.
Before you reload, do you expect the detail panel to keep its placeholder or display the first change record?
- Save app.js.
- Return to the local portal.
- Reload the page.
You should see CR-1042 in the detail panel. Its risk should be High.
- Select CR-1041 in the review queue.
You should see Medium risk with Impact Assessment status.
- Select CR-1038 in the review queue.
You should see Low risk with Approved status.
For the final check, what values do you expect the detail panel to show when you return to CR-1042?
- Select CR-1042 in the review queue.
You should see High risk with Pending QA status. That result proves the queue and detail panel work together.
Detail panel does not update?
- Confirm that you removed the temporary startup call before adding the final block.
- Check that every detail ID in app.js matches its ID in index.html.
- Confirm that every table button retains its data-change-id attribute.
Need help following the selection? Help me debug why clicking a change record does not update the detail panel.
✔️ Awesome, I've got everything!
Your script now renders the queue and keeps the selected review details in sync. Save app.js one more time.
ⓧ I'd like to double check the full code
const changes = [
{ id: "CR-1042", title: "Update LIMS stability-study calculation rules", system: "LIMS", owner: "Maya Chen", risk: "High", status: "Pending QA", validation: "Impact assessment and regression evidence required", evidence: "Protocol VAL-220 and revised training matrix" },
{ id: "CR-1041", title: "Add deviation category to the quality event form", system: "eQMS", owner: "Daniel Ortiz", risk: "Medium", status: "Impact Assessment", validation: "Targeted form and workflow testing required", evidence: "Traceability update and approved test script" },
{ id: "CR-1038", title: "Rotate service account used for training reports", system: "Learning Hub", owner: "Priya Shah", risk: "Low", status: "Approved", validation: "No functional change to the validated workflow", evidence: "Access review and approval record" }
];
const tableBody = document.querySelector("#changeTableBody");
const detailId = document.querySelector("#detailId");
const detailTitle = document.querySelector("#detailTitle");
const detailOwner = document.querySelector("#detailOwner");
const detailRisk = document.querySelector("#detailRisk");
const detailStatus = document.querySelector("#detailStatus");
const detailValidation = document.querySelector("#detailValidation");
const detailEvidence = document.querySelector("#detailEvidence");
function toClassName(value) { return value.toLowerCase().replace(/\s+/g, "-"); }
function renderRows() {
tableBody.innerHTML = changes.map((change) => `
<tr>
<td><button class="change-button" data-change-id="${change.id}">${change.id}</button></td>
<td>${change.system}</td>
<td><span class="risk-pill risk-${toClassName(change.risk)}">${change.risk}</span></td>
<td><span class="status-pill status-${toClassName(change.status)}">${change.status}</span></td>
</tr>`).join("");
}
function renderDetails(change) {
detailId.textContent = change.id;
detailTitle.textContent = change.title;
detailOwner.textContent = change.owner;
detailRisk.textContent = change.risk;
detailStatus.textContent = change.status;
detailValidation.textContent = change.validation;
detailEvidence.textContent = change.evidence;
}
tableBody.addEventListener("click", (event) => {
const button = event.target.closest("[data-change-id]");
if (!button) return;
renderDetails(changes.find((change) => change.id === button.dataset.changeId));
});
renderRows();
renderDetails(changes[0]);
That is your first major proof complete. The local portal now renders three synthetic reviews and opens each record's quality details.
Next, you will design the Terraform archive blueprint and encounter the provider initialization barrier.
Draft the AWS Blueprint
The portal now makes each synthetic change easy to inspect. Its records still have no durable archive design.
In this step, you will use Terraform to describe a future private archive in Amazon S3. You will then test the blueprint while its AWS provider dependency is still absent.
In this step, get ready to:
- Pin the Terraform toolchain versions.
- Model a protected archive for future change records.
- Test the blueprint before provider initialization.
Pin the versions and define the input
Version constraints keep the local configuration aligned with later automated checks. The archive also needs a required input for its future bucket name.
- Use the file-creation control beside infra/ in the Visual Studio Code Explorer sidebar to create versions.tf.
- Add the toolchain constraints to infra/versions.tf by pasting this configuration:
terraform {
required_version = "= 1.17.1"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.72.0"
}
}
}
What does this configuration do?
- The required_version constraint pins the configuration to Terraform 1.17.1.
- The required_providers block declares the provider dependency used by the archive resources.
- The source address points to hashicorp/aws.
- The provider constraint pins that dependency to 6.72.0.
- Save infra/versions.tf.
- Confirm the Explorer sidebar lists versions.tf inside infra/.
Is versions.tf in the wrong place?
- Move versions.tf beneath infra/ if it appears beside index.html.
- Compare both version constraints with the configuration above if either value differs.
Need help checking the file? Help me compare my Terraform version configuration.
✔️ Awesome, I've got everything!
Your Terraform CLI constraint plus the AWS provider constraint are recorded in infra/versions.tf.
ⓧ I'd like to double check the full code
terraform {
required_version = "= 1.17.1"
required_providers {
aws = {
source = "hashicorp/aws"
version = "= 6.72.0"
}
}
}
- Use the file-creation control beside infra/ in the Explorer sidebar to create variables.tf.
- Define the required archive input in infra/variables.tf by pasting this configuration:
variable "bucket_name" {
description = "Globally unique name for a future GxP change-record archive bucket."
type = string
}
What does this variable do?
- The bucket_name variable gives the future archive a configurable name.
- The string type requires the future value to contain text.
- The missing default keeps the bucket name as a required input for any future deployment.
- Save infra/variables.tf.
- Confirm the Explorer sidebar lists variables.tf inside infra/.
Is the variable incomplete?
- Check that the variable name is exactly bucket_name.
- Check that the type is exactly string.
Need another pair of eyes? Help me check the bucket variable.
✔️ Awesome, I've got everything!
Your future archive now has a required bucket name input in infra/variables.tf.
ⓧ I'd like to double check the full code
variable "bucket_name" {
description = "Globally unique name for a future GxP change-record archive bucket."
type = string
}
Define the protected archive
An infrastructure as code blueprint makes each archive control reviewable as configuration. These declarations describe future infrastructure without creating any AWS resources.
- Use the file-creation control beside infra/ in the Explorer sidebar to create main.tf.
- Define the future archive bucket in infra/main.tf by pasting this resource:
resource "aws_s3_bucket" "change_records" {
bucket = var.bucket_name
force_destroy = false
tags = {
Name = "GxP change record archive"
DataClassification = "GxP-demo"
}
}
What does the bucket resource do?
- The aws_s3_bucket.change_records resource represents the future archive.
- The bucket_name input supplies the future bucket name.
- The force_destroy setting prevents automatic deletion of a non-empty archive.
- The tags record the archive purpose plus its synthetic data classification.
- Save infra/main.tf.
- Confirm main.tf contains the aws_s3_bucket.change_records resource.
Is the bucket resource incomplete?
- Check that main.tf appears inside infra/.
- Compare the bucket input plus both tags with the resource above.
Still stuck? Help me check the archive bucket resource.
- Place your cursor after the closing brace of aws_s3_bucket.change_records in infra/main.tf.
- Add the public-access controls by pasting this resource below the bucket resource:
resource "aws_s3_bucket_public_access_block" "change_records" {
bucket = aws_s3_bucket.change_records.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
How is public access blocked?
- The bucket reference attaches the controls to aws_s3_bucket.change_records.
- The ACL settings block public access through access control lists.
- The policy setting blocks public bucket policies.
- The restriction setting prevents the bucket from becoming public.
- Save infra/main.tf.
- Confirm the public-access resource contains four settings with the value true.
Are any public controls missing?
- Count four settings inside aws_s3_bucket_public_access_block.change_records.
- Check that every public-access setting uses true.
Need help comparing the controls? Help me check the public-access block.
- Place your cursor after the closing brace of aws_s3_bucket_public_access_block.change_records in infra/main.tf.
- Enable archive versioning by pasting this resource below the public-access block:
resource "aws_s3_bucket_versioning" "change_records" {
bucket = aws_s3_bucket.change_records.id
versioning_configuration {
status = "Enabled"
}
}
Why enable versioning?
- The resource attaches versioning to the future archive.
- The Enabled status preserves successive versions of future objects.
- Earlier object versions support recovery after an accidental overwrite.
- Save infra/main.tf.
- Confirm the versioning_configuration block contains status = "Enabled".
Is the versioning block misplaced?
- Check that versioning_configuration sits inside aws_s3_bucket_versioning.change_records.
- Check that the status uses the capitalized value Enabled.
Still unsure? Help me check the versioning resource.
- Place your cursor after the closing brace of aws_s3_bucket_versioning.change_records in infra/main.tf.
- Add server-side encryption by pasting this resource below the versioning resource:
resource "aws_s3_bucket_server_side_encryption_configuration" "change_records" {
bucket = aws_s3_bucket.change_records.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
How is the archive encrypted?
- The resource attaches server-side encryption to the future archive.
- The default encryption rule applies to future stored objects.
- The AES256 algorithm protects future object data at rest.
- Save infra/main.tf.
- Confirm the final resource contains sse_algorithm = "AES256".
Is the encryption rule incomplete?
- Check that apply_server_side_encryption_by_default sits inside the rule block.
- Check that AES256 appears inside quotation marks.
Need help with the nesting? Help me check the encryption resource.
✔️ Awesome, I've got everything!
Your archive blueprint now includes blocked public access plus versioning. It also includes server-side encryption.
ⓧ I'd like to double check the full code
resource "aws_s3_bucket" "change_records" {
bucket = var.bucket_name
force_destroy = false
tags = {
Name = "GxP change record archive"
DataClassification = "GxP-demo"
}
}
resource "aws_s3_bucket_public_access_block" "change_records" {
bucket = aws_s3_bucket.change_records.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "change_records" {
bucket = aws_s3_bucket.change_records.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "change_records" {
bucket = aws_s3_bucket.change_records.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
Test validation before initialization
Terraform validation checks configuration syntax plus internal consistency. The archive resources also rely on schemas supplied by the separate provider dependency.
- Use the Visual Studio Code Explorer sidebar to select the infra/ folder.
- Use the folder context menu to start an integrated terminal from infra/.
Before you run the check, do you think Terraform can validate the AWS resources using only the configuration files?
- Test the uninitialized blueprint by running this command:
terraform validate
What does this command check?
The command checks syntax plus internal consistency. Provider-backed resources require their schemas before the full configuration can pass validation.
You should see validation stop with a provider-related initialization failure. The output explains that the required provider is unavailable or that the directory needs initialization.
This failure is intentional
Your files declare the archive controls. Terraform currently lacks the provider schema needed to inspect those resource blocks.
No AWS resources have been created. The failed validation also creates no Terraform state.
Did you see a different result?
- Check that the terminal path ends with infra.
- Confirm versions.tf remains inside the same folder as main.tf.
- Compare the provider source plus version constraint with infra/versions.tf above.
Need help reading the result? Help me understand my validation output.
You now have a reviewable archive blueprint plus direct evidence of its missing provider dependency. Next, you will install that pinned dependency so the same validation can complete successfully.
Initialize and Validate Terraform
Your Terraform blueprint now describes a protected archive without creating any cloud resources. The failed validation from the previous step proved that Terraform cannot inspect those resource schemas before initialization.
Initialization installs the pinned HashiCorp AWS Provider for local checks. The backend remains unconfigured throughout this project.
A dependency lock file records the provider selection for repeatable checks. A successful validation then proves that the configuration is internally consistent.
In this step, get ready to:
- Apply canonical formatting to the existing Terraform files.
- Initialize the infra/ directory with the pinned provider.
- Validate the initialized configuration successfully.
Format and initialize the blueprint
Formatting gives every .tf file Terraform's canonical layout. This keeps local formatting aligned with the automated check you add later.
- Switch back to the terminal from the previous step.
- Apply Terraform's canonical formatting by running this command:
terraform fmt
What Does Formatting Do?
The formatting command rewrites existing Terraform files into a consistent layout. It preserves the resources and values in your blueprint.
- Confirm the terminal prompt returns without a formatting error.
Good progress. Your Terraform files now use one consistent layout.
The provider dependency still needs to be installed locally. The provider download is the slow part, so a short pause is normal while Terraform retrieves it.
- Initialize the existing infra/ directory without configuring a backend by running this command:
terraform init -backend=false
What Does Initialization Do?
Terraform downloads HashiCorp AWS Provider version 6.72.0 into the local .terraform/ directory.
The -backend=false flag skips backend configuration. This project creates no Terraform state because it never plans or applies the blueprint.
Terraform writes the provider selection to .terraform.lock.hcl. It also records provider checksums there.
You should see initialization finish successfully. No AWS credentials are needed because this command installs a local dependency without creating AWS resources.
- Switch back to Visual Studio Code.
- Confirm infra/.terraform.lock.hcl is listed in the file sidebar.
Initialization Did Not Finish?
- Confirm your terminal is still inside the infra/ directory.
- Check your internet connection because Terraform must download the provider.
- Confirm versions.tf still pins the provider to 6.72.0.
Still stuck? Help me troubleshoot Terraform initialization in my infra directory.
Validate the initialized blueprint
Validation can now load the installed provider schemas. This turns the previous initialization failure into a meaningful configuration check.
Before you run the check, do you expect the provider-related failure to remain?
- Validate the initialized configuration without colored terminal output by running this command:
terraform validate -no-color
What Does Validation Check?
The validation command checks Terraform syntax. It also checks internal consistency against the installed provider schemas.
Validation does not inspect remote state. It does not call provider APIs.
The initialization barrier is gone. You should see a success message confirming that the configuration is valid.
Your terminal returns to the prompt without an error. No Terraform state or AWS resources have been created.
Validation Still Failing?
- Confirm infra/.terraform.lock.hcl exists before retrying validation.
- Confirm versions.tf, variables.tf, and main.tf remain inside infra/.
Need another pair of eyes? Help me diagnose why my initialized Terraform configuration does not validate.
That dependency barrier is cleared. Your blueprint now validates with the pinned provider.
Next, you'll move this validation check into GitHub Actions before publishing the portal.
Publish the Portal and Automate Validation
Your Terraform blueprint now passes every local check. However, a local result is difficult to share with a recruiter or collaborator.
This step moves validation into GitHub Actions. It also publishes the portal through GitHub Pages, giving you a live demonstration without creating any AWS resources.
In this step, get ready to:
- Add an automated Terraform validation workflow.
- Publish the full repository on GitHub.
- Deploy the portal through GitHub Pages.
Create the Terraform validation workflow
A CI workflow repeats the same checks whenever matching files change. Your workflow uses the locked provider dependency without contacting AWS or creating infrastructure.
- In the Visual Studio Code window from earlier, select the .github/workflows/ folder in the Explorer sidebar.
- Click the New File icon in the Explorer sidebar.
- Name the file terraform-ci.yml.
- Add the workflow triggers and permission boundary by pasting this first section:
name: Validate Terraform
on:
push:
branches: ["main"]
paths:
- "infra/**"
- ".github/workflows/terraform-ci.yml"
pull_request:
branches: ["main"]
paths:
- "infra/**"
- ".github/workflows/terraform-ci.yml"
workflow_dispatch:
permissions:
contents: read
What does this workflow section do?
- The push trigger runs validation when matching files reach main.
- The pull_request trigger checks matching proposed changes before they merge into main.
- The path filters limit automatic runs to the Terraform blueprint or its validation workflow.
- The read-only content permission gives the workflow enough access to check out the repository.
- Save .github/workflows/terraform-ci.yml.
- Confirm the file now shows the name, on, and permissions sections with consistent indentation.
Does the indentation look uneven?
YAML uses spaces to show nesting. Make sure each value sits directly beneath its parent key.
Need another pair of eyes? Help me check the trigger section of my Terraform workflow.
- Add the validation job by pasting this section on a new line below contents: read:
jobs:
validate:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- name: Check out repository
uses: actions/checkout@v6.2.0
- name: Set up Terraform
uses: hashicorp/setup-terraform@v4.0.1
with:
terraform_version: "1.17.1"
- name: Check Terraform formatting
run: terraform fmt -check
- name: Initialize Terraform without a backend
run: terraform init -backend=false -input=false
- name: Validate Terraform
run: terraform validate -no-color
How does the validation job work?
- The job runs on a standard Ubuntu runner.
- The default working directory sends every shell step to infra.
- The pinned checkout action makes the repository files available to the runner.
- The setup action installs Terraform 1.17.1.
- The remaining steps check formatting before installing the provider dependency. The final step validates the configuration.
- Save .github/workflows/terraform-ci.yml.
- Confirm the file contains the validate job with five named steps.
Does the job appear outside the workflow?
Make sure jobs: starts at the far left. Every line beneath it must follow the indentation shown in the reference.
Still unsure? Help me check the job structure in my workflow.
✔️ Awesome, I've got everything!
Great. Double-check that .github/workflows/terraform-ci.yml is saved before creating the commit.
ⓧ I'd like to double check the full code
name: Validate Terraform
on:
push:
branches: ["main"]
paths:
- "infra/**"
- ".github/workflows/terraform-ci.yml"
pull_request:
branches: ["main"]
paths:
- "infra/**"
- ".github/workflows/terraform-ci.yml"
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- name: Check out repository
uses: actions/checkout@v6.2.0
- name: Set up Terraform
uses: hashicorp/setup-terraform@v4.0.1
with:
terraform_version: "1.17.1"
- name: Check Terraform formatting
run: terraform fmt -check
- name: Initialize Terraform without a backend
run: terraform init -backend=false -input=false
- name: Validate Terraform
run: terraform validate -no-color
How to use this reference
Compare this file with your saved workflow from top to bottom. The indentation and pinned versions must match exactly.
- Stage the complete repository and create its first commit by running these commands from the gxp-change-review-portal folder:
git add .
git commit -m "Build GxP change review portal"
What does this commit contain?
The staging command includes the portal files, Terraform blueprint, dependency lock file, and validation workflow. Your gitignore keeps the downloaded .terraform/ directory out of the commit.
The commit records the complete local project as one shareable version. It does not create a Terraform plan or any AWS resources.
- Confirm the terminal shows a new commit summary for Build GxP change review portal.
Did the commit stop?
A missing Git identity can stop the commit. Return to the identity setup from Step 1 before rerunning the commit command.
If the failure has another cause, help me troubleshoot my first project commit.
Create the public repository and push your commit
Publishing a repository can feel exposing. This project contains synthetic records only, so its public portal does not reveal regulated, personal, or production data.
- Return to your signed-in GitHub browser session.
- Select the plus menu in the upper-right corner.
- Click New repository.
- Enter gxp-change-review-portal as the repository name.
- Choose Public as the repository visibility.
- Leave the optional repository prepopulation choices unselected.
- Click Create repository.
GitHub now shows the empty repository's Quick Setup page. Its repository URL connects your local main branch to GitHub.
- Copy the repository URL from the Quick Setup page.
- Replace PASTE-YOUR-REPOSITORY-URL with the copied URL in this command:
git remote add origin PASTE-YOUR-REPOSITORY-URL
What does this command do?
This command saves the GitHub repository URL under the remote name origin. Future pushes can use that short remote name.
- Push the local main branch to GitHub by running:
git push -u origin main
What happens during the first push?
The upstream option links your local main branch with the remote branch. Later pushes can use that saved relationship.
Visual Studio Code may prompt you to authenticate through your browser. Complete the GitHub sign-in before returning to the terminal.
- Complete the browser sign-in if Visual Studio Code requests GitHub authentication.
- Refresh the GitHub repository page after the push completes.
- Confirm the repository shows index.html, infra/, and .github/workflows/terraform-ci.yml on main.
Did the push fail?
Confirm the repository is empty. A GitHub-created README can introduce a remote commit that your local branch does not contain.
If authentication stopped the push, finish the browser sign-in before retrying. Help me diagnose my GitHub push failure.
Publish the portal with GitHub Pages
GitHub Pages turns the committed static files into a public website. GitHub generates the deployment workflow from its current template, so the workflow keeps the action versions GitHub selects at setup time.
Why use GitHub's generated workflow?
Deployment actions change independently from your portal code. Using the generated workflow avoids copying stale deployment versions into the repository.
Your authored Terraform workflow remains separate. One workflow validates infrastructure code while the other publishes the static portal.
- On the repository page, click Settings.
- Select Pages in the repository settings sidebar.
- Under Build and deployment, set Source to GitHub Actions.
- Choose the suggested static-site workflow.
- Commit the GitHub-generated workflow without changing its generated action versions.
The first workflow runs can take a little while as GitHub prepares fresh runners. A changing status means the jobs are still progressing.
Before you check, consider whether both workflows will finish successfully from the files now stored on GitHub.
- Select the repository's Actions tab.
- Open the Validate Terraform workflow.
- Open its latest run from the main branch.
- Open the validate job to inspect its steps.
This job reproduces the local validation environment on a clean GitHub-hosted runner. Its logs show whether another machine can verify the same blueprint.
- Confirm Check out repository completed successfully.
- Confirm Set up Terraform installed Terraform 1.17.1 successfully.
- Confirm the formatting check completed successfully.
- Confirm provider initialization completed successfully without a backend.
- Confirm Terraform validation completed successfully.
The green run proves the committed blueprint is formatted, initialized, and valid. No AWS credentials or Terraform state are involved.
- Return to the workflow list in the Actions tab.
- Open the GitHub-generated Pages deployment workflow.
- Confirm its latest deployment is green.
- Copy the published site URL from the successful deployment.
- Record the live address here: your live GitHub Pages URL.
- Open your live GitHub Pages URL in a browser tab.
- Select CR-1042 in the live portal.
You should see the GxP Change Review Portal load from GitHub Pages. The detail panel should show High risk with Pending QA status.
Is a workflow still red?
Open the failed job before changing any files. The highlighted step identifies whether the problem belongs to checkout, Terraform setup, formatting, initialization, validation, or deployment.
Keep the generated Pages action versions unchanged. Help me investigate the failed GitHub Actions run.
You made the complete project portable, repeatable, and public. Your live portal now demonstrates the result while GitHub Actions proves the Terraform blueprint stays valid.
Secret mission
Add a Status Filter
Turn the live portal into a focused review queue by filtering records by status. Push the source change to trigger a second GitHub Pages deployment automatically.
Clean Up Your Resources
Clean Up Your Resources
Your public GitHub repository can remain online at $0 while it stays public. Your cleanup options cover the GitHub Pages deployment plus the local gxp-change-review-portal folder because no AWS resources or Terraform state exist.
Cost warning
Standard GitHub-hosted runners are free for public repositories. Private-repository usage follows your account allowances and billing rules.
Resources you used:
- The public gxp-change-review-portal repository on GitHub.
- The GitHub Actions workflow history for Terraform CI plus Pages deployment.
- The live GitHub Pages deployment.
- The local gxp-change-review-portal folder containing infra/.terraform/ plus infra/.terraform.lock.hcl.
Keep everything running
No action needed. This option keeps your live portal available while you continue improving it.
- Leave the gxp-change-review-portal repository public.
- Keep the GitHub Pages site published.
- Retain the local gxp-change-review-portal folder on your Linux device.
- Keep only synthetic data in the public repository.
Pause - I'll come back to this later
Pausing unpublishes the live portal while preserving your repository. Your local project files also stay available.
- Return to the gxp-change-review-portal repository page on GitHub.
- Open the GitHub Pages site menu.
- Select Unpublish site.
- Check the former live site URL.
- Leave the public repository in place.
- Retain the local gxp-change-review-portal folder.
You should see that the portal is no longer published. A later successful Pages workflow can publish it again.
Delete - I don't want to use this again
Repository deletion looks drastic because it removes the public demo. Your local folder remains safe until the separate local cleanup action.
- Return to the repository's Settings page.
- Select General.
- Scroll to Danger Zone.
- Select Delete this repository.
- Complete the confirmation steps shown by GitHub.
- Check the former repository URL.
The repository URL should stop loading. This teardown also removes its workflow history plus the Pages deployment.
The remote copy is now gone. The local repository remains on your Linux device until you remove it.
- Change to the directory that contains gxp-change-review-portal in your terminal.
- Remove the local repository folder by running this command:
rm -rf gxp-change-review-portal
What does this command remove?
The rm -rf command removes the folder recursively without moving it to trash.
This deletes the portal source files.
It also deletes infra/.terraform/ plus infra/.terraform.lock.hcl.
- Check the parent directory in your file manager.
You should no longer see gxp-change-review-portal. You have cleared the project footprint from GitHub plus your Linux device.
Nice Work!
Nice Work!
You did it! Your GxP-inspired change review portal is live through GitHub Pages.
You've learned how to:
- Published a live GxP change review portal with synthetic records through GitHub Pages. The site lets you inspect each change request's risk, status, ownership, validation impact, plus supporting evidence.
- Created a validation-only blueprint in Terraform for a private Amazon S3 archive. The design blocks public access. It also enables versioning plus server-side encryption without creating AWS resources.
- Built automated Terraform checks with GitHub Actions. The workflow installs the pinned provider before checking formatting plus configuration validity.
- Completed the Secret Mission by adding a status filter to the live review queue. A source change triggered a second automatic deployment. Selecting Approved now displays only CR-1038.
Ready to quiz yourself?