From 621481d7e086a880e13cd68643c33bdca7da722f Mon Sep 17 00:00:00 2001 From: barkz Date: Sun, 27 Sep 2026 21:21:23 -0500 Subject: [PATCH] Tie the version to the PR number and stop hardcoding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit __version__ had been "0.1.0" since the initial commit — unchanged through 38 merged PRs — so it described nothing. It is now 0.2., where the patch component is the number of the pull request that introduced the change, making any build traceable to one PR and its diff. Because a PR number does not exist until the PR is opened, the bump happens inside the PR via tools/set_version.py (which can infer the number from gh), and a new `version` job in release.yml fails the PR when __version__ and the PR number disagree. That job is what stops the scheme rotting the way the old constant did. The User-Agent strings were the real damage from hardcoding: client.py carried "glean-code/0.1" in two places and auth/oauth.py in a third, all stale from the moment the version moved. They now derive from __version__ through a single client.USER_AGENT, and the doctor probe reports the version too. A test asserts no string anywhere in glean_code/ hardcodes a version. It reads string tokens via tokenize rather than raw lines, so prose in a comment that mentions an old value does not trip it — which it did on the first attempt, flagging the comment explaining the very problem. Tests: 1,173 -> 1,177. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 22 ++++++++++++ CHANGELOG.md | 10 ++++-- CLAUDE.md | 16 +++++++++ glean_code/__init__.py | 2 +- glean_code/auth/oauth.py | 4 ++- glean_code/client.py | 10 ++++-- glean_code/commands.py | 3 +- tests/test_ui.py | 48 +++++++++++++++++++++++++ tools/set_version.py | 68 +++++++++++++++++++++++++++++++++++ 9 files changed, 176 insertions(+), 7 deletions(-) create mode 100755 tools/set_version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd30b83..d4c4f78 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,28 @@ permissions: contents: read jobs: + # The patch component of __version__ is the number of the PR that introduces + # the change (0.2.), so it cannot be known until the PR is opened. This + # job is what stops the scheme silently rotting. + version: + name: version matches the PR number + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check 0.2. + env: + PR: ${{ github.event.pull_request.number }} + run: | + have=$(python3 -c "import re;print(re.search(r'__version__ = \"([^\"]+)\"', open('glean_code/__init__.py').read()).group(1))") + want="0.2.$PR" + if [ "$have" != "$want" ]; then + echo "::error::__version__ is $have but this is PR #$PR, so it must be $want" + echo "Fix it with: python3 tools/set_version.py $PR" + exit 1 + fi + echo "__version__ = $have, matching PR #$PR" + test: name: python ${{ matrix.python }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ee051..56225ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,14 @@ # Changelog All notable changes to Glean Code are documented here. The format follows -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project does not tag -releases, so entries are grouped by date rather than version number. +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), with entries grouped by date. + +**Versioning.** `__version__` is `0.2.`, where the patch component is the number +of the pull request that introduced the change — so any build traces straight back to +one PR and its diff. The number only exists once a PR is open, so the bump happens +inside the PR (`python3 tools/set_version.py`) and the `version` job in +`release.yml` fails the PR if the two disagree. Every User-Agent string derives from +`__version__`; nothing else hardcodes it. For what Glean Code is and how to run it, see the [README](README.md). diff --git a/CLAUDE.md b/CLAUDE.md index 60de19d..82a8c84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,22 @@ Client API (`/rest/api/v1`, `api_token`) and Indexing API (`/api/index/v1`, `ind **Token safety** — never let real secrets reach disk or the history buffer. Secure refs (`token.secure.client` / `token.secure.indexing`) are stored verbatim and resolved from `$GLEAN_CLIENT_TOKEN` / `$GLEAN_INDEXING_TOKEN` at request time via `resolve_secure`. `_sanitize_for_history` masks `--token`/`--indexing-token` values and `/config set ` values before they enter `command_history`. `_display_token` masks literals to `***1234`. Preserve all of this when adding any command that handles a token. +## Versioning + +`__version__` in [glean_code/__init__.py](glean_code/__init__.py) is `0.2.` — the +patch component is the pull request number, so a build maps to exactly one PR. Bump it +inside the PR once the number exists: + +```bash +python3 tools/set_version.py # infers the number from the open PR via gh +python3 tools/set_version.py 41 # or set it explicitly +``` + +The `version` job in `release.yml` fails a PR whose `__version__` does not match its +number. `client.USER_AGENT` and the auth/doctor User-Agents all derive from +`__version__`, and a test asserts no string in `glean_code/` hardcodes a version — +they were `"glean-code/0.1"` for 38 PRs and went stale immediately. + ## Docs `docs/COMMANDS.md` (full per-command reference, mirrors `/help`), `docs/NATURAL_LANGUAGE.md` (planner design), `docs/PERSONAL.md` (the local index), and `docs/TESTING.md` (test-suite notes) supplement the README. Keep `docs/COMMANDS.md` and the `DOCS` dict consistent when changing command behavior. diff --git a/glean_code/__init__.py b/glean_code/__init__.py index 6e2e01d..bf5c756 100644 --- a/glean_code/__init__.py +++ b/glean_code/__init__.py @@ -1,5 +1,5 @@ """Glean Code. A local, terminal-first client for the Glean Client REST API.""" -__version__ = "0.1.0" +__version__ = "0.2.40" from . import auth_commands # noqa: F401 # registers /auth and help docs diff --git a/glean_code/auth/oauth.py b/glean_code/auth/oauth.py index ba4a0a6..b4655d8 100644 --- a/glean_code/auth/oauth.py +++ b/glean_code/auth/oauth.py @@ -18,7 +18,9 @@ from dataclasses import dataclass from typing import Dict, Optional -_USER_AGENT = "glean-code/0.1 (auth)" +from .. import __version__ + +_USER_AGENT = f"glean-code/{__version__} (auth)" _TIMEOUT = 30 diff --git a/glean_code/client.py b/glean_code/client.py index 6135dda..8145aad 100644 --- a/glean_code/client.py +++ b/glean_code/client.py @@ -43,8 +43,14 @@ from . import flow as _flow from . import mock_corpus from . import personal +from . import __version__ from .config import Config +# Sent on every request. Derived from the package version so a version bump +# cannot leave a stale string behind, which is exactly what happened while this +# was hardcoded as "glean-code/0.1". +USER_AGENT = f"glean-code/{__version__}" + class GleanError(Exception): pass @@ -60,7 +66,7 @@ def _headers(self) -> Dict[str, str]: h = { "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "glean-code/0.1", + "User-Agent": USER_AGENT, } if self.config.effective_api_token: h["Authorization"] = f"Bearer {self.config.effective_api_token}" @@ -132,7 +138,7 @@ def _indexing_headers(self) -> Dict[str, str]: return { "Content-Type": "application/json", "Accept": "application/json", - "User-Agent": "glean-code/0.1", + "User-Agent": USER_AGENT, "Authorization": f"Bearer {self.config.effective_indexing_token}", } diff --git a/glean_code/commands.py b/glean_code/commands.py index 39618bf..c04be46 100644 --- a/glean_code/commands.py +++ b/glean_code/commands.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple # noqa: F401 +from . import __version__ from . import ui from . import _indexing_walk as _walk from . import flow as _flow @@ -718,7 +719,7 @@ def line(label: str, status: str, detail: str = "", colour: str = ui.C.GREEN): f"{base}/search", data=probe_body, method="POST", headers={"Content-Type": "application/json", "Authorization": f"Bearer {cfg.api_token}", - "User-Agent": "glean-code/doctor"}, + "User-Agent": f"glean-code/{__version__} (doctor)"}, ) t0 = time.time() try: diff --git a/tests/test_ui.py b/tests/test_ui.py index 24c204a..fa04784 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -350,3 +350,51 @@ def test_clear_title_emits_empty_title(self): with mock.patch.object(ui.sys, "stdout", buf): ui.clear_title() self.assertEqual(buf.getvalue(), "\033]0;\007") + + +class TestVersionScheme(unittest.TestCase): + """__version__ is 0.2., and everything else derives from it. + + The User-Agent strings were hardcoded as "glean-code/0.1" and went stale the + moment the version moved, so these assert they are derived rather than + duplicated. + """ + + def test_version_follows_the_pr_series(self): + from glean_code import __version__ + self.assertRegex(__version__, r"^0\.2\.\d+$", + "version must be 0.2.") + + def test_client_user_agent_derives_from_the_version(self): + from glean_code import __version__ + from glean_code.client import USER_AGENT + self.assertEqual(USER_AGENT, f"glean-code/{__version__}") + + def test_auth_user_agent_derives_from_the_version(self): + from glean_code import __version__ + from glean_code.auth.oauth import _USER_AGENT + self.assertIn(__version__, _USER_AGENT) + + def test_no_hardcoded_version_strings_remain(self): + # A literal version in any string will go stale the next time the + # version moves. Only string tokens are checked, so prose in a + # comment that mentions an old value does not trip it. + import io as _io + import re + import token as _token + import tokenize + from pathlib import Path + + root = Path(__file__).resolve().parent.parent / "glean_code" + pattern = re.compile(r"glean-code/\d+\.\d+") + offenders = [] + for py in sorted(root.rglob("*.py")): + if py.name == "__init__.py" and py.parent == root: + continue # the one place the version may be a literal + src = py.read_text(encoding="utf-8") + for tok in tokenize.generate_tokens(_io.StringIO(src).readline): + if tok.type == _token.STRING and pattern.search(tok.string): + offenders.append(f"{py.relative_to(root)}:{tok.start[0]}") + self.assertEqual( + offenders, [], + "hardcoded version in a string; derive it from __version__") diff --git a/tools/set_version.py b/tools/set_version.py new file mode 100755 index 0000000..b9ceec0 --- /dev/null +++ b/tools/set_version.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Set __version__ to 0.2., the scheme this repo uses. + +The patch component is the number of the pull request that introduces the +change, so releases are traceable straight back to a PR. Because that number +only exists once the PR is open, the bump happens inside the PR and the +`version` job in release.yml enforces it. + + python3 tools/set_version.py 40 # -> 0.2.40 + python3 tools/set_version.py # infer from the open PR via gh +""" +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +INIT = Path(__file__).resolve().parent.parent / "glean_code" / "__init__.py" +PATTERN = re.compile(r'^__version__ = "([^"]+)"', re.M) +SERIES = "0.2" + + +def current() -> str: + m = PATTERN.search(INIT.read_text(encoding="utf-8")) + if not m: + sys.exit(f"could not find __version__ in {INIT}") + return m.group(1) + + +def infer_pr() -> str: + """Ask gh for the PR number of the current branch.""" + try: + out = subprocess.run( + ["gh", "pr", "view", "--json", "number", "-q", ".number"], + capture_output=True, text=True, timeout=30, + ) + except (OSError, subprocess.SubprocessError): + sys.exit("gh is unavailable — pass the PR number explicitly") + if out.returncode != 0 or not out.stdout.strip(): + sys.exit( + "no open PR found for this branch. Open it first, then re-run:\n" + " python3 tools/set_version.py " + ) + return out.stdout.strip() + + +def main(argv=None) -> int: + args = (argv if argv is not None else sys.argv[1:]) + pr = args[0] if args else infer_pr() + if not pr.isdigit(): + sys.exit(f"expected a PR number, got {pr!r}") + + new = f"{SERIES}.{pr}" + was = current() + if was == new: + print(f"__version__ is already {new}") + return 0 + + text = INIT.read_text(encoding="utf-8") + INIT.write_text(PATTERN.sub(f'__version__ = "{new}"', text, count=1), encoding="utf-8") + print(f"__version__ {was} -> {new}") + print("User-Agent strings derive from it, so nothing else needs editing.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())