"""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"]