From 82393ccdc56cf00db26f4df444b43020faf9a106 Mon Sep 17 00:00:00 2001 From: "m2 (AI Agent)" Date: Thu, 2 Jul 2026 03:38:59 +0200 Subject: [PATCH] T027: publish command (validate -> branch -> release -> PR) Co-Authored-By: Claude Sonnet 5 --- .../__pycache__/__init__.cpython-312.pyc | Bin 171 -> 216 bytes .../__pycache__/catalog.cpython-312.pyc | Bin 6146 -> 6191 bytes .../m2_market/__pycache__/cli.cpython-312.pyc | Bin 18352 -> 19441 bytes .../__pycache__/config.cpython-312.pyc | Bin 3324 -> 3369 bytes .../__pycache__/ledger.cpython-312.pyc | Bin 5687 -> 5732 bytes .../__pycache__/output.cpython-312.pyc | Bin 4495 -> 4540 bytes .../__pycache__/publish.cpython-312.pyc | Bin 0 -> 10440 bytes .../__pycache__/registry.cpython-312.pyc | Bin 6491 -> 9280 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 1361 -> 1406 bytes .../apply/__pycache__/base.cpython-312.pyc | Bin 7201 -> 7246 bytes cli/src/m2_market/cli.py | 22 ++- cli/src/m2_market/publish.py | 181 ++++++++++++++++++ cli/src/m2_market/registry.py | 50 +++++ ...t_cli_install.cpython-312-pytest-9.1.1.pyc | Bin 30087 -> 30132 bytes .../test_publish.cpython-312-pytest-9.1.1.pyc | Bin 0 -> 13364 bytes cli/tests/test_publish.py | 161 ++++++++++++++++ .../test_schemas.cpython-312-pytest-9.1.1.pyc | Bin 12566 -> 12611 bytes 17 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 cli/src/m2_market/__pycache__/publish.cpython-312.pyc create mode 100644 cli/src/m2_market/publish.py create mode 100644 cli/tests/__pycache__/test_publish.cpython-312-pytest-9.1.1.pyc create mode 100644 cli/tests/test_publish.py diff --git a/cli/src/m2_market/__pycache__/__init__.cpython-312.pyc b/cli/src/m2_market/__pycache__/__init__.cpython-312.pyc index 7a2e66787ee71c02836061c31312ac7ffa903ade..db70bd1036a252ae7d21925b094540cdbbafceed 100644 GIT binary patch delta 86 zcmZ3@c!QDaG%qg~0}zBAn8@Yn5UZb&pPQy{W8nd=smCgo%nXXqy9rc6w81pt*49IF5T delta 41 ucmcb?xSEmcG%qg~0}u#pnaJfSY@nZ!pPQJX!!k)NBYpKGMAmyueOQlwv=UzA-^l$u%$6w}R3 nEXqzT(J#*|(9Jah$?KLF7@6x9lqTh57H8-t=cX)XViN)Y?i?KW delta 43 vcmZ2)&}6`MnwOW00SJV)Y~*^%Dy*-ck)NBYpKAm}y19u(*{LOq_1S~~0_6-x diff --git a/cli/src/m2_market/__pycache__/cli.cpython-312.pyc b/cli/src/m2_market/__pycache__/cli.cpython-312.pyc index b8f3f5f55873e3852b6a7624fdb5d0f71ae0467a..e627e80601953884a89f465ef200cd0a366ce01c 100644 GIT binary patch delta 3785 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 diff --git a/cli/src/m2_market/__pycache__/config.cpython-312.pyc b/cli/src/m2_market/__pycache__/config.cpython-312.pyc index d34d1d5a70dff0c9c847bfd03af4cd74447b4d5e..99a6537b77dbe7d33cfc6b55de770729875e7fb7 100644 GIT binary patch delta 89 zcmew(xl)SjG%qg~0}zBA*vQqu2LKxD4h#SQ 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 0000000000000000000000000000000000000000..e505a74cdd22bb754d82483847f68aab860d0fb3 GIT binary patch literal 10440 zcmb_CYj7LKd3U%2aCnn=kRrv$(bR*WNKm3JIhJHbl0}JnQ5HozBg!%i;*KIHys~!? z$s5qdWHbp`D?>SPD|k}HaNDWrkup^u(|Y=gP3#%ZG(P}=DUl01<4&BJ<{up@qm=p6 zZ|?zsG*ovwT@rhHyWf7h`|a-cynl5#tOTTAFAq-rz6qZAMNdYxKyLga6z&onF-CAC zXNZz9!Nv|SdaRzS=dAE_b8gNCPY>te?C|t*Ud{ne zALrwo@ND23I2Sw{XDNSU=pacDFQ>+%5n+-ICt|TsoMR)gWR#EbaWN!D5^=UGoQR7k z6c&ZPa5NH#acolU&;vd67Q|nV2qK#8jfQ3uDG{Lj0ju@ZP&C4YL|$M+?0729MR_)K zJrs!xA}dbv>;y_ke4G`+lYA^Buw7cAuRAa$B;x)aHW>=v2wmp|Ycez&O@z3mpP)JxF_kT|UFqMB=4N^b0e_wT`>;)yX54wiu<;UH1-!~`6$ zTlO(}o|qxWjF$;NEjMVpr;P#R7(^(6q%$L-h`@8~^dwLi6I-MIL?{v!G?KG7)%~fx z%lr&1{GdM&HdM_KpL&=kx$!a-{*=5+h~>rwLz?(Lv~%Q?b~nmRP)L*a;i%!HKS`)d z6G3g!1Tjna4a1V*2n(w+3aKQIWF{Dl#3N!bDAQAkNL)7Ycx{58l4*gDP9O)MlP$qu zI2sa!U{JugST?iyTHjhWP?$p_S$q0=re8xAc2MK4;f;OaV(e%%#N)0 z{igSta)*jd-7Cz#QbWr;{e9a8z`)9twQO48B8pexB3<*F@ap5C7!Rwr27|E#mx|)D zJsA9UDiqaP%)ua+2nU18q-2LiiZCCI27`VQxd69->3$5=L5>(bYs3w>>JPVwU(%PK z&<11cmjq;}UIz(7p`0*f;YV)#0~D%veX9C&XyCQ>pDXYG9FZoFO*HGc)8!L!nW)r? zc&bX$&&w31`?hpnd!MpzYk*hI04$r}C<*%Et0J8r;eln`1m5sf-or&u4?CHNCr~7Q zT>-ezAG~mUX6Tn@*P6?7XXx!W^GxfS&Gjzx_Q0yGqhRYO+B#Ql z`wF&wOUDbgz{943?QotxtZaN(yQ6@VAWZO6_B;)RyRd6h)vXu;5=TBT=sR?fm?K3@ z03}V~g>Fj~E)X_wATv4QzIJ{{*rQJ30G!roke*iv68>h18R`l#P5PPpRRax~MRiRIs(&;uwUnk~_Vwr2E>8bMV0GwK?+!KzxMF7<`%AF*x zog;`j6Gu&1blA$1qi+#u)0CpeRBCbQB%$9PeI7HUfHfE6oH=cr!n}1`N*jJeaHh0b z$xTzvZO!odBjTP>$3_yjFVI99iz)N)R}Pj3Gm|0cQcb7|am4cBfn$dc;ee15i*K z_d{0!vycq&$>`5Je;&?X9$meBrEvL5@$6V}@M?xot_)zOZt%04WZ#xG;`u*|*N9Kq zIXKMt0$l@s3|FMa!T2-sE(rvyr{U25gqkK%A9Mo-j(fwQHU##|WU~55Z(5FC=i~fL z5)DW#wN|0G9smr27qb7vqUce>{#?hqLiWJjn}vq`|0UpQe#h5HN~WVIkq`$Y!(rCX zD0c+m^C+8y1QK~pwnao91y%WrFt=4mE1N<|xPqK)j`4y3s+ymX8LZ!d{>1AXE2!K^ zV>C3*M**Lr?a3C6@Cl?!aSBk&6zqX=zgR7)oX=8TA36mi;|nZ&2QrYXj>fF5=-8Dx zwN|$)_iC}OCv&FcaKWp~cPFyiu&>atZ)vF5aIkRX>Wb^N-+_et#ynMW)-O&jOacLN z!iO^-%&au*FFN->avps+1dWVy(Xrsj`m*LM%5~;^x$z~>-|YNo=aM7eaOfAz^Xo=H zvTh>m?#zY%focAm!UWR5Ah7_N167AJC?x}2cF^YLX!sc|wb=x{rM}Ey=Zt9zL?_@= zQ-y_mhZM^fR=?J8jVTdp07^>JX)|ZSGXPPnu`LPu%u!pQi~wa;pbXogo;EUIHcl8Y z`xZ5pL=ctxTkG~t75HfRU6}jl}`b%Jin`_HFe!O-^f~;s0$+<5S1{ z=j~(qnNS04uBLJG?1@)~Po5vd>OtRDlVl%@xew(`KJX*Fm_lHwbm?e% zbOe4D3^5@U4#O=w!Xm8N3v6u-RKe7hi1Vy=C0Wp<*_)9>R59mzz)%WDQ&@nqA|DUM z#ombs;-|q@0y8NJXchSimO~`2pwj0OP%o~sl)VBQndsxA0uK`dZ3<1pgaQiqrlZ^e zOIyiaO@d*`wWakiD>^M7cUQDvjBC$IhS2$FJBx!$%Bf( ztFQ-c;tq=#!Go+C*fEp9!O#fUE|;a_K@P zsE&dz0o)-Bi#3A`vF;>%?YZ`XcYo$W$`OkMWXHJz`*vwFAXM5)KI!zh- z);z7*Skcon&#ap$V_?nQl6|e{?w&V)$qFgacntIbY3dZmolgR!|bR+^SRA)=UaJOOP+4| z!{biE;eB^>)#oqx{CQh9R)6|*?&w;ga;bbn?-ZVXdMrjwsb9J>}#>moS_;^h11tEaMC1aq)b4=PWrAe_$E66sxKHG!DpIF5Xy=~qKz7xkShI8s8f!YjfoU^2v zDa;tQrL;xI16rAnCM?AEd2%l3xqDkTc-3c!CD>O@n%ha$q}guj-0sp$ojPfBG=#wO z^}lm?al9ZZj0&WJB0boM2=1N9Q2)V0hmTJ3GXv~oNSF*@>r%le*{WP<5g{QBcLWLND&Vf94Q+nCKYWxqxvkK16rxc7s-OS}IvkWn>tMOl-~8gcv{*$jBF6^^hu2ul_eaX^JQ&}ZRAX;OrW zkxRvr0=S>B>B-Ax*r7A=i3I4Y*!&bC64XI2+(9-(60-TkEI2*ST$Ih2_|eEXsJ+UT z$TU`?Wm77N-J-GuHi(OWgH!a|&^ed{8pboRly_COY1^jEOxrqDOi?g-*{KVr;5Z%; z(Bo;DRGhLgJei8$K-U1LV1rCks^Ou!gJf?ls-kHC9m9S4fOP;NKL@EZTX1(}&Xu4v zS8(@a&V6C2TWncqxf97X6fIrr1Y#cj{8HMPFq{9bcz=;5X1T`NteSDS_lO~ao}7n{B{?_6^=EWWky zR_@}8>$!P)&D)rD7QK7t>D!hPV_UQ>*zTPC^Jm`gey=_l_nNP%*u4K?XVG^E1e>K}ez0VBFJ4%U3*UUwfB`YyO<@NR?k`Tf zFlePdVfG9*P@h_jgC6sz4aUKC^A=Akw$&Ab(+*%gGYzbpCFV> zoHuPo2F@2JIXld@AmG0UY?kCTXqhfWREA?0^6PJ_jL%GzoJf?@^lD!47T*19m(3 zDT(T3-`FJI82$)5rN04v!J`JFRQn##VW^fmz+1_IPauhccQU98gI$T-1Pd-Cf=tk2Q`TPgs!YyEzH96@%(z8z zUt_mnwE8K86)?rwBh%xFMAUCl#Gi6FdB*T0(uKKW^g0);bzfy@%ubv z0_J#S5u|!{oLkQj`aXapF>Dtw4J>(@@AkaYlbtKNdmp)#H%X=0$Js+Q2 z=@=^R7<#nh)z8l6XVKHFHdcRaH1&5dLROP-x~kG^v>=X^*NJqgo#M6OeW(Xw7gFy1?}kId{^b6a-i{q%e3e9w!; z_JLyaH|JeYmt#J(e_&sFy~rMY#GY6-thS#nw4croT`9JY6`QZlyGo3G-9S3rzijGy zr*kQsukVKepRIb$_YZ>e$#I(elAS8W!Q-8VWSP^jq#WeEZ1Q8n*0>wFKpPB_y&)xE`3VmN6W;bV03 zWX>Yuc`2#NBTl9*V91z{LOqyXSWVfM(&h2NtfFf9r9l?KsyNHNDtFgZ4HE~}Y~^eZ z?D~wR%0^CQNY0_JigQX(4Qf8dU3ZU48z0o)*Qoq8GjV_IOvwN9OguGa;;YPGFQq|7 zD$+>V?*~48=5tn{VJ*|m$~EBg07eZ~VYVfx#lDVh>Av=^(^H{J*?g-2bB$oE;b_&i z#O+9zO=h5RlS1j{d&Y{{2Ir?7=Kz_%^Fd2_9X75N|LGbDjBVrE@Cv?abiGw>(TuEi z>Cy=BcN_=Q;<^>#+V!=;f4^>PDc=;p*8uD*Bj*FMRdWpmBb>x&VVfb|u`)`O(Pguz`W^hu6B-4CWjvR;AJj-po~ zSB$d_tY}%wV|Pg&|6veHobE(azp5~x0RWRt>M|wgR-dKh)%xg$h0;m#oKU~s&_9Bh zNFW@hXT;ETB}OgR!PFtR8N}a^fW#6e(G@(c+juAg5JX?ZVOD973~bjX>6hskd<}x* z{bED}J4^wmC&3H^;Q?Q949m=<7>fo|DC)0=(LhyFY%KREloALufRo_641=_tRl=%@ z=_1oyBrM8~Fqo4fA5_;V+qBoRQHRFy1hM%lJGC}#b+R{=RKkVYdk{V@fVeGQkZi>^ zs%C6?v~I~1MB5Sn?Gy-GRb*l@=C@ZYIz?j2jIP_EMLhJoIJ<+9+Qe8A8+ahx5hy4SHZF?-*arma(tb5#%Nu>y4JWed#>2nwRB_Y+Q)nI z`%V-ZPt4ob8uu-o{g_$qDK=i5w}0Vj%rZY5m}g3zee=wnwgSU`&eW|kP-x5ETw(T< z@LNlPX(>6Ck#jHR?FZKEb&F>g&fb~Mef#0b75fXT_G1P6v1Jh5XFeM&*e{hD+OkqM zn(uxw?;R+4n$|6ZrS&mLSu4mqnCljV19^MDj&Np)d^oydKfG!`Ua%iu-dnVv{p^*3 zeWZ+VAn*O=R)kK%vvbw$FSz|BA6sheEY-J^JbQ9dE}E}@76$foZxBY0b>6HEy}Q8d z&Rt*PK8QVhZS~-(!ogFY*;j{xg`r?^D73zh2lCV4HrxphC zbkmbFeCmDr%YxD6q-~oka8RRv=XCJZ^lpHFrXY{;5PrJUPi}o z3ClEe9A`M%h!jXSl%W4PT%ok%TB%-yo55ej_p67+`8B{+fx*rTWek+q(oc4Sye!x2ctcOGwkJ8 zIFO>oB76nW9L|(aYOp>6k!STgo&ogt@QV4hfIThXJ}1d94Mvjwy^|n2e@!@lMOc1C z*pwW~u74+X{D#=~8>08I$w;y?BBRV?s$n&NE2~F?}pw5y+k$>H}o|m zL}VgCCJ$*QHQ?FdB`w4QMlE@Qc%k<}NGqv@5WiYS!o)WwOyPS&iv>pfk+zwX{kF(I zG8^1)^E+lfaJTS$uX$JcMt%-Lag*OuFl=o=MaF;H1ybjMdBv{%*9p5OyN; zm?!-)-(D~+CsmWGqO0_f`L(};KQZ&0{|CO&4ns@L0R}wt4<^hjj;Pt?X)^sjmnYBX zZNzrVHf^7>nWsE27fx^yp+D!24pJo@PpEptX8F?Ug@N+(Kw3$umM@beD;;-u+5_qD zh%^M9C1f+Yv2vf_j0jZ5F=;uMB`v3+_!hdraRn|6je0TG8>gybsFjs+&BxUt4HAzW zOloS{=vp=UI?g`=aG5Llf>-n7`2}Bi&KE9u{8#(O`)~Hnd!8zFL}y=_t2?$Ra{jgl zoZT86SCnwCD;^52TBW087mY2IKIr3Wr0v_cA42-80#KinwLGzRhd`58$BXeE<+hA>)UTAbVxM@;+a%RGoaYeHppNJgBdcy zj)M|-ULyEB*pwn*6x9)!oY(SOTj3YhEa1nQYgwP;%AWAJ^y2^<(2SD)wYk02-cxF7 zhX4C+vC5^3aT(Z~J!PauyBENUBVZmXJ^t0{X=p0?WxN{a!=|Q|%?RpfYTr-(W?*xB^gn zepuCw+?EQdg*9b)WzZE!sUHQ{fYd$np7xR)o@~D-%#yjfS07iP*A|4z=gpat%RBR3 z;AfscZQic$E_4{ky#g{>u)05`jhGUL5(L8N_)bJudZq7CQ*p<*U|AYK*UCuZGm=Hr!X&0hAWQMT9ydCmO!+)0qz5n9>#8aw~$PjwX z{AW`ef5QwlrR0a(9isfeF99^w0EC>P{4fB%MZY=Tw5ta1Svro;V}9Gz$A4x9Hb3jT N#PeN$bBOF$!oN1j-2eap delta 642 zcmY+9&1(};6vgLF-XxvOU12 zc9Z|$3GUn~h=PoJvk*7#T#mTZg)4OPCa(m>t}zjHYEyf<%#&9C+J=Ts`G;F^4Y zqw~Uimlo<&t=jT>PPJFZcfMzfZ6}aJb@gB$YtI!&u^1?psw=tzCUN|M6tID2#UKsp zfLk0g5JS}<3mi2LInYoOFbQ$g37CQes*XB=nuJrJqZ*D0ry)7uujzKUEG_laC{Kr< zVlOqeD_wnu)xz)k3}X!$Gd`SI!p?5G>U-UO(DvL)$M;-u1w*kMelvzVyFk7|s1p|D zo2-?&j5bOLr|a0hBd*Ko+#I_XF6F+k@X6WVjIGN~zR5OZm|r$*+Qtd@<^TNc_ye-* z^6u;kTa(Xbf1ZC#!3^QZE zxwtfzMjYCk0mfIyV$r8^T&#}LHFW=yejo~ag*$f8IzE&BqJ+JpPwh~7d$fbD67ff# z*Da^zMzLIK(C8Xrh42s&nSNjg?aiJOv^)@={8&84_T`UaPyJPsZt0?NfvQ`CMftYW UWP9>&sgb$KSmlpG6ei9N0q&%ia{vGU diff --git a/cli/src/m2_market/apply/__pycache__/__init__.cpython-312.pyc b/cli/src/m2_market/apply/__pycache__/__init__.cpython-312.pyc index 90c5df9f4e22734555300aa5cc86a1da2a261646..d5e1bca4aa79fa7cf6f5981aa78486ab6e592cbe 100644 GIT binary patch delta 89 zcmcb}^^c3|G%qg~0}zBA*vNH(*&$g!BR@A)Ki5cKFC(=mrAWU#zbLz;C^fYhD5jg6 oSd^VwqF 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_cli_install.cpython-312-pytest-9.1.1.pyc b/cli/tests/__pycache__/test_cli_install.cpython-312-pytest-9.1.1.pyc index 01b2939e4fe8a53df3760821ff3acbcc4dbeab34..f2c01a2a4b7118de695f7a43ef4d3f88c7045b71 100644 GIT binary patch delta 91 zcmZpF&A8b82s^NmOO(?gRzD*@H&s8^NMA1_wJ4=XzdXMvyQC;JwHPR- qo10jaom!$_o>`!qYXp+lEio`M*DWYb%E>Iw&`r)w*=)oangak^%^slu delta 46 zcmdn;nz8*gBiCtOUM>b8sNb=XOO#XCKtCftH&s8^2#9oZ6N|D_OE$-IhUNeOO@j_$ 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 0000000000000000000000000000000000000000..8de446a74694deb07210aa4eb8ffa4092bbdad4d GIT binary patch literal 13364 zcmd@)ZEPFImAmAUBDws~w`9wf)*rD&TO=j;Q8l^dw=By-A{L1Xg zHW@NiTNjbzqHzybL`8L|TeKG&aT_15hjW1g&IJneio*e97c(A!pTZp6E5}@Cq(!PNq5RK;W2Q$ zDc?jh#BCET#6HnVnkL$aW1^in<)31*mNzn;FF?9;&m2Yy~|1uOHg* z%WY)aqHm&;43g~^`H3!BObnXCQTvjCi`?5ra$*e$T;wL!k|4ky5(2m`XY~(7H^QKe zrjzlsES-~Sd@>%3s_{%(n#@p1otCBS?Ac^onU;T~X;oCN?t;_)0cIRcSIhBTJb{ z*72hmIwdDE(&1C1LD%~@jDB7dqv>=;Wh117A?-YpjGvxOr)8RNNh)e2l?r6ZWPr*@ zF5jYLlB=@1KY|rFHxwKU4(2@yNhpC#R;E!kL-XF$a1g4`;$#HR4dpvdoRCgEb5!~a zFiT`2oz|P!c1(uGqpHk$jRlCLx35T(G?S7}MClC4oKNSEoT3>r3*9&76xfWC+(Q*% z>aw${bUv<5LsWvA$;{H2obLxD1qOBR@Zj+7z~HXH z&``kW3knVlp*elWlJ5ILzia2e2aG&%0KXf}wZ6sFPs&P8QRUR$^E9r? z`PPt{%9?Yd&nA*lVjx(WfXl=0)x7%3GwErfDQJ;6(S&p~C2LK}EUa~!)9lC&@aZCQ zHkrw#Fz$i1mIOs{HlB>DIn50S(X<-T8*#|z;sjTj=0-IE_>t+TGOgKQfkUPprZ_26 ztqBOoX+_pVkoZ)bO$kgMNIj?=k17L&7}sWcm*(Mi?sqwSy^G?&3Qj3Mp7jAoPT>NtBr2f}z{XfC4Wpxd2*M!OK# z0a{m{f&$u$d3~(DIgk-^E?SI%J{MQw#@O3{VO4=qh0m;_iAE1_(ZsAk#SAuxK$^cl z$u`LXmw@l;fLqKB%i9(`pXUl3vF1gNOVm>`Dyc}3g@oOV)xHJ(E0zMEwy6#??`@+! z^UJ6UpOU%-s9S(KGYYT;ldAb;)NM$;Xf0S9_w|Oo4%|~P zUku|CZpubseZw3%U}J+VA~OT3-c;-fC6ms(O^L@8;^&f?DDm@jC-f|Dh&=45dB-Mc z8dgunl@}kerqRJOkO<=9q$ZRUBXx=l2 zkB>eR89RCW^W&o@!sIFzixdHhBnS^) z2u){Faws(%3Qo(E&=77KDwT1=7!H6&nUU4d`FJ*v8piT~Iyk&1VD3k;6hVU`gd2du zV8keb-9z(4^j_#3(L>PW)9|nS2*4ay5xgrxds%2-7{B&RNoZddhAKASl~CEXVMPd( zg}@E+PU3pv_7_ScFZ`@~Ig(lmrk8~b;snb!X+;<)3j;UC-1 zw!Rf%ds*0C8aTErJX;Yv0RKKP%EgS;g>kM4{@JSh2}It+^~N)q1?g@mvz9su;~T9? zVy&&qLKVAiMK`V`)M}~76APy4Xtr5L#RAf83+F|qTc?6*26p}5xJ;D`8c;eQZ9F@h zlj+b#m#(xh2j>-bcib#Cx{@-Pl4)d?Cb4K*RkO<8P)N@WB4!mOnvF+hY7+;W=2k9!xY%jnokTzP8>e{ z+{k$3$jR{0(PLUiEobb=vm+-Ck42stJ?$6h9vDL0>!7#iG%HxfS_|mpYz8(-GzIt^E_T3!8#>NuHXpwE_q^=*SP z&(&7qM|aVpFvS%FV%-mwRoLb90Q!TCT0yvBGgoWTrh3eBbJUCWf<56gb8BDKI7-8g z-&)HxW5lkaUBg1bX3p+Z)m|tz6`B%g(yR(aM;%=_ysr*LXTec$&cGZLT?N+_Ug=bO zOq|3zGgkYOrh;pdCl0JNX?fciN8(h`YGNTF8Iih=+4pmt(osi$V;zbSEi{>ZqxG1Z zABCCbq5T@yjc}`01aUn!r)%nHHsG{2u2qifYc$~EqcwnYS6wR&>J*$Nbs{bbd9e11 zVqJ|bjm9N`D{57!QgP>@gm?;KoM+VB>UI;gj_!b&SNj&+CJpoZRi1Dc_@V>;T}3Cc z7Cj(?4cabx3!Z}a99Q%ee9DjOT4^L--hvP0%ZD{;@>N%-(HMD5j{fZB&|Bpc+*NG+ zo6%`iXf|e}<*{d@#iV3@joI+PZ1{@a9OrKf$7-|3lq3g>l)sN!;7v}g9zh6SBjLA% z^E=J$SW3+*sst_<@CM;YP6ekEoZQY&!>-IcK!&p_8c!z$q0;%PJbT ztbTBRG6Qs!gTaTX%Fv+V1u)0G>s?n`Kepr@FFD599sXF|ftp=!4{Uy1=CG&f zUbpM~1IvK}em;NZiT-SE*sr)=F(P1-U~JH@1kqmxE&(FDiqZi+d$)-R5d(84V2*rY zbUeZ={KIJNyNa3}Hp$tfsx^Vx9RdFXSo~I?ieMPQP6WI2&8~y!TmcUmHu|Jr!R#kt zOSZ(**ZF)9bBCa*c$y*XGMk5p(fkf*UUx^hir1}JeF(u01iK!1>!|P1sqgW3!7eYu zu@-D68=8j~G&^|P;3%aDBpy@gUKlBM3ZMcTLYO+R*65coA*8^;M~`CESsfqEw`u@1 z*CFso;j~B5K0`r3BaMP*2@h?W8;U^^;z{sFfDq|ZtaYPHU^Yn@YGf*zIU7wznA5}= zQKFMFQUGdTLV}|qqW3JNOlfw#|MU!U)n=B<7qZD{nu)Q$l|Bn)%&w!O7<&Ox-DjgD zVxAZ&?)Wfm%;!iWm=KXmy_uo<#IE|p4n3hWwWmJuR6W{YePU;QVz|D}?)t=&3K|Pw zp{@$L!MguXSyuELK$*b5@@3E!bKLJOyzTS+Jv*~yyUIe>qV0R`@3^l{UW2pTf#0=t zEqa&Qw$2@|^lg|seg!Pxb?>(HE?l_QU+x>e^~L3u&(3}Jcb?A6FU}pUc-r20>C#J! zd%m~-JNvKse>!$+?LS0H{*fim(Yd4Vi7hK)Pg(3K2_uW=SNe9B`*uUL1Q`$)cdrN| z@cR!lM^9E^Ss0l&P?l=ZWrkfv(jz*3Mh#G*DHcW4K7#_}+_L|R=VzW(H6aO=*|~Dd zLg)9^e`kG3*fPI&VSP#5vLfK`uAbHtOPJ83RR|RpI;+KIbQyBjS8=Kl$bN+3`z3J1 zyw>(w`)eI&{!Zbc0RJqDw%6OprujD5POTT+ueYl(m3l~^Dqa;DnZ&Pm%(u^XOj?L= z(ZP)B28W49Qzf>CjB2OZC>YCjJZzggPf@5D(x$Okv=wZLs`+fz1d|(8Itz)a$!z9; zz&5FEW~v~V^y>^|G{4bjxGEHz2$(X3rh){FmTM?F;lokA3cw&LZRv+15>z$DXl_-WDS!Ul#j-(8rYvevt}}jhN5eQQpiMAg1BzGE5y&-D+aj(QRNp7DSVV`87(rp|raR!ogquFQ%9Bc+Uu+ASp19h|;%~b6e)wyV4hPM}Qc?(`c8hwu~ zjXqNf&95PidjFl!|23@tO*K=#cBqA8(%NEk!C7c#TBfDYqWr3k^Twmn0&>}db!wwR z*3}(}9<628)X{ICWz=n^Je&QY^)(ud&8AN`pGJ5%>hW>O6e{UbD$9+~*s| zV!YL_9&1}4dp=uDO6Ip-yMRo$6q{jArN3~Q-tZUR|BZji{Rd~l<}h;vgUi>hzwp4z zbh!RTPcM7oq1o^hq-qv8!Bt%gpjz;L5)3l_{y6wnzs=)4O9HZ1hejr8kuUIx>P=^@ zM$KVC_X9O-VO1a&RIfB}$wI6bEEg;|dElJ|V=`uqS@0C6R!z|an9r*Nu@O5Ml5R8q zyWHPe;`|MV>DL{Do5%uxj-v-3*?tJxcVXL&{Jn*$+XcMMpTrG3b1J~YriXd>n9-?v z!|2{ea%OxcV+f+E&lm$8gqeDt@}_ zk>j-m#;fB~at6=P9-h0}Aq9+(hB6F2SOX=d2-bZ;8mkAJuk!x2;k+}XFG^UC!l{#E z-YtjLYK?IJ%yM+`J23X|XpVN{GGz8Q_T0(-cooK-5^;3cUj9 z_t8)Qr=A6#sW{Z=6!W^|+p0*Rox>rf0u=f!tlkuaoAKaOK5q|YXW>}~{cFh89Mftl ziT8gG2F+VX)7{8!|M2o!zo>?XK>DjTy?SU*zI`?g4jY*{At}BlQ`EiE177mGG%HYK zT*F0wnke{$O0%bAc<@AMmbH6YhF50zzfxUqVJ6ZmkuoSEQ?QE+xkxMdhpiKJKH`} zakpIQzkG7x<+5A4eDdA4?l%v9>)_Q**UsF$@C()>WytDObq_Fw&a|=Ud_qxl^85AnbZIw=` zw0XSLappstwcUGGn?VGeS<%lx*b-bLPMce-RX^+v%$c-&Jg+$EvFO~J191OCVMI9IUzkpCvnbFh6{ z!kQBvZR#%_{T5v&JrBKqgsoM;7kKn}AVt4`;4J`}GehH3;2Mvs3UUa3zBxnw-0Z$u zCaf^O`|*Xaz8)@Z1(!BR;=8?@R(glZy+b#*F7@u6x6i*)cJ%zG1IE4N=wE2R6a0_0|44vaHqL)Iz_oO~>Hd~`rTK|+^AmHUe-JD-|2@ZQ z|99f%z_Rf4@2bA;rIsCkw5L0?@N!8AfsY&Xy9n>0Lh!q5Waz0XEDIs@a3jW2ExOE* z_3S$WkgMWf-SSr8`+;h*8iDpI;(?M7zTNrD^*;v>cDN)SxD77s@QQ%HyGDkdT4E_Z zUWKf3wfr{tv$5hbkOU{TL8cl33Kg;WO82FwOG4j56u|t`upNl-RttZ3^|YQ?!h{~Z zgUEWc9s)+i*M8;sORtuMP2bot|LT(13$d#k0F=butMG(m(~5w(^E^e z=(4bB{#8Bq4y#v(arJ(7W(C0P_bIK35jujT`>%>S;Z(gb6)*2a)w?Jp>G-)_!SUN$6RCq3N05 zw*Vv4vm)T{uAbHtOPJ83cMw^R*286?hiQJ;%)N{Q%hz&4-m>>Cqozj(u$8`0@CPr)Z~7mOyU2Y_ z{4MAHFV6F8Zqt8reeZdsIakHIVNR@g`vEw+=j?Ei*0r_L)?0CJs`&O*ykqZmK2_-q zRn{K;ph;-6-*a(YTdw&^Tc0d-JXL95Uuj)iT6eV6a;((+Y^D9BN_Wq-&Rbi{p3hXg z?Ugl~ZmDJ8GnL-K(%x{n{bZ@_)CW(rwOZ!9_XdCryrIN~y8FOZGMa1dd6+`?WM%F8 zg^tSFo}2u?wv;{3RlFTg=YziH7R#LH{uT${d9RbRI9}`hO791jX1?pbm9sS8!`Sb) USowXwcYFD*_cspk>)3exAHMPbZ~y=R literal 0 HcmV?d00001 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 diff --git a/schemas/tests/__pycache__/test_schemas.cpython-312-pytest-9.1.1.pyc b/schemas/tests/__pycache__/test_schemas.cpython-312-pytest-9.1.1.pyc index 4b79e298d904e6254364b5c2c627928e2169a6bc..4a058898e4b8e9948732dae5e3db53ac9dba435b 100644 GIT binary patch delta 89 zcmbQ1bU2CYG%qg~0}zBA*vO^C>=3J;k)NBYpKGMAmyueOQlwv=UzA-^l$u%$6w}R3 oEXqzT(J#*|(9Jah$?KLF7@6x9lqTh57H8-t=ca77XTGir00yuf`~Uy| delta 44 xcmX?{G%bniG%qg~0}x1W+{mTGENq~kk)NBYpKAm}y19u(*{LO)bC|E|0ss|G4N(99