Skip to content

feat(play_tracks): collect Play release-track + rollout state - #26

Merged
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/play-track-state
Aug 26, 2026
Merged

feat(play_tracks): collect Play release-track + rollout state#26
ErikBjare merged 3 commits into
ActivityWatch:masterfrom
TimeToBuildBob:feat/play-track-state

Conversation

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Why

data/releases.csv records GitHub tags — when a version was cut, not when it reached users. Those are different events: v0.14.0b2 is in releases.csv as 2026-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:

  • "we pushed b2 to prod on Monday — is the rollout complete?"
  • "which versionCode is production actually serving?"

Worse than unanswerable: answering them from releases.csv gives 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.py records 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 in data/android-tracks.csv.

$ uv run play_tracks.py
Play track state for net.activitywatch.android (2026-08-26):
  production   versionCode 31         IN PROGRESS — staged at 10%
  production   versionCode 27         complete (100% of users)
  internal     versionCode 33         complete (100% of users)

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

  • Both releases on a staged track are kept. Production carries the completed release and its replacement during a rollout; dropping either makes the rollout question unanswerable.
  • A completed release reports 1.0, not blank. The API omits userFraction when a release is at 100%, and blank would read as "unknown".
  • The edit is always deleted, including when the read fails — abandoned edits linger on the account.
  • Permissions. This uses the Publisher API, not the Reporting API vitals.py uses. 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 exits 3 with the exact grant instructions instead of a stack trace.
  • CI surfaces a denial rather than hiding it. The collect step is || 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_JSON service 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 check and mypy clean. Not ruff format-ed — 7 of 8 existing files in the repo would also reformat, so this matches current style rather than adding churn.

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-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds daily collection of Android Publisher release-track and staged-rollout state.

  • Adds authenticated track retrieval, parsing, reporting, and idempotent CSV snapshots.
  • Integrates collection and conditional staging into the existing Play workflow.
  • Documents the dataset, permissions, and relationship to Android vitals.
  • Adds offline coverage for parsing, replacement semantics, access failures, and edit cleanup.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
play_tracks.py Adds Publisher API collection, release parsing, full same-day snapshot replacement, rollout output, permission diagnostics, and best-effort edit cleanup; the previously reported upsert and cleanup failures are fixed.
test_play_tracks.py Covers multi-release tracks, rollout fractions, same-day replacement, historical preservation, permission handling, and cleanup success and failure paths.
.github/workflows/collect-play.yml Runs track collection without blocking other Play datasets and conditionally stages the optional output, fixing the previously reported missing-path commit failure.
README.md Documents track-state collection, command usage, required Publisher API permissions, and how rollout data complements vitals.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (3): Last reviewed commit: "fix(upsert_csv): replace same-day rows w..." | Re-trigger Greptile

Comment thread .github/workflows/collect-play.yml Outdated
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Optional output breaks commit

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.

Suggested change
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

Comment thread play_tracks.py
Comment on lines +174 to +175
for row in rows:
existing[(row["date"], row["track"], row["version_codes"])] = row

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Upsert retains stale releases

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.

Suggested change
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

Comment thread play_tracks.py Outdated
Comment on lines +125 to +127
requests.delete(
f"{BASE}/applications/{package}/edits/{edit_id}", headers=headers, timeout=30
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Suggested change
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.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

Addressed two real bugs from the Greptile review:

1. Cleanup exception could discard a successful read (play_tracks.py)

The finally block called requests.delete bare — if that raised a transport exception (timeout, connection reset), Python would propagate it instead of the return value, discarding the track data that was already fetched. The comment correctly described the intent ("failing to delete it must not lose the data we just read") but the implementation didn't match. Wrapped in try/except Exception: pass. New test test_fetch_returns_data_even_when_delete_raises covers this path.

2. git add of absent file could fail the Commit step (collect-play.yml)

If play_tracks.py exits with code 3 (no publisher permission) on the first run before data/android-tracks.csv exists, the unconditional git add data/android-tracks.csv would fail with fatal: pathspec '...' did not match any files. Changed to [ -f data/android-tracks.csv ] && git add data/android-tracks.csv || true.

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.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@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.
@TimeToBuildBob

Copy link
Copy Markdown
Contributor Author

@greptileai review

@ErikBjare
ErikBjare merged commit 2d435a1 into ActivityWatch:master Aug 26, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants