T027: publish command (validate -> branch -> release -> PR)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
aeba02f287
commit
82393ccdc5
17 changed files with 412 additions and 2 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
cli/src/m2_market/__pycache__/publish.cpython-312.pyc
Normal file
BIN
cli/src/m2_market/__pycache__/publish.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -18,6 +18,7 @@ from m2_market.catalog import CatalogClient
|
||||||
from m2_market.config import Config, ConfigError, load_config
|
from m2_market.config import Config, ConfigError, load_config
|
||||||
from m2_market.ledger import InsufficientFunds, LedgerClient
|
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.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
|
from m2_market.registry import RegistryClient, RegistryError
|
||||||
|
|
||||||
# Exit-code map (contracts/cli.md).
|
# Exit-code map (contracts/cli.md).
|
||||||
|
|
@ -304,8 +305,25 @@ def wallet(ctx: click.Context, operator: str | None) -> None:
|
||||||
@click.argument("bundle_dir")
|
@click.argument("bundle_dir")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def publish(ctx: click.Context, bundle_dir: str) -> None:
|
def publish(ctx: click.Context, bundle_dir: str) -> None:
|
||||||
"""Validate a bundle and open a listing PR on the registry."""
|
"""Validate a bundle and open a listing PR on the registry (Story 2 step 1-2)."""
|
||||||
_not_implemented(ctx, "publish")
|
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()
|
@main.command()
|
||||||
|
|
|
||||||
181
cli/src/m2_market/publish.py
Normal file
181
cli/src/m2_market/publish.py
Normal file
|
|
@ -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 "<root>"
|
||||||
|
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:<hex> 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"]
|
||||||
|
|
@ -84,6 +84,56 @@ class RegistryClient:
|
||||||
|
|
||||||
return dest_path
|
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
|
@staticmethod
|
||||||
def verify_bundle(path: str | Path, content_hash: str) -> bool:
|
def verify_bundle(path: str | Path, content_hash: str) -> bool:
|
||||||
algo, _, expected = content_hash.partition(":")
|
algo, _, expected = content_hash.partition(":")
|
||||||
|
|
|
||||||
Binary file not shown.
BIN
cli/tests/__pycache__/test_publish.cpython-312-pytest-9.1.1.pyc
Normal file
BIN
cli/tests/__pycache__/test_publish.cpython-312-pytest-9.1.1.pyc
Normal file
Binary file not shown.
161
cli/tests/test_publish.py
Normal file
161
cli/tests/test_publish.py
Normal file
|
|
@ -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
|
||||||
Binary file not shown.
Loading…
Reference in a new issue