From ccef2208585211c50b58e88727ffdb631a0a185d Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 20:46:09 -0700 Subject: [PATCH 1/2] ci(sdk): typecheck both typed SDKs, and catch a pin that drifted from its manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the SDK directory shipped on a claim rather than a run. The TypeScript type changes in PR #87 were never typechecked — no local toolchain, and `npx tsc` failed in that environment. `sdk (typescript) is a conformant implementation` does run `tsc` as a side effect of `npm run build`, so the coverage was not zero, but it arrives only after a full `cargo build --workspace --bins` and lasts only as long as the build script stays `tsc`. `sdk (typescript) typechecks` asks the question directly. The Python SDK ships `py.typed`, which promises downstream typecheckers that its annotations are meant to be believed, and nothing checked them; `sdk (python) typechecks` runs `mypy --strict`. Both are clean on this tree. The scaffolder's `DEFAULT_SDK` table names versions of two packages it does not own, and it sat at `^0.1.0` against a shipped `1.0.0` for an unknown length of time. The scaffold job could not have caught it: it overrides both published pins with local paths so CI never depends on a publish, which is the right call for that job. `check-sdk-version-pins.py` is the guard that belongs elsewhere — offline, stdlib only, no registry call. The rule is same major, not equality, because the pins are deliberately ranges: a patch release should not need an edit here, and a major is what a scaffold cannot survive (ADR 0011 widened `FrameKind` in 2.0.0, so a `1.x` pin generates code against a retired vocabulary). ADR 0012 carries the reasoning, including why `sdk/go` and `schema/reference-vectors.ndjson` are out of scope. Closes #94, Closes #98 Signed-off-by: macanderson --- .github/scripts/check-sdk-version-pins.py | 251 ++++++++++++++++++ .github/workflows/ci.yml | 61 +++++ CHANGELOG.md | 13 + MIGRATION.md | 13 +- .../0012-sdk-version-pins-share-a-major.md | 97 +++++++ 5 files changed, 429 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/check-sdk-version-pins.py create mode 100644 docs/adr/0012-sdk-version-pins-share-a-major.md diff --git a/.github/scripts/check-sdk-version-pins.py b/.github/scripts/check-sdk-version-pins.py new file mode 100755 index 0000000..ef2f2d0 --- /dev/null +++ b/.github/scripts/check-sdk-version-pins.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +""" +Hold the scaffolder's default SDK pins to the SDK manifests they name. + +Usage: + python3 .github/scripts/check-sdk-version-pins.py + +Exits 0 if every version one manifest states about a package owned by another +manifest is consistent with that package's own manifest. Exits 1 otherwise. +Stdlib only, offline — it reads no registry, so it cannot depend on a publish. + +Why this exists (see docs/adr/0012-sdk-version-pins-share-a-major.md): + + `sdk/create-contextgraph-provider/index.js` carries a `DEFAULT_SDK` table — + the dependency every scaffolded project resolves when the caller passes no + `--sdk`. It names versions of two packages it does not own, and it sat at + `^0.1.0` for TypeScript while `sdk/typescript/package.json` shipped `1.0.0`, + a full major behind, for an unknown length of time (#98). + + Nothing detected it, and the job that looks closest could not have. The + `create-contextgraph-provider scaffolds a conformant project` job in + `ci.yml` overrides both published pins with local paths + (`--sdk "file:$GITHUB_WORKSPACE/sdk/typescript"`, `pip install ./sdk/python`) + so that CI never depends on a registry. That is the right call for that job: + it proves the *templates* are conformant, and says nothing about whether the + versions they name exist. So the guard belongs here instead. + +The rule, and why it is not equality: + + The two pins are deliberately ranges — a caret (`^2.0.0`) and a floor + (`contextgraph-sdk>=2.0.0`) — so that a scaffold picks up an SDK patch + release without a commit here. Equality would defeat that and would make + every SDK patch a two-repo-file change nobody would remember to make. + + What a scaffold cannot survive is a **major**: majors are where the SDK's + API changes, and ADR 0011's open-`FrameKind` break is the worked example — + a project scaffolded against a `1.x` pin generates code against the closed + vocabulary `2.0.0` retired. So the pin's floor must share the manifest's + major. + + The floor must also not run *ahead* of the manifest, which is drift in the + other direction: a pin naming a version that has never been published + resolves to nothing at all. The manifest version is the newest release that + can exist, because these manifests are what `publish-sdks.yml` publishes. + +What is deliberately not checked, and why: + + * `sdk/go` carries no package version. A Go module is versioned by its git + tag (`sdk/go/v…`), not by a field in `go.mod`, and the scaffolder emits no + Go template, so there is no in-tree pin to compare against. Its + `ProtocolVersion` constant is a *protocol* version — a different axis from + package version, governed by SPEC.md §3.1. + * `schema/reference-vectors.ndjson` carries `"version": "1.0.0"` strings. + Those are the *provider* versions of the fixtures in the vectors, not + package versions of anything in this repository, and they must stay put: + rewriting them would change the bytes the reference vectors pin. + * `create-contextgraph-provider`'s own `version` is a version it owns, and + it is free to move on its own cadence. +""" +import json +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +SCAFFOLDER = ROOT / "sdk/create-contextgraph-provider/index.js" +TS_MANIFEST = ROOT / "sdk/typescript/package.json" +PY_MANIFEST = ROOT / "sdk/python/pyproject.toml" +TS_TEMPLATE = ROOT / "sdk/create-contextgraph-provider/templates/typescript/package.json" +PY_TEMPLATE = ROOT / "sdk/create-contextgraph-provider/templates/python/pyproject.toml" +CARGO = ROOT / "Cargo.toml" + +# The placeholder `index.js` substitutes the resolved pin into. If a template +# ever hardcodes a version instead, the pin this script checks stops being the +# pin that ships, and the guard would pass while guarding nothing. +PLACEHOLDER = "{{SDK_SPEC}}" + +# Range operators whose leading version is a floor — the only shape the rule +# below can reason about. An unlisted operator (`<`, `!=`, a compound range) +# fails rather than being silently read as a floor. +TS_OPERATORS = ("^", "~", ">=", "") +PY_OPERATORS = (">=", "~=", "==") + +DEFAULT_SDK_BLOCK = re.compile(r"^const DEFAULT_SDK = \{(.*?)^\};", re.S | re.M) +DEFAULT_SDK_ENTRY = re.compile(r'^\s*(\w+):\s*"([^"]+)",?\s*$', re.M) +SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$") +PY_REQUIREMENT = re.compile( + rf"^([A-Za-z0-9._-]+)\s*({'|'.join(re.escape(op) for op in PY_OPERATORS)})\s*(.+)$" +) + +failures = 0 + + +def check(label: str, ok: bool, detail: str = "") -> bool: + global failures + print(f" {'PASS' if ok else 'FAIL'} {label}") + if not ok: + failures += 1 + if detail: + for line in detail.splitlines(): + print(f" {line}") + return ok + + +def parse_semver(raw: str) -> tuple[int, int, int] | None: + match = SEMVER.match(raw.strip()) + return (int(match[1]), int(match[2]), int(match[3])) if match else None + + +def default_sdk_pins() -> dict[str, str]: + """The `DEFAULT_SDK` table in the scaffolder, as `{lang: dependency-spec}`.""" + block = DEFAULT_SDK_BLOCK.search(SCAFFOLDER.read_text(encoding="utf-8")) + if block is None: + return {} + return {lang: spec for lang, spec in DEFAULT_SDK_ENTRY.findall(block[1])} + + +def split_range(spec: str, operators: tuple[str, ...]) -> tuple[str, str] | None: + """Split `^2.0.0` into `("^", "2.0.0")`, or return None on an unknown operator.""" + for operator in sorted(operators, key=len, reverse=True): + if operator and spec.startswith(operator): + return operator, spec[len(operator):].strip() + return ("", spec) if "" in operators and spec[:1].isdigit() else None + + +def check_pin(label: str, pin: str, floor: str | None, shipped: str, source: str) -> None: + """A pin's floor shares `shipped`'s major and does not run ahead of it.""" + parsed_floor = parse_semver(floor) if floor is not None else None + parsed_shipped = parse_semver(shipped) + if parsed_floor is None or parsed_shipped is None: + check( + f"{label} pin is a floor this check can read", + False, + f"pin {pin!r} against {source} version {shipped!r}\n" + f"remedy: write the pin as a floor over an x.y.z version" + f" (e.g. ^{shipped} or >={shipped}).", + ) + return + + remedy = ( + f"pin {pin!r} names major {parsed_floor[0]}; {source} ships {shipped}\n" + f"remedy: move the pin in {SCAFFOLDER.relative_to(ROOT)}'s DEFAULT_SDK" + f" onto {shipped}, or explain the split in" + f" docs/adr/0012-sdk-version-pins-share-a-major.md." + ) + check( + f"{label} pin shares the major {source} ships", + parsed_floor[0] == parsed_shipped[0], + remedy, + ) + check( + f"{label} pin does not name a version {source} has never shipped", + parsed_floor <= parsed_shipped, + f"pin {pin!r} floors at {floor}, ahead of the {shipped} in {source}\n" + f"remedy: publish {floor} first, or lower the pin.", + ) + + +print("the scaffolder's default pins agree with the SDK manifests they name") + +pins = default_sdk_pins() +if not check( + "DEFAULT_SDK is readable in the scaffolder", + set(pins) == {"typescript", "python"}, + f"parsed {sorted(pins)} from {SCAFFOLDER.relative_to(ROOT)}, expected" + " typescript and python\n" + "remedy: keep DEFAULT_SDK a flat `lang: \"spec\"` object literal, or teach" + " this parser the new shape.", +): + sys.exit(1) + +ts_manifest = json.loads(TS_MANIFEST.read_text(encoding="utf-8")) +py_manifest = tomllib.loads(PY_MANIFEST.read_text(encoding="utf-8"))["project"] +ts_template = json.loads(TS_TEMPLATE.read_text(encoding="utf-8")) +py_template = tomllib.loads(PY_TEMPLATE.read_text(encoding="utf-8"))["project"] + +# Half of what makes the pin above the *shipped* pin: the templates have to +# take it from DEFAULT_SDK rather than restating a version of their own. +check( + "the TypeScript template takes its SDK dependency from DEFAULT_SDK", + ts_template.get("dependencies", {}).get(ts_manifest["name"]) == PLACEHOLDER, + f"expected {ts_manifest['name']!r}: {PLACEHOLDER!r} in" + f" {TS_TEMPLATE.relative_to(ROOT)}, found" + f" {ts_template.get('dependencies')!r}", +) +check( + "the Python template takes its SDK dependency from DEFAULT_SDK", + py_template.get("dependencies") == [PLACEHOLDER], + f"expected [{PLACEHOLDER!r}] in {PY_TEMPLATE.relative_to(ROOT)}, found" + f" {py_template.get('dependencies')!r}", +) + +ts_pin = pins["typescript"] +ts_split = split_range(ts_pin, TS_OPERATORS) +check_pin( + "typescript", + ts_pin, + ts_split[1] if ts_split else None, + ts_manifest["version"], + TS_MANIFEST.relative_to(ROOT).as_posix(), +) + +py_pin = pins["python"] +py_requirement = PY_REQUIREMENT.match(py_pin) +if check( + "the Python pin names the package sdk/python publishes", + py_requirement is not None and py_requirement[1] == py_manifest["name"], + f"pin {py_pin!r} does not read as {py_manifest['name']} followed by one of" + f" {', '.join(PY_OPERATORS)} and an x.y.z version", +): + assert py_requirement is not None + check_pin( + "python", + py_pin, + py_requirement[3], + py_manifest["version"], + PY_MANIFEST.relative_to(ROOT).as_posix(), + ) + +# The lockstep the scaffolder's own DEFAULT_SDK comment and MIGRATION.md §5.4 +# both assert: an SDK major and a crate major are the same release. The pins +# above are only meaningful while it holds — a `^2.0.0` pin protects a +# scaffold from ADR 0011 exactly because SDK 2 and crate 2 are one break. +print("\nthe SDK majors move in lockstep with the crates") + +workspace_version = tomllib.loads(CARGO.read_text(encoding="utf-8"))["workspace"]["package"][ + "version" +] +majors = { + "Cargo.toml [workspace.package]": workspace_version, + TS_MANIFEST.relative_to(ROOT).as_posix(): ts_manifest["version"], + PY_MANIFEST.relative_to(ROOT).as_posix(): py_manifest["version"], +} +parsed = {source: parse_semver(version) for source, version in majors.items()} +unreadable = [source for source, version in parsed.items() if version is None] +if check( + "every versioned manifest states an x.y.z version", + not unreadable, + "\n".join(f"{source} -> {majors[source]!r}" for source in unreadable), +): + check( + "the SDKs and the crates share one major", + len({version[0] for version in parsed.values() if version}) == 1, # type: ignore[index] + "\n".join(f"{source} -> {version}" for source, version in majors.items()) + + "\nremedy: bump them together, as MIGRATION.md §5.4 says they move.", + ) + +print(f"\n{'OK — no version drift' if failures == 0 else f'{failures} failure(s)'}") +sys.exit(1 if failures else 0) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 770fbd1..78ef559 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,67 @@ jobs: kill $SERVER 2>/dev/null || true exit $code + sdk-typescript-types: + name: sdk (typescript) typechecks + runs-on: ubuntu-latest + # `sdk (typescript) is a conformant implementation` runs `npm run build`, + # which is `tsc`, so a type error does redden something today — but only + # after that job has spent a full `cargo build --workspace --bins` it does + # not need, and only for as long as the build script stays `tsc`. The + # `FrameKind` type changes PR #87 shipped went in on a claim rather than a + # run: no local toolchain, and `npx tsc` failed in that environment (#94). + # This is the run — no Rust, no emit, one question. + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: TypeScript SDK typechecks + working-directory: sdk/typescript + run: | + npm install + npx tsc --noEmit + + sdk-python-types: + name: sdk (python) typechecks + runs-on: ubuntu-latest + # The Python SDK ships `py.typed`, which promises every downstream + # typechecker that its annotations are meant to be believed. Nothing + # checked them, so the promise was untested. `--strict`, because a + # `py.typed` package that leaks `Any` across its own boundary keeps that + # promise to its callers and not to itself. + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + # Pinned: an unpinned strict typechecker turns each of its releases into + # a possible red main on code nobody touched. Bumping it is a PR that + # reads the new diagnostics. + - run: pip install "mypy==2.3.1" + - name: Python SDK typechecks + working-directory: sdk/python + # `--python-version 3.10`, not the 3.9 floor `pyproject.toml` declares: + # mypy 2.x refuses to target 3.9 at all, and 3.10 is the nearest target + # it accepts. + run: mypy --strict --python-version 3.10 contextgraph_sdk examples + + sdk-version-pins: + name: sdk version pins name a version their manifest ships + runs-on: ubuntu-latest + # The scaffolder's DEFAULT_SDK table states versions of two packages it + # does not own, and it sat a full major behind sdk/typescript/package.json + # for an unknown length of time (#98). The scaffold job below cannot catch + # that: it overrides both published pins with local paths so CI never + # depends on a publish — the right call for that job, and the reason this + # guard is a separate one. Offline, stdlib only, reads no registry. + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 .github/scripts/check-sdk-version-pins.py + sdk-scaffold: name: create-contextgraph-provider scaffolds a conformant project runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1551b..4db6d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,19 @@ text lands without a human merge. own reranker or per-provider quotas instead of raw `score`. - **TypeScript SDK:** `KnownFrameKind`, `KNOWN_FRAME_KINDS`, `isKnownFrameKind`. **Python SDK:** `KnownFrameKind`, `KNOWN_FRAME_KINDS`. +- **CI typechecks both typed SDKs.** `sdk (typescript) typechecks` runs + `tsc --noEmit`, and `sdk (python) typechecks` runs `mypy --strict` — the + Python SDK ships `py.typed`, so its annotations are a promise downstream + typecheckers act on, and nothing checked them. The `FrameKind` types above + shipped on a claim rather than a run (#94). +- **CI catches a version pin that has drifted from the manifest it names** + ([ADR 0012](./docs/adr/0012-sdk-version-pins-share-a-major.md)). + `.github/scripts/check-sdk-version-pins.py` holds the scaffolder's + `DEFAULT_SDK` pins to the major each SDK manifest ships, and holds the two + SDK manifests and the crates to one major between them. The TypeScript pin + had sat at `^0.1.0` against a shipped `1.0.0` for an unknown length of time, + and the scaffold job overrides both published pins with local paths so it + could never have seen it (#98). ### Changed - **Crate version `1.0.0` → `2.0.0`; protocol version stays `contextgraph/1.0`.** diff --git a/MIGRATION.md b/MIGRATION.md index 2d33327..0cf36a7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -217,9 +217,10 @@ now tied to the kind, because an unknown kind owns its string. ### 5.4 SDKs move in lockstep -`contextgraph-sdk` (Python) and `@contextgraph/sdk` (TypeScript) also go to -`2.0.0`, for the same reason in their own type systems: `FrameKind` widens to -accept any string, so an exhaustive `switch` that relied on `never`-narrowing -stops type-checking. Narrow with the exported `isKnownFrameKind` / -`KNOWN_FRAME_KINDS` when you need to branch only on kinds you understand. The -Go SDK is unchanged in this release — porting it is tracked in issue #93. +`contextgraph-sdk` (Python) and `@contextgraphprotocol/typescript-sdk` +(TypeScript) also go to `2.0.0`, for the same reason in their own type systems: +`FrameKind` widens to accept any string, so an exhaustive `switch` that relied +on `never`-narrowing stops type-checking. Narrow with the exported +`isKnownFrameKind` / `KNOWN_FRAME_KINDS` when you need to branch only on kinds +you understand. The Go SDK is unchanged in this release — porting it is tracked +in issue #93. diff --git a/docs/adr/0012-sdk-version-pins-share-a-major.md b/docs/adr/0012-sdk-version-pins-share-a-major.md new file mode 100644 index 0000000..181832c --- /dev/null +++ b/docs/adr/0012-sdk-version-pins-share-a-major.md @@ -0,0 +1,97 @@ +# 0012 — A version pin names its manifest's major + +**Status:** Accepted (repository policy; no wire or spec impact) + +## Context + +`sdk/create-contextgraph-provider/index.js` carries a `DEFAULT_SDK` table: the +dependency each scaffolded project resolves when the caller passes no `--sdk`. +It states a version of two packages it does not own — +`@contextgraphprotocol/typescript-sdk` and `contextgraph-sdk` — and those two +packages state their own versions in `sdk/typescript/package.json` and +`sdk/python/pyproject.toml`. + +Those numbers went out of agreement and stayed that way. The TypeScript entry +sat at `^0.1.0` while its manifest shipped `1.0.0` — a full major behind, for +an unknown length of time, found only in passing during the `2.0.0` bump +(#98). Every project scaffolded in that window resolved an SDK from the wrong +major. + +Nothing in CI could have caught it, and the job that looks closest is the one +that proves it. `create-contextgraph-provider scaffolds a conformant project` +runs the scaffolder with `--sdk "file:$GITHUB_WORKSPACE/sdk/typescript"` and +`pip install ./sdk/python`, so **both published pins are overridden by local +paths on every run**. That is the correct design for that job — resolving the +real pin would make CI depend on a publish, and a red CI after a registry +outage teaches nobody anything — but it means the job proves the *templates* +are conformant and says nothing about whether the versions they name exist. + +## Decision + +**A version pin naming a package this repository publishes must share that +package's major and must not run ahead of it.** Enforced offline by +`.github/scripts/check-sdk-version-pins.py`, run in CI as `sdk version pins`. + +### Same major, not equality + +The two pins are deliberately ranges, and differently shaped ones: a caret +(`^2.0.0`) for npm and a floor (`contextgraph-sdk>=2.0.0`) for pip. Equality +would turn every SDK patch release into an edit here that nobody would +remember to make, and the range exists precisely so a scaffold picks up a +patch without one. + +A major is the thing a scaffold cannot survive. Majors are where the SDK's API +changes, and ADR 0011 is the worked example: `FrameKind` widened to an open +vocabulary in `2.0.0`, so a project scaffolded against a `1.x` pin generates +code against a vocabulary that release retired. Matching the major is +therefore the weakest rule that still catches every break, which is what a +guard should be — a stricter one would fail on changes that are correct. + +### Not ahead of the manifest, either + +Drift has a second direction: a pin flooring at `2.1.0` when only `2.0.0` has +ever shipped resolves to nothing at all. The check reads the manifest version +as the newest release that can exist, which holds because these manifests are +what `publish-sdks.yml` publishes. + +### The lockstep is checked too, because the pins lean on it + +The scaffolder's own comment and `MIGRATION.md` §5.4 both assert that an SDK +major and a crate major are one release. The pin rule borrows its meaning from +that: `^2.0.0` protects a scaffold from ADR 0011 only while SDK 2 and crate 2 +name the same break. So the check also holds `Cargo.toml`'s +`[workspace.package] version`, `sdk/typescript/package.json` and +`sdk/python/pyproject.toml` to one major. Its practical effect is that a +version bump is one commit across the three files rather than three commits +with a window in between. + +### What it does not cover + +- **`sdk/go`** carries no package version. A Go module is versioned by its git + tag (`sdk/go/v…`), not by a field in `go.mod`, and the scaffolder emits no Go + template — so there is no in-tree pin to compare. Its `ProtocolVersion` + constant is a *protocol* version, a separate axis governed by `SPEC.md` §3.1. +- **`schema/reference-vectors.ndjson`**'s `"version": "1.0.0"` strings are the + *provider* versions of the fixtures in the vectors, not package versions of + anything here. Rewriting them would change the bytes the reference vectors + pin, so they stay put. +- **`create-contextgraph-provider`'s own `version`** is a version it owns and + may move on its own cadence. +- **Prose.** A README's `npm install …@2` would not be caught. Extending the + check there is possible and was left out as a separate judgement, not an + oversight. + +## Consequences + +- A `DEFAULT_SDK` entry left behind by an SDK bump fails CI at the commit that + bumps the SDK, which is the commit that can still fix it cheaply. +- Bumping an SDK major becomes one atomic change across four files: the two + SDK manifests, `Cargo.toml`, and `DEFAULT_SDK`. A staged bump is refused. + That is the intent — the staged state is exactly the drift #98 recorded. +- A template that hardcodes an SDK version instead of taking `{{SDK_SPEC}}` + fails too, because it would make the pin this check reads stop being the pin + that ships. +- The check reads no registry, so it cannot go red because npm or PyPI is + down, and it cannot verify that a pinned version was actually published. The + `sdk (…) is a conformant implementation` jobs and `publish-sdks.yml` own that + half. From c11e8e6ad91cadefcd62fdd60b07325821a0f8f0 Mon Sep 17 00:00:00 2001 From: macanderson Date: Sat, 29 Aug 2026 20:55:05 -0700 Subject: [PATCH 2/2] ci(sdk): resolve the typecheck job's compiler from the lockfile `npm ci` rather than `npm install`, so the job runs typescript 5.9.3 as `package-lock.json` pins it instead of whatever `^5.9.0` resolves to today. The mypy job beside it is pinned for the same reason and this one was not, which Sourcery caught against #94's definition of done. The conformance jobs keep `npm install`: they ask whether the SDK still behaves, not whether one compiler release still accepts it. Refs #94 Signed-off-by: macanderson --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78ef559..a0a99e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -277,8 +277,15 @@ jobs: node-version: "22" - name: TypeScript SDK typechecks working-directory: sdk/typescript + # `npm ci`, not the `npm install` the conformance jobs use: it resolves + # `package-lock.json` exactly, which pins the compiler at typescript + # 5.9.3 rather than whatever `^5.9.0` means this week. Same reason the + # job below pins mypy — a floating strict typechecker turns each of its + # releases into a possible red main on code nobody touched. The + # conformance jobs can float because they ask whether the SDK still + # behaves, not whether a particular compiler still accepts it. run: | - npm install + npm ci npx tsc --noEmit sdk-python-types: