From 73653ad4cc54ee5a9286f8eb92254b4bd28be48a Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 15:58:17 +0000 Subject: [PATCH 1/3] feat(play_tracks): collect Play release-track + rollout state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/collect-play.yml | 8 +- README.md | 25 ++- play_tracks.py | 240 +++++++++++++++++++++++++++++ test_play_tracks.py | 201 ++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 play_tracks.py create mode 100644 test_play_tracks.py diff --git a/.github/workflows/collect-play.yml b/.github/workflows/collect-play.yml index f6edb2ee..8a168f88 100644 --- a/.github/workflows/collect-play.yml +++ b/.github/workflows/collect-play.yml @@ -37,13 +37,19 @@ jobs: uv run android_installs.py --bucket "$PLAY_BUCKET" update uv run android_ratings.py --bucket "$PLAY_BUCKET" fi + # Track/rollout state (Publisher API). Needs a release permission the + # vitals-only SA may not have, so a denial (exit 3) must not fail the + # whole collection -- but it must stay visible rather than silently + # skipping, which is how the vitals feed stalled unnoticed for a week. + uv run play_tracks.py --update data/android-tracks.csv \ + || echo "::warning::play_tracks.py failed (exit $?) - track state not updated" rm -f "$GOOGLE_APPLICATION_CREDENTIALS" - name: Commit if: env.HAS_SA == 'true' run: | 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 git diff --cached --quiet || git commit -m "chore: update Play Console stats" - name: Push changes if: env.HAS_SA == 'true' diff --git a/README.md b/README.md index 118a317b..f24bbfaa 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ All data is stored in the `data` folder. These data are updated automatically in CI: - `stats.csv` - Downloads & GitHub stars - - `releases.csv` - Release dates of past releases + - `releases.csv` - Release dates of past releases (GitHub *tags* — when a version was cut, not when it reached users) + - `android-tracks.csv` - Play release-track state: which versionCode each track serves, and staged-rollout progress - `stats-assets.csv` - Per-asset download counts (source for per-platform / per-version breakdowns) - `android/installed.csv` - Android install base (Active Device Installs, Play Console bulk reports) - `android-crash-rate.csv` / `android-anr-rate.csv` - Android vitals (Play Developer Reporting API) @@ -46,6 +47,28 @@ Needs a Google Cloud service account granted "view app quality / Android vitals" access in the Play Console; point at the key with `--credentials` or `GOOGLE_APPLICATION_CREDENTIALS`. See the module docstring for setup. +## Play release tracks + +`releases.csv` records when a version was *tagged*, which is not when it +reached users — v0.14.0b2 was tagged 2026-07-22 and promoted to production a +month later. `play_tracks.py` records the other half: which release each Play +track currently serves, and how far a staged rollout has progressed. + +```shell +uv run play_tracks.py # human summary per track +uv run play_tracks.py --update data/android-tracks.csv # daily snapshot (idempotent) +uv run play_tracks.py --csv # rows to stdout +``` + +This uses the Android Publisher API (not the Reporting API `vitals.py` uses), +so 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; the tool exits 3 with that advice rather than a stack trace. + +Pair it with `vitals.py by-version` to attribute a crash rate to a release: +the app-wide rate is dominated by whatever the install base still runs, so a +partial rollout of a genuinely fixed build barely moves it. + `android_installs.py` automates `android/installed.csv` (the install base currently exported by hand) from the Play Console bulk "installs" reports in Cloud Storage: diff --git a/play_tracks.py b/play_tracks.py new file mode 100644 index 00000000..ced70003 --- /dev/null +++ b/play_tracks.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Collect Play Store *release track* state for the ActivityWatch Android app. + +Why this exists +--------------- +`data/releases.csv` records **GitHub tags** — when a version was cut, not when +(or whether) it reached users. Those are different events: v0.14.0b2 was tagged +on 2026-07-22 and promoted to the production track a month later. Nothing in +this repo captured the promotion, so questions like + + "we pushed b2 to prod on Monday — is the rollout complete?" + "which versionCode is production actually serving?" + +were unanswerable from collected data, and answering them from `releases.csv` +gives a confidently wrong answer. + +This tool records the missing half: for each Play track (production, beta, +alpha, internal), which release is live, its status, and the staged-rollout +fraction. Combined with `vitals.py by-version`, that makes a crash rate +attributable to a release instead of app-wide. + +Usage +----- + uv run play_tracks.py list # human table + uv run play_tracks.py list --update data/android-tracks.csv + +Auth +---- +Same service-account key as `vitals.py` (`--credentials` / +`GOOGLE_APPLICATION_CREDENTIALS`), but a different API and scope: the Android +Publisher API, scope `.../auth/androidpublisher`. The account needs an app +permission that permits opening an edit (e.g. "Release to testing tracks"); +"View app quality information" alone is *not* enough. A permission failure is +reported loudly and never written to CSV — see `_check_access`. +""" +from __future__ import annotations + +import csv +import os +from datetime import date + +import click +import requests + +SCOPE = "https://www.googleapis.com/auth/androidpublisher" +BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3" +DEFAULT_PACKAGE = "net.activitywatch.android" +DEFAULT_CREDENTIALS = os.path.expanduser("~/.config/activitywatch/play-sa.json") + +CSV_HEADER = ["date", "track", "status", "user_fraction", "version_codes", "release_name"] + +# Exit code for "authenticated fine, but this account may not read tracks". +# Distinct from 1 so a collector can tell a permission gap from a real failure. +EXIT_NO_ACCESS = 3 + + +def _access_token(credentials: str | None) -> str: + """OAuth token for the Android Publisher API from a service-account key.""" + import google.auth + from google.auth.transport.requests import Request + from google.oauth2 import service_account + + credentials = credentials or ( + DEFAULT_CREDENTIALS if os.path.exists(DEFAULT_CREDENTIALS) else None + ) + if credentials: + creds = service_account.Credentials.from_service_account_file( + credentials, scopes=[SCOPE] + ) + else: + creds, _ = google.auth.default(scopes=[SCOPE]) + creds.refresh(Request()) + return creds.token + + +def _check_access(resp: requests.Response) -> None: + """Turn a 401/403 into an actionable message instead of a stack trace. + + The common case is a service account provisioned for vitals only: it has + "View app quality information" but not the release permission an edit + needs. That reads as a hard failure unless we say what to grant. + """ + if resp.status_code not in (401, 403): + return + click.echo( + f"Play Publisher API denied access ({resp.status_code}).\n" + " The service account can reach the API but is not allowed to open an " + "edit for this app.\n" + " Grant it in Play Console -> Users & permissions -> the SA's email ->\n" + " app permissions -> at least 'Release to testing tracks'.\n" + " ('View app quality information', which vitals.py uses, is not enough.)\n" + f" API said: {resp.text[:300]}", + err=True, + ) + raise SystemExit(EXIT_NO_ACCESS) + + +def fetch_tracks(package: str, credentials: str | None) -> list[dict]: + """Return the current release state of every Play track. + + Opening an "edit" is how the Publisher API exposes track state; the edit is + a scratch transaction, never committed, and deleted again below — so this + is a read even though it takes a POST to start. + """ + token = _access_token(credentials) + headers = {"Authorization": f"Bearer {token}"} + + r = requests.post(f"{BASE}/applications/{package}/edits", headers=headers, timeout=30) + _check_access(r) + r.raise_for_status() + edit_id = r.json()["id"] + + try: + r = requests.get( + f"{BASE}/applications/{package}/edits/{edit_id}/tracks", + headers=headers, + timeout=30, + ) + _check_access(r) + r.raise_for_status() + return r.json().get("tracks", []) + finally: + # Best-effort cleanup: an abandoned edit expires on its own, and + # failing to delete it must not lose the data we just read. + requests.delete( + f"{BASE}/applications/{package}/edits/{edit_id}", headers=headers, timeout=30 + ) + + +def parse_tracks(tracks: list[dict], today: str) -> list[dict]: + """Flatten the API shape into one row per (track, release). + + A track can carry more than one release at once — a staged rollout runs + alongside the completed release it is replacing — so the rollout question + is only answerable if both rows survive. + """ + rows = [] + for track in tracks: + name = track.get("track", "") + for release in track.get("releases", []): + codes = ";".join(str(c) for c in release.get("versionCodes") or []) + fraction = release.get("userFraction") + rows.append( + { + "date": today, + "track": name, + "status": release.get("status", ""), + # A completed release serves everyone; the API omits + # userFraction there rather than sending 1.0. + "user_fraction": ( + "1.0" + if fraction is None and release.get("status") == "completed" + else ("" if fraction is None else repr(float(fraction))) + ), + "version_codes": codes, + "release_name": release.get("name", ""), + } + ) + rows.sort(key=lambda r: (r["track"], r["release_name"], r["version_codes"])) + return rows + + +def upsert_csv(path: str, rows: list[dict]) -> int: + """Merge rows into the CSV keyed by (date, track, version_codes). + + Re-running on the same day overwrites that day's snapshot rather than + appending a duplicate, so the daily collector is idempotent. + """ + existing: dict[tuple[str, str, str], dict] = {} + if os.path.exists(path): + with open(path, newline="") as f: + for r in csv.DictReader(f): + existing[(r["date"], r["track"], r["version_codes"])] = r + for row in rows: + existing[(row["date"], row["track"], row["version_codes"])] = row + with open(path, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=CSV_HEADER, lineterminator="\n") + w.writeheader() + for key in sorted(existing): + w.writerow({k: existing[key].get(k, "") for k in CSV_HEADER}) + return len(existing) + + +def rollout_summary(rows: list[dict]) -> list[str]: + """One line per track answering 'is the rollout complete?'.""" + out = [] + for row in rows: + codes = row["version_codes"] or "?" + if row["status"] == "completed": + state = "complete (100% of users)" + elif row["status"] == "inProgress": + frac = row["user_fraction"] + pct = f"{float(frac) * 100:g}%" if frac else "unknown %" + state = f"IN PROGRESS — staged at {pct}" + elif row["status"] == "halted": + state = "HALTED" + else: + state = row["status"] or "unknown" + out.append(f" {row['track']:<12} versionCode {codes:<10} {state}") + return out + + +@click.command() +@click.option("--package", default=DEFAULT_PACKAGE, show_default=True) +@click.option( + "--credentials", + envvar="GOOGLE_APPLICATION_CREDENTIALS", + help="Path to service-account JSON (or set GOOGLE_APPLICATION_CREDENTIALS).", +) +@click.option( + "--update", + "update_path", + default=None, + help="Upsert today's snapshot into this CSV (idempotent per day).", +) +@click.option("--csv", "as_csv", is_flag=True, help="Print CSV rows to stdout.") +def list_tracks(package, credentials, update_path, as_csv): + """Show which release each Play track is currently serving.""" + tracks = fetch_tracks(package, credentials) + rows = parse_tracks(tracks, date.today().isoformat()) + if not rows: + raise SystemExit(f"No track releases returned for {package}") + + if as_csv: + w = csv.DictWriter(click.get_text_stream("stdout"), fieldnames=CSV_HEADER, + lineterminator="\n") + w.writeheader() + w.writerows(rows) + else: + click.echo(f"Play track state for {package} ({rows[0]['date']}):") + for line in rollout_summary(rows): + click.echo(line) + + if update_path: + total = upsert_csv(update_path, rows) + click.echo(f"Wrote {len(rows)} row(s) to {update_path} ({total} total)", err=True) + + +if __name__ == "__main__": + list_tracks() diff --git a/test_play_tracks.py b/test_play_tracks.py new file mode 100644 index 00000000..e7a2aa9d --- /dev/null +++ b/test_play_tracks.py @@ -0,0 +1,201 @@ +"""Offline tests for play_tracks.py — no network, no credentials.""" +from __future__ import annotations + +import csv + +import pytest +import requests + +import play_tracks + + +# --- fixtures -------------------------------------------------------------- + +# Shape of a real Publisher API tracks response mid-rollout: production is +# serving a completed release *and* staging its replacement at 10%. +TRACKS_MID_ROLLOUT = [ + { + "track": "production", + "releases": [ + {"name": "0.14.0b2", "versionCodes": ["31"], "status": "inProgress", + "userFraction": 0.1}, + {"name": "0.12.1", "versionCodes": ["27"], "status": "completed"}, + ], + }, + { + "track": "internal", + "releases": [ + {"name": "0.14.0b4", "versionCodes": ["33"], "status": "completed"}, + ], + }, +] + + +class FakeResponse: + def __init__(self, status_code=200, payload=None, text=""): + self.status_code = status_code + self._payload = payload if payload is not None else {} + self.text = text + + def json(self): + return self._payload + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"HTTP {self.status_code}") + + +# --- parse_tracks ---------------------------------------------------------- + +def test_parse_flattens_one_row_per_release(): + rows = play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26") + assert len(rows) == 3 + assert {r["track"] for r in rows} == {"production", "internal"} + assert all(r["date"] == "2026-08-26" for r in rows) + + +def test_parse_keeps_both_releases_on_a_staged_track(): + """The rollout question is unanswerable if the staged release is dropped.""" + rows = play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26") + prod = [r for r in rows if r["track"] == "production"] + assert len(prod) == 2 + staged = next(r for r in prod if r["status"] == "inProgress") + assert staged["user_fraction"] == "0.1" + assert staged["version_codes"] == "31" + + +def test_completed_release_reports_full_fraction(): + """The API omits userFraction on a completed release; '' would read as unknown.""" + rows = play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26") + done = next(r for r in rows if r["release_name"] == "0.12.1") + assert done["user_fraction"] == "1.0" + + +def test_parse_joins_multiple_version_codes(): + rows = play_tracks.parse_tracks( + [{"track": "beta", "releases": [ + {"name": "x", "versionCodes": ["40", "41"], "status": "completed"}]}], + "2026-08-26", + ) + assert rows[0]["version_codes"] == "40;41" + + +def test_parse_tolerates_missing_fields(): + rows = play_tracks.parse_tracks([{"track": "alpha", "releases": [{}]}], "2026-08-26") + assert rows == [{"date": "2026-08-26", "track": "alpha", "status": "", + "user_fraction": "", "version_codes": "", "release_name": ""}] + + +def test_parse_empty_tracks(): + assert play_tracks.parse_tracks([], "2026-08-26") == [] + + +# --- upsert_csv ------------------------------------------------------------ + +def test_upsert_writes_header_and_rows(tmp_path): + path = str(tmp_path / "tracks.csv") + rows = play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26") + assert play_tracks.upsert_csv(path, rows) == 3 + with open(path, newline="") as f: + got = list(csv.DictReader(f)) + assert list(got[0]) == play_tracks.CSV_HEADER + assert len(got) == 3 + + +def test_upsert_is_idempotent_within_a_day(tmp_path): + """The daily collector re-runs; a second run must not duplicate the snapshot.""" + path = str(tmp_path / "tracks.csv") + rows = play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26") + play_tracks.upsert_csv(path, rows) + assert play_tracks.upsert_csv(path, rows) == 3 + + +def test_upsert_appends_a_new_day_and_keeps_history(tmp_path): + path = str(tmp_path / "tracks.csv") + play_tracks.upsert_csv(path, play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26")) + play_tracks.upsert_csv(path, play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-27")) + with open(path, newline="") as f: + got = list(csv.DictReader(f)) + assert len(got) == 6 + assert {r["date"] for r in got} == {"2026-08-26", "2026-08-27"} + + +def test_upsert_overwrites_a_changed_same_day_snapshot(tmp_path): + """Rollout fraction moves during the day; the latest read wins.""" + path = str(tmp_path / "tracks.csv") + play_tracks.upsert_csv(path, play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26")) + advanced = [{"track": "production", "releases": [ + {"name": "0.14.0b2", "versionCodes": ["31"], "status": "inProgress", + "userFraction": 0.5}]}] + play_tracks.upsert_csv(path, play_tracks.parse_tracks(advanced, "2026-08-26")) + with open(path, newline="") as f: + got = {(r["track"], r["version_codes"]): r for r in csv.DictReader(f)} + assert got[("production", "31")]["user_fraction"] == "0.5" + assert len(got) == 3 # the other two rows survive + + +# --- rollout_summary ------------------------------------------------------- + +def test_summary_names_the_staged_percentage(): + rows = play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26") + text = "\n".join(play_tracks.rollout_summary(rows)) + assert "IN PROGRESS — staged at 10%" in text + assert "complete (100% of users)" in text + + +def test_summary_flags_a_halted_rollout(): + rows = play_tracks.parse_tracks( + [{"track": "production", "releases": [ + {"name": "x", "versionCodes": ["9"], "status": "halted"}]}], + "2026-08-26", + ) + assert "HALTED" in play_tracks.rollout_summary(rows)[0] + + +# --- access failure -------------------------------------------------------- + +@pytest.mark.parametrize("status", [401, 403]) +def test_permission_failure_exits_with_distinct_code(status, capsys): + """A vitals-only service account must produce advice, not a stack trace.""" + with pytest.raises(SystemExit) as exc: + play_tracks._check_access(FakeResponse(status, text="caller lacks permission")) + assert exc.value.code == play_tracks.EXIT_NO_ACCESS + err = capsys.readouterr().err + assert "Release to testing tracks" in err + assert "caller lacks permission" in err + + +def test_check_access_passes_through_success(): + assert play_tracks._check_access(FakeResponse(200)) is None + + +# --- fetch_tracks ---------------------------------------------------------- + +def test_fetch_opens_reads_and_always_deletes_the_edit(monkeypatch): + calls = [] + monkeypatch.setattr(play_tracks, "_access_token", lambda c: "tok") + monkeypatch.setattr(requests, "post", + lambda *a, **k: calls.append("post") or FakeResponse(200, {"id": "e1"})) + monkeypatch.setattr(requests, "get", + lambda *a, **k: calls.append("get") + or FakeResponse(200, {"tracks": TRACKS_MID_ROLLOUT})) + monkeypatch.setattr(requests, "delete", + lambda *a, **k: calls.append("delete") or FakeResponse(204)) + + tracks = play_tracks.fetch_tracks("pkg", None) + assert tracks == TRACKS_MID_ROLLOUT + assert calls == ["post", "get", "delete"] + + +def test_fetch_deletes_the_edit_even_when_the_read_fails(monkeypatch): + """An abandoned edit lingers on the account; cleanup must not depend on success.""" + calls = [] + monkeypatch.setattr(play_tracks, "_access_token", lambda c: "tok") + monkeypatch.setattr(requests, "post", lambda *a, **k: FakeResponse(200, {"id": "e1"})) + monkeypatch.setattr(requests, "get", lambda *a, **k: FakeResponse(500, text="boom")) + monkeypatch.setattr(requests, "delete", + lambda *a, **k: calls.append("delete") or FakeResponse(204)) + + with pytest.raises(requests.HTTPError): + play_tracks.fetch_tracks("pkg", None) + assert calls == ["delete"] From e8154aa67d0dc4ef6a4c0ff18c7bf886763bce3a Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 16:09:10 +0000 Subject: [PATCH 2/3] fix: guard cleanup exception + conditional track CSV staging - 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. --- .github/workflows/collect-play.yml | 4 +++- play_tracks.py | 9 ++++++--- test_play_tracks.py | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/collect-play.yml b/.github/workflows/collect-play.yml index 8a168f88..65da7d7e 100644 --- a/.github/workflows/collect-play.yml +++ b/.github/workflows/collect-play.yml @@ -49,7 +49,9 @@ jobs: run: | 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 data/android-tracks.csv + git add data/android-crash-rate.csv data/android-anr-rate.csv data/android/installed.csv data/android-ratings.csv + # android-tracks.csv may not exist yet if play_tracks.py lacked permission + [ -f data/android-tracks.csv ] && git add data/android-tracks.csv || true git diff --cached --quiet || git commit -m "chore: update Play Console stats" - name: Push changes if: env.HAS_SA == 'true' diff --git a/play_tracks.py b/play_tracks.py index ced70003..360772d5 100644 --- a/play_tracks.py +++ b/play_tracks.py @@ -122,9 +122,12 @@ def fetch_tracks(package: str, credentials: str | None) -> list[dict]: finally: # Best-effort cleanup: an abandoned edit expires on its own, and # failing to delete it must not lose the data we just read. - 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 Exception: + pass def parse_tracks(tracks: list[dict], today: str) -> list[dict]: diff --git a/test_play_tracks.py b/test_play_tracks.py index e7a2aa9d..28d8bdc5 100644 --- a/test_play_tracks.py +++ b/test_play_tracks.py @@ -199,3 +199,17 @@ def test_fetch_deletes_the_edit_even_when_the_read_fails(monkeypatch): with pytest.raises(requests.HTTPError): play_tracks.fetch_tracks("pkg", None) assert calls == ["delete"] + + +def test_fetch_returns_data_even_when_delete_raises(monkeypatch): + """A transport error during cleanup must not discard a successful read.""" + monkeypatch.setattr(play_tracks, "_access_token", lambda c: "tok") + monkeypatch.setattr(requests, "post", lambda *a, **k: FakeResponse(200, {"id": "e1"})) + monkeypatch.setattr(requests, "get", + lambda *a, **k: FakeResponse(200, {"tracks": TRACKS_MID_ROLLOUT})) + monkeypatch.setattr(requests, "delete", + lambda *a, **k: (_ for _ in ()).throw( + requests.exceptions.ConnectionError("timeout"))) + + tracks = play_tracks.fetch_tracks("pkg", None) + assert tracks == TRACKS_MID_ROLLOUT From 2c3dc43ed74dda3ef8358332c53b56e0db814ca5 Mon Sep 17 00:00:00 2001 From: Bob Date: Wed, 26 Aug 2026 19:22:33 +0000 Subject: [PATCH 3/3] fix(upsert_csv): replace same-day rows wholesale on rerun 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. --- play_tracks.py | 9 +++++++-- test_play_tracks.py | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/play_tracks.py b/play_tracks.py index 360772d5..631276a7 100644 --- a/play_tracks.py +++ b/play_tracks.py @@ -166,14 +166,19 @@ def parse_tracks(tracks: list[dict], today: str) -> list[dict]: def upsert_csv(path: str, rows: list[dict]) -> int: """Merge rows into the CSV keyed by (date, track, version_codes). - Re-running on the same day overwrites that day's snapshot rather than - appending a duplicate, so the daily collector is idempotent. + Re-running on the same day replaces that date's snapshot entirely rather + than merging key-by-key, so revoked or superseded releases do not persist. + Previous dates are preserved unchanged. """ existing: dict[tuple[str, str, str], dict] = {} if os.path.exists(path): with open(path, newline="") as f: for r in csv.DictReader(f): existing[(r["date"], r["track"], r["version_codes"])] = r + # Drop all rows for dates covered by the new snapshot before inserting, + # so a rerun that no longer reports a release doesn't retain the stale 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 with open(path, "w", newline="") as f: diff --git a/test_play_tracks.py b/test_play_tracks.py index 28d8bdc5..d4fca4f7 100644 --- a/test_play_tracks.py +++ b/test_play_tracks.py @@ -131,7 +131,27 @@ def test_upsert_overwrites_a_changed_same_day_snapshot(tmp_path): with open(path, newline="") as f: got = {(r["track"], r["version_codes"]): r for r in csv.DictReader(f)} assert got[("production", "31")]["user_fraction"] == "0.5" - assert len(got) == 3 # the other two rows survive + # Only the one row the second run returned survives for today; prior-day + # rows from other dates are not affected (tested separately). + assert len(got) == 1 + + +def test_upsert_removes_stale_same_day_release(tmp_path): + """A release revoked between two same-day runs must not remain in the CSV.""" + path = str(tmp_path / "tracks.csv") + # Morning: production carries two releases (staged rollout in progress) + play_tracks.upsert_csv(path, play_tracks.parse_tracks(TRACKS_MID_ROLLOUT, "2026-08-26")) + # Evening: the in-progress release was paused and removed from the track + evening = [{"track": "production", "releases": [ + {"name": "0.12.1", "versionCodes": ["27"], "status": "completed"}]}, + {"track": "internal", "releases": [ + {"name": "0.14.0b4", "versionCodes": ["33"], "status": "completed"}]}] + play_tracks.upsert_csv(path, play_tracks.parse_tracks(evening, "2026-08-26")) + with open(path, newline="") as f: + got = list(csv.DictReader(f)) + version_codes = {r["version_codes"] for r in got} + assert "31" not in version_codes, "revoked release must not survive a rerun" + assert len(got) == 2 # --- rollout_summary -------------------------------------------------------