From e2f9856e9f54f85f22f73ae1625cee41e42ca431 Mon Sep 17 00:00:00 2001 From: MountainOwl Date: Thu, 22 Oct 2026 03:27:58 +0000 Subject: [PATCH] fix(action): keep one anonymous install id across Action runs runner.temp starts empty on every run, so each Action run created a new random install_id and new project ids. One repository using the Action showed up as one install per run, which inflated installs and pushed retention towards zero. The Action now restores var/state/install_id and analytics_projects.sqlite with actions/cache, keyed by github.repository_id. The cached files hold only random ids; nothing about the repository is sent. --- action.yml | 11 +++++ docs/pages/github-action.mdx | 11 +++-- tests/test_action_install_id.py | 82 +++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 tests/test_action_install_id.py diff --git a/action.yml b/action.yml index a9426db..6056c4d 100644 --- a/action.yml +++ b/action.yml @@ -121,6 +121,17 @@ runs: npm install -g @openai/codex \ || echo "::warning::Codex install failed; ensure the agent CLI is on PATH." + # runner.temp starts empty on every run. Restore Bubo's random analytics + # install id and project-id table, so one repository's runs count as one + # install. Nothing about the repository is sent; the key stays on GitHub. + - name: Restore anonymous install identity + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ${{ runner.temp }}/bubo/var/state/install_id + ${{ runner.temp }}/bubo/var/state/analytics_projects.sqlite + key: bubo-analytics-identity-${{ github.repository_id }} + - name: Initialize Bubo workspace shell: bash env: diff --git a/docs/pages/github-action.mdx b/docs/pages/github-action.mdx index fd264f1..bc51a6a 100644 --- a/docs/pages/github-action.mdx +++ b/docs/pages/github-action.mdx @@ -86,11 +86,14 @@ model request. 1. Resolve the compatible Bubo version and install it from PyPI. 2. Optionally install Codex on a hosted runner. -3. Initialize an ephemeral Bubo root below `${{ runner.temp }}`. -4. Write a configuration containing one GitHub repository and the triggering +3. Restore Bubo's anonymous analytics install id with `actions/cache`, keyed + by the repository ID, so a repository's runs count as one install. The + cached files hold only random IDs, and nothing about the repository is sent. +4. Initialize an ephemeral Bubo root below `${{ runner.temp }}`. +5. Write a configuration containing one GitHub repository and the triggering PR number as `target_merge_request_iid`. -5. Validate the installation with `bubo doctor`. -6. Run `bubo-poller run-once` and wait for the review result. +6. Validate the installation with `bubo doctor`. +7. Run `bubo-poller run-once` and wait for the review result. diff --git a/tests/test_action_install_id.py b/tests/test_action_install_id.py new file mode 100644 index 0000000..7742135 --- /dev/null +++ b/tests/test_action_install_id.py @@ -0,0 +1,82 @@ +"""The GitHub Action keeps one anonymous install id across runs. + +Each Action run starts from an empty ``runner.temp``. The Action restores +Bubo's random install id with actions/cache, so a repository's runs are one +install, not one install per run. Runs the real ``bubo init`` and identity +code in two fresh roots, carrying over only the paths the Action caches. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def _steps() -> list[dict]: + return yaml.safe_load((ROOT / "action.yml").read_text())["runs"]["steps"] + + +def _cache_step() -> dict: + return next(step for step in _steps() if str(step.get("uses", "")).startswith("actions/cache@")) + + +def _cached_paths(runner_temp: Path) -> list[Path]: + raw = _cache_step()["with"]["path"].strip().splitlines() + return [Path(p.replace("${{ runner.temp }}", str(runner_temp))) for p in raw] + + +def _run(runner_temp: Path) -> str: + """One Action run: init a fresh root, then read the analytics install id.""" + root = runner_temp / "bubo" + env = {k: v for k, v in os.environ.items() if not k.startswith("BUBO_")} + env.update(BUBO_ROOT=str(root), HOME=str(runner_temp / "home")) + bubo = Path(sys.executable).with_name("bubo") + subprocess.run( + [str(bubo), "init", "--root", str(root), "--no-agent-config"], + env=env, + check=True, + capture_output=True, + ) + probe = "from bubo import analytics; print(analytics.install_id())" + out = subprocess.run( + [sys.executable, "-c", probe], env=env, check=True, capture_output=True, text=True + ) + return out.stdout.strip() + + +def test_cache_is_restored_before_bubo_starts_and_keyed_per_repository() -> None: + names = [step.get("name") for step in _steps()] + cache = _cache_step() + assert names.index(cache["name"]) < names.index("Initialize Bubo workspace") + assert "${{ github.repository_id }}" in cache["with"]["key"] + # Pinned by commit like every other action in this repository. + assert len(cache["uses"].split("@", 1)[1]) == 40 + + +def test_two_action_runs_share_one_install_id(tmp_path: Path) -> None: + first = tmp_path / "run1" + first_id = _run(first) + assert first_id + assert first_id != "None" + + # The next run gets a new, empty runner.temp; actions/cache restores + # exactly the cached paths into it before Bubo runs. + second = tmp_path / "run2" + cached = _cached_paths(first) + assert first / "bubo" / "var" / "state" / "install_id" in cached + for source in (path for path in cached if path.exists()): + target = second / source.relative_to(first) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + assert _run(second) == first_id + + +def test_without_the_cache_each_run_is_a_new_install(tmp_path: Path) -> None: + assert _run(tmp_path / "run1") != _run(tmp_path / "run2")