From 84b57b6ff9f560a33f5502a20168d56e01d19171 Mon Sep 17 00:00:00 2001 From: Jake Fineman Date: Sat, 1 Aug 2026 23:16:14 -0400 Subject: [PATCH 1/4] ci(release): type-check the packed tarball from the consumer side (closes #77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e-smoke asserted every declared `types` target EXISTS in the tarball. It never asserted they RESOLVE — and only the second is a thing a consumer experiences. `dist/sdk-server.d.ts` references a type from @anthropic-ai/claude-agent-sdk, an OPTIONAL peer dependency, so the existence check stays green on a package that fails to type-check for anyone who did not install that peer. Adopts option (a) from #77: requiring the peer to type-check ./sdk-server is honest, because that subpath exists to build a config for the Agent SDK. The gate now enforces both halves of the contract: root must type-check WITHOUT the optional peer ./sdk-server must type-check WITH it It deliberately does NOT assert that ./sdk-server fails without the peer — that would freeze current behaviour into the gate and turn a future switch to self-contained declarations (option (b)) into a spurious release failure. Each arm installs the real packed tarball into its own throwaway project, so its install shape is exactly what it claims to test; re-running `npm install --no-save` in the existing smoke dir would rebuild that tree from its empty package.json and could drop the tarball itself. Compiler, node types and the peer are pinned to the versions package-lock.json already resolves, read from the lockfile rather than `require("/package.json")` — the Agent SDK ships an exports map with no "./package.json" entry, so requiring its manifest as a subpath throws ERR_PACKAGE_PATH_NOT_EXPORTED. A package missing from the lockfile fails loudly instead of becoming an empty version string that installs whatever `latest` happens to be. skipLibCheck is off in the probe tsconfig — with it on, tsc never looks inside node_modules declarations, which is the entire class being tested. That also makes tsc visit dependency declarations, so diagnostics that do not name @wave-av/mcp-server are reported as warnings: a gate that fails a release on somebody else is a gate that gets switched off. Proven by negative control before merge, both against a real build+pack: NC1 leak the optional peer into a root-reachable declaration -> `types ok` still green, root arm FAILS (TS2307) NC2 point the subpath declaration at a nonexistent module -> `types ok` still green, ./sdk-server arm FAILS (TS2307) NC3 arm with no compiler installed -> fails loudly, does not pass vacuously happy path on unmodified HEAD -> both arms green Also extends .gitignore: the smoke packs a *.tgz into the repo root, and this public repo had no entries for local-secret files or OS cruft. None has ever been committed here — the entries exist only so a stray `git add -A` cannot be the first time. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 82 ++++++++++++++++++- .gitignore | 10 +++ CHANGELOG.md | 7 +- scripts/smoke/consumer-types/package.json | 6 ++ scripts/smoke/consumer-types/root.ts | 18 ++++ scripts/smoke/consumer-types/run-arm.sh | 66 +++++++++++++++ scripts/smoke/consumer-types/sdk-server.ts | 11 +++ .../smoke/consumer-types/tsconfig.base.json | 23 ++++++ .../smoke/consumer-types/tsconfig.root.json | 4 + .../consumer-types/tsconfig.sdk-server.json | 4 + 10 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 scripts/smoke/consumer-types/package.json create mode 100644 scripts/smoke/consumer-types/root.ts create mode 100755 scripts/smoke/consumer-types/run-arm.sh create mode 100644 scripts/smoke/consumer-types/sdk-server.ts create mode 100644 scripts/smoke/consumer-types/tsconfig.base.json create mode 100644 scripts/smoke/consumer-types/tsconfig.root.json create mode 100644 scripts/smoke/consumer-types/tsconfig.sdk-server.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f32a1d..bcd2d21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,11 @@ name: Release (public npm) # PACKS the real tarball, installs it into a throwaway # project, and imports it -- root AND the ./sdk-server # subpath. ESM only: this package ships no `require` -# condition, so a CJS arm would be a false failure. +# condition, so a CJS arm would be a false failure. The +# smoke then type-checks the installed tarball from the +# CONSUMER's side (#77): root without the optional peer +# dependency, ./sdk-server with it. Shipping a .d.ts is not +# the same claim as shipping one that resolves. # 3. publish — only after 1+2 pass. The tag version MUST equal # package.json version, and the npm dist-tag is derived from # the version: any prerelease -> `next`, stable -> `latest`, @@ -208,6 +212,82 @@ jobs: } console.log('types ok:',[...targets].join(', '));" + # ------------------------------------------------------------------- + # Consumer-side TYPE RESOLUTION (#77). + # + # The check above proves the .d.ts files are IN the tarball. It does + # not prove they RESOLVE, and only the second is something a consumer + # experiences. `dist/sdk-server.d.ts` references a type from + # @anthropic-ai/claude-agent-sdk, which is an OPTIONAL peer, so the + # existence check would stay green on a package that fails to + # type-check for anyone who did not install that peer. + # + # Decision (a) on #77: requiring the peer to type-check ./sdk-server + # is honest -- that subpath exists to build a config for the Agent + # SDK, so a consumer using it has the SDK by definition. The contract + # this gate enforces is therefore: + # + # root must type-check WITHOUT the optional peer + # ./sdk-server must type-check WITH it + # + # It deliberately does NOT assert that ./sdk-server fails without the + # peer. That would freeze today's behaviour into the gate and turn a + # future switch to self-contained declarations (option (b)) into a + # spurious release failure. + # + # Each arm gets its own throwaway project so its install shape is + # exactly what it claims to test -- re-running `npm install --no-save` + # in the smoke dir above would rebuild that tree from its (empty) + # package.json and could drop the tarball itself. + # ------------------------------------------------------------------- + cd "$GITHUB_WORKSPACE" + # Pin both installs to the versions this repo's lockfile resolves. A + # floating `typescript@latest` would let a compiler release nobody + # vetted decide whether a publish goes out, and a floating peer could + # type-check against a different major than the one we build against. + # + # Read from package-lock.json, NOT `require('/package.json')`: + # @anthropic-ai/claude-agent-sdk ships an `exports` map with no + # "./package.json" entry, so requiring its manifest as a subpath + # throws ERR_PACKAGE_PATH_NOT_EXPORTED -- the same trap the bin check + # above already documents for this package. And a package missing + # from the lockfile fails here rather than silently becoming an empty + # version string that installs whatever `latest` happens to be. + LOCKED="$(node -e " + const lock=require('./package-lock.json'); + const pick=n=>{ + const e=(lock.packages||{})['node_modules/'+n]; + if(!e||!e.version){ + console.error('::error title=version not pinned in lockfile::'+n+' has no resolved version in package-lock.json - refusing to install a floating version into the release gate'); + process.exit(1); + } + return e.version; + }; + process.stdout.write([pick('typescript'),pick('@types/node'),pick('@anthropic-ai/claude-agent-sdk')].join(' ')); + ")" + read -r TSC_VERSION TYPES_NODE_VERSION PEER_VERSION <<< "$LOCKED" + echo "type-resolution arms: typescript@$TSC_VERSION, @types/node@$TYPES_NODE_VERSION, optional peer @anthropic-ai/claude-agent-sdk@$PEER_VERSION" + + # ARM 1 — the plain consumer: the tarball and nothing else. @types/node + # is a stand-in for the ambient environment any Node consumer has, not + # a dependency of ours; see tsconfig.base.json. + ARM_ROOT="$(mktemp -d)" + cp -R "$GITHUB_WORKSPACE/scripts/smoke/consumer-types" "$ARM_ROOT/typecheck" + cd "$ARM_ROOT" + npm init -y >/dev/null 2>&1 + npm install --no-save --ignore-scripts "$TARBALL" "typescript@$TSC_VERSION" "@types/node@$TYPES_NODE_VERSION" >/dev/null 2>&1 + bash "$GITHUB_WORKSPACE/scripts/smoke/consumer-types/run-arm.sh" \ + "$ARM_ROOT" tsconfig.root.json "root entry, optional peer NOT installed" + + # ARM 2 — the Agent SDK consumer: the tarball plus the optional peer. + ARM_SDK="$(mktemp -d)" + cp -R "$GITHUB_WORKSPACE/scripts/smoke/consumer-types" "$ARM_SDK/typecheck" + cd "$ARM_SDK" + npm init -y >/dev/null 2>&1 + npm install --no-save --ignore-scripts "$TARBALL" "typescript@$TSC_VERSION" "@types/node@$TYPES_NODE_VERSION" "@anthropic-ai/claude-agent-sdk@$PEER_VERSION" >/dev/null 2>&1 + bash "$GITHUB_WORKSPACE/scripts/smoke/consumer-types/run-arm.sh" \ + "$ARM_SDK" tsconfig.sdk-server.json "./sdk-server subpath, optional peer installed" + # --------------------------------------------------------------------------- # Gate 3 — publish. Runs ONLY if secret-scan + verify are green. # diff --git a/.gitignore b/.gitignore index 3c25e1e..480c52b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ node_modules/ dist/ *.log + +# `npm pack` — which the release smoke runs, and which anyone reproducing it +# locally will run — drops a publishable tarball in the repo root. +*.tgz + +# Public repo. These have never been committed here; the entries exist so a +# stray `git add -A` cannot be the first time. +.env +.env.* +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index e3607fb..0cccfc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,12 @@ All notable changes to this project are documented here. The format is based on optional `@anthropic-ai/claude-agent-sdk` peer dependency, so type-checking an import of `@wave-av/mcp-server/sdk-server` without that package installed now fails with TS2307 (instead of silently resolving to `any`); install the - peer dependency to consume that subpath. + peer dependency to consume that subpath. This is a deliberate choice rather + than an accident of the build (see #77): the `./sdk-server` subpath exists to + hand a config object to the Agent SDK, so requiring the SDK to type-check it + is honest. The release gate now enforces both halves of that contract — the + root entry must type-check for a consumer who has NOT installed the peer, and + `./sdk-server` must type-check for one who has. ### Changed diff --git a/scripts/smoke/consumer-types/package.json b/scripts/smoke/consumer-types/package.json new file mode 100644 index 0000000..9d30dcf --- /dev/null +++ b/scripts/smoke/consumer-types/package.json @@ -0,0 +1,6 @@ +{ + "name": "wave-mcp-consumer-types-probe", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/scripts/smoke/consumer-types/root.ts b/scripts/smoke/consumer-types/root.ts new file mode 100644 index 0000000..0121254 --- /dev/null +++ b/scripts/smoke/consumer-types/root.ts @@ -0,0 +1,18 @@ +// Consumer-side probe for the ROOT entry point. +// +// Asks the only question a consumer cares about: installed the published +// tarball and nothing else, does `@wave-av/mcp-server` resolve its types? +// +// `typeof import(...)` is a purely type-level reference -- it forces tsc to +// resolve `exports["."].types` and check the declarations it reaches, without +// emitting a runtime import. That matters here: the root entry is the +// executable (`#!/usr/bin/env node`, calls `server.connect(transport)` at top +// level), so a real import would start the MCP server and hang. +// Scope, stated plainly: `dist/index.d.ts` is currently `export {};` — the root +// entry is an executable with no library surface — so this arm today proves +// that the root `types` target RESOLVES and nothing beyond it. That is thin +// because the package is thin at root, not because the check is lax: the moment +// the root gains an export, tsc follows it, and a reference to the optional peer +// leaking into a root-reachable declaration fails this arm. Verified by +// negative control before merge. +export type Root = typeof import("@wave-av/mcp-server"); diff --git a/scripts/smoke/consumer-types/run-arm.sh b/scripts/smoke/consumer-types/run-arm.sh new file mode 100755 index 0000000..3ca8234 --- /dev/null +++ b/scripts/smoke/consumer-types/run-arm.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Run one consumer-side type-resolution arm of the release e2e-smoke. +# +# run-arm.sh