diff --git a/pyproject.toml b/pyproject.toml index 9dd9b8a..a95dedf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ forerunner = "codeforerunner.cli:main" where = ["src"] [tool.setuptools.package-data] -codeforerunner = ["py.typed", "prompts/**/*.md", "tasks.json"] +codeforerunner = ["py.typed", "prompts/**/*.md", "tasks.json", "release_surfaces.json"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/codeforerunner/release_surfaces.json b/src/codeforerunner/release_surfaces.json new file mode 100644 index 0000000..0bd06ff --- /dev/null +++ b/src/codeforerunner/release_surfaces.json @@ -0,0 +1,68 @@ +{ + "$comment": "Release Surface Manifest — single source of truth for which release surfaces exist and their release policy (version source, registry target, auth mode, required validations). See release_surfaces.py for the accessor API.", + "surfaces": [ + { + "name": "pypi", + "kind": "package_registry", + "registry": "https://pypi.org/project/codeforerunner/", + "auth_mode": "oidc", + "workflow": "publish.yml", + "version_source": { "file": "pyproject.toml", "kind": "toml_path", "selector": ["project", "version"] }, + "validations": ["check_versions", "tag_matches_version", "validate_codex_marketplace", "build_sdist_wheel"] + }, + { + "name": "npmjs", + "kind": "package_registry", + "registry": "https://www.npmjs.com/package/codeforerunner", + "auth_mode": "oidc", + "workflow": "npm-publish.yml", + "version_source": { "file": "package.json", "kind": "json_path", "selector": ["version"] }, + "validations": ["tag_matches_version", "provenance"] + }, + { + "name": "github-packages", + "kind": "package_registry", + "registry": "https://npm.pkg.github.com", + "auth_mode": "github_token", + "workflow": "npm-publish.yml", + "version_source": { "file": "package.json", "kind": "json_path", "selector": ["version"] }, + "validations": [] + }, + { + "name": "docker", + "kind": "container_registry", + "registry": "ghcr.io/derek-palmer/codeforerunner, heyderekp/codeforerunner", + "auth_mode": "github_token", + "workflow": "docker-publish.yml", + "version_source": { "file": "pyproject.toml", "kind": "toml_path", "selector": ["project", "version"] }, + "validations": ["check_versions", "tag_matches_version"] + }, + { + "name": "codex-marketplace", + "kind": "plugin_marketplace", + "registry": "plugins/codex/marketplace.json (GitHub release asset)", + "auth_mode": "github_token", + "workflow": "codex-marketplace-publish.yml", + "version_source": { "file": "plugins/codex/marketplace.json", "kind": "json_path", "selector": ["marketplace", "version"] }, + "validations": ["check_versions", "validate_codex_marketplace", "tag_matches_version"] + }, + { + "name": "installer-shim-sh", + "kind": "installer_shim", + "registry": "install.sh (pins codeforerunner@ from npm)", + "auth_mode": "none", + "workflow": null, + "version_source": { "file": "install.sh", "kind": "regex", "selector": "^NPM_PKG=\"codeforerunner@([^\"]+)\"" }, + "validations": ["check_versions"] + }, + { + "name": "installer-shim-ps1", + "kind": "installer_shim", + "registry": "install.ps1 (pins codeforerunner@ from npm)", + "auth_mode": "none", + "workflow": null, + "version_source": { "file": "install.ps1", "kind": "regex", "selector": "^\\$NpmPkg\\s*=\\s*\"codeforerunner@([^\"]+)\"" }, + "validations": ["check_versions"] + } + ] +} diff --git a/src/codeforerunner/release_surfaces.py b/src/codeforerunner/release_surfaces.py new file mode 100644 index 0000000..49f98f0 --- /dev/null +++ b/src/codeforerunner/release_surfaces.py @@ -0,0 +1,109 @@ +"""Release Surface Manifest — single source of truth for release policy. + +Catalogs the surfaces codeforerunner publishes to (PyPI, npmjs, GitHub +Packages, Docker, the Codex marketplace, installer shims) and, for each, the +version source, registry target, auth mode, and required validations. + +The manifest itself lives in ``release_surfaces.json``; this module is the +typed accessor over it, mirroring the Task Registry (``tasks.py``). Version +values are read lazily via :func:`read_surface_version` from a repo checkout — +nothing here touches the filesystem at import time beyond the packaged JSON. +""" + +from __future__ import annotations + +import importlib.resources +import json +import re +import tomllib +from dataclasses import dataclass +from pathlib import Path + +KINDS = frozenset( + {"package_registry", "container_registry", "plugin_marketplace", "installer_shim"} +) +AUTH_MODES = frozenset({"oidc", "github_token", "pat", "none"}) +VERSION_SOURCE_KINDS = frozenset({"toml_path", "json_path", "regex"}) + + +@dataclass(frozen=True) +class ReleaseSurface: + name: str + kind: str + registry: str + auth_mode: str + workflow: str | None + version_source: dict + validations: tuple[str, ...] + + +def _load() -> list[ReleaseSurface]: + data = json.loads( + importlib.resources.files("codeforerunner") + .joinpath("release_surfaces.json") + .read_text(encoding="utf-8") + ) + return [ + ReleaseSurface( + name=entry["name"], + kind=entry["kind"], + registry=entry["registry"], + auth_mode=entry["auth_mode"], + workflow=entry.get("workflow"), + version_source=entry["version_source"], + validations=tuple(entry.get("validations", [])), + ) + for entry in data["surfaces"] + ] + + +_SURFACES = _load() +_BY_NAME: dict[str, ReleaseSurface] = {s.name: s for s in _SURFACES} + + +def all_surfaces() -> list[ReleaseSurface]: + return list(_SURFACES) + + +def names() -> tuple[str, ...]: + return tuple(s.name for s in _SURFACES) + + +def get(name: str) -> ReleaseSurface: + try: + return _BY_NAME[name] + except KeyError: + raise KeyError(f"unknown release surface: {name!r}") from None + + +def version_bearing_surfaces() -> list[ReleaseSurface]: + """Surfaces that pin a published version (all of them, currently).""" + return [s for s in _SURFACES if s.version_source] + + +def _navigate(data, selector): + for key in selector: + data = data[key] + return data + + +def read_surface_version(surface: ReleaseSurface, repo_root: Path) -> str: + """Read a surface's version from its declared source under ``repo_root``. + + Lets version-drift checks be driven entirely by the manifest: read every + version-bearing surface and assert the values agree. + """ + src = surface.version_source + path = repo_root / src["file"] + kind = src["kind"] + if kind == "toml_path": + with path.open("rb") as f: + return str(_navigate(tomllib.load(f), src["selector"])) + if kind == "json_path": + return str(_navigate(json.loads(path.read_text(encoding="utf-8")), src["selector"])) + if kind == "regex": + m = re.search(src["selector"], path.read_text(encoding="utf-8"), re.MULTILINE) + if not m: + raise ValueError(f"{surface.name}: pattern did not match in {src['file']}") + return m.group(1) + raise ValueError(f"{surface.name}: unknown version_source kind {kind!r}") diff --git a/tests/test_release_surfaces.py b/tests/test_release_surfaces.py new file mode 100644 index 0000000..8f53ae6 --- /dev/null +++ b/tests/test_release_surfaces.py @@ -0,0 +1,75 @@ +"""Behavior tests for the Release Surface Manifest (release_surfaces.py).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from codeforerunner import release_surfaces as rs + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def test_manifest_loads_with_valid_shape(): + surfaces = rs.all_surfaces() + assert surfaces, "manifest must declare at least one surface" + for s in surfaces: + assert s.name, "surface needs a name" + assert s.kind in rs.KINDS, f"{s.name}: bad kind {s.kind!r}" + assert s.auth_mode in rs.AUTH_MODES, f"{s.name}: bad auth_mode {s.auth_mode!r}" + assert isinstance(s.validations, tuple) + vs = s.version_source + assert isinstance(vs, dict) and "file" in vs and "kind" in vs + assert vs["kind"] in rs.VERSION_SOURCE_KINDS, f"{s.name}: bad version_source kind" + + +def test_surface_names_are_unique(): + names = rs.names() + assert len(names) == len(set(names)), f"duplicate surface names in {names!r}" + + +def test_known_release_surfaces_are_represented(): + expected = { + "pypi", + "npmjs", + "github-packages", + "docker", + "codex-marketplace", + "installer-shim-sh", + "installer-shim-ps1", + } + assert expected <= set(rs.names()), f"missing surfaces: {expected - set(rs.names())}" + + +def test_get_raises_on_unknown_surface(): + with pytest.raises(KeyError): + rs.get("does-not-exist") + + +def test_version_sources_resolve_and_agree_in_this_checkout(): + # Manifest-driven version-parity check: every version-bearing surface reads + # a concrete version from this repo, and they all agree (no drift). + versions = { + s.name: rs.read_surface_version(s, REPO_ROOT) + for s in rs.version_bearing_surfaces() + } + assert all(versions.values()), f"a surface resolved an empty version: {versions}" + assert len(set(versions.values())) == 1, f"version drift across surfaces: {versions}" + + +def test_read_surface_version_detects_drift(tmp_path): + # Build a fake checkout where pyproject and package.json disagree; the + # manifest-driven reader must surface two distinct values. + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "codeforerunner"\nversion = "0.4.5"\n', encoding="utf-8" + ) + (tmp_path / "package.json").write_text( + json.dumps({"name": "codeforerunner", "version": "0.4.6"}), encoding="utf-8" + ) + pypi = rs.get("pypi") + npmjs = rs.get("npmjs") + assert rs.read_surface_version(pypi, tmp_path) == "0.4.5" + assert rs.read_surface_version(npmjs, tmp_path) == "0.4.6" + assert rs.read_surface_version(pypi, tmp_path) != rs.read_surface_version(npmjs, tmp_path)