Skip to content
zorenkontePublic

Latest commit

 

History

134 Commits

Folders and files

Repository files navigation

⭐ Stars Archive

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 an Archived section 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 at github.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 archive Docker volume; the copies in this repository are a snapshot.

Self-hosted (Raspberry Pi 5, Docker Compose)

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.

Setup

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, syncs

Then 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.json into the archive volume, so the history (gone repos, first_seen dates, 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:user is 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 ./backup copies 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 dev

Why append-only?

GitHub'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.

Entry shape

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" }
}

How it works

scripts/archive_stars.py (Python standard library only — no pip install) runs this pipeline:

  1. Fetch every page of the starred API (per_page=100, until an empty page) using the application/vnd.github.star+json media type so it also captures starred_at.
  2. 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-in GITHUB_TOKEN gets totalCount: 0, so this step uses the STARS_TOKEN personal 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.
  3. Load the existing stars.json.
  4. Merge the live list into it (append-only, per the table above), then refresh list membership for active repos.
  5. Render STARS.md.
  6. Render TODAY.md (this stamps reviewed_at on the repos it surfaces).
  7. 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.

Setup

All configuration is in .env (see .env.example); the archiver container reads it. Public stars need nothing beyond STARS_USERNAME.

Archiving star lists

Star lists are only visible to the account that owns them, so the archiver needs a token that is you:

  1. Create a classic personal access token with the read:user scope (add repo too if you also want private stars, below).
  2. Put it in .env as STARS_TOKEN and restart: docker compose up -d.
  3. The archiver log line Archive updated: … N lists confirms 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.

Archiving private starred repos

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.

Configuration reference

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.

Running / testing locally

# 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

Honest limitations

  • "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 Archived section. 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 by full_name, the old name is flagged gone and the new name is added as if brand new. There's no reliable, token-free way to follow a rename.
  • stars.json is 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_at comes straight from GitHub and is accurate. first_seen is 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/stars reflect 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_TOKEN is set (5,000/hour), so this only matters for ad-hoc local runs without GH_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 without STARS_TOKEN lists 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.

Releases

Packages

Contributors

Languages