Skip to content

Data Management

Ferran Buireu edited this page Oct 17, 2026 · 21 revisions

How GitHub Star Tracker stores, rotates, and manages tracking data.


Storage Location

All data lives on an orphan branch (default: star-tracker-data). This branch has its own Git history, completely separate from your main branch.

The branch name is configurable via data-branch:

with:
  data-branch: 'my-star-data'

The working directory for the branch is derived from the name: a dot followed by the branch name (e.g. .star-tracker-data/).


Generated Files

File Description When Created
README.md Markdown report with embedded charts Every run
stars-data.json Historical snapshot data Every run
stars-data.csv Flat per-repo export (repository,owner,name,stars,previous,delta,status) Every run
stars-badge.svg Star count badge Every run
stargazers.json Stargazer login map Only with track-stargazers: true
charts/star-history.svg Total stars chart Charts on, once the charted series has at least 2 points
charts/comparison.svg Top repos comparison Same condition, plus at least one top repository
charts/forecast.svg Growth forecast Same, plus the 3 points a forecast needs
charts/{owner}-{repo}.svg Per-repo charts Same condition, one per top repository
charts/forecast-{owner}-{repo}.svg Per-repo growth forecast Same, and only for a top repository whose forecast was fitted to its own reconstructed history

"The charted series" is not always the reconstruction. Charts prefer the Reconstructed History, built from each stargazer's starred_at date, which is why they render on the very first run. When that reconstruction has fewer than 2 points, for a repository whose stargazers could not be read at all, the chart falls back to the Stored History instead. It is still drawn; it just spans the tracker's own runs rather than the repository's life, so on a first run there may be too little of it and the file is skipped.

Charts this run did not produce are deleted from charts/, so a repository that drops out of top-repos does not leave its file behind. Nothing outside charts/ is ever removed, and only .svg files are considered.

The HTML report is not on this branch. It is written to the runner's temp directory and exposed as the report-html-path output, so it never reaches a commit. It is written on every run, including read-only ones and runs where no repository matched.


Snapshot Structure

Each run creates a snapshot appended to the snapshots array in stars-data.json:

{
  "version": 1,
  "snapshots": [
    {
      "timestamp": "2026-02-15T00:00:00.000Z",
      "totalStars": 523,
      "repos": [
        {
          "fullName": "user/repo-a",
          "name": "repo-a",
          "owner": "user",
          "stars": 300
        },
        {
          "fullName": "user/repo-b",
          "name": "repo-b",
          "owner": "user",
          "stars": 223
        }
      ]
    }
  ],
  "starsAtLastNotification": 520
}

Fields

  • version - the on-disk format version, stamped by the writer as the first key. It is not part of a snapshot and never appears in a report; it exists so a newer branch cannot be misread by an older action
  • timestamp - ISO 8601 datetime of when the run occurred
  • totalStars - sum of all tracked repos' stars
  • repos[] - per-repo data (fullName, name, owner, stars)
  • starsAtLastNotification - optional. The star total captured when the notification baseline last advanced. It is absent until the first notification fires, and an absent value is read as 0. How it is compared depends on notification-mode: net uses the absolute change, gains only counts upward movement

When the baseline advances is not simply "when an email is sent". A run that decides to notify advances it both when the send succeeds and when no SMTP transport is configured at all, because in that case the should-notify output is itself the notification. Only a send that was configured and failed holds it back, so the accumulated change is not lost. A run that does not decide to notify never touches it, which is what makes the counter keep growing across quiet runs.

What the reader must not do to this file

The things that make a run stop rather than continue on a guess:

If stars-data.json The run
is not valid JSON fails, naming the parse error, and asks you to fix or delete the file on the data branch
is valid JSON but not an object (null, an array, a number, a string) fails rather than reading it as an empty history and pushing over your record
declares a version higher than this action writes fails and asks you to upgrade the action, or to point data-branch at a branch this version wrote
is an object whose snapshots key is present but not an array fails rather than reading it as an empty history
holds a snapshot, a repository entry or a starsAtLastNotification of the wrong shape (a missing repos list, a star count written as a string) fails and names where, for example snapshots[3].repos[0].stars (expected number, found "7"), rather than comparing against a baseline it misread

An absent version is fine and always will be: every data branch predating the field has none, so it is read as version 1. An absent snapshots key is the one tolerated case, because that is what a first run looks like; it reads as an empty list while starsAtLastNotification survives. Keys the action does not know about are kept and written back untouched, and a timestamp only has to be a string: one that is not a parseable date is tolerated here and handled where it is read.

Why they all stop the run rather than starting over, and the accepted cost that a broken file blocks every later run until a human fixes it, is ADR 0021. The version field itself is ADR 0015.


History Rotation

The max-history setting (default: 52) controls how many snapshots are retained. When the limit is exceeded, the oldest snapshots are pruned.

