diff --git a/cli/src/m2_market/__pycache__/publish.cpython-312.pyc b/cli/src/m2_market/__pycache__/publish.cpython-312.pyc new file mode 100644 index 0000000..e505a74 Binary files /dev/null and b/cli/src/m2_market/__pycache__/publish.cpython-312.pyc differ diff --git a/cli/src/m2_market/cli.py b/cli/src/m2_market/cli.py index 08804ad..f89742a 100644 --- a/cli/src/m2_market/cli.py +++ b/cli/src/m2_market/cli.py @@ -18,6 +18,7 @@ from m2_market.catalog import CatalogClient from m2_market.config import Config, ConfigError, load_config from m2_market.ledger import InsufficientFunds, LedgerClient from m2_market.output import emit_json, format_price, render_kv, render_table, search_results_table +from m2_market.publish import PublishValidationError, publish_bundle, validate_bundle from m2_market.registry import RegistryClient, RegistryError # Exit-code map (contracts/cli.md). @@ -304,8 +305,25 @@ def wallet(ctx: click.Context, operator: str | None) -> None: @click.argument("bundle_dir") @click.pass_context def publish(ctx: click.Context, bundle_dir: str) -> None: - """Validate a bundle and open a listing PR on the registry.""" - _not_implemented(ctx, "publish") + """Validate a bundle and open a listing PR on the registry (Story 2 step 1-2).""" + config: Config = ctx.obj["config"] + json_output = ctx.obj["json"] + + try: + solution, listing = validate_bundle(Path(bundle_dir)) + except PublishValidationError as exc: + for line in exc.errors: + click.echo(f"m2-market publish: {line}", err=True) + ctx.exit(EXIT_VALIDATION) + return + + with RegistryClient(config.forgejo_url, config.forgejo_token) as registry: + pr_url = publish_bundle(Path(bundle_dir), solution, listing, registry) + + if json_output: + emit_json({"status": "ok", "pr_url": pr_url}) + else: + click.echo(pr_url) @main.command() diff --git a/cli/src/m2_market/publish.py b/cli/src/m2_market/publish.py new file mode 100644 index 0000000..44deaf6 --- /dev/null +++ b/cli/src/m2_market/publish.py @@ -0,0 +1,181 @@ +"""Publish command implementation (contracts/cli.md publish, contracts/registry-layout.md). + +Validates a bundle against the frozen schemas (schemas/*.json), packages +payload/+recipe.yaml+solution.json into a release tarball, and runs the +publish protocol against the registry: branch -> commit -> release -> PR. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import tarfile +import tempfile +from pathlib import Path + +from jsonschema import Draft202012Validator + +from m2_market.registry import RegistryClient + + +class PublishValidationError(Exception): + """Raised when the bundle fails schema validation (contracts/cli.md exit 5).""" + + def __init__(self, errors: list[str]): + super().__init__("; ".join(errors)) + self.errors = errors + + +def schemas_dir() -> Path: + """Resolve the frozen schemas dir, honoring the M2_MARKET_SCHEMAS_DIR override.""" + override = os.environ.get("M2_MARKET_SCHEMAS_DIR") + if override: + return Path(override) + return Path(__file__).resolve().parents[3] / "schemas" + + +def _load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as f: + return json.load(f) + + +def _validate(instance: dict, schema_path: Path, label: str) -> list[str]: + schema = _load_json(schema_path) + validator = Draft202012Validator(schema) + errors = [] + for err in sorted(validator.iter_errors(instance), key=lambda e: list(map(str, e.path))): + loc = "/".join(str(p) for p in err.path) or "" + errors.append(f"{label}: {loc}: {err.message}") + return errors + + +def validate_bundle(bundle_dir: Path) -> tuple[dict, dict]: + """Validate BUNDLE_DIR/solution.json + listing.json against the frozen schemas. + + Returns (solution, listing) on success; raises PublishValidationError + (one message per violation, including tenant-firewall rules encoded in + solution.schema.json's if/else) otherwise. + """ + solution_path = bundle_dir / "solution.json" + listing_path = bundle_dir / "listing.json" + + errors: list[str] = [] + solution: dict | None = None + listing: dict | None = None + + try: + solution = _load_json(solution_path) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"solution.json: {exc}") + try: + listing = _load_json(listing_path) + except (OSError, json.JSONDecodeError) as exc: + errors.append(f"listing.json: {exc}") + + if errors: + raise PublishValidationError(errors) + + schemas = schemas_dir() + errors += _validate(solution, schemas / "solution.schema.json", "solution.json") + errors += _validate(listing, schemas / "listing.schema.json", "listing.json") + + if errors: + raise PublishValidationError(errors) + + return solution, listing + + +def build_bundle_tarball(bundle_dir: Path, solution: dict, dest_path: Path) -> str: + """Tar payload/+recipe.yaml+solution.json into dest_path; return its sha256: hash.""" + payload_dir = bundle_dir / "payload" + recipe_path = bundle_dir / "recipe.yaml" + + with tarfile.open(dest_path, "w:gz") as tar: + if payload_dir.is_dir(): + tar.add(payload_dir, arcname="payload") + if recipe_path.is_file(): + tar.add(recipe_path, arcname="recipe.yaml") + solution_bytes = json.dumps(solution, indent=2, sort_keys=True).encode("utf-8") + info = tarfile.TarInfo(name="solution.json") + info.size = len(solution_bytes) + tar.addfile(info, io.BytesIO(solution_bytes)) + + digest = hashlib.sha256() + with dest_path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + digest.update(chunk) + return f"sha256:{digest.hexdigest()}" + + +def render_pr_body(solution: dict, listing: dict) -> str: + """PR template body (registry-layout.md publish protocol step 2).""" + permissions = solution.get("permissions") or [] + permissions_lines = "\n".join(f"- `{perm}`" for perm in permissions) or "- (none)" + price = solution.get("price") or {} + tenant_scope = solution.get("tenant_scope", "-") + + body = ( + f"## Evidence summary\n{listing.get('evidence_summary', '-')}\n\n" + f"## Price\n{price.get('amount', '-')} {price.get('currency', '-')}\n\n" + f"## Permissions\n{permissions_lines}\n\n" + f"## Rollback\nRe-run `m2-market install` after uninstall; the apply adapter " + f"restores pre-install backups (contracts/apply-adapter.md).\n" + ) + + if tenant_scope != "m2-core": + owner_initiated = bool(solution.get("owner_initiated")) + double_scrubbed = bool((solution.get("scrub_status") or {}).get("double_scrubbed")) + body += ( + "\n## Tenant-derived attestations\n" + f"- [{'x' if owner_initiated else ' '}] owner_initiated\n" + f"- [{'x' if double_scrubbed else ' '}] double_scrubbed\n" + ) + + return body + + +def publish_bundle(bundle_dir: Path, solution: dict, listing: dict, registry: RegistryClient) -> str: + """Run the publish protocol (branch -> commit -> release -> PR); returns the PR URL.""" + listing_id = listing["listing_id"] + branch = f"listing/{listing_id}" + install_ref = listing["install_ref"] + + with tempfile.TemporaryDirectory(prefix="m2-market-publish-") as tmp_dir_str: + tarball_path = Path(tmp_dir_str) / f"{install_ref}.tar.gz" + content_hash = build_bundle_tarball(bundle_dir, solution, tarball_path) + + solution = dict(solution) + solution["content_hash"] = content_hash + + registry.create_branch(branch) + registry.create_file( + f"listings/{listing_id}/listing.json", + json.dumps(listing, indent=2, sort_keys=True).encode("utf-8") + b"\n", + message=f"listing({listing_id}): add listing.json", + branch=branch, + ) + registry.create_file( + f"listings/{listing_id}/solution.json", + json.dumps(solution, indent=2, sort_keys=True).encode("utf-8") + b"\n", + message=f"listing({listing_id}): add solution.json", + branch=branch, + ) + + release = registry.create_release( + tag_name=install_ref, + target_commitish=branch, + name=install_ref, + body=f"Bundle release for {listing_id}", + ) + registry.upload_release_asset(release["id"], tarball_path.name, tarball_path.read_bytes()) + + pr = registry.create_pull_request( + title=f"listing: {listing.get('name', listing_id)} ({listing_id})", + body=render_pr_body(solution, listing), + head=branch, + base="main", + ) + + return pr["html_url"] diff --git a/cli/src/m2_market/registry.py b/cli/src/m2_market/registry.py index 511e5bf..4598381 100644 --- a/cli/src/m2_market/registry.py +++ b/cli/src/m2_market/registry.py @@ -84,6 +84,56 @@ class RegistryClient: return dest_path + def create_branch(self, new_branch: str, old_branch: str = "main") -> dict: + resp = self._client.post( + self._repo_api("/branches"), + json={"new_branch_name": new_branch, "old_branch_name": old_branch}, + ) + resp.raise_for_status() + return resp.json() + + def create_file(self, repo_path: str, content: bytes, message: str, branch: str) -> dict: + resp = self._client.post( + self._repo_api(f"/contents/{repo_path}"), + json={ + "content": base64.b64encode(content).decode("ascii"), + "message": message, + "branch": branch, + }, + ) + resp.raise_for_status() + return resp.json() + + def create_release(self, tag_name: str, target_commitish: str, name: str, body: str) -> dict: + resp = self._client.post( + self._repo_api("/releases"), + json={ + "tag_name": tag_name, + "target_commitish": target_commitish, + "name": name, + "body": body, + }, + ) + resp.raise_for_status() + return resp.json() + + def upload_release_asset(self, release_id: int, filename: str, content: bytes) -> dict: + resp = self._client.post( + self._repo_api(f"/releases/{release_id}/assets"), + params={"name": filename}, + files={"attachment": (filename, content, "application/gzip")}, + ) + resp.raise_for_status() + return resp.json() + + def create_pull_request(self, title: str, body: str, head: str, base: str = "main") -> dict: + resp = self._client.post( + self._repo_api("/pulls"), + json={"title": title, "body": body, "head": head, "base": base}, + ) + resp.raise_for_status() + return resp.json() + @staticmethod def verify_bundle(path: str | Path, content_hash: str) -> bool: algo, _, expected = content_hash.partition(":") diff --git a/cli/tests/__pycache__/test_publish.cpython-312-pytest-9.1.1.pyc b/cli/tests/__pycache__/test_publish.cpython-312-pytest-9.1.1.pyc new file mode 100644 index 0000000..8de446a Binary files /dev/null and b/cli/tests/__pycache__/test_publish.cpython-312-pytest-9.1.1.pyc differ diff --git a/cli/tests/test_publish.py b/cli/tests/test_publish.py new file mode 100644 index 0000000..51f627c --- /dev/null +++ b/cli/tests/test_publish.py @@ -0,0 +1,161 @@ +"""Inline verification for the publish CLI command (contracts/cli.md, registry-layout.md). + +Covers: (1) invalid bundle -> schema errors printed, exit 5, before any registry +call; (2) valid bundle -> happy-path branch/commit/release/PR sequence against +a MockTransport fake of the Forgejo API. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +from click.testing import CliRunner + +import m2_market.cli as cli_mod + +LISTING_ID = "lst_mm-pdf-report" +SOLUTION_ID = "sol_mm-pdf-report" +INSTALL_REF = "lst_mm-pdf-report-v1.0.0" +SELLER = "sdjs-operator" + +VALID_SOLUTION = { + "schema_version": "m2.solution.v1", + "solution_id": SOLUTION_ID, + "name": "MM PDF Branded Report", + "summary": "Generate branded PDF reports from Markdown", + "intent": "Produce publish-ready branded PDF output without manual design work", + "deployment": { + "recipe_ref": "recipes/mm-pdf-report/recipe.yaml", + "entrypoint": "skills/mm-pdf/render.py", + "verify_command": "python skills/mm-pdf/render.py --self-test", + }, + "applicability": {"image_classes": ["desktop-agent"]}, + "tenant_scope": "m2-core", + "evidence": [{"source": "session:2026-05-11-mm-pdf-branded-report"}], + "content_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "price": {"amount": 500, "currency": "m2cr", "model": "fixed"}, + "seller": SELLER, + "license": {"terms": "internal", "major_version_coverage": True}, + "permissions": [{"scope": "filesystem:write", "path": "/tmp/mm-pdf-output"}], +} + +VALID_LISTING = { + "schema_version": "m2.listing.v1", + "listing_id": LISTING_ID, + "solution_id": SOLUTION_ID, + "solution_version": "1.0.0", + "inventory_type": "solution", + "name": "MM PDF Branded Report", + "summary": "Generate branded PDF reports from Markdown", + "category": "reporting", + "price": {"amount": 500, "currency": "m2cr", "model": "fixed"}, + "seller": SELLER, + "evidence_summary": "Generated branded PDF report; operator confirmed styling matched brand deck.", + "tenant_visibility": ["*"], + "stats": {"installs": 0, "proposals_shown": 0, "proposals_accepted": 0}, + "status": "draft", + "install_ref": INSTALL_REF, +} + + +def _write_bundle(bundle_dir, solution=None, listing=None): + bundle_dir.mkdir(parents=True, exist_ok=True) + (bundle_dir / "solution.json").write_text(json.dumps(solution if solution is not None else VALID_SOLUTION)) + (bundle_dir / "listing.json").write_text(json.dumps(listing if listing is not None else VALID_LISTING)) + payload_dir = bundle_dir / "payload" + payload_dir.mkdir(exist_ok=True) + (payload_dir / "report.md").write_text("# hello\n") + (bundle_dir / "recipe.yaml").write_text("targets: []\nchecks: []\n") + return bundle_dir + + +@pytest.fixture +def config_env(tmp_path, monkeypatch): + config_path = tmp_path / "config.toml" + config_path.write_text( + """ +operator_id = "buyer1" +tenant = "test-tenant" +ledger_url = "https://ledger.test" +ledger_api_key = "lk" +memory_api_url = "https://catalog.test" +memory_api_key = "mk" +forgejo_url = "https://registry.test" +forgejo_token = "rt" +""" + ) + monkeypatch.setenv("M2_MARKET_CONFIG", str(config_path)) + monkeypatch.setenv("M2_MARKET_SCHEMAS_DIR", str(_repo_schemas_dir())) + return config_path + + +def _repo_schemas_dir(): + from pathlib import Path + + return Path(__file__).resolve().parents[2] / "schemas" + + +def test_invalid_bundle_fails_validation_before_any_network_call(tmp_path, config_env, monkeypatch): + def fail_if_called(*args, **kwargs): + raise AssertionError("registry must not be contacted when validation fails") + + monkeypatch.setattr(httpx.Client, "post", fail_if_called) + + bad_solution = dict(VALID_SOLUTION) + bad_solution["evidence"] = [] # violates evidence minItems (Principle III evidence-backed listings) + bundle_dir = _write_bundle(tmp_path / "bundle", solution=bad_solution) + + runner = CliRunner() + result = runner.invoke(cli_mod.main, ["publish", str(bundle_dir)]) + + assert result.exit_code == cli_mod.EXIT_VALIDATION, result.output + assert "evidence" in result.output + + +def test_publish_happy_path_opens_pr(tmp_path, config_env, monkeypatch): + calls = {"branch": 0, "files": [], "release": 0, "asset": 0, "pr": 0} + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/branches") and request.method == "POST": + calls["branch"] += 1 + return httpx.Response(201, json={"name": f"listing/{LISTING_ID}"}) + if "/contents/" in path and request.method == "POST": + calls["files"].append(path) + return httpx.Response(201, json={"content": {"path": path}}) + if path.endswith("/releases") and request.method == "POST": + calls["release"] += 1 + return httpx.Response(201, json={"id": 42, "tag_name": INSTALL_REF}) + if path.endswith("/releases/42/assets") and request.method == "POST": + calls["asset"] += 1 + return httpx.Response(201, json={"name": f"{INSTALL_REF}.tar.gz"}) + if path.endswith("/pulls") and request.method == "POST": + calls["pr"] += 1 + return httpx.Response( + 201, + json={"html_url": f"https://registry.test/m2/market-registry/pulls/7"}, + ) + raise AssertionError(f"unexpected request: {request.method} {request.url}") + + orig_init = httpx.Client.__init__ + + def patched_init(self, *args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + orig_init(self, *args, **kwargs) + + monkeypatch.setattr(httpx.Client, "__init__", patched_init) + + bundle_dir = _write_bundle(tmp_path / "bundle") + + runner = CliRunner() + result = runner.invoke(cli_mod.main, ["publish", str(bundle_dir)]) + + assert result.exit_code == 0, result.output + assert "https://registry.test/m2/market-registry/pulls/7" in result.output + assert calls["branch"] == 1 + assert len(calls["files"]) == 2 + assert calls["release"] == 1 + assert calls["asset"] == 1 + assert calls["pr"] == 1