Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<PR>), 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.<PR>
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 }}
Expand Down
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.<PR>`, 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).

Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token-key>` 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.<PR>` — 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.
2 changes: 1 addition & 1 deletion glean_code/__init__.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion glean_code/auth/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
10 changes: 8 additions & 2 deletions glean_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}"
Expand Down Expand Up @@ -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}",
}

Expand Down
3 changes: 2 additions & 1 deletion glean_code/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
48 changes: 48 additions & 0 deletions tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<PR number>, 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.<PR number>")

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__")
68 changes: 68 additions & 0 deletions tools/set_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Set __version__ to 0.2.<pr>, 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 <pr-number>"
)
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())
Loading