From d5248f61a29eed80da39a8e3451a646337297543 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 06:17:56 -0400 Subject: [PATCH] fix(archreview): CI pipeline + codegen freshness guards (IPC-01/09/19/20, TST-03) - IPC-01: regenerate the stale client descriptor set and add ClientProtoInputTests (semantic, protoc-free) so a missing contract symbol fails the build. - IPC-20: make publish-client-proto-inputs.ps1 -Check source_code_info-normalized so it is protoc-version tolerant. - IPC-19: document + guard the "Generated/ must be committed for net48" rule (docs/Contracts.md, csproj comment, check-codegen.ps1). - IPC-09: harden the per-client generate-proto scripts (PATH resolution + pinned grpcio/protobuf/java version asserts) so regeneration is reproducible. - TST-03: add .gitea/workflows/ci.yml (portable/java/windows/live jobs) running the build, tests, client checks, and the codegen guards. - Also: check-codegen.ps1 Check 3 guards the CLI-02 vendored Rust protos against drift. archreview: IPC-01/09/19/20 Done, TST-03 In review (pipeline authored + validated, not yet run on a Gitea runner). Verified on macOS: NonWindows build clean, ClientProtoInputTests 5/5, -Check exit 0. --- .gitea/workflows/ci.yml | 124 +++++++++++++++++ clients/go/generate-proto.ps1 | 50 +++++-- .../zb-mom-ww-mxgateway-client/build.gradle | 32 +++++ .../descriptors/mxaccessgw-client-v1.protoset | Bin 80222 -> 116804 bytes clients/python/generate-proto.ps1 | 47 ++++++- clients/python/pyproject.toml | 3 + docs/ClientProtoGeneration.md | 42 +++++- docs/Contracts.md | 35 ++++- docs/GatewayTesting.md | 28 ++++ scripts/check-codegen.ps1 | 96 +++++++++++++ scripts/publish-client-proto-inputs.ps1 | 131 ++++++++++++++---- .../ZB.MOM.WW.MxGateway.Contracts.csproj | 7 + .../Contracts/ClientProtoInputTests.cs | 97 +++++++++++++ 13 files changed, 645 insertions(+), 47 deletions(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 scripts/check-codegen.ps1 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9c6cbdb --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,124 @@ +# Continuous integration for mxaccessgw (Gitea Actions; origin is Gitea at gitea.dohertylan.com). +# +# Scope note: the x86 Worker (src/ZB.MOM.WW.MxGateway.Worker) and Worker.Tests target +# .NET Framework 4.8 / x86 and need MXAccess COM installed, so they build ONLY on a Windows host. +# They are out of scope for this Linux `portable` job — the `windows` job below (self-hosted windev +# runner) covers them, and live-MXAccess integration runs are scheduled-only so they never gate a push. +name: ci + +on: + push: + pull_request: + schedule: + # Nightly live-MXAccess smoke (windev). Push/PR runs skip the live-mxaccess job. + - cron: '0 6 * * *' + +jobs: + portable: + # Any Linux runner: no MXAccess, no x86. Covers the NonWindows solution, gateway fake-worker + # tests, the codegen/descriptor freshness guards, and the clients that build on Linux. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + # protoc is pinned to 34.1 (see docs/ToolchainLinks.md) so the committed client descriptor + # regenerates reproducibly. The freshness check normalizes source_code_info, so it tolerates + # patch drift, but keep the CI toolchain on the pin. + - name: Install protoc 34.1 + run: | + curl -sSL -o /tmp/protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v34.1/protoc-34.1-linux-x86_64.zip + sudo unzip -o /tmp/protoc.zip -d /usr/local bin/protoc 'include/*' + protoc --version + + - name: Build NonWindows solution + run: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx -c Release + + # IPC-01 / IPC-19 / IPC-20: descriptor set + Contracts/Generated must match the current protos. + - name: Codegen / descriptor freshness + shell: pwsh + run: ./scripts/check-codegen.ps1 + + - name: Gateway fake-worker tests + run: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj -c Release --no-build + + - name: .NET client + run: dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx -c Release + + - name: Go client + working-directory: clients/go + run: | + test -z "$(gofmt -l .)" || (gofmt -l . && echo 'gofmt needed' && exit 1) + go build ./... + go test ./... + + - name: Rust client + working-directory: clients/rust + run: | + cargo fmt --all -- --check + cargo test --workspace + cargo clippy --workspace --all-targets -- -D warnings + + - name: Python client + working-directory: clients/python + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + python -m pytest + + java: + # Java client runs on a JDK-17 Linux runner (the macOS dev box has no JRE). The protobuf gradle + # plugin rewrites MxaccessGateway.java with spurious protobuf-runtime-version churn on every + # build; when no .proto changed, revert that one file so checkGeneratedClean / a dirty tree does + # not fail the build (repo memory project_java_generated_churn). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Gradle test + working-directory: clients/java + run: gradle test + - name: Revert spurious protobuf-version churn (no .proto changed) + run: git checkout -- clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java || true + - name: Verify generated tree is clean + run: git diff --exit-code -- clients/java/src/main/generated + + windows: + # Self-hosted windev runner (10.100.0.48): the only host that can build the x86 / net48 Worker + # and MXAccess-adjacent code. Not required for a green portable build; runs where a runner exists. + runs-on: [self-hosted, windows] + steps: + - uses: actions/checkout@v4 + - name: Build x86 Worker + run: dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86 + - name: Worker tests + run: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86 + + live-mxaccess: + # Opt-in, scheduled only — needs installed MXAccess COM + live provider state. Never gates a push. + if: ${{ github.event_name == 'schedule' }} + runs-on: [self-hosted, windows, mxaccess] + env: + MXGATEWAY_RUN_LIVE_MXACCESS_TESTS: '1' + steps: + - uses: actions/checkout@v4 + - name: Live MXAccess smoke + run: dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests diff --git a/clients/go/generate-proto.ps1 b/clients/go/generate-proto.ps1 index 1e88740..9c807e6 100644 --- a/clients/go/generate-proto.ps1 +++ b/clients/go/generate-proto.ps1 @@ -5,25 +5,46 @@ $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') $protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos' $outputRoot = Join-Path $PSScriptRoot 'internal\generated' $modulePath = 'gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated' -$protoc = 'C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe' -$goPluginPath = 'C:\Users\dohertj2\go\bin' -if (-not (Test-Path $protoc)) { - throw "protoc was not found at $protoc. See docs/ToolchainLinks.md." -} +function Resolve-Tool { + # Resolve a codegen tool from PATH first (portable), then the documented Windows fallbacks, + # instead of the previous hard-coded per-machine paths. See docs/ToolchainLinks.md. + param( + [string[]]$Names, + [string[]]$FallbackPaths + ) -foreach ($pluginName in @('protoc-gen-go.exe', 'protoc-gen-go-grpc.exe')) { - $pluginPath = Join-Path $goPluginPath $pluginName - if (-not (Test-Path $pluginPath)) { - throw "$pluginName was not found at $pluginPath. See docs/ToolchainLinks.md." + foreach ($name in $Names) { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + return $cmd.Source + } } + + foreach ($fallback in $FallbackPaths) { + if ($fallback -and (Test-Path $fallback)) { + return $fallback + } + } + + throw "Could not find $($Names -join '/') on PATH. See docs/ToolchainLinks.md." } +$wingetProtoc = if ($env:LOCALAPPDATA) { + Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages\Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe\bin\protoc.exe' +} else { $null } +$goBin = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE 'go\bin' } elseif ($env:HOME) { Join-Path $env:HOME 'go/bin' } else { $null } + +$protoc = Resolve-Tool -Names @('protoc', 'protoc.exe') -FallbackPaths @($wingetProtoc) +$protocGenGo = Resolve-Tool -Names @('protoc-gen-go', 'protoc-gen-go.exe') -FallbackPaths @((if ($goBin) { Join-Path $goBin 'protoc-gen-go.exe' }), (if ($goBin) { Join-Path $goBin 'protoc-gen-go' })) +$protocGenGoGrpc = Resolve-Tool -Names @('protoc-gen-go-grpc', 'protoc-gen-go-grpc.exe') -FallbackPaths @((if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc.exe' }), (if ($goBin) { Join-Path $goBin 'protoc-gen-go-grpc' })) + +# protoc discovers the plugins on PATH; prepend the directories the resolved plugins live in. +$env:Path = (Split-Path $protocGenGo -Parent) + [System.IO.Path]::PathSeparator + (Split-Path $protocGenGoGrpc -Parent) + [System.IO.Path]::PathSeparator + $env:Path + New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null Get-ChildItem -Path $outputRoot -Filter '*.pb.go' -File | Remove-Item -$env:Path = "$goPluginPath;$env:Path" - & $protoc ` --proto_path=$protoRoot ` --go_out=$outputRoot ` @@ -35,6 +56,10 @@ $env:Path = "$goPluginPath;$env:Path" mxaccess_worker.proto ` galaxy_repository.proto +if ($LASTEXITCODE -ne 0) { + throw "protoc (go) failed with exit code $LASTEXITCODE." +} + & $protoc ` --proto_path=$protoRoot ` --go-grpc_out=$outputRoot ` @@ -44,3 +69,6 @@ $env:Path = "$goPluginPath;$env:Path" mxaccess_gateway.proto ` galaxy_repository.proto +if ($LASTEXITCODE -ne 0) { + throw "protoc (go-grpc) failed with exit code $LASTEXITCODE." +} diff --git a/clients/java/zb-mom-ww-mxgateway-client/build.gradle b/clients/java/zb-mom-ww-mxgateway-client/build.gradle index aea6eef..cdb2b4a 100644 --- a/clients/java/zb-mom-ww-mxgateway-client/build.gradle +++ b/clients/java/zb-mom-ww-mxgateway-client/build.gradle @@ -57,3 +57,35 @@ protobuf { } } } + +// IPC-09 guard: the generated Java tree is tracked (generatedFilesBaseDir above points at the +// committed src/main/generated). `gradle build` regenerates it, so an un-regenerated .proto change, +// or a plugin/protobuf version bump, silently drifts the committed output. checkGeneratedClean +// fails when the regenerated tree differs from what is committed. +// +// Caveat (repo memory project_java_generated_churn): the protobuf gradle plugin also rewrites +// MxaccessGateway.java with a spurious protobuf-runtime-version delta on every build even when no +// .proto changed. CI reverts that one file (git checkout) before invoking this task; locally, do the +// same when you did not touch a .proto. See docs/GatewayTesting.md "Continuous Integration". +tasks.register('checkGeneratedClean') { + group = 'verification' + description = 'Fails if the committed generated Java tree differs from a fresh regeneration.' + dependsOn 'generateProto' + doLast { + def generatedDir = 'clients/java/src/main/generated' + def stdout = new ByteArrayOutputStream() + def result = exec { + workingDir = rootProject.projectDir.parentFile.parentFile + commandLine 'git', 'status', '--porcelain', '--', generatedDir + standardOutput = stdout + ignoreExitValue = true + } + def dirty = stdout.toString().trim() + if (!dirty.isEmpty()) { + throw new GradleException( + "Generated Java is stale or churned:\n${dirty}\n" + + "Regenerate and commit after a .proto change, or 'git checkout' the spurious " + + "MxaccessGateway.java protobuf-version churn when no .proto changed.") + } + } +} diff --git a/clients/proto/descriptors/mxaccessgw-client-v1.protoset b/clients/proto/descriptors/mxaccessgw-client-v1.protoset index 6dd07cb9f386813485116e85529b8e0212a09f58..bdb64fb1824570a7ec8501f9dd82fef6ba09d79f 100644 GIT binary patch literal 116804 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 XY+Q1p&>xO`GO2vohv8VG@5lcFnrCv9 delta 30843 zcmZ9Vcf4Ii)%W+FnR9YWlAG)#_a?b1H;pvXE(z%gDS$}tNRt2oLQ6=YD32$R1nKPx z7Zi{pf*=qG1Vm5-DFP1)DvyG+2T_osQU&Gl{jOPS_W69?|MJVM-^Qj(Nu9^S6o?ZXm^T@8h?f)0|uU=bz ze?VT{GVH5w`7LXX*)w-skJNrTI-f;R&I5E1T&xI{#owc8dMv#!bA^$lC*Z(8TOrk;1Z`z-G|sUkf7k9;HTB%Ssj%6hq3R_5UKs`gAs%6bh=2wtyo+go*j-ldfuTJN$t z53P4uov(tX%`_!mc(antp;q`7x0jU#w6s)P6GBT%+vJ4cwanhbDz6m2k2}zcD$x2= z+Y*A-XW-O?sJzeIW^Bq3-6hIbeZ(^}oA?psk@8)gy?iPjM#O#;y7 zrdW}9uFWM2#K&#z6H=K-PMRE>k{4lcwvDz#I(~4&z^E>lq15gMMhXBs+s)3r7DrZl zW4~00zV_BQ1Vb*PV2D)=fHx%ZoK!HRae&csXhQ}Lvg&BxP^S&cor5;i^)NwEIkaI= zLY|HvI%KHvP%_Mou`&;DSXrI4dRSveDig_3qm32{KirM7q7YG2LMR#D9H$Mu$1($b}Etiqi(cWn~W%P8+h~png1V@xMS0n0Uf>mfE`X-yJ z&_-bMqd9{scRdO}#%(jWtJh5r)rAJ_o?enmG>yZ$inFE}qb+?xGCd1iU4a#us5y zHcicto|#0C%AE0H#$+=CfV#=14us%jGXsRW$!bOo9%WP9@aQ-IZk*zJCnRMpI{Ybv zH^nBPm!LJ(ybpjj)v5PE(59OALC~h+eX%|iVY_U5wO)oqZ9DT~5c8*-`2ftHZsvoK zoNnfW&^O(@7^JEh=0pIr86{1eIK!Ms8MGPZ#7YrnW%DqjM&ywpHp}K~Ma-COW&qGP z+w_4DoNZ=+&^O!6sE8SJ%nSguIVDZZm}6#825pXw{EDmL#kpm5;>EeHIXb@*y*Rh0 zMh6hQxl|`!tQKKGc7%FSM*4z=zHtmx#rlO#r#Jw03tdZ82SRXRpZ*DNN%1`%D<&|*3Pi^E9c$@o|t)TnBVM2q`tqy?dK@$eBwqh(7%T<}6Qj*lfl zLly{?OIl@i)+$m6Z^@`J#se%3IsvOr;_uQB7f}Rdyf5w7Zl=gOwse>V_NpWH>=-l> z0if+zS}6l<$Nq5*MTx|o9XmAIS8Eb`b_!!nrNo|{N;73(?9`e}k=V1-=xxkO1a}TP zKdVk+&(1+Z9SGXag@$|(w4K{E%vL4ze5tguN42N{lQSjse5o{3LeH0ynXaMmyM!5L zWdqgi5|Y;&pzShL7p$slfVNBLbhEOt@LvuZJgcq|+Lwb)W)QS5x7W5cosICmJY|~k znhL*b*x8Dj;O!b>#)JEs;O#nQQ7VJB>(ZT!)?E1Af(G5HYlgO4*>%m(cI%faZ-%zp z(BVzZH5q-bXW@4*M;JL1C^@Akyxq$Y))U_DEA+iTneb4s7W?;R!?tq-)lLmC@>a@OFz2c^;12j1Qjwl!Yg!tWEdGhSbK`-C*| z`%>LLLz2$E(Ds?yX|$s7`vzT0tFC~yZ|R}}+P*`lnaTp%zP0JPm{wiC!tWO}wE@7} zFC_Q%gSTH>8UX#^?KgJ3RoB1p`-hn7;fDUu_75ri^oO?p;H0uYwEZVdG1`E_A0V_E znO)TlfOkO28vyTsk~aX}0VS`s@CTOjpcUGIrBhm=9XKS-gH~tw{X=wZS{6?1MIV2bFy=u<&0A$zcQGeWjc~1EGDTJq?F}(7rNxs3cu)=A!EKuLR^8yjA5wPtV0ed=)eVMsNPC*CgP|QVC3Sgw;SUX|;qA~4 zEk{^8v_l7tvo3FkcId>(X62B=A66^7d~6hR0ct+@#Zuv|9>0uWs8Vz?9}Wx5>ziT{r&Rm%EujHaHH z$sX#MQYGulu`b3oieyDO*2U>42*G2A5zU&UH7qE{m93E_iQ9}dNtS|>+;r(tiX`}-7;#b33&B-`lY=`bG zHHsvro>5xTir^W#u2Duz{aWcD38G&s9n^;4*GdNwM896vEn)NPrI7=53NUxX@5kzm;d_ znp$Rg_*Ndb^&vEf+H-QIlV}B@71$#|WY5VP1|;Z<8kn;KQQKHl&(E`qOf4ZHoS!#{ z`$K3HwHG9{Ak+d?EfCoYl3F0fUZ7fqO5ue$y($)gpaQj>jjl!%2R9B(#c(go34CHv zQ&GJn&%SLI5qrWV$$w2^(WS{E5Nd&H5fIr+lSM$ZUYaaw5{oV~i)sM~7px`$W3gET zgnOA<6cDC=yXw^v>;)#OXzt26nUS_43$-h`x4EcZk!M#~V~CI83VKcVU(M2*?xYrk zTA;QDh;Vmm4G^{6sWr_~=ao6(PU-|f1tzLA=E}UGUn+%qWzH^A+}pFLez%rq*P2NL z;_zLmMS8raSafx=2!vXoss$o@b+QPE+N+aAJ;fr9Zq*_XRA8ctMb{*Yde)+UuSq8L zDyrAz*^Ooq5kFj)dc2odbbYc2gj%4g1tNQWvIvMp*C&g5iA6Ugi$G9;LS_F6j>YCA zAlw_0MZJsaO?h^US;W#3Zps@Z4G5vPSoDKr5eT(FwFrpp50XVdwEiGj)LSgN8H;MM zV}NkMi7OV}oSZ}{+?$g{Ek*U#JiEgzVhszo<_)qF4WUIWx-D4*LMu?U0+GEfSp-CD z?Y3l6i zciu3%sh8|OD297?PI4sEqHj^XH_z@ji&)UZy(td%6^phei$JIas#+kjTa!gVv~Eon z^%aZmOJfiO6e^$p_M`D8!=$Mc8A|wB-XKRyA@mcA9!?g4 z&^2=gA@vTwvmgML$m#Q400vYLQgS7f?F#j5&e~ zEIcla6RrKl5l1VIHRs`&8N_+OVsisAk`IdOnE@wuE$qc{Qt7nr!B`8k<_ zRtojGd~mWD^n8k=1Dp&dV4{i-pU*jMR_Xw8;`0(nuaLWm0UTq7-{oEZ`O~E}{*C*8 zm)sA8`n%M1K$yQv|5I{5h+2T!79hfZO6~`u_D^X9 zw~3S9%-KfEU;#k|>beSyrM5=^LVZ*A2JLII>g@Hx&ojiNr8pD1qypuAXfzp$iAD}0z~V(Xc5QwIKW1uFv;3#bt0#WtvH0p*F)&Jz#H=XX4*?))s$P|)39wG*=cebeop%$nH z0+C(sY#jxnb-jywTA@<7p~g2U(Mdoo+~CYYAgVSvb0JWqof!y3)dtt0(_&~*-RQD! znSrwH-{>sD4iy78B?Ccd1*(C-8p4~Bg<#ZfN)`?k3(s~o2tgPKR2KqKb+)rX2#mru z2!Uui+u0x-R#eY**#&0cFeJ})x-Y1OVPfHV$wClnfodTT;q#J#K-8X>3>+o~o}WAj z!a$%J2t?EQ$%DWsY#s!n>3ml+7Y;9~7rE>bvv4?~7dh5n>Eq#I;Kk1A))|CaphgEE zvKPBX9qK^TUhMiw!s$XsQN7G%U8c1I$;-6eE{rIum%Ho=Hkt7RW&~xIYp1y|vZ!9^ zvhP^YNQ$m>7F0%xF;_X;!GbUbsKx-1y~=S9EVB}bF;_X;Q;#aDSG(+5(>e;tt6f7) zL%}FfdyV4=P>3KF7@(fj01>{%wU14usJ+HbkdrtUMi(XzbX=xl8Q z(HNj6$v~(#I_^27sr!K8-ss{1mQ)M+zT>n_N)L2p z-fwYT*S&vFjekdAgAs`Rx1_-cMA0p-ujX1n6y4(D*^=l%_J@vhbge~Jd!VlN6hi%> zYidoUP=DwK>6$ZI+Ip*VwQa1liWOj;5|qZ`=2R^Q`;-ofCxleXeK3%3N@UoN=0Z5! z<~TT2EBF>gVCn!lGQ7<-CL6gD26EWnTc{1Stf=1Z+|Qg>wp`nX+Z{=^s%|L49ZU~X z&84wGUCu^3IX1k*6*~BUsJ_E-@UGOxBHZbMu63j3;#gqnA2}Ml)Ai--N{!~~7|ij$ za+}z~Gxe;H%VUA5S~lq0jd+@J}ml<$|o z+zcwWCtG;OwXSlxL>8D#mb0z9>mz|{WFVIUO6^sIEpW<ngnJxUgjy|^$pSTDqY&;rZt%oZ3ilqzEumK1!pVZ;GE=EsC<{y-BIi2yy7nol z6zaW>`%I;3YuqjiPTT_S{dH~u_x?J!UlD#>?^`aI1tzMTApF?1rS@>e z3@p30KL;L;n|HOCOJ;$sO*O-CA8>6cm~hVw9r5-DEDB1*U3ctND|<#auN5 z%htAX;NeOibJ;9V7pT#4Gg1S$jxwvbZw7Mjpw+e&;UUM@FKQw;&H{DC7%ex>!2W4^ zapepgnIgi#BK*{aaYp6RS)c~e(N5CwpSl5YcGVbu+&hEqzF|yJeZ*zIuvKCVqL0X; zE*6cERpQaK*nkK$KurLE$Ud4903cc))dYY>6xGLF_M~au2Fb@=JnLcnZzF1-NQ(~$ zwLn!1MED6=;>8U>)IO0`%CSZDDVIHCYR4k^R5Es~Xni_q1)&wFT7k$ujaH36VAMXH z)Q;mU!DYWTwc`+dHl=CfMD4GVS`cc1suqasuadDqwEik-9bZ(RciC@E>v$xeXIa++ zukoVxH)$mS(PKbe34sXz#`V!52Sn{}bR}VI71bA9_Ip!10m&DV4+^2sC~)VF&PLS0M!RTWM7gML^=$J)|Z?e zb!}TzUvb%Mrgd8x|F2kr8MYO*uO_u1)B;s45Vfx+wLsLqn$%7zs{iY&gh zCD|m=`bHYGAhZHi>m<$&5q=|$T8dHoMoL>JNe1$#l%Ig$0(E5t#!}lm1EKyYr6-dl zwRtm*6A)Bjs#X-gDdQx@ABy3=nKG(Lg8p1LSxzp1)#y+P;r`k6&>#Yg!nRkLB$>@$ zQjde60+Yp(%>2bQ>NuiQ?(Y7QdVF$G{j1B~H78C+^sgzIoh(j#$GNf51Q4|VHCX{N zuHJFYy6gal0 zUkW)uzMTPEQrszC`A?cOAlePob^~Ln?U{g3|C8oSr?`8)w~Y`8Do}eF2z9-;{XG!s zdM|TEz8&loxWU_24FngMxZ>^&-WDKAqj21+2^A-P(_1J6(ORJH0Dw@x>Fve<2=$vj zhC-oI%~`cNw<5S+t?VAo@)kxBhI>|>OFbLC)0D9@?iPTW5d)!a^h0!y2!y)PPtlaI zQ+5lRJRe=ic<-#a9<%_gqkvL0Z}PVB0HSJ>w~Yr7RhztRJUaQJG@R{ih=XVVFm;#g z4$k&=+eazXv%T${Ivu-%Z+X|L>o1UM!KnqZJ@{6=1+qK%mgmb-8ONQD-N8BD&D9n- z*&P5ARdxsG_#v7SBMbE$KWmOr*&UqgEf#dTW`4LLI5obR7M$ztD2HNHo$GDy2SnAm z-ZK79K0ylSdE2{!paN6lWwUUeFUP8E7S8jn1LLl%lP{&K7kGD>_Zt8C55zC<4L!|D zHWL?m%NjeKY$kvj@PWu)=vy^22cr5yZ<%?g>?JPpw*3J?1?u((2=yXw+aI9B|BJlk z$en!H6fX9BI~YfXlg$KBqdJ9fFZS)4^8=~&V$T<&+9hlzF7Y9CiR>kSdW_iVKU`o5ao+SuU7QsH7{NsRj_1QT+%E`W;P?a) z{*K>PzSk95M%#BiUoWYw#0p^CzDOam-}U2UAK}7wMfF;rea~X`c1T|9uXT_whWEEoD*tzRzy5q8Su@-^Y_c7q%~|H~Z{IR9Dbq~x#a7?_v*G!3(*fw+ngRq6*{$AgPk`v%Dg*NC zkxAhLzWWy)`Kd5v8GNA56h}PZ`7M>Sl~R}w$ml#cGI7L1zWd}6`Tj6vm^@TxiXR?I z5t&k$4~YwRh)mGKzWdV=`P>;Z<8j)<-i}g%Fdz1+E7OBW4`;?k@;q* zfl41s4g^Ad%s1#M3J~gJo@3Wnl#1|EzI(S(`5&-X4$z)TqX!7}DL+Ib0}$#{e&(#E zGo!u8KI^-`GCDsErks~OTW87&_^cnIr$Cg#eAaI_z3IfrWCeWQcZV_gv6w5zWzW}r zBn#g2-cn>rVLmS-^xJAKE8Yvf`?@jt#ZYCzd!bI11@8sls2LNbP+#z5l4mQG74Jpg zee^c@2JCHw`eL0b>)ngKQO}Dgh5DlJ-`ccaw3qenW#2t$Y`!OzHo<(k&XncuWpA4h zN@2e2+XgjVu9dRhz3#iO8Jk}MRSv^mPtyVj^>yE*N3%evuX{d+wWhMZz3ID;9G72Q zo9W~%?9Dn=mbW*3lP*b=LVeQ@l%q`vKGnB;_P&M(`B77N%U5CwT5&QZ-cG?2L?KXv zCy>GWb_$*`vi>zjaQVO(h%i_~I1u5#%8ZeK7~}3cb;VLEFe#Ss@J?MZ*1nsT24(@W zz@%5A^1F4t4DI)(Sh_?tGMeA>PCwNIBK)4@LmP9#!7O$mK07;fKeKKAjoK_kKk)Vq z!Ypa|hgN9-(ThM$7l6oqn4$p?3qLd)5T@`Wi|&G;0uxo-@=*#0ltTST!a=-ypCv8- zIK@;DTwvmg=8scApcL-MDW=X6^hpWUneVS>TeBoB{ye2~AgI7Zm8kG}nq`zi{oHdCaa6RJpzwuva|Z9< zao};wPwrTI-13f-R(9_+CEsKEEGK8?l%}>zkoY2{r<9`U3qNai)A3OgF=Bn_esW5_ ze8#L8Bi4t~28j{tgGDAvVXhDSAZdfl^jVS=pB1`~oSL6W%OyyhRi{djI4f9Wq7>>` z!4BJJvE>UJgJtm`>;>xD352>aSdaih-53ZG+E>JgO`&X?1PNd*aV1J@3Kk`1#VD~U zSd{QZ^_-Ah7~&SPBBSY?U{Qh}my&1)OR+!{0(CM28PewlOR?mar{TPMq0ELFIG*8G zC`I_Zz)vM5N_d$K=LgHFKnMdh9|OixOUJwn{|kcUR3K!5niB$%y&%M#(1qDW_2Q6S zrELkb<*N4LU>DD`r7@QT+gyU^8K4dR1xr3Ch1wMerIHBD7Ib;&9=JV; z008FYb*8xC@?e*!ltx*meYXt4*)s314Bh8!pI=r3K)td~m05pf8o!i6y)wky&xJWf z^}8YazIkvClHUz>+BioXcy)3h2(3W%0ub4&lLLWhy;>X?!`~cf=rzIaCP8q4i7Otw zCfNB6rEsqab~iak9C)que*iJIfttnupHUt{ zieq9-Sa#G29mgzNapLl$JC?1D|FdMz-8)WPcEdHDYae~ zY(om9p6db$+%BDD@2cZ_IYJte!ocbMz z)|=DBnJc|>OPV-yb>@H*SNi>yG;tAC z>ZIi-ukBc~?#M}NS1(^aesaf-C#_n0M#o9Zmz}(((YPnb=b7?(H^p%8Nx@;BH1FQDW`f`XweNs%?+teH1%!I9 zOjF5@Ls7jyWWUg^tB8U3hf2&rDnYvL$7#s{Q3$N*nghngKh`xTAWZ24=5Rp@qlX0| z`+#{E2>OXNRS>eknD`5fr5g8uSo>gIub1fyPKssWd$6t;_diq@mgWGHtYpUz)n#$g zPlJmGNK_*Z29A@y5s2(h!^C(?48*@bGg*FK5j_e{!u+z4;!>E@?cu=h%~X@*YQT8F z36&o=0>{U@Tp+pH&+CdL8v|-;K_Rj~PpJhtP!S%%|EX3~1I)3E{hJg6t@lc7MFtN>H2t1MM08v;2YdQl)&l4$~iF>Oj zgPwXol_3C(#~cEYeKP1VhwPvDMmyOjyQv6uAEVo+Uj{wbMV2A(R4L2n-3Wt|und8x z>cR|xr%U0uQUH^z41uTXvJ8P|>ax;wV3L&~@JwBnA@JXUUyaHY88*qJ0#Av(3`F?9 zL#G@B21*$M&x)|h$`Al1Ss4P)s;mq1i|TVBd&6Sqd?cTfcqsYoeDVJCDW3&l!t;Tj zmx@{`Mfmw($5%ky^n6+c=Zi;wW8OzrQVpPn1Yj(+Pg8+Ve-rG|Zoc%*Z&LsSK?Ul4 z-+Vr=hx^;mLjxeiaDU4$Ow44^?@|<-AA=__RVzLByOiNj8ig&xnJ=;Pg}TKOJAoPi zDTMlhtSa$}5)AhRFAj0zZm%CyZXFZA^w1MPcc6ZgBR1D0toj--BU>aF$`X+b7d0;OjH>a zFV(5CiF>I|B{2N4PL-n_piqfF^W&#Zf2?z5+xEvgmq77yu;cFeao+~i91cjeFNbD5 zW&uKdIqjh4OW1g&PL+KdFi|Cnyi%)kW#jfroy*4U)nMoK^W(-1sE6)AxUYs`dR`BN z`f9NA`uTEs@mgJ0Hg3RX^SSKXUJE_VV#Yt**Xq@BJMntGZ+l@fFgaOnCteRtx|q+8 zw-c{x-@34%sJ&_s(#;MKi~ekJkWvc& zk_Ivei-0=XfU(r39uVqZ($rfZQ|~PcK`4?b0aX73(exJn*K;{As@@8PrZGTNy_M3K z1%lpAGZ6$An5>YQ_;#9!lty8ji3_CKzXtoj7z7olGjRc5Uc>#X?1yAZPz?94vR#vz zxIkv&yTN^IGZBpRyOJ--bXp)yc#m&mjVs3~z+MR=`(Eg+^AuS$zn2aM7RVy}eh9S~ zpV1^|tYD2pK)COR7M-X-xbFx13~zzV#J>gmf)4~0n5Z%n|5m5UO#EA&%1r!wo!U@? z3r<{Fvj1M^%1r!woy$!8N8oWB^_1Ky0(BR&AkM^pgg(iSG86w1I^x*?&0{8hP^ZeR zA`q(Nf0R<~2X(H@#1HCRX5xn-jJIm#2nDEl^MW`NKMehnQ8E)h4E&;B`-hqMQJRSh z;yEi&R~#U<);GWAz$v_M+ppI)G zgW|K$tbPGP{VeV7fK>8X+L`ZA)T;ju+5cHBJ0SXRemx#**+FXgA`N*EwE(piAhmpv zHm^XaUxdEe6d<*Hp$WAMi;C*{O7_i)eo(Lo$@LYhWKm6O*-)`f{GwP3P-_8F%Z7?= zk%4eGRIC;twQQ)wdFI06qPnq?oo%%&Msj1SWwBJUDOCcZDL|=&_yeYvO{o?j+)b$# zAhm2twJhOetCF2(wJbsMoQi#+zC>y{w_;~)AZh_>Q-D-*ZpESv5bC*=qNZ0~U}`zH zqWg%jlq=*)c9GSx6wwP(ElZ`A3sWs1Y5{63Kx(-#)dGZiVTI?4WXb@kNM`r4^@lEFfwDYArzKz@?RDy`=_1y|gk$zrO@h%cYgc^4+Zq zI~CP$SF$UtlAVzJc7^*Q8OJ+ONiAHS`UOZWK&=HxTP{!C0)%^c>J}iiTyEV$;HzF) z$!^zLDiXY|tW;v&SE))#{-xCRr^lWwG|^x~wc1K>b7&O_FtAT~~}BuBi*l3IR-tCF{PXE{l_{ zt>_*ZMUs61ZSj3I9t51&%9?lp=gXC7vJ!AaVP~itWnc z(_#YkHV268jTMdY$P%Z&Un-7)8EF0NcwvT8{eB%;0`pDcwYdKPVgxu@%p)^EO&1d!#;8K8c| zT;X{uzS+L5uBkv67?TEs3xsd0=xqX!Z?u0@?`Qca8yJ^DDMa>1l>z#8#Lh+a&PsN- z&HJ5^yfX!ton=ODuEc#W@fXOr1nWr-5aG?00h+e}nL3**_R-$XGRy9=aR^n?L7*mL zz*uT0*g&XvRpP~!tMJ&$J(cWd79(Ul-&3j7;%h6F9%9nH$wwecf$=LxDWqlhR^nHV z0Z8FiHNPTAp?VC6>{c_EQqX;Auz-*S>hT4T^Ea@aU-Xb9^ZwLtAcTSAV!r`Vb$?}| z9-{P+tn$ZoS&0$ABr7rE$8}kp^gzWvKIsu-1TZEvHB6Ks@j%7CKItJr;wNcPf=~?9 zGYTNGKdIQ2OAm<=4_54s1%xb62PF{M2P-4x7m(sLV#GsrBP2!uYtafRMfjn*5d?{! zR`iEZ6iSQ$>IeoR`_qd4@`*LR`tu6!IqO|hJoBkkVxm-OkY0TxO+OH7fEsFmsHr`Y zVoihe&7*1hffUB26HU^skLuDHfS_NbhJ%m=YQurC)EeF(z4}<1xgcbL8WI{fe?j=M zO1x|m#dPcAWog_T0F%Yit&i6Yrdyv#eclke6{sgzKxCh&*a=pH^y-r-8iFtasQZ6v zm2Q19h4TjK)?eDPOrdluFy?jwk^QAEykaoj`c&OQ@jp=4DhiQ(Dy>xw68fL6>y=&| z22P5lTc1Ypk=0eJSFT;TUDGQI?i+h}KCdN|4|q3zK3sD5EITtk-`(2Y)X{T_hM|@^U*xa){9VLh!>uEajR;G zXW)7fgOo;5Z&o`kA_RdFjbY<^bLD4c(0a4-2~D5zZoxNN#^%kplstxzZ{ZseD}&d< z?X^}1=tG9AMQlAmsX9iJ@53dRm5HS7J2QE*wQ$3{mNm$aOnt!_j_TyKx_(@7Vg$=3iH)530sJwrOFY)o-Hfa|~(kyv>o7BVi?~}}Ax9R)aL!7); zSNH+7lE+@t4=8zzML(eAvD4%+?h#fU@7lH66S0}>GeOBkj7r~H#P{~7oLwd#4(JQ9 zJY`q-HhYy30If}a4Wwo=LVa7CKFdfUyf%B5k%#RHKd^L)yoNfkbP9vj4=kO+?$Qq| zox%%vgZMp;>Sug{WDg}yVQ1+F^@|Rx34}LDUo!*oAl_hmmJtANu)Qn`f;YG|KGY|I z&<1a-&oaj6@!CsYa=PQ&OJ9=5`1aD5Y$$zu>B}0Am0%GUrB@JkdFtn;g|ioc5#HfQk7;qOsYwYQ9K~WsfKNoLE23A_f3; z<4he0!EqKbK&TsM5knRTUmI`cgW%Pl02lMeoB5Q%8*k>zO5!J!)rs{JI5`q~DS|d3 ze(V_$vSb^ zkXCQ6?=cEM-}Wr=q7Q`N_8s~zF9>zplaDBkqM5GK1m!vGnb8bV-FTDdXWGNOAb2yU zPK#!+n9s@Ps~HkX=h&Y*sE8SJ?e7f$&^MQiOUwWvICqTBX%On>^7jVB47oq@^YmR# z0rHacJab39P4e^XDO?b|dEB;$H&|U4W{cDeiOdVRU=@9`jrKc`qf5mAI%Nmzl(?t% zJ2dJ-07BgkTsw<8GKnSGPElP=9ys(%Z0(hd+b=cm0%+D!Hfy3irr&-kKXf;QzNO|} zNxuD#W(EK=b~H0$Chm7MGeFRGG&4w2zEsP;tj&@Y;7g2HX_lmjeis`w0L<9Myc_dH zzYE72Vg?9(yV#f^k=ZTVL)FR3u$#@4Mp3uBMK=KIcDD)8$Wa55yARXwMj`rkC%lQ9 z*yiq)9i;kX$=Hj#X)&)!^zCgk4?uJGwwVV)aBrJ=Ak^(`Gf!SH^!r$b0>F%Y99vy! zNPNH0@6%gDX?(uW?_()Slgzw*-8>VNWqV&6GZdk7-yt(o8P)AOXRcWZ*pJLp^-H|j z&pM?^@?F25JZ>*Xtw0fF|DdfS3)$big%{xMU-D!s?r&=WWzhDwwSZgJ!XLoRqE^TK z6G#&xil7}}f7$~C?Ew4J9yR``OW_Z+AqRkWV9ArQeqf^xIm+N2$dHr1;aK6|Y?=0r zEXxO5UpGtN9Ad2op!^UrYB36g;32J=LxE6tNcw9Q@*tx>)WTbQj}fHigQU#D8wlQ^ z7T%hrZw|A;1i*ENaa5+&iAN6OnA8wT4&y^2Lnt|n&{`9hH%r7kysTWh>hQ91>8iua z%B80cFDs|Iud-=TmC{pREostIU*!`ZD}(k`eQ%iB5Ilk}MWv_)!beBg^a8;dMd>6`ik&#|n0;28stRpViaua4E_KoClfWy7aN;;Z90LD9;@ zJI6`Dk`JBrDd^)kkW)$Avw|}nl@x!hP#+`6{>`soTd0CK?sy&X6v2|8sFlz@AJZa69#zXn6{1WqJXB}wv$rKH5f6O*KD+x&?LN?(x4t<28Uz7RjI zwT5EHpoI(Y?9v`Qy~AQY_9=?bFg6gN5MAA%x`sjVO+PvPuARRUH!`8k*TNJe<8 z-He2!jBjaGkiJ-B&rHXk-hh%u>5Da`M*3o{E}k`1N@uLKcY#4juGRIDGWud&sZ#o4 zU8z#~VqK|{zBsisOZwteT|dwveQ_#3MNlK@i_`37Y@SP!{nu$~3X;+nr?KX$Bz_Wa?wp#*RdW)GN1iyaYPPLpradg?j`*yt|-?!0sKI8AZ>xTT9ys~)I zt*%4s#{pPtWA7X5;05W%l&Q#x-5W8Ls@LtzOxn z`%bzkZ_Mj|Ypa+3!4>^mTfN4H1Nay$8hnM-Zc;_lz%zt3XuI;`TL~ z47!%`?@!5~YbpQ!ltkHA>*NJ?mh?>EF3(=RR209wy(=Y`CsH(uAS* z$8h508)kon2f)}J#y2InNcL1R(uVbDOpt1Z_3Ra^!I}}-s924(Yy_WUN;M?@qcZ#B zRMIu0_?Z(NAux|RH}*pXxp~zA2a_kmV5$e_87|}H1Qu} zxu+)nV=VV16Cal?)z->P9fx`H8{U|SPwYP8_I$&HiI)Co3O+GjiN=JSR!-!^WREBh zc~W=t9r<2UCfPEjsrMv1nbFjH5^2v0k(Z;AscyO-d`S1^d-4I@-@7BIbP_##4@4Wg!LJa}mcBc@pnCg0lmPQKXCB3WrW`S!yQv`&79Ye*z*^AluY z-;PV}=~^aB?RF(kys}-hrWHuS+m2jnU2HRgGrMoRGe2#@Ok3aMcX)p0&^YfgR+iYA z95!zk<>{2!-Agy;GiJ@UJc7@XP%_){i1=ZipUqQUYKi1Tv-vPVzJlia1-2U9cWuu1 zn>Dv&^GS}M%MGW>%i=S)h*4SOVb3Lm?jG61yZPPo@5=X_z}Gh>FJW&!zkpD-9L&sT z2|6Uo6ZRH%Z@nu&Y}UfEHhyL07jn(5@)8Rd_R*6mKJJ6PkaLy;Bb!*bsC(DD^RG-; zWTTgRY~&a9NF68VGK;w7ke_4)iHJ+OpS?Rjpthuxm%zBBN3u>1WR~#r44FJ^+;+-# z(@`tS#!fbQ;>K;~`c{p8-Pw%SZQRa$HYCRDHg4yEy0_GA+|DEuvzi)gAGb^QZCmm= z6Lv9Q>HcjO+dS+3Z5PImv{t?a^t*Pq+>?K0$*wk6;unE_*Z6puR3{^ISJE1>p8eh) z**FqsaTC`htdV5cw5f!o1+b=s@6-n3c zN17}K#f{zp-51`A`%{{(JG=wzxD$jy2QYb;MtR5wb@$nt?=;~ct5>&n2MtL(LW$T1 zk)F%1BISz3AKbluYkp|$;8I@Z<-rzYkU;)mU2KsD9Kv0KxE{bLI>dT8Ub*-~dgv$u zi6pl^5(?$Qg*;sc3;^1p=3WrB9V#bWs#E6Wp_=2tL-H`2kbsDKn4k=o!`SL6O{V8z zq@~g=Y*>%TPEc>k#5lsX;c>sZOfy-1zTYo1GaBN1Cw^J{v?6U-mkrRPZn@p?N7~sl z05guX(G;&b{E^lfAj~+@&YtC>gFm*Shq?f0N0l^Dd6aDzDC76O{wUio$fXCVxq1r# z@92^zE6maSs8L!?nMj(m=7+VZk>;^gJF-K7lLatpOwc1YIFdq1jDoP3P+ zX}lNl$Jjarf_IFqQ*tXp60czx0PWbaI++Z|GKN*91ixc#mn`=qr1ZM~1VB43(m3G( z!aFYh&N?D^$Jqu{?n+X+k6;6BU10$Mgq9T+J3%N}VTaowl&sLq4j!@ac>Vhu0#I^% zQo?0A4LY8;TGVKXiO09c?S+V<<#?XGlHtYq#Yx#2I=n>cBnF|3@_2r6vh_CrqfWMd z10i^_^|u~hoNV1Kf5XAAl#kh^;`lclfEw6AQfT`_5N52j{b8dFuT?fo093ciIsgQ1 z6~kUt%79vB!=zEbDW#P%x=xYWwIJ{QdFabeZ-9wiX z5VX}C9;s8PU`<(_jIK4Ml`@6b*c7fc#^JT5td8EWzx9G$GQ8H7)yeQ$+e1e>WmLD; zPDL7Jc&+2^SsO0HYh78LjIMR@$5YZ&Syb1tB&(GT{H?H4ZM|uX1M1Y0Cj;u#_^UPH z$$&alx2qrq)M<9~2B5mrN-Je-oYo`tfsCNj`1!F`$Etd|op&_KZxa3K+mt+6T~9Ba ODXZ)0MgMfK_x}LxgUx#Y diff --git a/clients/python/generate-proto.ps1 b/clients/python/generate-proto.ps1 index b336eee..a98eb29 100644 --- a/clients/python/generate-proto.ps1 +++ b/clients/python/generate-proto.ps1 @@ -1,15 +1,52 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# Pinned generator baseline. The committed Python bindings are stamped with +# GRPC_GENERATED_VERSION = 1.80.0 and "Protobuf Python Version: 6.31.1". A newer grpcio-tools +# stamps a higher GRPC_GENERATED_VERSION than the pinned grpcio runtime (pyproject.toml: +# grpcio>=1.80,<2), which makes _pb2_grpc import raise at runtime and breaks pytest. Regeneration +# MUST use this exact grpcio-tools version. See docs/ClientProtoGeneration.md and the repo memory +# project_python_client_regen_pin. +$PinnedGrpcioToolsVersion = '1.80.0' + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') $protoRoot = Join-Path $repoRoot 'src\ZB.MOM.WW.MxGateway.Contracts\Protos' $outputRoot = Join-Path $PSScriptRoot 'src\zb_mom_ww_mxgateway\generated' -$python = 'C:\Users\dohertj2\AppData\Local\Programs\Python\Python312\python.exe' -if (-not (Test-Path $python)) { - throw "Python was not found at $python. See docs/ToolchainLinks.md." +function Resolve-Python { + # Prefer python on PATH (python on Windows, python3 on Linux/macOS), then the documented + # Windows install location. Avoids the previous hard-coded per-machine path. + foreach ($name in @('python', 'python3', 'python.exe')) { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($null -ne $cmd) { + return $cmd.Source + } + } + + if ($env:LOCALAPPDATA) { + $documentedPath = Join-Path $env:LOCALAPPDATA 'Programs\Python\Python312\python.exe' + if (Test-Path $documentedPath) { + return $documentedPath + } + } + + throw 'Could not find python on PATH. See docs/ToolchainLinks.md.' } +function Assert-GrpcioToolsVersion { + param([string]$Python) + + $version = (& $Python -c 'import grpc_tools; from importlib.metadata import version; print(version("grpcio-tools"))').Trim() + if ($version -ne $PinnedGrpcioToolsVersion) { + throw "grpcio-tools $version is installed, but regeneration is pinned to $PinnedGrpcioToolsVersion. " + + "Install the pin (python -m pip install 'grpcio-tools==$PinnedGrpcioToolsVersion') before regenerating, " + + "or the generated bindings will stamp a GRPC_GENERATED_VERSION the pinned grpcio runtime rejects." + } +} + +$python = Resolve-Python +Assert-GrpcioToolsVersion -Python $python + New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null Get-ChildItem -Path (Join-Path $outputRoot '*_pb2.py') -File | Remove-Item Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item @@ -21,3 +58,7 @@ Get-ChildItem -Path (Join-Path $outputRoot '*_pb2_grpc.py') -File | Remove-Item mxaccess_gateway.proto ` mxaccess_worker.proto ` galaxy_repository.proto + +if ($LASTEXITCODE -ne 0) { + throw "grpc_tools.protoc failed with exit code $LASTEXITCODE." +} diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml index bd09fbb..2b0e748 100644 --- a/clients/python/pyproject.toml +++ b/clients/python/pyproject.toml @@ -38,6 +38,9 @@ Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues" [project.optional-dependencies] dev = [ + # Runtime range for local dev, but REGENERATION is pinned to grpcio-tools==1.80.0 + # (protobuf 6.31.1): a newer version stamps a GRPC_GENERATED_VERSION the pinned grpcio + # runtime rejects and breaks pytest. generate-proto.ps1 asserts the exact pin. "grpcio-tools>=1.80,<2", "pytest>=9,<10", "pytest-asyncio>=1.3,<2", diff --git a/docs/ClientProtoGeneration.md b/docs/ClientProtoGeneration.md index 306209d..597869b 100644 --- a/docs/ClientProtoGeneration.md +++ b/docs/ClientProtoGeneration.md @@ -48,25 +48,55 @@ session. ## Descriptor Publishing Run this command after changing either source `.proto` file or the client proto -manifest: +manifest, with the **pinned protoc 34.1** (see [Toolchain Links](./ToolchainLinks.md)): ```powershell -scripts/publish-client-proto-inputs.ps1 +pwsh -File scripts/publish-client-proto-inputs.ps1 ``` The script writes `clients/proto/descriptors/mxaccessgw-client-v1.protoset` with imports and source information included. The descriptor is a generated artifact; do not edit -it by hand. +it by hand. Generating the committed artifact requires the pinned protoc version +so the checked-in bytes are reproducible; the script fails fast on a version +mismatch when generating. Use the check mode in CI or before committing: ```powershell -scripts/publish-client-proto-inputs.ps1 -Check +pwsh -File scripts/publish-client-proto-inputs.ps1 -Check ``` -`-Check` rebuilds the descriptor in a temporary path and fails when the checked -in descriptor is stale. +`-Check` rebuilds the descriptor and fails when the checked-in descriptor is +stale. The comparison normalizes both the committed and freshly built descriptor +through the same protoc with `source_code_info` stripped, so it is tolerant of +protoc-version encoding drift and does not false-fail across protoc releases +(it warns, rather than fails, when protoc is off the pin). + +The gateway test project carries an independent, protoc-free freshness guard: +`ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField` reflects +over the in-process contract descriptors and fails if any contract message or +field is missing from the committed protoset. This is the primary CI gate for +descriptor staleness; a red test means "regenerate and commit the protoset." + +### Pinned generator versions + +Regeneration is reproducible only with the pinned toolchain. Regenerating with a +different version silently produces incompatible or noisy output, so the per-client +scripts assert the pin and resolve tools from `PATH`: + +| Generator | Pinned version | Guard | +|-----------|----------------|-------| +| protoc (descriptor set) | 34.1 | version assertion in `scripts/publish-client-proto-inputs.ps1` | +| `Grpc.Tools` (C# `Generated/`) | 2.80.0 (contracts csproj) | `scripts/check-codegen.ps1` git-diff of `Generated/` | +| `grpcio-tools` (Python) | 1.80.0 (protobuf runtime 6.31.1) | version assertion in `clients/python/generate-proto.ps1` | +| protobuf / grpc-java (Java) | `protobufVersion` / `grpcVersion` in `clients/java/build.gradle` | `checkGeneratedClean` gradle task | + +A newer `grpcio-tools` stamps a `GRPC_GENERATED_VERSION` above the pinned grpcio +runtime and breaks Python `pytest`; the Java protobuf plugin rewrites +`MxaccessGateway.java` with spurious protobuf-runtime-version churn on every build +(revert that one file when no `.proto` changed — see +[Gateway Testing](./GatewayTesting.md) "Continuous Integration"). ## Output Directories diff --git a/docs/Contracts.md b/docs/Contracts.md index 1690ab7..f0ee3d2 100644 --- a/docs/Contracts.md +++ b/docs/Contracts.md @@ -97,6 +97,24 @@ behavior. Generated C# output is written to `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`. Do not hand-edit generated files. +The `Generated/` C# is **tracked and must be committed after any `.proto` change.** The +contracts project `Compile Remove`s `Generated/**/*.cs` and has `Grpc.Tools` regenerate them, +but `Grpc.Tools` skips regeneration when the committed output looks up to date. On the net10 +build the freshly regenerated code is compiled, so a stale `Generated/` is invisible there — +but the **net48 worker** consumes the committed `Generated/` and fails to build with `CS0246` +on any new type when the checked-in code lags the `.proto`. So after editing a `.proto` you must +regenerate and commit `Generated/`. If a build does not pick up your proto change, delete the +stale output to force a full regeneration: + +```bash +rm src/ZB.MOM.WW.MxGateway.Contracts/Generated/*.cs +dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj +``` + +`scripts/check-codegen.ps1` enforces this in CI (it force-regenerates and fails on any +`git diff` against the committed `Generated/`); the Windows CI job's net48 worker build is the +definitive guard. + Client generation inputs are published through `clients/proto/proto-inputs.json` and the descriptor set under `clients/proto/descriptors/`. See @@ -124,12 +142,25 @@ gateway and test projects: dotnet build src/ZB.MOM.WW.MxGateway.slnx ``` -Regenerate the client descriptor after changing either `.proto` file: +Regenerate the client descriptor after changing either `.proto` file, using the pinned protoc +(34.1, see [Toolchain Links](./ToolchainLinks.md)): ```bash -powershell -ExecutionPolicy Bypass -File scripts/publish-client-proto-inputs.ps1 +pwsh -File scripts/publish-client-proto-inputs.ps1 ``` +Freshness is guarded two ways so a skipped regeneration cannot ship silently: + +- `ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField` (gateway test project) + reflects over the in-process contract descriptors and fails if any message or field is missing + from the committed protoset. It is semantic (symbol presence), needs no protoc, and runs in the + Linux CI. A red test means "regenerate and commit the protoset." +- `pwsh -File scripts/publish-client-proto-inputs.ps1 -Check` rebuilds the descriptor and compares + it to the committed one. The comparison normalizes both sides through the same protoc with + `source_code_info` stripped, so it does not false-fail across protoc releases. +- `pwsh scripts/check-codegen.ps1` runs both the descriptor check and the `Generated/`-clean check + together; it is the single guard the CI pipeline invokes. + ## Related Documentation - [Client Proto Generation](./ClientProtoGeneration.md) diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index bdb04ba..6743272 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -408,6 +408,34 @@ Run the gateway test project after shared gateway test infrastructure changes: dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj ``` +## Continuous Integration + +CI runs on Gitea Actions (`.gitea/workflows/ci.yml`; origin is Gitea at +`gitea.dohertylan.com`) on every push and pull request. The pipeline is split by +runtime because the x86 Worker cannot build on Linux: + +- **`portable`** (Linux runner) — builds `src/ZB.MOM.WW.MxGateway.NonWindows.slnx`, + runs the codegen/descriptor freshness guard (`scripts/check-codegen.ps1`), runs the + gateway fake-worker tests, and builds/tests the clients that run on Linux: .NET client + build, Go (`gofmt` + `go build` + `go test`), Rust (`cargo fmt --check` + `cargo test` + + `cargo clippy -D warnings`), and Python (`pytest`). +- **`java`** (Linux, JDK 17) — `gradle test`. The protobuf gradle plugin rewrites + `MxaccessGateway.java` with spurious protobuf-runtime-version churn on every build, so + when no `.proto` changed the job reverts that one file (`git checkout`) before asserting + the generated tree is clean. The dev Mac has no JRE, so Java verification is CI-only. +- **`windows`** (self-hosted windev runner) — the only host that builds the **x86 / + net48 Worker and Worker.Tests**; these are Windows-only and out of scope for the Linux + jobs. This job also proves the net48 build against the committed `Generated/` (the + definitive guard for the "regenerate and commit `Generated/`" rule). +- **`live-mxaccess`** (self-hosted, MXAccess-installed) — scheduled only + (`MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`); never gates a push. + +The freshness guard `scripts/check-codegen.ps1` fails the build when the committed +client descriptor set or the C# `Generated/` no longer matches the current `.proto` +sources — the codegen drift class this repo has hit repeatedly (stale client +descriptors, net48 `CS0246` on unregenerated protos). See +[Client Proto Generation](./ClientProtoGeneration.md) and [Contracts](./Contracts.md). + ## Related Documentation - [Cross-Language Smoke Matrix](./CrossLanguageSmokeMatrix.md) diff --git a/scripts/check-codegen.ps1 b/scripts/check-codegen.ps1 new file mode 100644 index 0000000..62fe57f --- /dev/null +++ b/scripts/check-codegen.ps1 @@ -0,0 +1,96 @@ +#!/usr/bin/env pwsh +# Codegen freshness guard for CI (IPC-01, IPC-19, IPC-20, CLI-02). +# +# Three checks, all Linux/macOS-runnable (no Server build, no x86 worker): +# 1. Published client descriptor set matches the current .proto sources (delegates to +# publish-client-proto-inputs.ps1 -Check, which normalizes source_code_info so it is +# protoc-version tolerant). +# 2. The committed C# under Contracts/Generated matches a fresh regeneration. Grpc.Tools is +# pinned in the contracts csproj, so a clean checkout regenerates byte-identical output; a +# non-empty git diff means a .proto was edited without regenerating and committing Generated/ +# (which breaks the net48 worker build with CS0246 — see docs/Contracts.md). +# 3. The Rust crate's vendored protos (clients/rust/protos/*.proto — build inputs that make the +# crate buildable outside the repo, CLI-02) are byte-identical to the canonical Contracts +# protos. A drift means a .proto was edited without refreshing the vendored copies, which would +# publish a stale wire contract to crate consumers while the in-repo build stays correct. +# +# The x86 Worker + Worker.Tests are Windows-only and are guarded by the Windows CI job, not here. + +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$generatedDir = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/Generated' +$contractsProject = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj' +$failures = New-Object System.Collections.Generic.List[string] + +Write-Host '== Check 1/2: client descriptor set freshness ==' +try { + & (Join-Path $PSScriptRoot 'publish-client-proto-inputs.ps1') -Check + if ($LASTEXITCODE -ne 0) { + $failures.Add('Client descriptor set is stale (publish-client-proto-inputs.ps1 -Check failed).') + } +} +catch { + $failures.Add("Descriptor freshness check failed: $($_.Exception.Message)") +} + +Write-Host '' +Write-Host '== Check 2/2: Contracts/Generated matches a fresh regeneration ==' +try { + # Force a full regeneration: Grpc.Tools skips regen when the committed .cs look up to date, so + # remove them first (the documented "del Generated/*.cs to force regen" trick). + if (Test-Path $generatedDir) { + Get-ChildItem -Path $generatedDir -Filter '*.cs' -Recurse -File | Remove-Item -Force + } + + & dotnet build $contractsProject -c Release --nologo | Out-Host + if ($LASTEXITCODE -ne 0) { + $failures.Add('Contracts project failed to build during codegen check.') + } + + $diff = (& git -C $repoRoot status --porcelain -- 'src/ZB.MOM.WW.MxGateway.Contracts/Generated').Trim() + if (-not [string]::IsNullOrEmpty($diff)) { + Write-Host $diff + $failures.Add('Contracts/Generated differs from a fresh regeneration. Run `dotnet build` on the contracts project and commit Generated/ (required for the net48 worker build).') + } +} +catch { + $failures.Add("Generated codegen check failed: $($_.Exception.Message)") +} + +Write-Host '' +Write-Host '== Check 3/3: Rust vendored protos match canonical Contracts protos ==' +try { + $canonicalProtoDir = Join-Path $repoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/Protos' + $vendoredProtoDir = Join-Path $repoRoot 'clients/rust/protos' + foreach ($vendored in Get-ChildItem -Path $vendoredProtoDir -Filter '*.proto' -File) { + $canonical = Join-Path $canonicalProtoDir $vendored.Name + if (-not (Test-Path $canonical)) { + $failures.Add("Vendored Rust proto has no canonical counterpart: $($vendored.Name).") + continue + } + $vendoredHash = (Get-FileHash -Algorithm SHA256 $vendored.FullName).Hash + $canonicalHash = (Get-FileHash -Algorithm SHA256 $canonical).Hash + if ($vendoredHash -ne $canonicalHash) { + $failures.Add("Rust vendored proto drifted from canonical: clients/rust/protos/$($vendored.Name). Refresh it from src/ZB.MOM.WW.MxGateway.Contracts/Protos/$($vendored.Name).") + } + } +} +catch { + $failures.Add("Rust vendored proto check failed: $($_.Exception.Message)") +} + +Write-Host '' +if ($failures.Count -gt 0) { + Write-Host 'Codegen freshness check FAILED:' -ForegroundColor Red + foreach ($failure in $failures) { + Write-Host " - $failure" -ForegroundColor Red + } + exit 1 +} + +Write-Host 'Codegen freshness check passed.' -ForegroundColor Green diff --git a/scripts/publish-client-proto-inputs.ps1 b/scripts/publish-client-proto-inputs.ps1 index 737ad7c..29deb5c 100644 --- a/scripts/publish-client-proto-inputs.ps1 +++ b/scripts/publish-client-proto-inputs.ps1 @@ -6,23 +6,46 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +# Pinned protoc toolchain version used to generate the committed client descriptor set. +# See docs/ToolchainLinks.md. Generating (committing) the canonical artifact must use this +# exact version so the checked-in bytes are reproducible. The -Check comparison is made +# tolerant of protoc-version encoding drift (chiefly source_code_info, see IPC-20) by +# normalizing both the committed and the freshly built descriptor through the *same* protoc +# with source info stripped, so it does not false-fail across protoc releases. +$PinnedProtocVersion = "34.1" + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $protoRoot = Join-Path $repoRoot "src/ZB.MOM.WW.MxGateway.Contracts/Protos" $manifestPath = Join-Path $repoRoot "clients/proto/proto-inputs.json" $descriptorPath = Join-Path $repoRoot "clients/proto/descriptors/mxaccessgw-client-v1.protoset" +$protoFiles = @("mxaccess_gateway.proto", "mxaccess_worker.proto", "galaxy_repository.proto") function Resolve-Protoc { - $pathCommand = Get-Command "protoc.exe" -ErrorAction SilentlyContinue - if ($null -ne $pathCommand) { - return $pathCommand.Source + # Prefer protoc on PATH (protoc on Linux/macOS, protoc.exe on Windows), then fall back to + # the documented winget install location on Windows. + foreach ($name in @("protoc", "protoc.exe")) { + $pathCommand = Get-Command $name -ErrorAction SilentlyContinue + if ($null -ne $pathCommand) { + return $pathCommand.Source + } } - $documentedPath = Join-Path $env:LOCALAPPDATA "Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe" - if (Test-Path $documentedPath) { - return $documentedPath + if ($env:LOCALAPPDATA) { + $documentedPath = Join-Path $env:LOCALAPPDATA "Microsoft/WinGet/Packages/Google.Protobuf_Microsoft.Winget.Source_8wekyb3d8bbwe/bin/protoc.exe" + if (Test-Path $documentedPath) { + return $documentedPath + } } - throw "Could not find protoc.exe. See docs/ToolchainLinks.md for the documented protobuf toolchain path." + throw "Could not find protoc on PATH. See docs/ToolchainLinks.md for the documented protobuf toolchain (protoc $PinnedProtocVersion)." +} + +function Get-ProtocVersion { + param([string]$Protoc) + + # `protoc --version` prints e.g. "libprotoc 34.1". + $raw = (& $Protoc --version) | Select-Object -First 1 + return ($raw -replace '^libprotoc\s+', '').Trim() } function Ensure-Directory { @@ -33,7 +56,29 @@ function Ensure-Directory { } } -function Compare-FileBytes { +function New-SourceInfoFreeDescriptor { + # Re-emits an existing descriptor set with source_code_info stripped, by round-tripping it + # through protoc with --descriptor_set_in and *without* --include_source_info. Because both + # the committed and freshly built descriptors are normalized through the same protoc binary, + # any protoc-version-specific encoding cancels out and the byte comparison is stable. + param( + [string]$Protoc, + [string]$InputDescriptor, + [string]$OutputDescriptor + ) + + & $Protoc ` + "--descriptor_set_in=$InputDescriptor" ` + "--include_imports" ` + "--descriptor_set_out=$OutputDescriptor" ` + @protoFiles + + if ($LASTEXITCODE -ne 0) { + throw "protoc normalization (--descriptor_set_in) failed with exit code $LASTEXITCODE." + } +} + +function Test-FileBytesEqual { param( [string]$ExpectedPath, [string]$ActualPath @@ -66,31 +111,67 @@ foreach ($output in $manifest.generatedOutputs.PSObject.Properties.Value) { } $protoc = Resolve-Protoc -$outputPath = $descriptorPath -if ($Check) { - $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-" + [System.Guid]::NewGuid().ToString("N") + ".protoset") -} +$protocVersion = Get-ProtocVersion $protoc + +if ($Check) { + # Version drift no longer produces a false "stale" failure: the comparison is normalized + # below. A mismatch is surfaced as a warning so it is visible without blocking the gate. + if ($protocVersion -ne $PinnedProtocVersion) { + Write-Warning "protoc version '$protocVersion' differs from the pinned '$PinnedProtocVersion'. The freshness comparison is normalized and tolerant of this, but regeneration must use the pinned version (see docs/ClientProtoGeneration.md)." + } + + $freshDescriptor = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-fresh-" + [System.Guid]::NewGuid().ToString("N") + ".protoset") + $committedNormalized = Join-Path ([System.IO.Path]::GetTempPath()) ("mxaccessgw-client-v1-committed-" + [System.Guid]::NewGuid().ToString("N") + ".protoset") + + try { + # Fresh descriptor from the current .proto sources, source info omitted (already normalized). + & $protoc ` + "--proto_path=$protoRoot" ` + "--include_imports" ` + "--descriptor_set_out=$freshDescriptor" ` + @protoFiles + + if ($LASTEXITCODE -ne 0) { + throw "protoc failed with exit code $LASTEXITCODE." + } + + if (-not (Test-Path $descriptorPath)) { + throw "Committed descriptor '$descriptorPath' does not exist. Run scripts/publish-client-proto-inputs.ps1 to create it." + } + + # Normalize the committed descriptor (which carries source info) through the same protoc. + New-SourceInfoFreeDescriptor -Protoc $protoc -InputDescriptor $descriptorPath -OutputDescriptor $committedNormalized + + if (-not (Test-FileBytesEqual -ExpectedPath $committedNormalized -ActualPath $freshDescriptor)) { + throw "Client proto descriptor is stale. Run scripts/publish-client-proto-inputs.ps1 with the pinned protoc ($PinnedProtocVersion) and commit the updated descriptor." + } + + Write-Host "Client proto descriptor is up to date." + } + finally { + foreach ($temp in @($freshDescriptor, $committedNormalized)) { + if (Test-Path $temp) { + Remove-Item -LiteralPath $temp + } + } + } +} +else { + # Generating the canonical committed artifact must use the pinned protoc so the bytes are reproducible. + if ($protocVersion -ne $PinnedProtocVersion) { + throw "protoc version '$protocVersion' does not match the pinned toolchain version '$PinnedProtocVersion'. Install the pinned protoc (see docs/ToolchainLinks.md) before regenerating the committed descriptor, or run with -Check to only verify freshness." + } -try { & $protoc ` "--proto_path=$protoRoot" ` "--include_imports" ` "--include_source_info" ` - "--descriptor_set_out=$outputPath" ` - "mxaccess_gateway.proto" ` - "mxaccess_worker.proto" ` - "galaxy_repository.proto" + "--descriptor_set_out=$descriptorPath" ` + @protoFiles if ($LASTEXITCODE -ne 0) { throw "protoc failed with exit code $LASTEXITCODE." } - if ($Check -and -not (Compare-FileBytes -ExpectedPath $descriptorPath -ActualPath $outputPath)) { - throw "Client proto descriptor is stale. Run scripts/publish-client-proto-inputs.ps1 and commit the updated descriptor." - } -} -finally { - if ($Check -and (Test-Path $outputPath)) { - Remove-Item -LiteralPath $outputPath - } + Write-Host "Wrote client proto descriptor to $descriptorPath (protoc $protocVersion)." } diff --git a/src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj b/src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj index b23971b..8cfb3fe 100644 --- a/src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj +++ b/src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj @@ -23,6 +23,13 @@ + diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs index 4848871..981b54f 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Contracts/ClientProtoInputTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Google.Protobuf; +using Google.Protobuf.Reflection; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts.Proto; @@ -7,6 +8,102 @@ namespace ZB.MOM.WW.MxGateway.Tests.Contracts; public sealed class ClientProtoInputTests { + ///

+ /// Guards the published client descriptor set against silent staleness (IPC-01). Every message + /// and field compiled into the in-process contract (which the build regenerates from the current + /// .proto sources) must appear in the committed protoset. A missing symbol means the + /// descriptor was not regenerated after a proto change; run + /// scripts/publish-client-proto-inputs.ps1 and commit the refreshed protoset. + /// The check is semantic (symbol presence) rather than byte-wise, so it is independent of protoc + /// version and does not require protoc on the test runner. + /// + [Fact] + public void Descriptor_ContainsEveryContractMessageAndField() + { + DirectoryInfo repositoryRoot = FindRepositoryRoot(); + string descriptorPath = Path.Combine( + repositoryRoot.FullName, + "clients", + "proto", + "descriptors", + "mxaccessgw-client-v1.protoset"); + + Assert.True(File.Exists(descriptorPath), $"Expected descriptor set '{descriptorPath}' to exist."); + + FileDescriptorSet descriptorSet = FileDescriptorSet.Parser.ParseFrom(File.ReadAllBytes(descriptorPath)); + + HashSet publishedMessages = new(StringComparer.Ordinal); + HashSet publishedFields = new(StringComparer.Ordinal); + foreach (FileDescriptorProto file in descriptorSet.File) + { + foreach (DescriptorProto message in file.MessageType) + { + CollectPublishedSymbols(file.Package, message, publishedMessages, publishedFields); + } + } + + List missing = []; + foreach (FileDescriptor file in new[] { MxaccessGatewayReflection.Descriptor, MxaccessWorkerReflection.Descriptor }) + { + foreach (MessageDescriptor message in file.MessageTypes) + { + CollectMissingContractSymbols(message, publishedMessages, publishedFields, missing); + } + } + + Assert.True( + missing.Count == 0, + "Published client descriptor is stale; regenerate with scripts/publish-client-proto-inputs.ps1 and commit. " + + "Missing symbols: " + + string.Join(", ", missing)); + } + + private static void CollectPublishedSymbols( + string package, + DescriptorProto message, + HashSet messages, + HashSet fields) + { + string fullName = string.IsNullOrEmpty(package) ? message.Name : package + "." + message.Name; + messages.Add(fullName); + + foreach (FieldDescriptorProto field in message.Field) + { + fields.Add(fullName + "/" + field.Name); + } + + foreach (DescriptorProto nested in message.NestedType) + { + CollectPublishedSymbols(fullName, nested, messages, fields); + } + } + + private static void CollectMissingContractSymbols( + MessageDescriptor message, + HashSet publishedMessages, + HashSet publishedFields, + List missing) + { + if (!publishedMessages.Contains(message.FullName)) + { + missing.Add(message.FullName); + } + + foreach (FieldDescriptor field in message.Fields.InDeclarationOrder()) + { + string key = message.FullName + "/" + field.Name; + if (!publishedFields.Contains(key)) + { + missing.Add(key); + } + } + + foreach (MessageDescriptor nested in message.NestedTypes) + { + CollectMissingContractSymbols(nested, publishedMessages, publishedFields, missing); + } + } + /// Verifies that the proto inputs manifest declares current protocol versions and existing source files. [Fact] public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs()