An append-only archive of @zorenkonte's starred repositories, rendered to Markdown by a nightly archiver.
STARS.md— the full archive, grouped by language, with anArchivedsection for repos that have left GitHub.TODAY.md— a small daily rotation of repos to (re)review.stars.json— the machine-readable source of truth. Everything else is rendered from this file. It also carries your GitHub star lists (the curated groups atgithub.com/stars/<user>/lists) and which lists each repo belongs to.
These three files are generated by the archiver — don't hand-author them. When self-hosting they live in the
archiveDocker volume; the copies in this repository are a snapshot.
Everything runs on your own machine; nothing is published anywhere. Three
containers, defined in docker-compose.yml:
| Service | What it does |
|---|---|
db |
Postgres 17. A queryable mirror of the ledger. |
archiver |
Once a day (ARCHIVE_AT, local time) runs scripts/archive_stars.py against the GitHub API, appends to stars.json in the archive volume, renders STARS.md / TODAY.md there, then mirrors the JSON into Postgres with scripts/sync_to_postgres.py. Also runs once at container start. |
web |
The Nuxt 4 reader (web/), served on http://<host>:3000. Reads Postgres; the API routes (/api/repos, /api/meta, /api/health) and the UI are unchanged from the hosted version. |
The JSON file stays the append-only source of truth (the tested ledger
logic lives in archive_stars.py); Postgres is rebuilt from it after every
run, so the database can always be thrown away and recreated.
git clone https://github.com/zorenkonte/stars && cd stars
cp .env.example .env # set POSTGRES_PASSWORD, STARS_USERNAME, STARS_TOKEN
docker compose up -d --build
docker compose logs -f archiver # first run: seeds the ledger, fetches, syncsThen open http://<pi-ip>:3000. Set WEB_BIND=127.0.0.1 in .env if the
reader should only answer on the Pi itself (pair it with Tailscale or an SSH
tunnel for remote access).
- Seeding: on the very first start the archiver copies the repository's
committed
stars.jsoninto thearchivevolume, so the history (gone repos,first_seendates, list membership) carries over. After that the file in the volume is the ledger; the one in the repository is just a snapshot. - Token: a classic PAT with
read:useris enough for public stars plus your private star lists. Leave it empty to archive public stars only (no lists). - Backups:
docker compose cp archiver:/data ./backupcopies the ledger and the Markdown. Postgres needs no backup; it is regenerated from the JSON. - Updates:
git pull && docker compose up -d --build.
The web reader gives you live server-side search (name, description, topics, language, list names), language / star-list / status filters with faceted counts, sorting by most recently starred, stars, most recently added, or name, grouping by language or status, virtual scrolling over the whole archive, and filter state in the URL. Gone repos are greyed with a "gone since" stamp. It follows your system light/dark preference.
Local development without Docker (Node 22.19+, a Postgres you point
DATABASE_URL at):
cd web
pnpm install
DATABASE_URL=postgres://stars:pw@localhost:5432/stars pnpm devGitHub's "starred" API only ever returns repositories that currently exist and are still starred. The moment a repo is deleted, made private, renamed, or unstarred, it disappears from that API forever. A naive "fetch the stars and overwrite a file" tool would therefore silently lose history every time a repo went away.
This project instead treats stars.json as a durable ledger:
| Situation | What happens |
|---|---|
| A newly starred repo | Added with first_seen, status: "active". |
| A repo still in the live list | Mutable fields (description, language, stars, topics, html_url) are refreshed; last_seen updated. first_seen, starred_at, and reviewed_at are preserved. |
| A repo that vanished from the live list | Flagged status: "gone" with a gone_since date. Its entry — and last-known metadata — is never deleted. |
| A "gone" repo that reappears | Re-activated (status: "active", gone_since: null). |
| Star lists fetched OK | Every active repo's lists is replaced with its live membership; gone repos keep their last-known lists. The top-level lists catalog is refreshed (a deleted list is kept only while a gone repo still references it). |
| Star lists could not be fetched | Nothing list-related changes. A failed fetch is "unknown", never "no lists". |
The Markdown is always rendered from this JSON, so nothing that was ever captured can be lost by a later API response.
Each entry in stars.json (keyed by full_name) looks like:
{
"full_name": "owner/repo",
"html_url": "https://github.com/owner/repo",
"description": "…",
"language": "Python",
"stars": 1234,
"topics": ["cli", "productivity"],
"starred_at": "2025-03-01T00:00:00Z",
"first_seen": "2026-07-06T06:17:00Z",
"last_seen": "2026-07-06T06:17:00Z",
"status": "active",
"gone_since": null,
"reviewed_at": null,
"lists": ["selfhost", "shell"]
}lists holds the slugs of the star lists the repo is in (sorted). The
top-level lists object maps each slug to its display name and description:
"lists": {
"selfhost": { "slug": "selfhost", "name": "Selfhost", "description": null },
"shell": { "slug": "shell", "name": "Shell", "description": "CLI tools" }
}scripts/archive_stars.py (Python standard library only — no
pip install) runs this pipeline:
- Fetch every page of the starred API (
per_page=100, until an empty page) using theapplication/vnd.github.star+jsonmedia type so it also capturesstarred_at. - Fetch star lists via the GraphQL API (
viewer.lists→items, paged 100 at a time). Lists have no REST endpoint and GitHub only shows them to their owner: the built-inGITHUB_TOKENgetstotalCount: 0, so this step uses theSTARS_TOKENpersonal access token (see Archiving star lists). It is non-fatal: without a PAT, or on any GraphQL error, it logs a message and the previous list data is kept. - Load the existing
stars.json. - Merge the live list into it (append-only, per the table above), then refresh list membership for active repos.
- Render
STARS.md. - Render
TODAY.md(this stampsreviewed_aton the repos it surfaces). - Save
stars.json— after step 6, so the daily rotation persists.
TODAY.md picks DAILY_COUNT (default 10) active repos: unreviewed ones
first (oldest first_seen first), then — if there aren't enough — the
least-recently-reviewed ones. Every picked repo gets reviewed_at = now, so the
selection rotates through your whole list over time.
All configuration is in .env (see .env.example); the
archiver container reads it. Public stars need nothing beyond
STARS_USERNAME.
Star lists are only visible to the account that owns them, so the archiver needs a token that is you:
- Create a classic personal access token with the
read:userscope (addrepotoo if you also want private stars, below). - Put it in
.envasSTARS_TOKENand restart:docker compose up -d. - The archiver log line
Archive updated: … N listsconfirms it; the web reader's star-list filter and badges appear after the next run.
Without the token the run logs Skipping star lists … and keeps whatever list
data was archived before.
The public starred endpoint can't see stars on private repos. To include them,
give the token the repo scope and set USE_AUTH_USER=true in .env. That
makes the script query /user/starred (the authenticated user's stars, private
included) instead of the public /users/{username}/starred.
| Env var | Default | Meaning |
|---|---|---|
STARS_USERNAME |
zorenkonte |
User whose public stars are archived. |
GH_TOKEN |
(none) | Token for the REST calls. Falls back to STARS_TOKEN, which is what the Docker setup relies on. |
STARS_TOKEN |
(none) | Owner's PAT (read:user) used to fetch star lists as viewer. Unset → lists are skipped and previous list data kept. |
USE_AUTH_USER |
false |
If true, query /user/starred (needs a PAT). |
DAILY_COUNT |
10 |
How many repos TODAY.md surfaces per run. |
STARS_JSON / STARS_MD / TODAY_MD |
stars.json / STARS.md / TODAY.md |
Output paths (/data/… inside the archiver container). |
DATABASE_URL |
(none) | Postgres URL used by sync_to_postgres.py and the web reader. |
ARCHIVE_AT / TZ |
08:07 / Asia/Manila |
When the archiver container runs each day. |
# Generate the files against a live account (public stars):
STARS_USERNAME=zorenkonte python scripts/archive_stars.py
# Mirror them into a Postgres you have running:
DATABASE_URL=postgres://stars:pw@localhost:5432/stars python scripts/sync_to_postgres.py
# Offline self-test — no network, proves the append-only guarantees:
python scripts/test_archive_stars.py- "Gone" is ambiguous. The starred API can't tell you why a repo left the
list. Deleted, made private, renamed, or simply unstarred by you all look
identical, so they all land in the
Archivedsection. This is a fundamental limit of the API, not a bug. - Renames create a duplicate. GitHub reports a renamed repo under its new
full_name. Since entries are keyed byfull_name, the old name is flaggedgoneand the new name is added as if brand new. There's no reliable, token-free way to follow a rename. stars.jsonis the only source of truth. If you delete or rewrite it, the history of gone repos is lost — there is no way to recover it from GitHub. Keep it in version control (that's the whole point) and don't rebuild it from the live API.- First-seen ≠ first-starred (for pre-existing repos).
starred_atcomes straight from GitHub and is accurate.first_seenis when this archive first observed the repo, so for stars you made before adopting this tool it will be the archive's first run, not the actual star date. - Metadata is a snapshot. For active repos,
description/language/starsreflect the last successful fetch. For gone repos they're frozen at their last-known values. - Scheduled runs only fire on the default branch. The cron trigger won't run from a feature branch or a fork's PR.
- Public rate limits are low. Unauthenticated requests are capped at 60/hour;
the archiver sends a token whenever
STARS_TOKENis set (5,000/hour), so this only matters for ad-hoc local runs withoutGH_TOKEN. - Star lists need the owner's PAT. GitHub exposes star lists only to their
owner (verified: the Actions token sees
totalCount: 0, and the lists page is a 404 when anonymous), so withoutSTARS_TOKENlists are skipped and whatever was archived before is kept. List badges appear on the web reader after the first archive run that fetches lists successfully.