Schedule max-history: 52 covers max-history: 104 covers
Daily ~7 weeks ~15 weeks
Weekly ~1 year ~2 years
Monthly ~4 years ~8 years
with:
  max-history: '104'  # Keep more history

Lowering max-history throws snapshots away. When more snapshots are stored than the new limit allows, the run logs a warning naming how many it is about to drop and telling you to raise max-history before this run if you want to keep them. Once that run pushes, they are gone; the data branch is the only copy.

How Pruning Works

Pruning is a pure domain function (addSnapshot() in src/domain/snapshot.ts). It returns a new History object with the snapshot appended and old entries trimmed - no mutation, no side effects.

The infrastructure layer (writeHistory()) only handles serialization to disk.


Stargazer Data

When track-stargazers: true, the action maintains a separate stargazers.json file:

{
  "user/repo-a": ["octocat", "defunkt"],
  "user/repo-b": ["octocat"]
}

This stores only login names (not avatars or dates) for efficient diffing between runs. Full stargazer details (avatar, profile URL, starred date) are only shown in reports.

Entries are added and overwritten, never removed. A repository that leaves the tracked set keeps its entry, so returning does not report its existing stargazers as new; the file therefore grows with the number of repositories ever tracked.

A repository whose stargazers could not be read (a failed request, an empty response for a repo that has stars, or a repo covered by smart-sampling) keeps its previous login list rather than being written as empty. Without that, one transient failure would wipe the entry and the next successful run would report every existing stargazer as new. The trade-off is that such an entry can be stale, and if the repository is permanently unreadable it will never report a new stargazer again. Each affected repository is named in a warning in the run log. The full reasoning is ADR 0012.

A repository with no entry has every stargazer reported as new, because there is nothing to diff against. That happens more often than it sounds:

Situation What the next run reports
The first run with track-stargazers on, whether on a fresh branch or one that ran for months without it Every stargazer of every repository, all at once
A repository newly in the Tracked Set: created, unarchived, or let in by an edited filter Every stargazer of that repository
A repository renamed or transferred Every stargazer under its new full name, since entries are keyed by full name; the old entry stays behind
stargazers.json edited into something the reader repairs (below) Every stargazer of each repository whose entry was lost

How the file is read decides which damage stops the run. It is the one file on the branch the action repairs rather than refuses:

  • Bytes that are not JSON fail the run, with the same ... is not valid JSON message stars-data.json gets.
  • JSON that is not an object is read as empty, silently, so every repository falls into the first row above.
  • An entry that is not a list of logins is dropped, silently, and its siblings survive.

The file is only read and written with track-stargazers on.


First Run Behavior

On the first writing run:

  1. The action creates the data branch as an orphan branch
  2. An initial empty commit is made
  3. All repos are recorded as New Repositories with delta: 0 (no previous data to compare against), which still makes stars-changed true
  4. Stargazers are fetched (charts are on by default) and the Reconstructed History is built from their starred_at dates, so charts are generated on the first run
  5. Forecasts are generated on the first run too, provided that reconstruction has at least 3 points
  6. With track-stargazers on, every existing stargazer is reported as new
  7. With email configured, the Notification is due as soon as your star total reaches the threshold, which under the default notification-threshold: 0 means this run: the first email lists every repository as new, and its subject shows the whole total as the delta, e.g. (+523)
  8. Everything is committed in one commit on top of the empty one

The data branch is a Git worktree checked out beside your main checkout at the dot-prefixed branch name (.star-tracker-data/), so the action never runs git checkout on the branch your workflow is using.


Read-Only Runs

With read-only: true a run touches the data branch only to read it. It still opens the worktree, reads stars-data.json and stargazers.json, computes, renders and writes every artefact above into that worktree, sends the email and sets every output. It then logs Read-only run: leaving <branch> untouched and throws the worktree away without committing.

Things follow from that, and they surprise people:

  • The branch must already exist. A read-only run refuses to create one and fails outright, so the "First Run Behavior" above does not apply to it. Point data-branch at the branch your tracking workflow maintains, or drop read-only for one run so it can be created.
  • Nothing is remembered. No snapshot is appended and starsAtLastNotification never advances, so a notification-threshold other than 0 cannot work on a read-only run. The action warns when both are set.

This is what lets another workflow, typically a weekly digest, share a data branch with the workflow that maintains it without the two racing to push.


Manual Data Management

Resetting Data

Delete the data branch to start fresh:

git push origin --delete star-tracker-data

The next writing run will recreate it. A read-only run will not.

Cloning the branch, downloading stars-data.json and querying it with jq are all in Viewing Reports, which is where the raw-data recipes live.


Data Size Estimates

Repos Tracked Approximate Size per Snapshot
10 ~1 KB
50 ~4 KB
100 ~8 KB
500 ~40 KB

With max-history: 52 and 100 repos, stars-data.json stays under ~500 KB.


Next Steps

Clone this wiki locally