Build a Python Release Dashboard
Test and deploy a Python release dashboard with GitHub Actions and Pages.
Introduction
30 Second Summary
A change can look harmless until it reaches real users. Without an automatic checkpoint, one missed problem can turn a routine release into a broken page.
In this project, you will build a Python release dashboard that turns automated test results into a visible READY or BLOCKED signal. A GitHub Actions pipeline will package passing code for GitHub Pages before publishing it to a public URL.
What You'll Build
You will open a public dashboard where a green READY card shows 4 / 4 checks passed after the tested release reaches GitHub Pages.
By the end of this project, you'll have:
- A local dashboard you can open in your browser to see whether a release is READY or BLOCKED.
- A seven-test safety net that rejects impossible check counts before a release moves forward.
- A CI/CD job graph that makes the path from tested code to a deployable artifact to a live site visible.
- Secret Mission: Introduce a controlled regression that blocks deployment until you restore the last good release with Git.
Before We Start
Before any hands-on work, this is your moment to commit to the release dashboard you are building. You will connect its READY or BLOCKED signal to a rule that prevents failed tests from reaching deployment.
Set Up the Free Toolchain
Your release dashboard needs a repeatable local environment. The hosted runner later needs the same dependency definition.
You will verify Python plus Git. You will install pytest version 9.2.0 inside a virtual environment. A public GitHub repository gives the future pipeline access to the same files without adding a paid service.
In this step, get ready to:
- Verify the local development tools.
- Prepare an isolated project environment with the pinned test dependency.
- Link the local project to an empty public repository.
Create the local environment
The project needs Python 3.10 or newer because that is the minimum version supported by the pinned pytest release. Checking first prevents an incompatible installation from breaking later.
- Open your terminal from the NixOS application launcher.
- Check the installed Python version by running this command:
python3 --version
What does this check do?
The command asks the active Python installation to report its version. The result determines whether it can run pytest version 9.2.0.
✔️ I see version 3.10 or higher
Your Python installation is compatible with this project. Keep this terminal open for the remaining setup.
Why This Version Matters
pytest version 9.2.0 requires Python 3.10 or newer. A compatible interpreter keeps local test behavior aligned with the future pipeline.
ⓧ I see an older version
Your current Python release cannot run the pinned pytest version. A temporary Nix shell provides a newer interpreter without replacing your system installation.
- Enter a temporary environment with a full Python installation by running this command:
nix-shell -p python3Full
What does this command do?
Nix creates a temporary shell with the python3Full package available on your command path. Keep this shell active while you complete the project setup.
- Check the Python version inside the Nix shell by running:
python3 --version
What should you see?
The reported version should be 3.10 or higher. This confirms the temporary shell provides a compatible interpreter.
Still Seeing an Older Version?
Confirm that the Nix shell finished loading before you repeated the version check. Keep the new shell open after the prompt returns.
Use the official NixOS Python guide if your configured package channel does not provide a compatible release.
Get help with the version mismatch.
ⓧ Command not found
Your current shell cannot find Python. Nix can provide a temporary Python installation for this project.
- Enter a temporary environment with a full Python installation by running this command:
nix-shell -p python3Full
What does this command do?
Nix prepares a temporary shell containing the python3Full package. The package becomes available until you leave that shell.
- Verify Python inside the Nix shell by running:
python3 --version
What should you see?
The terminal should report Python 3.10 or higher. Keep this shell open for the next commands.
Still Cannot Find Python?
Check that Nix finished building the temporary environment before you repeated the version command. Follow the official NixOS Python guide if the package is unavailable in your configured channel.
Get help starting Python on NixOS.
- Verify Git in the same terminal by running this command:
git --version
What does this check do?
The command confirms that Git is available before the project becomes a local repository. You should see a Git version number in the output.
Cannot Run Git?
Confirm that you typed the command in the same terminal where Python worked. Check your NixOS configuration if the shell reports that Git is unavailable.
Get help finding Git on NixOS.
The local folder keeps every project file in one predictable location. The .venv directory keeps this project's Python packages isolated from other work.
- Move to your Desktop by running this command:
cd ~/Desktop
What does this command do?
The command moves your terminal into the Desktop directory. Your new project folder will be easy to locate there.
- Create the project directory and enter it by running these commands:
mkdir python-release-dashboard
cd python-release-dashboard
What do these commands do?
- The first command creates the python-release-dashboard directory on your Desktop.
- The second command makes that directory the active terminal location.
- Confirm your active location by running:
pwd
What should you see?
The printed path should end with Desktop/python-release-dashboard. This proves the next files will be created in the correct directory.
- Create the isolated Python environment by running this command:
python3 -m venv .venv
What does this command do?
Python creates a local environment inside .venv. Packages installed there stay scoped to this project.
- Activate the virtual environment by running this command:
source .venv/bin/activate
What does activation change?
Activation points Python package commands at the local .venv directory. Your terminal prompt should now include the environment name.
Virtual Environment Did Not Activate?
Confirm that your terminal is inside python-release-dashboard. Check that the previous environment creation command completed without an error.
Get help activating the environment.
The requirements file pins one exact test dependency. The ignore file keeps generated directories out of future commits.
- Create requirements.txt plus .gitignore by running these terminal commands:
cat > requirements.txt <<'EOF'
pytest==9.2.0
EOF
cat > .gitignore <<'EOF'
.venv/
__pycache__/
.pytest_cache/
dist/
EOF
What do these files control?
The requirements.txt file pins pytest to version 9.2.0. The same pin will later be installed on the hosted runner.
The .gitignore file excludes the local environment plus generated cache directories. It also excludes the generated dist directory.
- Confirm both files and the hidden environment directory exist by running:
ls -a
What should you see?
The listing should include requirements.txt. It should also include .gitignore plus .venv.
✔️ Awesome, I've got everything!
Your project configuration files are in place. Keep the virtual environment active for the dependency installation.
ⓧ I'd like to double check the full code
Compare your requirements file with this complete version.
pytest==9.2.0
What should match?
The file contains one dependency pin. The final newline keeps the text file in a standard format.
Compare your ignore file with this complete version.
.venv/
__pycache__/
.pytest_cache/
dist/
What should match?
The file contains exactly four ignored paths. Each path appears on its own line.
Install the pinned test dependency
A dependency pin gives local development one exact pytest release. The future CI runner reads the same pin before running your tests.
- Install the dependency declared in requirements.txt by running:
python3 -m pip install -r requirements.txt
What does this command do?
Python runs the package installer associated with the active virtual environment. The installer reads requirements.txt and installs its exact pytest pin.
Before you check, which pytest version do you expect the active environment to report?
- Verify the installed pytest version by running:
pytest --version
What should you see?
The output should report pytest version 9.2.0. This proves the requirements pin was installed inside the active environment.
That dependency is now reproducible. Your machine has the exact pytest release the pipeline will use later.
pytest Has the Wrong Version?
- Confirm that your prompt still shows the active .venv environment.
- Check that requirements.txt contains the exact pin from the comparison tab.
- Repeat the installation command after correcting the file.
Get help fixing the pytest installation.
Link the public repository
The local repository will store your project history. Its remote counterpart gives GitHub Actions access to that history in a later step.
Your repository is intentionally public. Keep credentials plus unrelated personal files outside python-release-dashboard.
- Sign in to GitHub with your existing account.
- Select New repository from the repository creation menu.
- Enter python-release-dashboard in the repository name field.
- Select Public as the repository visibility.
- Leave the starter file options unselected.
- Click Create repository.
- Copy the HTTPS repository URL shown on the empty repository page into your repository URL.
The GitHub repository is now empty and ready for your local history. Next, the local folder needs its main branch plus an origin remote.
- Initialize the local repository on the main branch by running:
git init -b main
What does this command do?
Git starts tracking project history inside python-release-dashboard. The repository begins on the main branch.
- Connect the local repository to your GitHub repository by running:
git remote add origin [[YOUR_REPO_URL="your repository URL"]]
What does this command do?
Git stores your repository URL under the remote name origin. Future push commands can use that short remote name.
Before the final check, which four setup details do you expect the terminal to confirm?
- Verify Python plus Git plus pytest plus the remote connection by running these commands:
python3 --version
git --version
pytest --version
git remote -v
What does this checkpoint prove?
The first three lines confirm that the required development tools are available. The pytest line should report version 9.2.0.
The final command displays the configured repository remotes. You should see origin associated with your public repository URL.
Your local toolchain is ready. Python plus Git plus pytest now work inside the project environment.
The remote output should list origin for both fetch and push operations. Both entries should point to your public python-release-dashboard repository.
Remote Link Missing?
- Confirm that the copied URL belongs to the public python-release-dashboard repository.
- Check that you ran the remote command inside the local python-release-dashboard directory.
- Repeat the final remote check after correcting the connection.
Get help checking the repository link.
Your free toolchain now has an isolated Python environment plus a linked public repository. Next, you will turn that empty project into a visible release dashboard.
Build a Visible Release Dashboard
Your local toolchain is ready. You can now turn the empty repository into a dashboard that shows whether a release is ready to move forward.
A visible dashboard gives your CI/CD pipeline an outcome you can inspect. This first version uses a deliberately simple rule that marks a release READY when the number of passed checks is greater than or equal to the total.
In this step, get ready to:
- Define the release rule that produces READY or BLOCKED.
- Generate a release dashboard that shows 4 / 4 checks passed.
- Commit the baby dashboard to the public repository.
Define the release rule and page
The dashboard separates its decision from its HTML rendering. This keeps the READY rule small enough to test directly in the next step.
- Use the terminal file-writing method from the previous step to create dashboard.py inside the existing python-release-dashboard directory.
- Paste this first section into dashboard.py:
from html import escape
from pathlib import import Path
def release_status(passed: int, total: int) -> str:
return "READY" if passed >= total else "BLOCKED"
def render_dashboard(release_name: str, passed: int, total: int) -> str:
status = release_status(passed, total)
status_class = status.lower()
safe_name = escape(release_name)
return f"""<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{safe_name}</title></head>
<body><main><p>CI/CD release signal</p><h1>{safe_name}</h1><div class="status {status_class}"><span>{status}</span><span>{passed} / {total}</span></div><p>Automated checks passed before delivery and deployment.</p></main></body>
</html>
"""
What does this code do?
- The release_status() function compares passed checks with the total number of checks.
- The render_dashboard() function places the resulting status inside an HTML page.
- The escape() function converts unsafe characters in the release name before placing it in the page.
- Save dashboard.py.
- Check that Python can load the functions by running this command:
python dashboard.py
What did this check?
Python reads both function definitions before returning you to the shell prompt. A clean return with no traceback confirms that this section has valid Python syntax.
Seeing a Python traceback?
- Check that the opening and closing triple quotes around the HTML match.
- Check that each line inside both functions uses four spaces of indentation.
Ask for help with the exact line named in your traceback:
The page renderer now exists in memory. The next function writes that HTML into the dist directory so a browser can load it.
- In dashboard.py, locate the closing triple quotes at the end of render_dashboard().
- Leave two blank lines after the closing triple quotes.
- Paste this code at the end of dashboard.py:
def build_dashboard(output_dir: Path = Path("dist")) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / "index.html"
output_file.write_text(render_dashboard("Release Dashboard", passed=4, total=4), encoding="utf-8")
return output_file
if __name__ == "__main__":
built_file = build_dashboard()
print(f"Built {built_file}")
How does the builder work?
- The build_dashboard() function creates the dist directory when it is missing.
- The function writes the rendered page to dist/index.html using UTF-8 encoding.
- The final block runs the builder when you execute dashboard.py directly.
- Save dashboard.py.
- Generate the dashboard by running this command:
python dashboard.py
What did this build?
The command generates dist/index.html with the release name, READY status, and 4 / 4 check count. You will see Built dist/index.html in the terminal.
Was the dashboard not generated?
- Confirm that your terminal is inside the python-release-dashboard directory.
- Check that the if __name__ == "__main__": block starts at the far left with no indentation.
- Check that dist/ still appears in .gitignore.
Get help diagnosing the failed build:
Your complete generator should now match this reference.
✔️ Awesome, I've got everything!
Great. Double-check that you saved dashboard.py before continuing.
ⓧ I'd like to double check the full code
from html import escape
from pathlib import Path
def release_status(passed: int, total: int) -> str:
return "READY" if passed >= total else "BLOCKED"
def render_dashboard(release_name: str, passed: int, total: int) -> str:
status = release_status(passed, total)
status_class = status.lower()
safe_name = escape(release_name)
return f"""<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{safe_name}</title></head>
<body><main><p>CI/CD release signal</p><h1>{safe_name}</h1><div class="status {status_class}"><span>{status}</span><span>{passed} / {total}</span></div><p>Automated checks passed before delivery and deployment.</p></main></body>
</html>
"""
def build_dashboard(output_dir: Path = Path("dist")) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / "index.html"
output_file.write_text(render_dashboard("Release Dashboard", passed=4, total=4), encoding="utf-8")
return output_file
if __name__ == "__main__":
built_file = build_dashboard()
print(f"Built {built_file}")
Preview the generated dashboard
A local HTTP server lets your browser request the generated page from dist. The server keeps the terminal busy until you stop it with Ctrl+C.
Before you start the server, consider what status four passed checks out of four should produce.
- Start the local dashboard preview by running this command:
python -m http.server --bind 127.0.0.1 --directory dist 8000
What does this command do?
The command serves files from dist on your computer at port 8000. The loopback address keeps this preview local to your machine.
This server is suitable for the local preview. GitHub Pages handles the public deployment later in the project.
- Switch back to the browser you used for the repository setup.
- Enter http://127.0.0.1:8000 in the address bar.
You will see Release Dashboard with a READY signal and 4 / 4 checks passed. The visible result proves that your Python code generated a page the browser can load.
Cannot see the dashboard?
- Confirm that the terminal running the server remains open.
- Confirm that python dashboard.py created dist/index.html before you started the server.
- Confirm that your browser address uses port 8000.
Get help with the local preview:
- Return to the terminal running the local server.
- Press Ctrl+C to stop the local server.
Commit and verify the baby dashboard
The generated dist directory stays outside version control because Git can recreate it from dashboard.py. Your public GitHub repository stores the source needed for every future build.
Before you push, consider which source file Git should include while dist remains excluded.
- Stage the project files, create the first commit, and push main by running these commands:
git add .
git commit -m "First commit"
git push -u origin main
What do these commands record?
- The first command stages the source files that are not excluded by .gitignore.
- The second command records the baby dashboard in local Git history.
- The third command publishes the main branch to the existing remote repository.
Did the push fail?
- Confirm that your terminal is inside the existing python-release-dashboard directory.
- Confirm that your GitHub authentication from the repository setup is still active.
- Confirm that the remote points to the empty public repository created in the previous step.
Get help with the push while keeping credentials private:
- Return to the public python-release-dashboard repository page in your browser.
- Refresh the repository page.
You will see dashboard.py, requirements.txt, and .gitignore on the main branch. The generated dist directory remains absent because Git ignores it.
For the final check, consider whether rebuilding the same 4 / 4 release should change its status.
- Rebuild the page and restart the local preview by running these commands:
python dashboard.py
python -m http.server --bind 127.0.0.1 --directory dist 8000
What does the final check prove?
The first command regenerates dist/index.html from the committed source. The second command serves that generated artifact through the local preview.
- Switch back to the browser tab for the local dashboard.
- Refresh the page.
You will see Release Dashboard with READY and 4 / 4 checks passed. That confirms the committed generator still produces the expected visible release signal.
Does the final preview fail?
- Check that the build command prints Built dist/index.html before the server starts.
- Check that no other process is already using port 8000.
Ask for help checking the final build and preview:
- Return to the terminal running the local server.
- Press Ctrl+C to stop the local server.
Your first release dashboard is visible and stored on GitHub. Next, you will put its release rule under tests to see how it behaves at impossible boundaries.
Make Tests Expose the Boundary Bug
Your release dashboard already shows a green READY signal for 4 / 4 checks. Now it needs evidence that the release gate handles values a real release should never produce.
The naive passed >= total rule looks correct for ordinary data. Boundary tests expose the impossible values hidden outside that happy path.
In this step, get ready to:
- Lock in the normal READY and BLOCKED behavior.
- Challenge the naive rule with impossible release counts.
- Prove the finished dashboard passes seven tests.
Build the first release checks
Unit tests lock in the behavior that valid inputs must keep. Pytest discovers each function whose name starts with test.
- Create the tests directory inside your existing python-release-dashboard folder by running this command:
mkdir -p tests
What does this command do?
The -p option creates the named directory if it is missing. It also keeps the command safe when the directory already exists.
- Create tests/test_dashboard.py with your terminal file-writing method by adding this initial test code:
from pathlib import Path
from tempfile import TemporaryDirectory
from dashboard import build_dashboard, release_status, render_dashboard
def assert_invalid_release(passed: int, total: int, expected_message: str) -> None:
try:
release_status(passed, total)
except ValueError as error:
assert str(error) == expected_message
else:
raise AssertionError("Expected release_status to reject invalid counts")
def test_release_is_ready_when_all_checks_pass() -> None:
assert release_status(4, 4) == "READY"
def test_release_is_blocked_when_a_check_fails() -> None:
assert release_status(3, 4) == "BLOCKED"
What do these tests cover?
- The imports bring the dashboard functions into the test file.
- The assert_invalid_release() helper checks that invalid counts raise ValueError with the expected message.
- The first test protects the normal 4 / 4 READY result.
- The second test protects the normal 3 / 4 BLOCKED result.
- Save tests/test_dashboard.py.
- Check the first two cases by running this command:
python -m pytest
What should you see?
You should see two passing tests. This proves the naive release rule handles the two ordinary examples.
Tests not being discovered?
- Run the command from inside the python-release-dashboard folder.
- Check that the file path is exactly tests/test_dashboard.py.
- Confirm your active virtual environment contains the pinned pytest dependency.
Help me diagnose why pytest cannot discover or import my dashboard tests.
Expose the impossible boundaries
Normal examples show what the release gate does most often. Invalid boundaries reveal whether the gate can reject data that should never reach a release decision.
- Add these three boundary cases below the existing tests in tests/test_dashboard.py.
def test_zero_total_is_rejected_as_a_regression_case() -> None:
assert_invalid_release(0, 0, "total must be greater than zero")
def test_negative_passed_count_is_rejected() -> None:
assert_invalid_release(-1, 4, "passed must be between zero and total")
def test_passed_count_above_total_is_rejected() -> None:
assert_invalid_release(5, 4, "passed must be between zero and total")
Why test these values?
- The 0 / 0 case becomes a regression test for an empty release gate.
- The -1 / 4 case checks that a passed count cannot be negative.
- The 5 / 4 case checks that passed checks cannot exceed the total.
- Save tests/test_dashboard.py.
Before you run the suite, consider whether the naive rule will reject all three impossible values.
- Reveal how the naive gate behaves by running this command:
python -m pytest
Why this failure is intentional
The normal READY and BLOCKED tests still pass. The three new boundary tests fail because release_status() returns a status instead of raising ValueError.
The dashboard looked correct in the browser because 4 / 4 never challenged the broken edge cases. The failing suite has now exposed the gap that the visual result could not show.
Fix the gate and regenerate the dashboard
The boundary failures now give you a precise release contract. Before changing the gate, complete the suite with escaping and filesystem checks.
- Add the final two tests below the boundary cases in tests/test_dashboard.py.
def test_release_name_is_escaped_before_rendering() -> None:
page = render_dashboard("<script>alert('x')</script>", 4, 4)
assert "<script>" in page
assert "<script>" not in page
def test_build_writes_the_dashboard() -> None:
with TemporaryDirectory() as directory:
output_file = build_dashboard(Path(directory))
assert output_file.name == "index.html"
assert output_file.exists()
assert "Release Dashboard" in output_file.read_text(encoding="utf-8")
What do the final tests protect?
- The escaping test confirms a release name cannot become an executable HTML tag.
- The build test writes the dashboard into a temporary directory.
- The temporary directory keeps the test isolated from your real dist directory.
- Save tests/test_dashboard.py.
- Confirm all seven tests are now discovered by running this command:
python -m pytest
What changed in the suite?
Four tests now pass. The same three boundary failures remain, which isolates the bug inside release_status().
Seeing a different test count?
- Check that every test function begins with test_.
- Check that the two final functions sit outside every earlier function body.
Help me find why my pytest suite does not discover exactly seven tests.
✔️ Awesome, I've got everything!
Your test file now contains all seven checks. Keep the three planned failures visible while you correct the release gate.
ⓧ I'd like to double check the full code
from pathlib import Path
from tempfile import TemporaryDirectory
from dashboard import build_dashboard, release_status, render_dashboard
def assert_invalid_release(passed: int, total: int, expected_message: str) -> None:
try:
release_status(passed, total)
except ValueError as error:
assert str(error) == expected_message
else:
raise AssertionError("Expected release_status to reject invalid counts")
def test_release_is_ready_when_all_checks_pass() -> None:
assert release_status(4, 4) == "READY"
def test_release_is_blocked_when_a_check_fails() -> None:
assert release_status(3, 4) == "BLOCKED"
def test_zero_total_is_rejected_as_a_regression_case() -> None:
assert_invalid_release(0, 0, "total must be greater than zero")
def test_negative_passed_count_is_rejected() -> None:
assert_invalid_release(-1, 4, "passed must be between zero and total")
def test_passed_count_above_total_is_rejected() -> None:
assert_invalid_release(5, 4, "passed must be between zero and total")
def test_release_name_is_escaped_before_rendering() -> None:
page = render_dashboard("<script>alert('x')</script>", 4, 4)
assert "<script>" in page
assert "<script>" not in page
def test_build_writes_the_dashboard() -> None:
with TemporaryDirectory() as directory:
output_file = build_dashboard(Path(directory))
assert output_file.name == "index.html"
assert output_file.exists()
assert "Release Dashboard" in output_file.read_text(encoding="utf-8")
The failures have defined the valid range for both counts. The gate can now reject invalid data before choosing READY or BLOCKED.
- In dashboard.py, find the current release_status() function shown below.
def release_status(passed: int, total: int) -> str:
return "READY" if passed >= total else "BLOCKED"
Why does this rule fall short?
The comparison accepts 0 / 0 and 5 / 4 as READY. It also turns a negative passed count into an ordinary BLOCKED result.
- Replace the naive function with this validated version:
def release_status(passed: int, total: int) -> str:
if total <= 0:
raise ValueError("total must be greater than zero")
if passed < 0 or passed > total:
raise ValueError("passed must be between zero and total")
return "READY" if passed == total else "BLOCKED"
What changed in the gate?
- The first guard rejects totals that cannot represent a real test run.
- The second guard keeps the passed count inside its valid range.
- The final comparison grants READY only when every check passes.
- Save dashboard.py.
Before you rerun the suite, predict what should happen to the three planned failures.
- Test the corrected release gate by running this command:
python -m pytest
What should you see now?
The result should end with 7 passed. Every invalid boundary now raises the message expected by its test.
Still seeing boundary failures?
- Check that both validation guards appear before the final return statement.
- Check that the final comparison uses passed == total.
- Check both ValueError messages against the expected strings in the tests.
Help me compare my release_status function with the failing boundary tests.
That is the release gate working as designed. Invalid counts can no longer produce a misleading status.
The finished renderer keeps the tested escaping behavior while giving the release signal a complete status card. Its replacement comes in two connected pieces so each code block stays readable.
- In dashboard.py, select the existing render_dashboard() function plus the build_dashboard() function below it.
- Replace the selected functions with this first connected piece:
def render_dashboard(release_name: str, passed: int, total: int) -> str:
status = release_status(passed, total)
status_class = status.lower()
safe_name = escape(release_name)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_name}</title>
<style>
:root {{ color-scheme: dark; font-family: system-ui, sans-serif; }}
body {{ min-height: 100vh; margin: 0; display: grid; place-items: center; background: #0f172a; color: #e2e8f0; }}
main {{ width: min(36rem, calc(100% - 2rem)); padding: 2rem; border: 1px solid #334155; border-radius: 1rem; background: #1e293b; box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%); }}
.eyebrow {{ color: #94a3b8; text-transform: uppercase; letter-spacing: .12em; }}
h1 {{ margin-bottom: 1.5rem; }}
.status {{ display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; border-radius: .75rem; font-weight: 800; }}
.ready {{ background: #14532d; color: #bbf7d0; }}
.blocked {{ background: #7f1d1d; color: #fecaca; }}
.metric {{ color: #cbd5e1; }}
</style>
What does the first piece define?
This piece calculates the safe status values before opening the HTML template. The style rules turn the release result into a centered green or red card.
The open multiline string continues directly in the next piece.
- Paste this second piece immediately after the closing </style> line:
</head>
<body>
<main>
<p class="eyebrow">CI/CD release signal</p>
<h1>{safe_name}</h1>
<div class="status {status_class}">
<span>{status}</span>
<span>{passed} / {total}</span>
</div>
<p class="metric">Automated checks passed before delivery and deployment.</p>
</main>
</body>
</html>
"""
def build_dashboard(output_dir: Path = Path("dist")) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / "index.html"
output_file.write_text(
render_dashboard("Release Dashboard", passed=4, total=4),
encoding="utf-8",
)
return output_file
What does the second piece finish?
- The HTML body displays the release name above the status and metric.
- The build_dashboard() function creates dist/index.html from the tested renderer.
- The existing main block prints the generated file path after a successful build.
- Save dashboard.py.
Before the final check, predict whether the escaping and file-build tests still pass with the completed renderer.
- Verify the complete implementation by running this command:
python -m pytest
What proves the code is ready?
The result should still end with 7 passed. This confirms the renderer changes preserved every tested behavior.
- Regenerate the corrected dashboard by running this command:
python dashboard.py
What should the build report?
You should see Built dist/index.html in the terminal. The generated file now comes from the corrected release gate.
- Return to the local dashboard browser tab from earlier.
- Refresh http://127.0.0.1:8000.
You should see a green READY card with 4 / 4 checks passed. The valid release still works after the boundary fix.
Dashboard not updating?
- Confirm the local server from earlier still serves the dist directory.
- Confirm the dashboard build finished before you refreshed the browser.
- Check that the browser address still uses port 8000.
Help me diagnose why my regenerated dist/index.html does not appear in the local browser.
✔️ Awesome, I've got everything!
Your corrected dashboard file matches the tested release behavior. Make sure dashboard.py is saved before continuing.
ⓧ I'd like to double check the full code
from html import escape
from pathlib import Path
def release_status(passed: int, total: int) -> str:
if total <= 0:
raise ValueError("total must be greater than zero")
if passed < 0 or passed > total:
raise ValueError("passed must be between zero and total")
return "READY" if passed == total else "BLOCKED"
def render_dashboard(release_name: str, passed: int, total: int) -> str:
status = release_status(passed, total)
status_class = status.lower()
safe_name = escape(release_name)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{safe_name}</title>
<style>
:root {{ color-scheme: dark; font-family: system-ui, sans-serif; }}
body {{ min-height: 100vh; margin: 0; display: grid; place-items: center; background: #0f172a; color: #e2e8f0; }}
main {{ width: min(36rem, calc(100% - 2rem)); padding: 2rem; border: 1px solid #334155; border-radius: 1rem; background: #1e293b; box-shadow: 0 1rem 3rem rgb(0 0 0 / 30%); }}
.eyebrow {{ color: #94a3b8; text-transform: uppercase; letter-spacing: .12em; }}
h1 {{ margin-bottom: 1.5rem; }}
.status {{ display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; border-radius: .75rem; font-weight: 800; }}
.ready {{ background: #14532d; color: #bbf7d0; }}
.blocked {{ background: #7f1d1d; color: #fecaca; }}
.metric {{ color: #cbd5e1; }}
</style>
</head>
<body>
<main>
<p class="eyebrow">CI/CD release signal</p>
<h1>{safe_name}</h1>
<div class="status {status_class}">
<span>{status}</span>
<span>{passed} / {total}</span>
</div>
<p class="metric">Automated checks passed before delivery and deployment.</p>
</main>
</body>
</html>
"""
def build_dashboard(output_dir: Path = Path("dist")) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
output_file = output_dir / "index.html"
output_file.write_text(
render_dashboard("Release Dashboard", passed=4, total=4),
encoding="utf-8",
)
return output_file
if __name__ == "__main__":
built_file = build_dashboard()
print(f"Built {built_file}")
Your local evidence is complete. Save the corrected dashboard and its tests to the public repository so the next step can run them automatically.
- Commit the corrected dashboard and seven tests by running these commands:
git add .
git commit -m "First commit"
git push -u origin main
What do these commands save?
Git records the corrected source file plus the new test file in one commit. The push updates the main branch in your public GitHub repository.
You should see the push complete for the main branch. Your remote repository now contains the seven-test release gate.
Push not completing?
- Confirm your terminal is still inside the python-release-dashboard folder.
- Confirm the repository still has the public remote configured earlier.
- Complete any GitHub authentication request shown by your terminal.
Help me diagnose why my corrected dashboard commit will not push to the existing GitHub remote.
Your release rule now has evidence behind it. Next, GitHub Actions will run the same seven tests whenever code reaches the repository.
Put the Tests in Continuous Integration
Your corrected dashboard rejects impossible check counts. Its seven tests now pass locally.
A local suite protects the release only when someone remembers to run it. That gap lets an untested commit reach the shared branch.
Continuous integration closes the gap. GitHub Actions runs the same suite on every push to main. It also runs for every pull request targeting main.
In this step, get ready to:
- Define when the continuous integration workflow runs.
- Configure a hosted runner to install the project dependency.
- Prove the hosted runner reports seven passing tests.
Define the workflow and test job
GitHub Actions discovers each YAML workflow file stored in .github/workflows. This workflow defines the events that start testing.
The test job recreates your local test process on a fresh hosted runner. A clean runner catches dependencies or assumptions that only work on your computer.
- Create .github/workflows/pipeline.yml inside your existing python-release-dashboard folder by running this command:
mkdir -p .github/workflows
cat > .github/workflows/pipeline.yml <<'EOF'
name: Test, Deliver, Deploy
on:
pull_request:
branches: ["main"]
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.1.0
with:
python-version: "3.15.2"
cache: pip
- run: pip install -r requirements.txt
- run: python -m pytest
EOF
What does this workflow do?
- The push trigger runs the workflow after a commit reaches main.
- The pull_request trigger tests proposed changes that target main.
- The workflow_dispatch trigger allows a manual run from GitHub.
- The contents: read permission gives the workflow read-only access to the repository contents.
- The test job runs on a fresh ubuntu-latest machine.
- The pinned actions/checkout@v7.0.1 action places your repository files on that machine.
- The pinned actions/setup-python@v7.1.0 action provides Python 3.15.2. Its pip cache can reuse downloaded dependencies across runs.
- The install step reads requirements.txt. Pytest then runs through python -m pytest.
✔️ Awesome, I've got everything!
Your workflow file now contains the triggers and hosted test job. Keep going.
ⓧ I'd like to double check the full code
name: Test, Deliver, Deploy
on:
pull_request:
branches: ["main"]
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-python@v7.1.0
with:
python-version: "3.15.2"
cache: pip
- run: pip install -r requirements.txt
- run: python -m pytest
Workflow file look different?
- Confirm the file path is exactly .github/workflows/pipeline.yml.
- Compare each indentation level with the full-file reference.
- Replace any tab characters in the YAML with spaces.
Help me compare my workflow file with the expected YAML.
Push the workflow to GitHub
The workflow starts when its commit reaches the remote main branch. This push becomes the first real test of your continuous integration gate.
- Commit the workflow and push it to main by running these commands:
git add .
git commit -m "Add continuous integration workflow"
git push -u origin main
What do these commands do?
- The git add . command stages the new workflow file.
- The git commit -m "Add continuous integration workflow" command records the staged workflow in Git history.
- The git push -u origin main command publishes the commit. The configured push trigger then starts the workflow.
Good progress. GitHub now owns the next test run.
Push rejected?
- Confirm your terminal is still inside the python-release-dashboard folder.
- Confirm origin still points to the public repository from earlier.
- Complete any GitHub authentication request shown by your terminal.
Help me diagnose why my workflow commit will not push.
Inspect the hosted test run
A successful run proves the hosted job completed. The job log proves the seven expected tests actually ran.
Before you inspect the run, consider whether the same seven tests should pass on a fresh runner.
- Return to the public python-release-dashboard repository from earlier.
- Click Actions under the repository name.
- Select Test, Deliver, Deploy in the left sidebar.
- Select the most recent workflow run.
- Select the test job in the visualization graph.
- Expand the final step in the job log.
You will see the run succeed. Its test log ends with 7 passed.
That is your local safety net running on a clean hosted machine. Every future push to main now receives the same check.
Your tests now guard every push to the shared branch. Next, you will package the passing dashboard as a deployable artifact.
Separate Delivery from Deployment
Your seven tests now run automatically in GitHub Actions. That gives every change the same continuous integration check before release work begins.
This step separates continuous delivery from continuous deployment. Your pipeline will package a passing build before it can publish that build.
In this step, get ready to:
- Configure GitHub Pages to accept deployments from GitHub Actions.
- Package the tested dashboard as a deployable Pages artifact.
- Compare manual delivery with automatic deployment.
Configure Pages and package the dashboard
GitHub Pages can publish static files produced by a workflow. The packaged files form a build artifact that a later job can deploy.
- In your GitHub repository navigation, select Settings.
- Select Pages from the repository settings sidebar.
- Under Build and deployment, set Source to GitHub Actions.
What did this setting change?
This setting tells GitHub Pages to accept a Pages artifact from your workflow. The public site will use the artifact deployed by the final job.
- Return to .github/workflows/pipeline.yml using the file-editing method from earlier.
- Replace the existing top-level permissions section and test job with the code below:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Python
uses: actions/setup-python@v7.1.0
with:
python-version: "3.15.2"
cache: pip
- name: Install test dependency
run: pip install -r requirements.txt
- name: Run tests
run: python -m pytest
What does this workflow section do?
- The top-level permissions setting gives jobs read access to repository contents.
- The concurrency group keeps Pages runs in the same deployment queue.
- The cancel-in-progress: false value allows the current Pages deployment to finish.
- The step names make each action readable in the workflow log.
- Save .github/workflows/pipeline.yml.
- Confirm the dashboard code still passes locally by running:
python -m pytest
What does this check prove?
This command runs the same seven tests used by the workflow. A passing local suite confirms the dashboard remains ready for packaging.
You should see the result end with 7 passed.
Tests failing after the workflow edit?
- Confirm your virtual environment is still active before running the suite.
- Check that requirements.txt still contains pytest==9.2.0.
- Confirm you ran the command inside the python-release-dashboard folder.
Help me diagnose why my seven dashboard tests stopped passing after I edited the GitHub Actions workflow.
The delivery job runs only after the test job succeeds. It recreates the dashboard on a fresh runner so the artifact comes from tested source code.
- Add the deliver job below the complete test job:
deliver:
if: ${{ github.event_name != 'pull_request' }}
needs: test
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Python
uses: actions/setup-python@v7.1.0
with:
python-version: "3.15.2"
- name: Configure GitHub Pages
uses: actions/configure-pages@v6.0.0
- name: Build dashboard
run: python dashboard.py
- name: Upload deployable Pages artifact
uses: actions/upload-pages-artifact@v5.0.0
with:
path: dist/
What does the delivery job do?
- The needs: test dependency blocks delivery until every test passes.
- The checkout step retrieves the tested repository contents on a fresh runner.
- The Python setup step provides Python 3.15.2 for the dashboard build.
- The Pages configuration step prepares repository metadata for publication.
- The upload step packages dist/ as the github-pages artifact.
- Save .github/workflows/pipeline.yml.
- Test the delivery job's build command locally by running:
python dashboard.py
What does this build command do?
The command runs build_dashboard() with its default output directory. It generates the static file that the delivery job packages.
Good progress. You should see Built dist/index.html, which confirms the deployable dashboard was regenerated.
Dashboard build failing?
- Confirm dashboard.py remains inside the python-release-dashboard folder.
- Check that the bottom of dashboard.py still calls build_dashboard().
- Confirm the terminal is inside the repository before running the command.
Help me troubleshoot why python dashboard.py no longer generates dist/index.html.
Prove delivery can stop before deployment
A delivered artifact is ready for production even when production remains unchanged. A temporary manual-only gate makes that boundary visible in the workflow graph.
- Add the deploy job below the complete deliver job:
deploy:
if: ${{ github.event_name != 'pull_request' }}
needs: deliver
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5.0.1
How is deployment controlled?
- The needs: deliver dependency waits for the packaged artifact.
- The job-level permissions authorize Pages publication and identity verification.
- The github-pages environment records the production deployment.
- The deployment action publishes the previously uploaded Pages artifact.
- Find the if: ${{ github.event_name != 'pull_request' }} line inside the deploy job.
- Replace that condition with if: ${{ github.event_name == 'workflow_dispatch' }}.
- Save .github/workflows/pipeline.yml.
- Create a commit containing .github/workflows/pipeline.yml with the message Deliver without deploying.
Before you push, picture which downstream job a normal main-branch push can reach with this manual-only deployment gate.
- Push the committed workflow to GitHub by running:
git push
What does this push trigger?
The main-branch push starts the workflow automatically. The temporary condition prevents the deployment job from running for this event.
The workflow may remain in progress while GitHub prepares its hosted runners. The changing job states show that the pipeline is still active.
- Return to the repository's Actions area.
- Open the workflow run created by your latest push.
You should see test succeed. You should see deliver succeed next.
You should see deploy skipped. This run delivered a production-ready artifact without changing the public site.
- Open the completed run summary to confirm it contains the github-pages artifact.
Delivery job not succeeding?
- Check the workflow log for the first step that failed inside deliver.
- Confirm needs: test is indented beneath the deliver job.
- Confirm the Pages source still shows GitHub Actions.
Help me diagnose why my GitHub Actions deliver job did not upload the github-pages artifact after seven tests passed.
Deploy passing changes automatically
The artifact has proved that delivery can finish without publication. Expanding the gate to non-pull-request events turns a passing main-branch push into an automatic production release.
- In .github/workflows/pipeline.yml, find the temporary manual-only condition inside the deploy job.
- Replace the temporary condition so the line looks exactly like this:
if: ${{ github.event_name != 'pull_request' }}
What does the final condition allow?
The condition allows deployment for main-branch pushes and manually started runs. It excludes pull requests so proposed changes can run tests without reaching production.
- Save .github/workflows/pipeline.yml.
✔️ Awesome, I've got everything!
Your workflow file now has separate test, delivery, and deployment jobs.
ⓧ I'd like to double check the full code
- Compare your complete .github/workflows/pipeline.yml file with this final version:
name: Test, Deliver, Deploy
on:
pull_request:
branches: ["main"]
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pages
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Python
uses: actions/setup-python@v7.1.0
with:
python-version: "3.15.2"
cache: pip
- name: Install test dependency
run: pip install -r requirements.txt
- name: Run tests
run: python -m pytest
deliver:
if: ${{ github.event_name != 'pull_request' }}
needs: test
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Set up Python
uses: actions/setup-python@v7.1.0
with:
python-version: "3.15.2"
- name: Configure GitHub Pages
uses: actions/configure-pages@v6.0.0
- name: Build dashboard
run: python dashboard.py
- name: Upload deployable Pages artifact
uses: actions/upload-pages-artifact@v5.0.0
with:
path: dist/
deploy:
if: ${{ github.event_name != 'pull_request' }}
needs: deliver
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5.0.1
- Create a commit containing the final workflow with the message Deploy passing dashboard.
Before you push, predict how the new condition changes the final job in the three-job graph.
- Push the final workflow commit by running:
git push
What happens after this push?
The push starts the final pipeline on main. Each successful job unlocks the next job through its dependency.
GitHub may keep the run in progress while the hosted runners build and deploy the site. The graph updates as each job finishes.
- Return to the latest workflow run in the repository's Actions area.
- Open the completed run after all job states settle.
You should see test complete first. You should see deliver complete second.
You should see deploy complete last. The github-pages environment should link to the successful deployment.
Before you open the deployment URL, predict which release signal the tested artifact contains.
- Open the public URL attached to the successful github-pages environment deployment.
You should see the Release Dashboard with a green READY signal. You should also see 4 / 4 checks passed.
You have closed the release loop. A passing change now becomes a packaged artifact before the tested artifact reaches the public site.
Live site not showing the dashboard?
- Confirm all three jobs succeeded in the latest workflow run.
- Confirm the deliver job uploaded dist/ as the Pages artifact.
- Open the URL from the latest successful github-pages environment deployment.
Help me troubleshoot why my successful GitHub Pages deployment does not show READY with 4 / 4 checks passed.
Secret mission
Prove a Failed Release Cannot Deploy
You will introduce a controlled regression. You will watch the test gate stop every downstream job. Then Git will restore the good release with a revert.
Clean Up Your Resources
Clean Up Your Resources
The designed path stays at $0 with a public GitHub repository on standard GitHub-hosted runners. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The public python-release-dashboard repository containing your corrected code and Git history.
- The final GitHub Actions workflow that tests, delivers, and deploys passing releases.
- The live public GitHub Pages site displaying READY with 4 / 4 checks passed.
- The github-pages deployment environment attached to the repository.
- The short-lived Pages artifacts produced by successful delivery jobs.
Keep everything running
No action needed. Choose this option while you are still building with your release dashboard.
- Leave the python-release-dashboard repository public.
- Keep .github/workflows/pipeline.yml unchanged.
- Continue using standard GitHub-hosted runners.
Each future push to main runs the passing pipeline. The live Pages site updates after deployment succeeds.
Pause - I'll come back to this later
Pause automatic releases without deleting the repository. The last good Pages site stays available.
- Switch back to .github/workflows/pipeline.yml in your local repository.
- Remove the push trigger under on.
- Save .github/workflows/pipeline.yml.
- Commit the workflow change.
- Push the pause commit to main.
Future pushes no longer start the pipeline automatically. Pull requests can still run the test job.
The workflow_dispatch trigger still provides a manual release path.
- Restore the push trigger when you want automatic releases again.
- Save .github/workflows/pipeline.yml.
- Commit the restored workflow.
- Push the resume commit to main.
Delete - I don't want to use this again
Remove the practice repository when you no longer need its code or public deployment. This option cleans up every hosted resource from the project.
Deletion is permanent
Repository deletion permanently removes the code and pipeline history. The public site disappears with its deployment resources.
- Return to the public python-release-dashboard repository on GitHub.
- Select Settings in the repository navigation.
- Use the repository deletion control in Settings to delete python-release-dashboard.
- Complete GitHub's confirmation step.
What Repository Deletion Removes
- The .github/workflows/pipeline.yml workflow and its run history.
- The short-lived Pages artifacts.
- The github-pages deployment environment.
- The public live GitHub Pages site.
After deletion, the repository page no longer opens. The live Pages URL also becomes unavailable.
That closes the practice project cleanly. No public release resources remain.
Nice Work!
Nice Work!
You did it! Your tested Python release dashboard is live on GitHub Pages with a passing pipeline behind every READY release.
You've learned how to:
- Build a Python release dashboard that shows READY only when all four checks pass.
- Protect each release with a seven-test suite covering normal behavior plus invalid boundaries. The same suite runs locally and in GitHub Actions.
- Separate continuous delivery from continuous deployment by packaging the tested site as a deployable artifact before publishing it.
- Secret Mission: Prove that a failing test blocks delivery and deployment. Restore the passing pipeline with a Git revert while the live site stays on the last good release.
Ready to quiz yourself?