diff --git a/.github/workflows/pitot-lab.yml b/.github/workflows/pitot-lab.yml index 0523372da..4179648c1 100644 --- a/.github/workflows/pitot-lab.yml +++ b/.github/workflows/pitot-lab.yml @@ -113,3 +113,37 @@ jobs: run: python labs/15-pitot/scripts/pitot_adapter_supervisor.py check - name: Verify projection and public surface run: python -m unittest discover -s labs/15-pitot/tests -p 'test_*.py' + + sdk-conformance: + name: SDK cross-language conformance + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + # The shared conformance suite drives Python, TypeScript, Go, and Rust + # runners through the SAME fixture cases so their wire behavior stays + # identical. It lives under pitot-distribution/sdk/tests/ (beside the SDKs + # it exercises), OUTSIDE the labs/15-pitot/tests/ discovery root — so until + # this job existed the parity guarantee was written but never enforced. Each + # language skips cleanly when its toolchain is absent; here we install all + # four so nothing skips silently in CI. + - uses: ./.github/actions/go-setup + with: + go-version-file: labs/15-pitot/pitot/go.mod + cache-dependency-path: labs/15-pitot/pitot/go.sum + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install tsx (TypeScript runner) + run: npm install -g tsx + - uses: dtolnay/rust-toolchain@stable + - name: Rust SDK builds and tests in isolation + run: cargo test --manifest-path labs/15-pitot/pitot-distribution/sdk/rust/Cargo.toml + - name: Cross-language runner conformance (Python, TypeScript, Go, Rust) + env: + GOWORK: "off" + run: >- + python -m unittest discover + -s labs/15-pitot/pitot-distribution/sdk/tests -p 'test_*.py' -v diff --git a/labs/15-pitot/pitot-distribution/UPSTREAM.json b/labs/15-pitot/pitot-distribution/UPSTREAM.json index 23de2e092..0d5e45f81 100644 --- a/labs/15-pitot/pitot-distribution/UPSTREAM.json +++ b/labs/15-pitot/pitot-distribution/UPSTREAM.json @@ -82,7 +82,7 @@ "runtime/descriptor_unix.go": "df41b6867e9840933f186c93b6bc61861e7ea5252a365455686ea0414bfa0044", "runtime/descriptor_windows.go": "2d9ffefe3af0154fa8042de6b67460d4e86dd3f4cdd9e986f180f7d0c535c9a5", "runtime/request.go": "198c44fd6c547022a15b6d0d48e4d0130fa8afb687994365115576e4d874550d", - "runtime/request_test.go": "86d8a2feb4ec72e8ed675b9567da2d1f5d628950eeec907c10b9cc1675aa1904", + "runtime/request_test.go": "7e1cafab3de6432af4ab60999c83e8e81c1a7e7c985e256e56cec2f24ce154cb", "runtime/runtime.go": "36fdf4643c04d058fef380333a0a3942913d1dd60e910b893dc134da4b65d6bf", "runtime/runtime_test.go": "afd78d122af20148bf30d0db873ff002544189df0dfec0f6167b8cf5cd0d42b1", "runtime/strict_test.go": "d2eb2b78563733e3279289d8b6d17a4276e831ed3c78f8b3ec9d6706992ffaec", @@ -91,6 +91,8 @@ "runtime/transport_test.go": "9b69f590f1e258470adea249b3ac6d4a00f1001f10bb08dfa7b56c6e2d6709ae", "schema/schema.go": "fd5c3b76979c88aeed75fadb7e3c94abcad62e067d77595f021fb422a60f2211", "sdk/csharp/Pitot/Types.cs": "3fecc1eabca6e3ad218c73741d185f815d868a618d1e7ed3b9320eeb6942977d", + "sdk/csharp/README.md": "dce102e6176e1a0ee8858fcd784bd9420a9603fb7f2079445e88f4b1e78df45d", + "sdk/java/README.md": "a5c3a6b350098473d7513126db2accd5b790128f6dcf5454d0547cc8284621ce", "sdk/java/src/main/java/com/operatorstack/pitot/Action.java": "2d7974afc2f687921c5e8009077f7b812a8973cdb174de5020cb73f41e3c6570", "sdk/java/src/main/java/com/operatorstack/pitot/BoundaryFault.java": "926c93d9a77b8ef1d94f4674e73e1b8c28aa19082e8f0261077be95ea975abb8", "sdk/java/src/main/java/com/operatorstack/pitot/Content.java": "a430934623b0edfdc3a96f8b17da3deb1502b29e534c18fb580b4cd672b60f71", @@ -102,16 +104,20 @@ "sdk/java/src/main/java/com/operatorstack/pitot/Observation.java": "179982beed89b31cc0a8218c46d8f1bfe6d9c7b1ecb7da82fbc26924d5051df7", "sdk/java/src/main/java/com/operatorstack/pitot/Types.java": "26f8722e6b61faaa093e5a8b92388d3f93a2228026dc04b4b68d10340874a135", "sdk/java/src/main/java/com/operatorstack/pitot/Usage.java": "bbddf4cb0fb7550ac14817600d9f233e68bfd84b72f73d5fbbf9a6cce6e3253d", + "sdk/python/README.md": "c49c15c48bbc064a4596815a94710c9f5270b38216c6b796cbd6fb38cf90fa34", "sdk/python/pitot/__init__.py": "9cab11b333536f167d4e7bb6089ef00488690ec7b5cd5701f08e8bbb13cef0f7", "sdk/python/pitot/runner.py": "8d4537ffc3aee2ea22b1d691559ac1eb1e95521df9ad5bf241ee6ac54c9925b1", "sdk/python/pitot/types.py": "c9a7221f1ad6627f152f26148155d3c74f249c52262c73a515251f469e8eb4ad", "sdk/python/pyproject.toml": "b7990a7a4d2a3db874a5f8c6f9e09673bf928936b64cf0bd4d4a1727aae3305d", "sdk/runner.go": "e9a8db96d3cf6df7ea7e661e6755650f97e580970995dfe881b4268db0c3f832", + "sdk/rust/.gitignore": "6c135f35993002b30f7bdae3d88e923bbf33e4a25faa35dde452f6ab6fd4a6b2", "sdk/rust/Cargo.toml": "1c42ed7ff351ad54ab87c29bbbef96a7bd574b4109ca12eb84d3f8daf42dcc77", + "sdk/rust/README.md": "a548821ca40287e4d17ffb12cd844f68ffc5d0f4358d3459f7bdafc8a55a0b2e", "sdk/rust/src/lib.rs": "fd2dcb9bf9df47fb58e52e4e94136f95867616d941ba66b4b324413d1c5b1777", "sdk/rust/src/runner.rs": "168eb3f926aed4b86aa27ad66ab605326974b6a345b07a35126d4b66e0968b33", "sdk/rust/src/types.rs": "82cd5a5ec12effa6688e8b9b6028bbef67ec43f24d9fd9d19b4235934061ffb8", - "sdk/tests/test_runners.py": "9e86a03b53b108b70204e797b4c65b3d157c6765543a9bb9d7bb892daaf4d8c4", + "sdk/tests/test_runners.py": "28af8d9c235afe9c61e1d6e5e9b70ffe7054e36ec04310daaba4fd7488bda0e0", + "sdk/typescript/README.md": "5fc46f917ef30e944e1b20efae04d0a53ccc60fb868d143e44b04bcad684a51c", "sdk/typescript/package.json": "747b5bab8df073d39b9ef92f28b6b7b43eb00bc127c0647cda4e4003b3db7295", "sdk/typescript/src/index.ts": "bddb336bc120ecd979de0288e432ed2b41749d39f11c697f019af5e54c2e9fba", "sdk/typescript/src/pitot.ts": "9c243824cbb7edc54b1e125abfd828bf2ed77e7151a0bd5c5d4f63e81ca9b00c", diff --git a/labs/15-pitot/pitot-distribution/release-notes/2026-07-31-sdk-readiness-for-external-builders.md b/labs/15-pitot/pitot-distribution/release-notes/2026-07-31-sdk-readiness-for-external-builders.md new file mode 100644 index 000000000..d0ce9ccfe --- /dev/null +++ b/labs/15-pitot/pitot-distribution/release-notes/2026-07-31-sdk-readiness-for-external-builders.md @@ -0,0 +1,32 @@ +### The SDKs are ready for outside builders to start with + +Building on Pitot means writing a Controller or Consumer in one of the language +bindings, so the bindings had to become something an outsider can pick up and +trust — not just code that happens to compile. This release closes the gaps +between "the SDK exists" and "the SDK is safe to build on". + +The cross-language conformance suite is now CI-enforced. It drove Python, +TypeScript, Go, and Rust runners through the same fixture cases (controller +stream order, malformed-line skip, deny, consumer stdout-cleanliness) but ran +outside the discovered test path, so nothing gated on it. It is now a dedicated +`sdk-conformance` job in the Pitot workflow, and the Rust SDK is built and +tested in isolation there for the first time. Enabling it immediately caught a +real defect the gap had hidden: the Rust consumer fixture referenced a struct +field (`type_field`) that does not exist — the wire field is `event_type` +(`#[serde(rename = "type")]`). An unenforced guarantee is a silent-degradation +risk; this one is now enforced. + +Java and C# are honestly labeled. They are generated types only — no runner, no +build manifest, no conformance entry — and each directory now carries a README +saying so and pointing builders at the four first-class SDKs (Python, +TypeScript, Go, Rust). A new lockstep-gate test fails the moment either preview +gains a build manifest without also being version-locked and conformance-tested, +so a preview can never quietly start shipping. + +The version-lockstep gate now documents and covers the Go binding (its version is +the scaffold constant `pitotPackageVersion`, the one Go-side string that can +drift). Each shipping SDK gained a concise quickstart README — build your first +Controller and Consumer, install through the CLI front door, run it behind an +agent. Build-artifact churn is closed too: the projection builder skips Rust +`target/` and `Cargo.lock`, with a matching `.gitignore`, so a local `cargo +build` can never perturb the projected public surface. diff --git a/labs/15-pitot/pitot-distribution/sdk/csharp/README.md b/labs/15-pitot/pitot-distribution/sdk/csharp/README.md new file mode 100644 index 000000000..bfd0e8d07 --- /dev/null +++ b/labs/15-pitot/pitot-distribution/sdk/csharp/README.md @@ -0,0 +1,21 @@ +# Pitot C# SDK — preview (not yet runnable) + +**Status: types-only preview. Not a shipping SDK.** `Pitot/Types.cs` holds the +generated C# data types for the Pitot wire protocol, emitted by +[`scripts/generate_types.sh`](../../../scripts/generate_types.sh) from the same +schema as every other binding. They let you *read the shapes* — they do **not** yet +give you a runnable Controller or Consumer. + +What is **not** here yet, and what a first-class SDK requires: + +- no build manifest (`.csproj` / solution), so the package does not build; +- no `runner` (the stdio JSON-Lines Controller/Consumer loop the other SDKs ship); +- no entry in the cross-language conformance suite + ([`../tests/test_runners.py`](../tests/test_runners.py)), which is what proves a + binding's wire behavior is identical to the others; +- not covered by the version-lockstep gate — it carries no release version. + +The first-class, runnable SDKs are **Python**, **TypeScript**, **Go**, and +**Rust**. Use one of those to build against Pitot today; see each SDK's `README.md` +for a quickstart. This preview will graduate only once it clears the same bar: a +runner, a conformance entry, a build, and version lockstep. diff --git a/labs/15-pitot/pitot-distribution/sdk/java/README.md b/labs/15-pitot/pitot-distribution/sdk/java/README.md new file mode 100644 index 000000000..e6ce63faa --- /dev/null +++ b/labs/15-pitot/pitot-distribution/sdk/java/README.md @@ -0,0 +1,21 @@ +# Pitot Java SDK — preview (not yet runnable) + +**Status: types-only preview. Not a shipping SDK.** These files are the generated +Java data types for the Pitot wire protocol (`com.operatorstack.pitot.*`), emitted +by [`scripts/generate_types.sh`](../../../scripts/generate_types.sh) from the same +schema as every other binding. They let you *read the shapes* — they do **not** yet +give you a runnable Controller or Consumer. + +What is **not** here yet, and what a first-class SDK requires: + +- no build manifest (`pom.xml` / `build.gradle`), so the package does not build; +- no `runner` (the stdio JSON-Lines Controller/Consumer loop the other SDKs ship); +- no entry in the cross-language conformance suite + ([`../tests/test_runners.py`](../tests/test_runners.py)), which is what proves a + binding's wire behavior is identical to the others; +- not covered by the version-lockstep gate — it carries no release version. + +The first-class, runnable SDKs are **Python**, **TypeScript**, **Go**, and +**Rust**. Use one of those to build against Pitot today; see each SDK's `README.md` +for a quickstart. This preview will graduate only once it clears the same bar: a +runner, a conformance entry, a build, and version lockstep. diff --git a/labs/15-pitot/pitot-distribution/sdk/python/README.md b/labs/15-pitot/pitot-distribution/sdk/python/README.md new file mode 100644 index 000000000..7ab1d1bcd --- /dev/null +++ b/labs/15-pitot/pitot-distribution/sdk/python/README.md @@ -0,0 +1,65 @@ +# Pitot Python SDK + +The Python binding for Pitot: typed wire types plus a tiny stdio runner for +building a **Controller** (decides `control.requested` → allow/deny) or a passive +**Consumer** (reads the observed action/event stream). It speaks the same +JSON-Lines protocol as every other Pitot SDK — proven identical by the +cross-language conformance suite ([`../tests/test_runners.py`](../tests/test_runners.py)). + +## Install + +Pitot SDKs install from the project's own registry through the CLI front door, +pinned to the CLI's version — never public PyPI: + +```bash +pitot install python # writes .pitot/registry + operatorstack-pitot== +``` + +`pitot init --language python` scaffolds a complete, runnable project and registers +it as a tenant fragment; the snippets below are the core of what it writes. + +## Build a Controller + +A Controller receives a typed `ControlRequested` and returns an `Outcome`. Return +`allow(...)` or `deny(...)`; the runner serializes one `control.response` per line, +in request order. + +```python +from pitot.runner import run_controller, allow, deny +from pitot.types import ControlRequested + +def handler(req: ControlRequested): + # decide from req.data, req.kind, etc. + if req.kind == "shell" and "rm -rf" in str(req.data): + return deny("blocked a destructive shell command") + return allow("approved") + +if __name__ == "__main__": + run_controller("my-controller", handler) +``` + +## Build a Consumer + +A Consumer passively reads the event stream. Keep stdout clean — a Consumer must +never write to stdout (the conformance suite enforces this); log to stderr. + +```python +import sys +from pitot.runner import run_consumer +from pitot.types import Event + +def handler(event: Event): + print("observed", event.type, file=sys.stderr) + +if __name__ == "__main__": + run_consumer(handler) +``` + +## Run it behind an agent + +```bash +pitot dev --host claude -- claude -p "…" # discovers your fragment, starts the controller +``` + +See the repository [README](../../../public-readme-preview/README.md) for the full +runtime, host-hook wiring, and the enforcement boundary. diff --git a/labs/15-pitot/pitot-distribution/sdk/rust/.gitignore b/labs/15-pitot/pitot-distribution/sdk/rust/.gitignore new file mode 100644 index 000000000..6850c8b1a --- /dev/null +++ b/labs/15-pitot/pitot-distribution/sdk/rust/.gitignore @@ -0,0 +1,5 @@ +# Rust build artifacts — the crate is a library; a resolved lockfile and the +# compiled target/ tree are produced by `cargo build`/`cargo test` (locally and +# in the conformance CI job) and are never part of the published SDK. +/target +Cargo.lock diff --git a/labs/15-pitot/pitot-distribution/sdk/rust/README.md b/labs/15-pitot/pitot-distribution/sdk/rust/README.md new file mode 100644 index 000000000..bcc89f72a --- /dev/null +++ b/labs/15-pitot/pitot-distribution/sdk/rust/README.md @@ -0,0 +1,60 @@ +# Pitot Rust SDK + +The Rust binding for Pitot: typed wire types plus a tiny stdio runner for building a +**Controller** (decides `control.requested` → allow/deny) or a passive **Consumer** +(reads the observed action/event stream). It speaks the same JSON-Lines protocol as +every other Pitot SDK — proven identical by the cross-language conformance suite +([`../tests/test_runners.py`](../tests/test_runners.py)). + +## Add the dependency + +The crate is `pitot`. During local development, depend on it by path; a released +version installs from the project registry pinned to the CLI's version. + +```toml +[dependencies] +pitot = { path = "path/to/sdk/rust" } # or the pinned registry version +``` + +## Build a Controller + +A Controller receives a typed `ControlRequested` and returns an `Outcome`. Return +`allow(...)` or `deny(...)`; the runner serializes one `control.response` per line, +in request order. + +```rust +use pitot::{run_controller, allow, deny, ControlRequested, Outcome}; + +fn handler(req: ControlRequested) -> Outcome { + if req.kind == "shell" { + return deny(Some("blocked a shell command".to_string())); + } + allow(Some("approved".to_string())) +} + +fn main() { + run_controller("my-controller", Box::new(handler)); +} +``` + +## Build a Consumer + +A Consumer passively reads the event stream. Keep stdout clean — a Consumer must +never write to stdout (the conformance suite enforces this); log to stderr. Note the +event's protocol `type` field is `event_type` in Rust (it is `#[serde(rename = +"type")]`, since `type` is a reserved word). + +```rust +use pitot::{run_consumer, Event}; + +fn handler(event: Event) { + eprintln!("observed {}", event.event_type); +} + +fn main() { + run_consumer(Box::new(handler)); +} +``` + +See the repository [README](../../../public-readme-preview/README.md) for the full +runtime, host-hook wiring, and the enforcement boundary. diff --git a/labs/15-pitot/pitot-distribution/sdk/tests/test_runners.py b/labs/15-pitot/pitot-distribution/sdk/tests/test_runners.py index eb82fafdf..be1e02ca2 100644 --- a/labs/15-pitot/pitot-distribution/sdk/tests/test_runners.py +++ b/labs/15-pitot/pitot-distribution/sdk/tests/test_runners.py @@ -163,7 +163,7 @@ def handler(event: Event): RUST_CONSUMER = """use pitot::{run_consumer, Event}; fn handler(event: Event) { - eprintln!("consumed {}", event.type_field); + eprintln!("consumed {}", event.event_type); } fn main() { diff --git a/labs/15-pitot/pitot-distribution/sdk/typescript/README.md b/labs/15-pitot/pitot-distribution/sdk/typescript/README.md new file mode 100644 index 000000000..10b2cde34 --- /dev/null +++ b/labs/15-pitot/pitot-distribution/sdk/typescript/README.md @@ -0,0 +1,59 @@ +# Pitot TypeScript SDK + +The TypeScript binding for Pitot: typed wire types plus a tiny stdio runner for +building a **Controller** (decides `control.requested` → allow/deny) or a passive +**Consumer** (reads the observed action/event stream). It speaks the same +JSON-Lines protocol as every other Pitot SDK — proven identical by the +cross-language conformance suite ([`../tests/test_runners.py`](../tests/test_runners.py)). + +## Install + +Pitot SDKs install from the project's own registry through the CLI front door, +pinned to the CLI's version — never public npm: + +```bash +pitot install typescript # scoped .npmrc + @operatorstack/pitot@ +``` + +`pitot init --language typescript` scaffolds a complete, runnable project and +registers it as a tenant fragment; the snippets below are the core of what it +writes. + +## Build a Controller + +A Controller receives a typed `ControlRequested` and returns an `Outcome`. Return +`allow(...)` or `deny(...)`; the runner serializes one `control.response` per line, +in request order. + +```typescript +import { runController, allow, deny, ControlRequested } from "@operatorstack/pitot"; + +runController("my-controller", async (req: ControlRequested) => { + if (req.kind === "shell" && String(req.data).includes("rm -rf")) { + return deny("blocked a destructive shell command"); + } + return allow("approved"); +}); +``` + +## Build a Consumer + +A Consumer passively reads the event stream. Keep stdout clean — a Consumer must +never write to stdout (the conformance suite enforces this); log to stderr. + +```typescript +import { runConsumer, Event } from "@operatorstack/pitot"; + +runConsumer(async (event: Event) => { + console.error("observed", event.type); +}); +``` + +## Run it behind an agent + +```bash +pitot dev --host claude -- claude -p "…" # discovers your fragment, starts the controller +``` + +See the repository [README](../../../public-readme-preview/README.md) for the full +runtime, host-hook wiring, and the enforcement boundary. diff --git a/labs/15-pitot/pitot/runtime/request_test.go b/labs/15-pitot/pitot/runtime/request_test.go index 608868a8c..3c9da5729 100644 --- a/labs/15-pitot/pitot/runtime/request_test.go +++ b/labs/15-pitot/pitot/runtime/request_test.go @@ -12,7 +12,7 @@ func TestNewControlRequestFillsEnvelopeAndMarshalsPayload(t *testing.T) { Actor string `json:"actor"` Operation string `json:"operation"` } - req, err := NewControlRequest("interlock.decide", body{Actor: "publisher", Operation: "artifact.publish"}) + req, err := NewControlRequest("interlock.effect", body{Actor: "publisher", Operation: "artifact.publish"}) if err != nil { t.Fatalf("NewControlRequest: %v", err) } @@ -22,8 +22,8 @@ func TestNewControlRequestFillsEnvelopeAndMarshalsPayload(t *testing.T) { if req.Type != schema.TypeControlRequested { t.Errorf("type = %q, want %q", req.Type, schema.TypeControlRequested) } - if req.Kind != "interlock.decide" { - t.Errorf("kind = %q, want interlock.decide", req.Kind) + if req.Kind != "interlock.effect" { + t.Errorf("kind = %q, want interlock.effect", req.Kind) } if req.ActionID == "" { t.Error("ActionID was not minted") diff --git a/labs/15-pitot/scripts/build_pitot.py b/labs/15-pitot/scripts/build_pitot.py index 39a042fba..35e32d3fe 100644 --- a/labs/15-pitot/scripts/build_pitot.py +++ b/labs/15-pitot/scripts/build_pitot.py @@ -75,8 +75,14 @@ def _iter_sdk_files(repo: Path) -> list[tuple[str, Path]]: if sdk_dir.is_dir(): for path in sorted(sdk_dir.rglob("*")): if path.is_file() and not path.is_symlink(): + # Never project a build artifact: Python bytecode caches, or a + # Rust `target/` tree and its lockfile left by a local/CI cargo + # build of the crate. These are not part of the published SDK and + # would otherwise churn the manifest on any machine that built it. if "__pycache__" in path.parts or path.suffix == ".pyc": continue + if "target" in path.parts or path.name == "Cargo.lock": + continue projected = path.relative_to(repo / LAB / "pitot-distribution").as_posix() entries.append((projected, path)) return entries diff --git a/labs/15-pitot/tests/test_sdk_versions.py b/labs/15-pitot/tests/test_sdk_versions.py index ee2c56417..0a65a5639 100644 --- a/labs/15-pitot/tests/test_sdk_versions.py +++ b/labs/15-pitot/tests/test_sdk_versions.py @@ -30,11 +30,16 @@ def test_sdk_manifests_and_scaffold_constant_agree(self): r'pitotPackageVersion = "([^"]+)"', workbench ).group(1) + # The Go binding ships from the pitot module itself (installed with + # `go install ...@`), so its version IS the release tag rather than a + # separate manifest. It is represented here by the scaffold constant + # `pitotPackageVersion` in workbench.go, which `pitot init` writes into the + # Go projects it generates — the one Go-side version string that can drift. versions = { "sdk/python": python_version, "sdk/typescript": package_json["version"], "sdk/rust": rust_version, - "scaffold constant (workbench.go pitotPackageVersion)": scaffold_version, + "sdk/go (scaffold constant workbench.go pitotPackageVersion)": scaffold_version, } self.assertEqual( len(set(versions.values())), @@ -42,6 +47,42 @@ def test_sdk_manifests_and_scaffold_constant_agree(self): f"SDK versions drifted out of lockstep: {versions}", ) + def test_preview_sdks_stay_unshippable_or_join_the_gate(self): + """Java and C# are types-only previews (see each dir's README): no runner, + no conformance entry, and no build manifest — so they carry no release + version and are deliberately excluded from the lockstep gate above. + + This guard makes that exclusion honest instead of a silent gap: the moment + someone adds a build manifest (turning a preview into something that + installs), this test fails and forces them to give it a real version and + fold it into the lockstep set — so a shipped-but-ungated SDK can never slip + through. + """ + preview_manifests = { + "sdk/java": [ + "pitot-distribution/sdk/java/pom.xml", + "pitot-distribution/sdk/java/build.gradle", + "pitot-distribution/sdk/java/build.gradle.kts", + ], + "sdk/csharp": [ + "pitot-distribution/sdk/csharp/Pitot/Pitot.csproj", + "pitot-distribution/sdk/csharp/Pitot.sln", + ], + } + shipped = { + name: rel + for name, rels in preview_manifests.items() + for rel in rels + if (LAB / rel).exists() + } + self.assertEqual( + shipped, + {}, + "a preview SDK gained a build manifest and now installs but is not " + f"version-locked: {shipped}. Add its version to the lockstep gate " + "above (and a conformance entry) before shipping it.", + ) + if __name__ == "__main__": unittest.main()