Skip to content
Draft
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
91 changes: 61 additions & 30 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions .github/workflows/release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include GRAPH.md
8 changes: 8 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 3 additions & 1 deletion contextmesh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 6 additions & 1 deletion contextmesh/ontology.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<cells>.+)\|\s*$")
_PAIR = re.compile(r"(?P<src>[a-z_]+)\s*(?:→|->)\s*(?P<dst>[a-z_]+)")
Expand Down
4 changes: 2 additions & 2 deletions contextmesh_mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,8 +41,6 @@
mesh_lineage,
)

__version__ = "0.1.0"

__all__ = [
"TOOLS",
"MeshToolError",
Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
@@ -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})
Loading