diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62d6f36..391e3db 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 bcd0bfe..b6f1b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,13 @@ node_modules dist/ .ts-out/ *.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 ed4ce10..d088250 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,7 +179,12 @@ tool call — those installs stay broken until `0.2.1` ships and consumers upgra 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. (#76) + 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. (#76) ### Security 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..c19789f --- /dev/null +++ b/scripts/smoke/consumer-types/run-arm.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Run one consumer-side type-resolution arm of the release e2e-smoke. +# +# run-arm.sh