diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index afdbd13..cc1904b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,43 +18,74 @@ jobs: with: python-version: "3.12" - - name: Verify release tag matches package metadata - run: | - python - <<'PY' - import os - from pathlib import Path - import tomllib - - project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] - expected_name = "contextmesh-graph" - expected_tag = f"v{project['version']}" - actual_tag = os.environ["GITHUB_REF_NAME"] - - assert project["name"] == expected_name, (project["name"], expected_name) - assert os.environ["GITHUB_REF_TYPE"] == "tag", os.environ["GITHUB_REF_TYPE"] - assert actual_tag == expected_tag, (actual_tag, expected_tag) - print(f"release identity: {project['name']} {project['version']} from {actual_tag}") - PY - - name: Build and check wheel and sdist run: | python -m pip install --upgrade pip - python -m pip install build twine + python -m pip install build packaging twine python -m build python -m twine check dist/* + + # Checked against the built wheel's own metadata rather than pyproject.toml: + # that is what PyPI receives. Explicit exits, not `assert`, which `python -O` + # or PYTHONOPTIMIZE would strip. + - name: Verify release identity + env: + RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} + run: | python - <<'PY' + import os + import zipfile + from email.parser import Parser from pathlib import Path - import tomllib - - version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] - wheels = list(Path("dist").glob("*.whl")) - sdists = list(Path("dist").glob("*.tar.gz")) - assert len(wheels) == 1, wheels - assert len(sdists) == 1, sdists - assert wheels[0].name.startswith(f"contextmesh_graph-{version}-"), wheels[0] - assert sdists[0].name == f"contextmesh_graph-{version}.tar.gz", sdists[0] - print(wheels[0]) - print(sdists[0]) + + from packaging.utils import canonicalize_name + from packaging.version import InvalidVersion, Version + + def fail(message): + raise SystemExit(f"::error::{message}") + + wheels = sorted(Path("dist").glob("*.whl")) + sdists = sorted(Path("dist").glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + fail(f"expected one wheel and one sdist, found {wheels} and {sdists}") + wheel, sdist = wheels[0], sdists[0] + + with zipfile.ZipFile(wheel) as archive: + found = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")] + if len(found) != 1: + fail(f"expected one METADATA file in {wheel.name}, found {found}") + metadata = Parser().parsestr(archive.read(found[0]).decode("utf-8")) + name, version = metadata["Name"], Version(metadata["Version"]) + + if canonicalize_name(name) != "contextmesh-graph": + fail(f"built distribution is {name!r}, not contextmesh-graph") + if os.environ["GITHUB_REF_TYPE"] != "tag": + fail(f"release ref is a {os.environ['GITHUB_REF_TYPE']}, not a tag") + tag = os.environ["GITHUB_REF_NAME"] + try: + tagged = Version(tag[1:]) if tag.startswith("v") else None + except InvalidVersion: + tagged = None + if tagged != version: + fail(f"tag {tag} does not name the built version {version}") + + # The release type has to agree with the version, or a GitHub pre-release + # cut to test the pipeline becomes what `pip install contextmesh-graph` gets. + prerelease = os.environ["RELEASE_PRERELEASE"] == "true" + if prerelease and not version.is_prerelease: + fail(f"GitHub pre-release {tag} carries the final version {version}") + if version.is_prerelease and not prerelease: + fail(f"{version} is a pre-release version; mark the GitHub release as one") + + # Filenames carry the normalized version: v1.0.0-rc.1 builds 1.0.0rc1. + stem = f"contextmesh_graph-{version}" + if not wheel.name.startswith(f"{stem}-"): + fail(f"wheel {wheel.name} does not start with {stem}-") + if sdist.name != f"{stem}.tar.gz": + fail(f"sdist {sdist.name} is not {stem}.tar.gz") + print(f"release identity: {name} {version} from {tag}") + print(wheel) + print(sdist) PY - name: Upload release distributions diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 7184515..82531e9 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -22,11 +22,21 @@ jobs: python -m build python -m twine check dist/* python - <<'PY' + import zipfile from pathlib import Path + + def fail(message): + raise SystemExit(f"::error::{message}") + wheels = list(Path("dist").glob("*.whl")) sdists = list(Path("dist").glob("*.tar.gz")) - assert len(wheels) == 1, wheels - assert len(sdists) == 1, sdists + if len(wheels) != 1 or len(sdists) != 1: + fail(f"expected one wheel and one sdist, found {wheels} and {sdists}") + # GRAPH.md ships inside the package. A top-level copy installs loose in + # site-packages, where another distribution's GRAPH.md can replace it. + graphs = sorted(n for n in zipfile.ZipFile(wheels[0]).namelist() if n.endswith("GRAPH.md")) + if graphs != ["contextmesh/GRAPH.md"]: + fail(f"wheel must carry exactly contextmesh/GRAPH.md, found {graphs}") print(wheels[0]) print(sdists[0]) PY @@ -58,6 +68,20 @@ jobs: wheel = next(Path("dist").glob("*.whl")) subprocess.check_call([sys.executable, "-m", "pip", "install", str(wheel)]) PY + - name: Installed version strings agree + shell: bash + run: | + python - <<'PY' + from importlib.metadata import version + + import contextmesh + import contextmesh_mcp + + found = {contextmesh.__version__, contextmesh_mcp.__version__, version("contextmesh-graph")} + if len(found) != 1: + raise SystemExit(f"::error::installed version strings disagree: {sorted(found)}") + print("version", *found) + PY - name: Core has no MCP dependency run: python -c "import importlib.util; assert importlib.util.find_spec('mcp') is None" - name: Exercise installed CLI diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7cea8d6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include GRAPH.md diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b61ab1d..62d87da 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,11 @@ +# Unreleased + +## Fixed + +- `contextmesh.__version__` and `contextmesh_mcp.__version__` match the installed distribution. The `0.1.1` wheel reports `0.1.0` from both. The version is now written once, in `contextmesh/__init__.py`, and the package metadata is read from it. +- `GRAPH.md` installs inside the `contextmesh` package instead of loose at the top of site-packages, where another distribution's `GRAPH.md` could overwrite or remove it. +- The publish workflow checks the tag against the built wheel's normalized version, so a tag such as `v1.0.0-rc.1` publishes `1.0.0rc1`, and it refuses a GitHub pre-release that carries a final version, or the reverse. + # ContextMesh v0.1.1 `v0.1.1` is a release-engineering patch for the first PyPI publication of ContextMesh. diff --git a/contextmesh/__init__.py b/contextmesh/__init__.py index 1ca9661..ad5ce8a 100644 --- a/contextmesh/__init__.py +++ b/contextmesh/__init__.py @@ -86,7 +86,9 @@ from .telemetry import TelemetryProjection, project_telemetry from .traverse import DeadEnd, Walk, Walker -__version__ = "0.1.0" +# The only copy of the version: pyproject.toml reads the distribution version +# from here, and contextmesh_mcp re-exports it. +__version__ = "0.1.1" __all__ = [ "LIVE_PROVIDERS", diff --git a/contextmesh/ontology.py b/contextmesh/ontology.py index 1d3ff16..74183d7 100644 --- a/contextmesh/ontology.py +++ b/contextmesh/ontology.py @@ -14,7 +14,12 @@ from pathlib import Path from typing import Dict, FrozenSet, Iterable, Tuple -ONTOLOGY_FILE = Path(__file__).resolve().parent.parent / "GRAPH.md" +_HERE = Path(__file__).resolve().parent +#: An installed wheel carries GRAPH.md inside the package (setup.py copies it +#: there at build time); a source checkout reads the repository-root original. +ONTOLOGY_FILE = ( + _HERE / "GRAPH.md" if (_HERE / "GRAPH.md").is_file() else _HERE.parent / "GRAPH.md" +) _ROW = re.compile(r"^\|(?P.+)\|\s*$") _PAIR = re.compile(r"(?P[a-z_]+)\s*(?:→|->)\s*(?P[a-z_]+)") diff --git a/contextmesh_mcp/__init__.py b/contextmesh_mcp/__init__.py index 8a81a9e..65a7d33 100644 --- a/contextmesh_mcp/__init__.py +++ b/contextmesh_mcp/__init__.py @@ -28,6 +28,8 @@ an assumption and have it rejected would make that rule a convention. """ +from contextmesh import __version__ as __version__ # re-exported: one version for both packages + from .session import Session, SessionError from .tools import ( TOOLS, @@ -39,8 +41,6 @@ mesh_lineage, ) -__version__ = "0.1.0" - __all__ = [ "TOOLS", "MeshToolError", diff --git a/pyproject.toml b/pyproject.toml index 1b613d5..916b848 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "contextmesh-graph" -version = "0.1.1" +dynamic = ["version"] description = "A typed context graph for AI agents: provenance, assumptions, and answers that walk a path you can read." readme = "README.md" requires-python = ">=3.9" @@ -32,8 +32,10 @@ contextmesh-mcp = "contextmesh_mcp.server:main" [tool.setuptools] packages = ["contextmesh", "contextmesh_mcp"] -[tool.setuptools.package-data] -contextmesh = ["../GRAPH.md"] +# One place to bump: the version is `contextmesh.__version__`, and the +# distribution metadata is read from it, so the two cannot disagree. +[tool.setuptools.dynamic] +version = {attr = "contextmesh.__version__"} [tool.ruff] target-version = "py39" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..614f53f --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +"""Build hook: ship GRAPH.md inside the ``contextmesh`` package. + +GRAPH.md stays at the repository root because it is the project's schema +document. Package data that points outside a package lands loose in +site-packages, where any other distribution's GRAPH.md can overwrite or delete +it, so the wheel build copies the file into ``contextmesh/`` instead, which is +where ``contextmesh.ontology`` looks first. Everything else is in pyproject.toml. +""" + +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + + +class BuildPyWithOntology(build_py): + def run(self): + super().run() + # An editable install builds nothing and reads the root file in place. + if not self.editable_mode: + self.copy_file("GRAPH.md", str(Path(self.build_lib, "contextmesh", "GRAPH.md"))) + + +setup(cmdclass={"build_py": BuildPyWithOntology})