A static site generator that turns a curated list of GitHub repos and gists into a personal tools portfolio — no CMS, no framework, no manual copy-pasting.
Each project page is built from its README, rendered to HTML at build time. Live URLs and docs links are extracted automatically from a portfolio.toml convention file you add to each project. Tags come from GitHub Topics (repos) or portfolio.toml (gists).
The site deploys automatically to GitHub Pages on every push to main.
portfolio.toml ← add to each repo/gist (live_url, docs_url)
GitHub Topics ← set on each repo for tags
↓
bootstrap.py ← seeds projects.yaml from the GitHub API (run once)
↓
projects.yaml ← your curated project list (edit to remove unwanted entries)
↓
build.py ← fetches READMEs, renders HTML, writes output/
↓
output/ ← deployed to GitHub Pages via CI
.
├── .cache/ # gitignored — cached README files
├── .env # gitignored — secrets and config
├── .env.example # committed — safe config template
├── .github/
│ └── workflows/
│ └── build.yml # CI: build and deploy on push to main
├── .gitignore
├── bootstrap.py # one-time seed script
├── build.py # site generator
├── lib/
│ └── github.py # shared GitHub API helpers
├── output/ # gitignored locally — generated site
├── portfolio.toml.example # convention template for your projects
├── projects.yaml # your curated project list
├── static/
│ └── style.css
└── templates/
├── base.html
├── index.html
└── project.html
git clone https://github.com/yourusername/dev-portfolio-hub
cd dev-portfolio-hubGo to github.com/settings/tokens and create a fine-grained token with:
- Repository access: All public repositories (read-only)
- Permissions:
Contents: Read,Metadata: Read
cp .env.example .envEdit .env:
GITHUB_TOKEN=ghp_yourtoken
GH_USERNAME=yourusername
CACHE_TTL_HOURS=1.0uv run bootstrap.pyThis generates projects.yaml from your public repos and gists.
To permanently exclude repos or gists, create an exclude.txt (one repo name or gist ID per line, # for comments) before running bootstrap. See exclude.txt.example.
For any project that has a live URL or docs site, add a portfolio.toml to its root:
live_url = "https://mytool.example.com"
docs_url = "https://docs.example.com/mytool"For gists, you can also add tags (repos use GitHub Topics instead):
tags = ["python", "cli"]See portfolio.toml.example for a full reference.
By default the site uses the built-in branding (~/tools, Tools & Projects, etc.).
To change it, copy site.toml.example to site.toml and edit:
cp site.toml.example site.tomlsite.toml is gitignored — it's your instance config. CI builds without it and
falls back to the defaults, so you only need this file if you want to change the
branding or use a custom theme.
To use a custom theme, point theme.templates_dir and theme.static_dir at your
own directories:
[theme]
templates_dir = "my-theme/templates"
static_dir = "my-theme/static"uv run build.pyOpen output/index.html in your browser to preview.
First-time setup:
- Push this repo to GitHub.
- Go to your repo Settings → Secrets and variables → Actions → Secrets and add:
GH_TOKEN= a personal access token withpublic_repoandread:userscopes
- Go to Settings → Secrets and variables → Actions → Variables and add:
GH_USERNAME= your GitHub usernameCUSTOM_DOMAIN= your custom domain e.g.tools.example.com(optional — omit to use the defaultusername.github.ioURL)
- Go to Settings → Pages and set the source to the
gh-pagesbranch. - If using a custom domain, add a DNS CNAME record pointing your subdomain at
your-username.github.io.
From then on, every push to main triggers a rebuild and deploy automatically. You can also trigger it manually from the Actions tab.
Note: CI builds without
site.tomland uses the default branding. If you want your customised branding on the deployed site, commit yoursite.tomlto the repo.
After building locally, copy the output/ directory to any web server:
scp -r output/ user@yourserver.example.com:/var/www/html/portfolio/Or with rsync (faster for incremental updates — only changed files are transferred):
rsync -az --delete output/ user@yourserver.example.com:/var/www/html/portfolio/The --delete flag removes files on the server that no longer exist locally, keeping
the remote in sync with your build.
| Task | What to do |
|---|---|
| Add a new project | Re-run uv run bootstrap.py, review projects.yaml |
| Add a live URL | Add portfolio.toml to that repo/gist |
| Update tags (repo) | Set GitHub Topics on the repo |
| Update tags (gist) | Edit portfolio.toml in the gist |
| Refresh README content | Cache expires per CACHE_TTL_HOURS, or push to trigger CI |
| Variable | Default | Description |
|---|---|---|
GITHUB_TOKEN |
— | Personal access token (public_repo, read:user scopes) |
GH_USERNAME |
— | Your GitHub username |
CACHE_TTL_HOURS |
1.0 |
Hours before cached content is re-fetched. Set to 0 to always re-fetch. |
| Secret / Variable | Description |
|---|---|
GH_TOKEN (secret) |
Personal access token — same scopes as above |
GH_USERNAME (variable) |
Your GitHub username |
CUSTOM_DOMAIN (variable, optional) |
Custom domain e.g. tools.example.com — omit for default username.github.io |
| Constraint | Detail |
|---|---|
| Personal GitHub accounts only | The pinned items query uses the GraphQL user type, which doesn't apply to organisations |
| Public repos and gists only | Private content is intentionally excluded — this is a public portfolio tool |
Gists must contain a .md file |
Gists without markdown are skipped by bootstrap |
| Maximum 6 pinned items | GitHub's own limit on pinned profile items |
- Python 3.11+
- uv
Dependencies are declared inline in each script via PEP 723 and installed automatically by uv run.