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())