From b22607574245428bac23961d44bc78e5a38b3ff3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 18:42:49 +0000 Subject: [PATCH] Single-source the version, package GRAPH.md, harden the publish gate The 0.1.1 wheel on PyPI reports __version__ "0.1.0" from both packages because the version was written by hand in three places and the publish check compared the tag against only pyproject.toml. The version now lives once, in contextmesh/__init__.py; pyproject.toml reads it as dynamic metadata and contextmesh_mcp re-exports it. GRAPH.md was shipped through package data "../GRAPH.md", which installs it loose at the top of site-packages where any other distribution's GRAPH.md can overwrite or delete it. A build_py hook in setup.py now copies it into contextmesh/, MANIFEST.in keeps it in the sdist, and the ontology loader prefers the packaged copy, falling back to the repository root for source checkouts and editable installs. The publish workflow now verifies release identity against the built wheel's metadata: tag and version are compared as PEP 440 versions and filenames use the normalized form, so v1.0.0-rc.1 publishes 1.0.0rc1. A GitHub pre-release must carry a pre-release version and a full release must not. The checks exit explicitly instead of using assert, which python -O strips. The release-candidate gate now fails a wheel without exactly contextmesh/GRAPH.md, and an installed wheel whose version strings disagree. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 91 +++++++++++++++++-------- .github/workflows/release-candidate.yml | 28 +++++++- MANIFEST.in | 1 + RELEASE_NOTES.md | 8 +++ contextmesh/__init__.py | 4 +- contextmesh/ontology.py | 7 +- contextmesh_mcp/__init__.py | 4 +- pyproject.toml | 8 ++- setup.py | 24 +++++++ 9 files changed, 136 insertions(+), 39 deletions(-) create mode 100644 MANIFEST.in create mode 100644 setup.py 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})