-
Notifications
You must be signed in to change notification settings - Fork 0
Data Pipeline
CI/CD architecture for the proton-pulse-data GitHub Pages mirror. This pipeline runs daily via GitHub Actions and produces the per-game report files the plugin consumes.
The update-data.yml workflow has three main stages:
Stage 1: build Stage 2: probe-chunks Stage 3: finalize
----------------- --------------------- ------------------
Process official -> Probe ProtonDB summaries -> Backfill probe
ProtonDB dump in parallel chunks discoveries
Manifest backfill (resumable, cache-backed) Merge Pulse Reports
Compute probe plan Build indexes
Coverage report
Deploy to gh-pages
The same three jobs run in different subsets depending on which mode fired the dispatch. This is what lets staging_with_finalize do a schema-shape verification in ~5 min instead of ~30.
flowchart LR
subgraph B[Stage 1: build]
B1[dump processing +<br/>backfill + chunk plan]
end
subgraph P[Stage 2: probe-chunks]
P1[ProtonDB probe<br/>parallel chunks<br/>persist to gh-pages]
end
subgraph F[Stage 3: finalize]
F1[restore chunk state<br/>from gh-pages] --> F2[probe backfill]
F2 --> F3[finalize.py<br/>build indexes]
F3 --> F4[stats + hardware]
F4 --> F5{deploy target}
F5 -- default --> F6[push to origin/gh-pages<br/>prod]
F5 -- staging_with_pipeline<br/>or staging_with_finalize --> F7[push to staging repo<br/>gh-pages]
end
Full[gh-run<br/>full pipeline] --> B --> P --> F
Resume[gh-resume<br/>rescue partial] --> B
Finalize[gh-finalize-only] --> F
StagingShell[gh-staging<br/>shell only] -.-> Shell[staging-preview job<br/>no data write]
StagingFull[gh-staging-pipeline] --> B
StagingFast[gh-staging-finalize] --> F
style Full fill:#1e3a5f,color:#fff
style StagingFast fill:#0f5132,color:#fff
style StagingFull fill:#664d03,color:#fff
style Resume fill:#155e75,color:#fff
style Finalize fill:#155e75,color:#fff
style StagingShell fill:#495057,color:#fff
The finalize job branches on the deploy target: prod runs by default, the staging repo receives the push when either staging_with_pipeline or staging_with_finalize is on. This is a hard mutual exclusion in the workflow gates, tested by tests/test_staging_workflow_gates.py. Missing either check is the exact bug that once force-pushed prod's frontend over a staging preview.
-
Process Official Reports -- Parses the bdefore/protondb-data monthly dump into
data/{appId}/{year}.jsonyear-bucket files. -
Manifest Backfill -- Apps listed in
config/live_backfill_app_ids.jsonthat are missing from the dump are fetched from ProtonDB's live detailed report endpoint. - Build Probe Chunk Plan -- Computes which Steam app IDs still need a ProtonDB summary probe, splits them into chunks for parallel execution, and outputs the chunk matrix.
Outputs: probe-input artifact (data files + pipeline-state.json) and the chunk matrix for Stage 2.
Runs the ProtonDB summary probe in sequential chunks (max-parallel: 1). Each chunk:
- Restores the pipeline cache (
.cache/protondb-summary-probe-cache.json) -
Idempotent skip check (
scripts/probe-idempotent-check.sh, #171 Phase 4) -- hashes the current cache file and compares against this chunk's storedinput_shain the manifest. Match = state hasn't drifted since last successful run, so probe would find nothing new. Emitsskip=trueand the probe step gates itself off. Warm reruns become near-instant. - Probes a slice of app IDs against ProtonDB's summary API (skipped if idempotent check fired)
- Saves the updated cache under a fresh cache key
-
Persists a durable snapshot (
scripts/persist-chunk-state.sh) togh-pages/.pipeline-state/chunks/chunk-NN.json.gzplus amanifest.jsonentry (status,app_count,input_sha,completed_at,run_id,run_attempt). Runs onif: always()so a stalled chunk still records whatever it wrote before dying. Push loop retries up to 3 times with jittered backoff for cross-run contention. Skipped when the idempotent check fired (nothing to persist).
Skipped entirely when chunk_count == 0 (all apps already cached).
The chunk-snapshot layout on gh-pages:
.pipeline-state/
manifest.json { updated_at, chunks: {"01": {status, app_count, input_sha, completed_at, run_id, run_attempt}, ...} }
chunks/
chunk-01.json.gz full probe cache after chunk 01 ran
chunk-02.json.gz full probe cache after chunk 02 ran (contains chunk 01's data too)
...
Each snapshot is the FULL cache state after that chunk, not a delta, so recovery only needs the highest-numbered file. Finalize (Stage 3) verifies the manifest against expected_matrix (the full plan) and aborts loud with chunk NN missing since T if a gap is found -- surfaces runner-starvation incidents at the right layer instead of silently finalizing on stale data.
-
Restore chunk state from gh-pages (
scripts/restore-chunk-state.sh) -- Reads.pipeline-state/manifest.json, verifies every chunk inexpected_matrix(the full plan from Stage 1) is present and completed, then gunzips the highest-numbered snapshot into.cache/protondb-summary-probe-cache.json. Aborts loud withchunk NN missing since Tif a gap is found so runner-starvation incidents surface here instead of finalizing on stale data. See #171 Phase 2. - Backfill Probe Discoveries -- Apps the probe found on ProtonDB but missing from local data get their live detailed reports fetched and written to year-bucket files.
-
Merge Pulse Reports -- Pulls user-submitted configs from Supabase (
user_configstable) and merges them into the same year-bucket files alongside ProtonDB data. See Pulse Report Merge below. -
Finalize -- Builds
index.json,latest.json, coverage report, search index, and data-index page. -
Deploy -- Force-pushes an orphan
gh-pagesbranch with the full dataset plus static web app files.
Pulse Reports are the plugin's own data: hardware-tagged configs submitted via the Decky UI or the web form. They live in Supabase (user_configs table) and were historically fetched live by the web UI. As of proton-pulse-data#TBD, the pipeline snapshots them into the same static JSON files that hold ProtonDB reports, so consumers (plugin, web, third-party scripts) only need to read one place.
scripts/pipeline/pulse.py -- called from finalize_output() right after generate_latest_files() and before app indexes are built, so latest/index files pick up the freshly merged Pulse records.
Every report in a year file now carries a source field so consumers can filter cleanly:
| source | Origin | Schema |
|---|---|---|
"protondb" |
ProtonDB archive or live fetch | Base shape: cpu, gpu, protonVersion, rating, notes, timestamp, ... |
"pulse" |
Supabase user_configs row |
Base shape plus launchOptions, formResponses, configKey, gameOwned, vramMb, durationMinutes, pulseId, submissionSource
|
Pulse records preserve the granular submission origin in submissionSource (e.g. "user" for the Decky plugin, "web-linux" for the web form), so the broader source: "pulse" tag never loses that information.
The Supabase row id is stored on each Pulse record as pulseId. On every pipeline run, existing pulse records in the year file with a pulseId matching an incoming row are replaced, not duplicated. This means users editing their submissions in Supabase have those edits reflected in the static snapshot on the next pipeline run.
ProtonDB records continue to dedupe by timestamp (handled in process.py, unchanged).
Pulse rows are bucketed by year using created_at. A submission from 2025-03-14 lands in data/{appId}/2025.json. Years are extracted from the ISO timestamp via datetime.fromisoformat() in UTC.
A Pulse report can land on a game that has no ProtonDB data at all. That game starts life as an extended Steam stub (No Rating, zero counts). The rating fills in on the next pipeline run through a fixed chain of steps in finalize_output():
-
merge_pulse_into_data_dir()runs first and createsdata/{appId}/{year}.jsonfrom the Supabase row, even if the app had no directory before. -
derive_index_keys_from_disk()walksdata/and picks up that new directory, so the generators see the app this run instead of only the apps reprocessed from ProtonDB. -
generate_search_index()calls_compute_game_summary(), which averages the per-report scores (a single PLATINUM report scores 1.0, mapping to the platinum tier) and emits the app into the primarysearch-index.jsonwithpulseCountset. - Because the app is now in the primary index,
generate_extended_steam_indexskips it, so the oldNo Ratingstub drops out and the frontend dedup shows the real tier.
So a freshly submitted report shows No Rating until the next "Build Site Data" run, then appears with its computed tier. No manual step is involved. The tier math matches the Scoring Algorithm page.
The merge function backfills source: "protondb" on any legacy untagged ProtonDB records it encounters in the same file. So after one full pipeline run, every record in every year file is self-describing.
If Supabase is unreachable (network, rate limit, schema mismatch), the function logs the error and returns without touching the year files. ProtonDB data still ships as normal.
Defaults to the production Supabase project's anon publishable key, which is read-only by RLS policy. Forks / staging can override via env:
SUPABASE_URL=https://<project>.supabase.co/rest/v1 \
SUPABASE_ANON_KEY=<your_anon_key> \
make gh-runThe .cache/ directory is persisted across runs via GitHub Actions actions/cache. It stores:
-
protondb-summary-probe-cache.json-- Which apps have been probed and their summary results - Steam title cache -- Resolved game titles to avoid repeated Steam Store API calls
Each stage saves its cache under a unique key (build, chunk-N, final) so they don't collide.
| Variable | Default | Purpose |
|---|---|---|
PROTONDB_PROBE_LIMIT |
5000 |
Max apps to probe per chunk |
PROTONDB_PROBE_BACKFILL_LIMIT |
0 (unlimited) |
Max apps to backfill from probe discoveries |
PROTONDB_PROBE_LOG_EVERY |
100 |
Log progress every N apps during probe |
update-data.yml exposes several workflow_dispatch inputs so a manual run can target a specific slice of the pipeline. Regular scheduled runs use all defaults and run the full stack.
| Input | Make target | What it does |
|---|---|---|
| (none, defaults) | make gh-run |
Full pipeline. Build -> probe-chunks -> finalize, deploys to prod. |
pages_only=true |
make gh-pages-only |
Skips the pipeline entirely, republishes only the HTML/JS/CSS shell + static assets against current gh-pages data. Used after a UI-only change is confirmed on staging. |
staging_only=true |
make gh-staging |
Deploys shell files to the staging repo (proton-pulse-web-staging). No pipeline run, no prod push. |
staging_with_pipeline=true |
make gh-staging-pipeline |
Full pipeline but deploys data + shell to the staging repo instead of prod. Slow (30+ min). Use when a pipeline-data change needs end-to-end verification before promotion (#117). |
backfill_app_ids=<comma list> |
make gh-backfill-apps ... |
Fetches specific app IDs into a small backfill artifact, publishes them. Skips the full pipeline. |
coverage_backfill_issue_type=... |
make gh-coverage-backfill ... |
Runs the coverage-issue backfill script for one class of missing data (no-titles / bad-app-id / no-protondb-data). |
resume=true |
make gh-resume |
Reads gh-pages/.pipeline-state/manifest.json, subtracts completed chunks from the chunk matrix, and re-runs only the missing ones. Rescue path after a partial run (e.g. chunk 8 stalled while 1-7 + 9-10 completed -> new dispatch runs just [08]). Finalize verifies the full expected_matrix afterwards (#171 Phase 3). |
finalize_only=true |
make gh-finalize-only |
Skips build + probe-chunks entirely, re-runs only finalize against whatever state is currently in gh-pages/.pipeline-state/. Use after a finalize-side bug fix when the probe cache is fine (#171 Phase 3). |
Only one non-default mode should be set per dispatch. The gates in the workflow enforce this: e.g. resume and finalize_only are mutually exclusive with staging_only, staging_with_pipeline, backfill_app_ids, and coverage_backfill_issue_type.
The "Backfill Probe Discoveries" step in finalize took 4+ hours every run, re-fetching ~29,600 apps even when nothing had changed.
backfill_probe_discoveries() in scripts/pipeline/backfill.py determined what needed backfilling by scanning data_output_path (/tmp/protondb-output/data), which is rebuilt fresh each run from the probe-input artifact. That artifact only contains apps from the official dump + manifest backfill (~8K apps). The ~21K apps that were probe-backfilled in previous runs only existed on the deployed gh-pages branch and never made it into the artifact.
So every run, all ~21K probe-discovered apps appeared "missing" and got re-fetched one by one (2-3 HTTP requests each).
Two-part fix, both landed:
Python side (scripts/pipeline/backfill.py):
-
backfill_probe_discoveries()takes analready_known_app_idsset and unions it into the on-disk scan -
run_probe_backfill()passesindexed_app_ids | backfill_app_idsfrom pipeline state
Workflow side (.github/workflows/update-data.yml, finalize job):
- Checkout
gh-pageswithfetch-depth: 1intogh-pages-data/ -
cp -rn gh-pages-data/data/. /tmp/protondb-output/data/before "Backfill Probe Discoveries" runs -
-rn(no-clobber) preserves the artifact's fresh files; gh-pages only fills historical gaps
The workflow piece is the load-bearing change. The Python side was already in place, but data_output_path.iterdir() only saw the artifact's small set until gh-pages data was merged in.
Probe-backfill drops from ~4 hours to seconds on no-change runs. The full pipeline now finishes in under 15 minutes when there's nothing new to fetch from ProtonDB.
From run 24434781792 (2026-04-15):
03:27:12 [probe-backfill] Backfilling 29,619 probe-discovered app(s)
03:27:12 [probe-backfill] (1/29619) Title for 10: 'Counter-Strike' via provided-catalog
...
07:46:08 [probe-backfill] Summary: attempted 29,619 app(s), succeeded 29,591, missed 28
4 hours 19 minutes to re-fetch apps that were already successfully backfilled in the previous run.
The pipeline writes these files to /tmp/protondb-output/ during Stage 3 (finalize). All are deployed to the gh-pages branch and served as static JSON.
| File | Script | Purpose |
|---|---|---|
data/{appId}/ |
finalize.py |
Per-game ProtonDB + Pulse report year-bucket files |
search-index.json |
finalize.py |
[appId, title, tier, protondbCount, pulseCount, appType] rows. Primary index: real data + Epic/GOG catalogs + Steam stubs filtered to ProtonDB-known apps |
search-index-steam-extended.json |
finalize.py |
Long-tail Steam catalog stubs not in the primary index (no ProtonDB gate). Lazy-loaded by the grouped search page only. See Extended Steam Index |
recent-reports.json |
finalize.py |
Top 100 games sorted by most recent report timestamp |
most_played.json |
most_played.py |
Steam's current top charts with ProtonDB tier, rated + unrated + catalog-only buckets |
steam-catalog.json |
most_played.py |
{ appId: name } for Steam chart games with no ProtonDB data (see below) |
game-images.json |
game_images.py |
{ appId: url } for games where the standard CDN header URL 404s (hashed paths) |
game-images-cache.json |
game_images.py |
Unified probe cache tracking status + probed date per app ID |
release-years-cache.json |
release_years.py |
{ appId: year } cache for Steam apps whose titles collide with another game's. Release dates never change, so this accumulates over time (#109 committed to gh-pages so the 200/run probe cap isn't burned from scratch on every run). |
stats.json |
stats.py |
Aggregate counts for the /stats page |
Some games appear in Steam's most-played charts but have no ProtonDB reports and no entry in search-index.json. Without a name source, the site previously skipped them entirely.
The pipeline now runs a catalog-only pass at the end of build_most_played():
- Any Steam chart game not in
search-index.jsonis looked up in the cached Steam game catalog (.cache/steam-game-catalog.json, populated by thesteam-catalogCLI step usingSTEAM_API_KEY). - Matched games are appended to
most_played.jsonwithrating: "catalog"and zero report counts. -
steam-catalog.jsonis written alongside: a minimal{ appId: name }map of just these catalog-only games.
The frontend uses steam-catalog.json in two ways:
- Home page:
rating: "catalog"entries are included in the "Not rated yet" toggle bucket alongsiderating: "pending"games. - Game page: when no reports exist and the search-index has no entry,
steam-catalog.jsonis fetched as a title fallback so a proper stub page is shown (with a submit CTA) instead of the generic "not in our mirror" error.
game_images.py treats all IDs in steam-catalog.json as hot, meaning their header images are always probed on each run instead of waiting for the backlog queue.
The primary search-index.json includes Steam catalog stubs only for apps that ProtonDB knows about (its curated compatibility_report_with_games.json signal plus our probe cache). That gate keeps the file small but hides real Steam games that have ProtonDB reports yet are not in the curated signal. Issue #134 hit this with "Thank You For Your Application" (app 2881370).
To close that gap, generate_extended_steam_index writes a companion file at the end of finalize: every Steam catalog entry that is not already in the primary index, with no ProtonDB gate. Same row shape as the primary so the frontend can reuse the same render helper.
The frontend loads it lazily. The grouped search results page (renderSearchPage, hit by pressing Enter on a query) calls loadExtendedSteamIndex() alongside loadSearchIndex() and merges matches deduped by appId. The instant dropdown (onSearchInput) stays primary-only so typing never triggers a multi-megabyte fetch.
Header images for these stubs are handled by the backlog drain, not an upfront fetch. Most extended apps render fine because the frontend card tries the standard Steam CDN header.jpg path client-side first, with no pipeline involvement. Only the minority whose standard path 404s need a hashed URL stored in game-images.json. game_images.py reads search-index-steam-extended.json and appends its uncached IDs to the tail of the backlog (_extended_steam_ids), behind apps that have real reports. The same PROBE_CAP per run bounds the work, so the long tail trickles in over many runs rather than probing 140k IDs at once and tripping Steam rate limits. Until an extended app gets probed, a hashed-path game shows no image (for example app 3761210).
The backup.yml workflow backs up Supabase data and a site snapshot to proton-pulse-data-backup.
| Trigger | Frequency | Commit label |
|---|---|---|
| Cron schedule | Every Sunday at 04:00 UTC | backup (scheduled): YYYY-MM-DD |
| After "Update ProtonDB Data" succeeds | Every successful CI run | backup (post-deploy): YYYY-MM-DD |
| Manual (workflow_dispatch) | On demand | backup (manual): YYYY-MM-DD |
| Archive | Contents | PII handling |
|---|---|---|
backup-YYYY-MM-DD-schema.tar.gz |
SQL DDL for all public tables, RLS policies | None |
backup-YYYY-MM-DD-user_configs.tar.gz |
All visible game compatibility reports | client_id + proton_pulse_user_id HMAC-pseudonymized; notes sanitized |
backup-YYYY-MM-DD-author_avatars.tar.gz |
Steam avatar cache | steam_id excluded; proton_pulse_user_id HMAC-pseudonymized |
backup-YYYY-MM-DD-site.tar.gz |
Snapshot of deployed gh-pages HTML/JS/CSS | None |
Auth data (auth.users, admins, banned_users, claimed_client_ids) is never exported.
Every run appends one line to backups.jsonl at the root of the backup repo. Each entry contains:
{
"ts": "2026-06-22T16:00:00.000Z",
"date": "2026-06-22",
"trigger": "scheduled",
"run_id": "12345678",
"run_url": "https://github.com/mdeguzis/proton-pulse-web/actions/runs/12345678",
"source_sha": "abc1234",
"source_url": "https://github.com/mdeguzis/proton-pulse-web/commit/abc1234",
"files": [
{ "name": "backup-2026-06-22-schema.tar.gz", "size_bytes": 8192, "row_count": null },
{ "name": "backup-2026-06-22-user_configs.tar.gz", "size_bytes": 204800, "row_count": 1234 }
]
}In the proton-pulse-web repo, go to Actions > Backup > Run workflow. Choose the backup type (default: all).
See RESTORE.md in claude-configs for the full team handoff and restore guide.
- ProtonDB Data Resolution -- Documents the plugin-side lookup strategy (mirror -> live-detailed -> live-summary) that consumes the data this pipeline produces.
-
Architecture -- The
src/lib/protondb.tssection covers how the plugin reads from the GitHub Pages mirror endpoints. -
Supabase Voting -- Describes the
user_configsSupabase table that the Pulse merge step reads from.