feat(play_tracks): collect Play release-track + rollout state - #26
Conversation
releases.csv records GitHub tags — when a version was cut, not when it reached users. v0.14.0b2 was tagged 2026-07-22 and promoted to the production track a month later, and nothing in this repo captured the promotion. That gap produced a confidently wrong answer to 'is the rollout complete?' this week. play_tracks.py records the missing half via the Android Publisher API: which release each track serves, its status, and the staged-rollout fraction, as an idempotent daily snapshot in data/android-tracks.csv. - keeps both releases on a staged track (the completed one and its replacement), since dropping either makes rollout unanswerable - a completed release reports 1.0, not blank — the API omits userFraction there and blank reads as unknown - the edit opened to read track state is always deleted, including on a failed read - a vitals-only service account gets exit 3 plus the exact permission to grant, not a stack trace; CI surfaces that as a ::warning:: rather than skipping silently 17 offline tests, no network or credentials.
Greptile SummaryThe PR adds daily collection of Android Publisher release-track and staged-rollout state.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Sequence DiagramsequenceDiagram
participant CI as GitHub Actions
participant Collector as play_tracks.py
participant Play as Android Publisher API
participant CSV as android-tracks.csv
CI->>Collector: Run daily update
Collector->>Play: Create temporary edit
Play-->>Collector: Edit ID
Collector->>Play: Read release tracks
Play-->>Collector: Track and rollout state
Collector->>Play: Delete temporary edit (best effort)
Collector->>CSV: Replace today's snapshot
CI->>CI: Stage CSV if present
CI->>CI: Commit refreshed Play data
Reviews (3): Last reviewed commit: "fix(upsert_csv): replace same-day rows w..." | Re-trigger Greptile |
| git config --local user.email "noreply@github.com" | ||
| git config --local user.name "GitHub Action" | ||
| git add data/android-crash-rate.csv data/android-anr-rate.csv data/android/installed.csv data/android-ratings.csv | ||
| git add data/android-crash-rate.csv data/android-anr-rate.csv data/android/installed.csv data/android-ratings.csv data/android-tracks.csv |
There was a problem hiding this comment.
When the first track collection fails before data/android-tracks.csv exists, the unconditional git add rejects the missing path, causing the Commit step to abort without committing the other successfully refreshed Play data.
| git add data/android-crash-rate.csv data/android-anr-rate.csv data/android/installed.csv data/android-ratings.csv data/android-tracks.csv | |
| git add data/android-crash-rate.csv data/android-anr-rate.csv data/android/installed.csv data/android-ratings.csv | |
| if [ -e data/android-tracks.csv ]; then | |
| git add data/android-tracks.csv | |
| fi |
| for row in rows: | ||
| existing[(row["date"], row["track"], row["version_codes"])] = row |
There was a problem hiding this comment.
When a same-day rerun returns a changed set of active releases, this merge only overwrites returned keys and retains omitted ones, causing the daily snapshot to report superseded releases or rollout states.
| for row in rows: | |
| existing[(row["date"], row["track"], row["version_codes"])] = row | |
| snapshot_dates = {row["date"] for row in rows} | |
| existing = { | |
| key: row for key, row in existing.items() if key[0] not in snapshot_dates | |
| } | |
| for row in rows: | |
| existing[(row["date"], row["track"], row["version_codes"])] = row |
| requests.delete( | ||
| f"{BASE}/applications/{package}/edits/{edit_id}", headers=headers, timeout=30 | ||
| ) |
There was a problem hiding this comment.
Cleanup discards successful reads
When the track read succeeds but deleting the temporary edit raises a transport exception, the unguarded finally block replaces the pending return, causing the collector to skip parsing and writing the daily snapshot.
| requests.delete( | |
| f"{BASE}/applications/{package}/edits/{edit_id}", headers=headers, timeout=30 | |
| ) | |
| try: | |
| requests.delete( | |
| f"{BASE}/applications/{package}/edits/{edit_id}", | |
| headers=headers, | |
| timeout=30, | |
| ) | |
| except requests.RequestException: | |
| pass |
- Wrap requests.delete in try/except so a transport error during the best-effort edit cleanup cannot discard a successful track read. The comment already said this must not happen; the implementation now matches the intent. Adds a test that would have caught it. - Stage data/android-tracks.csv conditionally in the CI commit step so the first run (or a run with no publisher permission) doesn't fail git-add on a missing file.
|
Addressed two real bugs from the Greptile review: 1. Cleanup exception could discard a successful read ( The 2. If Finding #2 (same-day stale rows) — not a bug. The key-based upsert intentionally retains multiple releases for the same track on the same day: that's what makes the staged-rollout question answerable (both the in-progress and completed releases must be visible simultaneously). If a release truly disappears mid-day, the morning record stays in the snapshot, which is the conservative choice for historical audit. 18 tests passing. |
|
@greptileai review |
A same-day rerun that no longer returns a previously recorded release (e.g. an in-progress rollout was paused and removed from the track) would leave the stale row in the CSV under the old key-only merge. Fix: clear all rows for the incoming snapshot dates before inserting, so each rerun produces a faithful point-in-time snapshot rather than a merge of successive API responses. Previous dates are not affected. Adds test_upsert_removes_stale_same_day_release covering this case. 19 tests passing.
|
@greptileai review |
Why
data/releases.csvrecords GitHub tags — when a version was cut, not when it reached users. Those are different events:v0.14.0b2is inreleases.csvas2026-07-22, and it was promoted to the production track roughly a month later.Nothing in this repo captured the promotion, so these are unanswerable from collected data:
Worse than unanswerable: answering them from
releases.csvgives a confident wrong answer. That happened this week on ActivityWatch/aw-android#185, where tag data led to "internal track only" for a build that had already gone to production.What
play_tracks.pyrecords the missing half via the Android Publisher API: for each track (production/beta/alpha/internal), which release is live, its status, and the staged-rollout fraction — as an idempotent daily snapshot indata/android-tracks.csv.Pairs with
vitals.py by-version(#25): the app-wide crash rate is dominated by whatever the install base still runs, so a partial rollout of a genuinely fixed build barely moves it. Track state is what makes that interpretable.Details worth a look
1.0, not blank. The API omitsuserFractionwhen a release is at 100%, and blank would read as "unknown".vitals.pyuses. The service account needs an app permission that allows opening an edit (at least "Release to testing tracks"); "View app quality information" alone is not enough. On denial the tool exits3with the exact grant instructions instead of a stack trace.|| echo "::warning::…"so a missing permission doesn't break vitals collection — but it shows up in the run summary. Silent skipping is how the vitals feed stalled unnoticed for a week (feat(vitals): per-version crash/ANR breakdown + stalled-feed warning #25 adds the freshness warning for that).If the existing
PLAY_SA_JSONservice account lacks the release permission, this step will warn on every run until it's granted — intentional and visible, but worth knowing before merge.Testing
17 offline tests (
test_play_tracks.py) — parsing, staged-rollout retention, CSV idempotency across same-day re-runs and multi-day history, rollout summary rendering, permission-failure exit code and message, and edit cleanup on both the success and failure paths. No network, no credentials.ruff checkandmypyclean. Notruff format-ed — 7 of 8 existing files in the repo would also reformat, so this matches current style rather than adding churn.