diff --git a/.github/version.py b/.github/version.py new file mode 100644 index 0000000..39b2655 --- /dev/null +++ b/.github/version.py @@ -0,0 +1,63 @@ +"""Read and check the package version. + +The version lives in two files that have to agree, and PyPI will not let a +version be replaced once uploaded — so a disagreement cannot be corrected in +place. This exists so the agreement does not depend on remembering. +""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parent.parent +PYPROJECT = ROOT / "pyproject.toml" +INIT = ROOT / "src" / "pine_assistant" / "__init__.py" + +PYPROJECT_RE = re.compile(r'^(version\s*=\s*")([^"]+)(")', re.MULTILINE) +INIT_RE = re.compile(r'^(__version__\s*=\s*")([^"]+)(")', re.MULTILINE) +SOURCES = ((PYPROJECT, PYPROJECT_RE), (INIT, INIT_RE)) + + +def read() -> dict[pathlib.Path, str]: + found = {} + for path, pattern in SOURCES: + match = pattern.search(path.read_text()) + if match is None: + sys.exit(f"no version found in {path.relative_to(ROOT)}") + found[path] = match.group(2) + return found + + +def check(expected: str) -> None: + """Fail unless both files carry `expected`. + + Guards a tag build: a tag naming one version while the package declares + another publishes something no one can find, and it cannot be corrected in + place. + """ + mismatched = {p: v for p, v in read().items() if v != expected} + if mismatched: + for path, version in mismatched.items(): + print(f"{path.relative_to(ROOT)}: {version} (tag says {expected})", file=sys.stderr) + sys.exit("version mismatch") + print(f"version {expected} agrees across {len(SOURCES)} files") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--check", metavar="VERSION", help="fail unless both files say VERSION") + group.add_argument("--show", action="store_true", help="print the current version") + args = parser.parse_args() + + if args.check: + check(args.check) + else: + print(next(iter(read().values()))) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9344b5e..26f051d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,6 +21,9 @@ jobs: with: python-version: "3.12" + - name: Check the tag matches the declared version + run: python .github/version.py --check "${GITHUB_REF_NAME#v}" + - name: Install build tools run: pip install build diff --git a/README.md b/README.md index 882bd31..21eaa12 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,12 @@ pip install pine-assistant # SDK only pip install pine-assistant[cli] # SDK + CLI ``` +For unreleased work, install from the repository — any branch or commit: + +```bash +pip install "git+https://github.com/19PINE-AI/pine-assistant-python@main" +``` + ## Quick Start (Async) ```python diff --git a/RELEASING.md b/RELEASING.md index 39a7eed..f70be46 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -30,3 +30,7 @@ It installs, imports, builds, and verifies the package across Python 3.10 – 3. git push && git push --tags ``` 3. The `publish.yml` workflow triggers on the `v*` tag, runs CI first, then publishes to PyPI. + +The workflow refuses a tag that disagrees with either file. PyPI will not let a +version be replaced once uploaded, so a tag naming one version while the package +declares another cannot be corrected in place — only yanked and re-cut.