diff --git a/.github/workflows/collect-play.yml b/.github/workflows/collect-play.yml index 5770a488..4d73fa45 100644 --- a/.github/workflows/collect-play.yml +++ b/.github/workflows/collect-play.yml @@ -60,6 +60,11 @@ jobs: git diff --cached --quiet || git commit -m "chore: update Play Console stats" - name: Push changes if: env.HAS_SA == 'true' - uses: ad-m/github-push-action@fe38f0a751bf9149f0270cc1fe20bf9156854365 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} + run: | + # Both stats workflows push to master; retry on a rejected push. + for i in 1 2 3 4 5; do + git push origin HEAD:master && exit 0 + sleep $((i * 5)) + git pull --rebase origin master + done + exit 1 diff --git a/.github/workflows/collect-stats.yml b/.github/workflows/collect-stats.yml index 20ab7d7b..60e626a9 100644 --- a/.github/workflows/collect-stats.yml +++ b/.github/workflows/collect-stats.yml @@ -10,7 +10,15 @@ on: #pull_requests: # branches: [ master ] schedule: - - cron: '0 */6 * * *' + # Every 10 minutes; the "Decide whether to collect" step below only collects + # every 6 hours outside a launch window. GitHub may delay scheduled runs, so + # every row carries its own timestamp. + - cron: '*/10 * * * *' + +# Never let two collections race on the push. +concurrency: + group: collect-stats + cancel-in-progress: false jobs: build: @@ -21,13 +29,32 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Decide whether to collect + id: cadence + run: | + # High-resolution window (UTC) around the v0.14.0 launch; 10-minute + # samples correlate downloads with posts (data/launch-events.csv). + # Outside it, collect only if the last sample is ~6 h old. + WINDOW_END=2026-10-27 + now=$(date -u +%s) + last=$(tail -n 1 data/stats.csv | cut -d, -f1) + age=$(( now - $(date -u -d "$last" +%s) )) + if [ "$GITHUB_EVENT_NAME" != "schedule" ] || [ "$now" -lt "$(date -u -d "$WINDOW_END" +%s)" ] || [ "$age" -ge 21000 ]; then + echo "collect=true" >> "$GITHUB_OUTPUT" + else + echo "collect=false" >> "$GITHUB_OUTPUT" + echo "Outside the launch window and last sample is only ${age}s old; skipping." + fi - name: Install uv + if: steps.cadence.outputs.collect == 'true' uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies + if: steps.cadence.outputs.collect == 'true' run: uv sync - name: Collect and append stats + if: steps.cadence.outputs.collect == 'true' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -42,12 +69,19 @@ jobs: # breakdowns). Appends only assets whose count changed since last logged. uv run python3 collect_stats.py --assets --csv >> data/stats-assets.csv - name: Commit + if: steps.cadence.outputs.collect == 'true' run: | git config --local user.email "noreply@github.com" git config --local user.name "GitHub Action" git add data/stats.csv data/releases.csv data/stats-assets.csv git commit -m "chore: update stats" - name: Push changes - uses: ad-m/github-push-action@fe38f0a751bf9149f0270cc1fe20bf9156854365 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} + if: steps.cadence.outputs.collect == 'true' + run: | + # Both stats workflows push to master; retry on a rejected push. + for i in 1 2 3 4 5; do + git push origin HEAD:master && exit 0 + sleep $((i * 5)) + git pull --rebase origin master + done + exit 1 diff --git a/README.md b/README.md index cdcf764d..7040dfa2 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ The following is manually updated: - `chrome-weekly-users.csv` - Chrome extension weekly active users - `firefox-daily-users.csv` - Firefox extension daily active users - `notes.csv` - Manual entries of major/interesting events + - `launch-events.csv` - Timestamped launch posts (UTC; channel, url, note), to line up with the + high-resolution download samples. During a launch window (see `WINDOW_END` in + `.github/workflows/collect-stats.yml`) stats are collected every ~10 minutes instead of every 6 hours. ## Android vitals diff --git a/analyze_stats.py b/analyze_stats.py index 8292fba9..7db7f3a2 100644 --- a/analyze_stats.py +++ b/analyze_stats.py @@ -4,6 +4,8 @@ import matplotlib.pyplot as plt import pandas as pd +from collect_stats import is_updater_asset + def _load_downloads(): df = pd.read_csv("data/stats.csv", index_col="timestamp", parse_dates=True) @@ -50,18 +52,35 @@ def _load_android_rating(): return df -def _load_assets(): +def _load_assets(include_updater: bool = False): """Raw per-asset download counts over time (long format). Columns: timestamp, tag, asset, platform, downloads. The collector logs an asset only when its count changes, so this is sparse — see _asset_series() for the forward-filled reconstruction. + + Tauri updater assets (latest.json polls, .sig, .app.tar.gz) are dropped + unless `include_updater`: they count update checks, not downloads. See + updater_checks(). """ df = pd.read_csv("data/stats-assets.csv") df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True) + if not include_updater: + df = df[~df["asset"].map(is_updater_asset)] return df +def updater_checks(df=None): + """Time series of Tauri update checks (latest.json fetches) per release. + + Every running aw-tauri polls releases/latest/download/latest.json, so the + growth rate of the current release's count tracks active Tauri installs. + """ + df = _load_assets(include_updater=True) if df is None else df + df = df[df["asset"] == "latest.json"] + return _asset_series(df) + + def _asset_series(df=None): """Wide per-(tag, asset) download counts, forward-filled over time. diff --git a/collect_stats.py b/collect_stats.py index 00ac54ec..f51e2cc3 100644 --- a/collect_stats.py +++ b/collect_stats.py @@ -78,10 +78,24 @@ def downloads_by_asset() -> list[dict]: return rows +def is_updater_asset(asset_name: str) -> bool: + """Assets fetched by the Tauri in-app updater, not by people downloading. + + `latest.json` is polled by every running aw-tauri install (via + releases/latest/download/latest.json), and the `.sig` / `.app.tar.gz` files + are what the updater downloads to apply an update. Their counts measure + update checks and applied updates, so they are kept in the per-asset series + but excluded from the headline download total. + """ + return asset_name == "latest.json" or asset_name.endswith((".sig", ".app.tar.gz")) + + def downloads(verbose=False) -> int: - """Total downloads across all release assets.""" + """Total downloads across all release assets, excluding updater traffic.""" total = 0 for r in downloads_by_asset(): + if is_updater_asset(r["asset"]): + continue if verbose: print(f' - {r["tag"]} [{r["platform"]}] {r["asset"]}: {r["downloads"]}') total += r["downloads"] diff --git a/data/launch-events.csv b/data/launch-events.csv new file mode 100644 index 00000000..32c733be --- /dev/null +++ b/data/launch-events.csv @@ -0,0 +1,3 @@ +timestamp,channel,url,note +2026-10-06T13:38:00+00:00,github,https://github.com/ActivityWatch/activitywatch/releases/tag/v0.14.0,v0.14.0 published +2026-10-06T17:00:00+00:00,discord,,v0.14.0 announcement in #announcements (approximate time) diff --git a/test_collect_stats.py b/test_collect_stats.py new file mode 100644 index 00000000..f4dfe93b --- /dev/null +++ b/test_collect_stats.py @@ -0,0 +1,53 @@ +import collect_stats +from collect_stats import is_updater_asset, platform + + +def test_updater_assets_are_excluded_from_downloads(): + assert is_updater_asset("latest.json") + assert is_updater_asset("activitywatch-tauri-0.14.0-darwin-aarch64.app.tar.gz") + assert is_updater_asset("activitywatch-tauri-0.14.0-linux-aarch64.AppImage.sig") + assert not is_updater_asset("activitywatch-v0.14.0-windows-x86_64-setup.exe") + assert not is_updater_asset("activitywatch-tauri-0.14.0-linux-x86_64.AppImage") + assert not is_updater_asset("activitywatch-v0.14.0-macos-arm64.dmg") + + +def test_platform(): + assert platform("activitywatch-tauri-0.14.0-darwin-aarch64.app.tar.gz") == "macos" + assert platform("latest.json") == "unknown" + + +def test_downloads_total_excludes_updater_assets(monkeypatch): + assets = [ + { + "tag": "v0.14.0", + "asset": "activitywatch-v0.14.0-windows-x86_64-setup.exe", + "platform": "windows", + "downloads": 300, + }, + { + "tag": "v0.14.0", + "asset": "activitywatch-tauri-0.14.0-linux-x86_64.AppImage", + "platform": "linux", + "downloads": 20, + }, + { + "tag": "v0.14.0", + "asset": "latest.json", + "platform": "unknown", + "downloads": 51, + }, + { + "tag": "v0.14.0", + "asset": "activitywatch-tauri-0.14.0-darwin-aarch64.app.tar.gz", + "platform": "macos", + "downloads": 6, + }, + { + "tag": "v0.14.0", + "asset": "activitywatch-tauri-0.14.0-darwin-aarch64.app.tar.gz.sig", + "platform": "macos", + "downloads": 3, + }, + ] + monkeypatch.setattr(collect_stats, "downloads_by_asset", lambda: assets) + assert collect_stats.downloads() == 320