Planning the Project
Every other project in this course was handed to you. That was on purpose — you needed somewhere to practice needs, matrix builds, OIDC, without also inventing a project from nothing. That training wheel comes off now. The Capstone has no prescribed repository. You pick the project, you decide what the pipeline should do, and only then do you open a YAML file.
This chapter is entirely about the part that happens before you write a single step: choosing something to build against, and thinking through what the pipeline actually needs to do. The next two chapters are where you build and deploy it.
Pick a Project
You need something small, real, and testable — not a toy hello.py, but not a sprawling monorepo either. Three options, pick one (or bring your own if it meets the same bar):
Option 1: speedtest-cli
sivel/speedtest-cli is a single-file, pure-Python command-line internet bandwidth tester. It has no dependencies beyond the standard library, over 14,000 stars, and an MIT license.
Note
The maintainer archived the repository — it’s read-only upstream, no new commits are landing there. The code itself still runs exactly as it always did; you’re just forking a project that’s finished rather than one that’s actively maintained. That’s fine for this purpose. Just don’t present it to anyone as a “live” upstream project — it isn’t one anymore.
This is the strongest choice if you want your pipeline to include a scheduled run with a threshold-based alert — e.g., run the speed test every morning, and fail the job (or fire a notification) if throughput drops below some number you pick. That kind of “runs on a timer and reacts to its own output” pipeline is a different shape than a pure CI pipeline, and it’s genuinely useful to have built one.
Option 2: Another corner of TheAlgorithms/Python
You’ve already forked one module from TheAlgorithms/Python earlier in this course. The same repository has dozens of other self-contained subfolders — compression, hashing, ciphers, scheduling algorithms, sorting, searching — each with its own tests and no dependency on the rest of the repo.
This is the safe, familiar option. No web framework to learn, no unfamiliar domain, tests already exist. Pick a subfolder you haven’t touched yet and treat it as a fresh, small Python package.
Option 3: stalefinder (built for this course)
This one doesn’t exist anywhere you can go find it — it’s written specifically for this chapter, and it’s deliberately unfinished. stalefinder walks a directory tree and reports which files haven’t been touched in N days — the kind of thing you’d actually run against a downloads folder, a build cache, or a shared drive that’s slowly filling up with junk nobody remembers to delete.
Create the project locally:
mkdir -p stalefinder/tests
cd stalefinder# stalefinder/__init__.py
import os
import time
from dataclasses import dataclass
@dataclass
class StaleFile:
path: str
age_days: float
def find_stale_files(root: str, days: int, now: float | None = None) -> list[StaleFile]:
"""Return files under `root` whose mtime is older than `days` days."""
now = now if now is not None else time.time()
threshold_seconds = days * 86400
stale = []
for dirpath, _, filenames in os.walk(root):
for name in filenames:
path = os.path.join(dirpath, name)
try:
mtime = os.path.getmtime(path)
except OSError:
continue # broken symlink, permission error, etc.
age_seconds = now - mtime
if age_seconds >= threshold_seconds:
stale.append(StaleFile(path=path, age_days=age_seconds / 86400))
return sorted(stale, key=lambda f: f.age_days, reverse=True)# tests/test_stalefinder.py
import os
import time
from stalefinder import find_stale_files
def test_finds_old_file_and_skips_recent_one(tmp_path):
old_file = tmp_path / "old.log"
old_file.write_text("stale")
recent_file = tmp_path / "recent.log"
recent_file.write_text("fresh")
now = time.time()
ten_days_ago = now - (10 * 86400)
os.utime(old_file, (ten_days_ago, ten_days_ago))
results = find_stale_files(str(tmp_path), days=5, now=now)
result_paths = [r.path for r in results]
assert str(old_file) in result_paths
assert str(recent_file) not in result_pathsThat’s genuinely all there is. No pyproject.toml, no command-line entry point, no Dockerfile. Filling those gaps in is part of designing your pipeline, not something handed to you — you’ll add a pyproject.toml and a small CLI wrapper (argparse is enough) as part of the Build chapter, because the pipeline you design needs something installable and runnable, and right now this is just a library.
Tip
Whichever option you pick, this chapter’s planning process applies identically. The concrete examples from here on lean on stalefinder because it’s the one nobody else has already built a pipeline for — if you picked Option 1 or 2, translate the same questions to your project as you read.
Before You Write Any YAML
The mistake almost everyone makes on a from-scratch pipeline is opening .github/workflows/main.yml immediately and start typing on:. Resist that. Answer these four questions on paper (or in a scratch file) first — they cost five minutes and save you from restructuring the workflow twice.
1. What triggers does this project actually need?
Not “what triggers exist” — what does this project’s lifecycle demand? A library like stalefinder needs CI on every push and pull request, full stop. If part of your design includes a scheduled run (the speedtest option, or a nightly stale-file sweep), that’s a second, independent trigger with its own job — don’t try to cram “run on push” and “run on a timer” logic into the same job with a pile of if: conditions when two trigger blocks under on: do it cleanly.
2. What jobs, in what order?
Draw the shape before you name anything. A typical CI → build → deploy pipeline has a dependency chain: tests must pass before an artifact is worth building, and a build must succeed before there’s anything to deploy. Use needs: to encode that, exactly as you did in the reusable-workflows and artifacts chapters earlier in this course.
flowchart LR
A[lint] --> C[build]
B[test] --> C
C --> D[deploy]
Lint and test can run in parallel — neither depends on the other’s outcome — but both gate the build, and the build gates deploy. If your project also has a scheduled component, that’s typically a separate workflow entirely, not a fourth box bolted onto this chain.
3. Which secrets does the pipeline actually touch?
List them by name before you write a step that references them. For a deploy-to-AWS pipeline, that’s usually just one: the IAM role ARN the workflow will assume. Notice what’s not on that list — no access key ID, no secret access key. That’s not an oversight; it’s the next point.
4. What does “done” look like?
Decide this now, not after you’ve written the deploy job. For stalefinder, “done” might be: a scheduled run produces a report and lands it in S3, and the README shows a badge proving the pipeline is green. Write that sentence down. It’ll stop scope creep later, when it’s tempting to add a Slack notification and a dashboard and a retry queue to something that was supposed to be a capstone, not a product launch.
No Regression to Long-Lived Keys
You already learned, in the security section, exactly why storing an AWS access key ID and secret access key as a repository secret is the wrong approach: those credentials don’t expire, they’re vulnerable if a workflow or a dependency is ever compromised, and they have to be manually rotated by a human who will, statistically, forget to. You set up OIDC specifically to avoid that.
There’s no honest reason to walk that decision back here just because this is a “fresh” project. The design for this pipeline uses OIDC from the very first draft — an IAM role trusted by GitHub’s OIDC provider, scoped to this one repository, assumed fresh on every run and gone within the hour. If you find yourself thinking “I’ll just use an access key for now and switch to OIDC later,” that’s the moment to stop and set up the trust policy instead. “Later” is how long-lived keys end up in a .env file that gets committed by accident.
The Deploy chapter walks through the concrete IAM role and trust policy for whichever AWS target your project needs. For now, the planning-stage takeaway is simpler: the only cloud secret this pipeline will ever hold is a role ARN.
Putting the Plan on Paper
For stalefinder, here’s what falls out of the four questions above:
| Question | Answer for stalefinder |
|---|---|
| Triggers | push/pull_request on the CI workflow; schedule (cron) on a separate scan-and-report workflow |
| Jobs | lint → test → build (CI workflow); single scan-and-upload job (scheduled workflow) |
| Secrets | One repository secret: the AWS IAM role ARN to assume via OIDC |
| Done | Every push is linted and tested; a nightly scheduled run scans a target directory and uploads the report to S3; README shows a status badge |
If you picked Option 1 or Option 2, fill in the same table for your project — the shape of the table doesn’t change, only the specifics in the second column.
Warning
Don’t skip the table because it feels obvious. The value isn’t in the answers — it’s in noticing, before you’ve written any YAML, if two of your answers contradict each other (for example, “deploy on every push” combined with “only deploy from main” without a branch filter to reconcile them).
What’s Next
The next chapter takes whichever project you picked and applies everything from the earlier build-pipeline material — linting, testing, matrix builds, coverage — to it. Nothing new gets taught there; it’s pure application of skills you already have, against code that’s unfamiliar to you the way a real new project always is.