chore(main): release 0.3.0 #61
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: "20" | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run lint | |
| - run: npm run typecheck | |
| - run: npm run build | |
| - name: LICENSE file and README agree with package.json license field | |
| run: | | |
| if [ ! -f LICENSE ]; then | |
| echo "::error::LICENSE file is missing from repo root"; exit 1 | |
| fi | |
| SPDX=$(node -e "console.log(require('./package.json').license)") | |
| # Map the SPDX id declared in package.json to the exact opening line | |
| # the corresponding license text is expected to start with. This is | |
| # a whitelist, not a substring match, precisely because a substring | |
| # check would false-pass a mismatch (e.g. "Apache-2.0" is not a | |
| # substring of "Apache License", so a naive check misses that swap | |
| # entirely). Only MIT is mapped: canonical Apache-2.0 text opens | |
| # with an indented "Apache License" line, so a first-line | |
| # comparison needs its own normalisation and is left for whoever | |
| # adds Apache-2.0 support to get right and test, rather than | |
| # carrying an entry here that would fail on a correct license. | |
| # Extend this map if the SDK's license ever changes. | |
| case "$SPDX" in | |
| MIT) EXPECTED="MIT License" ;; | |
| *) | |
| echo "::error::no known LICENSE text mapping for SPDX id '$SPDX'; add one to this step" | |
| exit 1 | |
| ;; | |
| esac | |
| ACTUAL=$(head -n1 LICENSE) | |
| if [ "$ACTUAL" != "$EXPECTED" ]; then | |
| echo "::error::package.json license is '$SPDX' but LICENSE begins with '$ACTUAL' (expected '$EXPECTED')" | |
| exit 1 | |
| fi | |
| # README.md must state the same SPDX id under its own "## License" | |
| # heading. Scope extraction to the text between that heading and the | |
| # next "## " heading (or EOF) rather than grepping the whole file, | |
| # so incidental mentions of a license name elsewhere in the prose | |
| # cannot produce a false pass or a false fail. If the section is | |
| # renamed or removed this intentionally finds nothing and fails | |
| # loudly below, rather than silently skipping the check. | |
| README_SECTION=$(awk '/^## License/{flag=1; next} /^## /{flag=0} flag' README.md) | |
| if [ -z "$(echo "$README_SECTION" | tr -d '[:space:]')" ]; then | |
| echo "::error::README.md has no non-empty '## License' section; add one stating the SPDX id ('$SPDX')" | |
| exit 1 | |
| fi | |
| README_LICENSE=$(echo "$README_SECTION" | grep -v '^[[:space:]]*$' | head -n1 | xargs) | |
| if [ "$README_LICENSE" != "$SPDX" ]; then | |
| echo "::error::package.json license is '$SPDX' but README.md's ## License section says '$README_LICENSE'" | |
| exit 1 | |
| fi | |
| test: | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| node-version: ["18", "20", "22", "24"] | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| cache: npm | |
| - run: npm ci | |
| - run: npm test | |
| packaging: | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| node-version: ["18", "20", "22", "24"] | |
| # The consumer checks below call the real init(), which heartbeats by | |
| # default to the real production endpoint with no API key: every run of | |
| # this job put a burst of unauthenticated pings in the platform's | |
| # auth-failure metrics. Off for the whole job so a consumer check added | |
| # later cannot reintroduce that, and the one step that needs a heartbeat | |
| # opts back in explicitly (an explicit option beats the environment). | |
| env: | |
| RIUS_HEARTBEAT: "false" | |
| steps: | |
| - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| cache: npm | |
| - run: npm ci | |
| - run: npm run build | |
| - run: npx publint | |
| # attw analyzes the packed tarball, so its verdict is the same on every | |
| # matrix leg — and the tool itself crashes under Node 18 ("Cannot read | |
| # properties of undefined (reading 'filename')", exit 3). | |
| - if: matrix.node-version != '18' | |
| run: npx --yes @arethetypeswrong/cli --pack . | |
| - name: ESM and CJS entrypoints both resolve | |
| run: | | |
| npm pack --pack-destination /tmp >/dev/null | |
| mkdir -p /tmp/consumer && cd /tmp/consumer && npm init -y >/dev/null | |
| npm i /tmp/glassflow-ai-rius-*.tgz @opentelemetry/api >/dev/null | |
| node -e "const m=require('@glassflow-ai/rius'); if(!m.init) throw new Error('CJS require broken')" | |
| node --input-type=module -e "import * as m from '@glassflow-ai/rius'; if(!m.init) throw new Error('ESM import broken')" | |
| - name: Dynamic import helper survives in dist/ | |
| run: | | |
| # tsup code-splits the ESM output into content-hashed chunk files, so | |
| # this cannot grep a fixed filename. Assert the dynamic-import helper | |
| # survives somewhere in dist/, and that the CJS build was not | |
| # rewritten to a require() form of the same call. | |
| if ! grep -rq "return import(s)" dist/*.js dist/*.cjs; then | |
| echo "::error::dynamic import helper not found anywhere in dist/"; exit 1 | |
| fi | |
| if grep -q "require(s)" dist/index.cjs; then | |
| echo "::error::dynamic import was rewritten to require() in the CJS build"; exit 1 | |
| fi | |
| - name: heartbeat resolves its own version in the shipped CJS build | |
| run: | | |
| # heartbeat.ts resolves SDK_VERSION via createRequire(import.meta.url). | |
| # tsup's CJS output stubs import.meta as `{}` unless `shims: true` is | |
| # set in tsup.config.ts, which makes import.meta.url undefined, | |
| # createRequire throw, and sdk_version silently degrade to "0.0.0" | |
| # forever. Nothing else in the repo needs the shim, so this guards | |
| # against it being removed as dead config. Asserted against the real | |
| # dist/index.cjs through the public init(), because that is what | |
| # consumers execute; a rebuild with different flags would prove | |
| # nothing about the shipped artifact. | |
| node -e " | |
| const { init } = require('./dist/index.cjs'); | |
| const pkgVersion = require('./package.json').version; | |
| let captured; | |
| const client = init({ | |
| endpoint: 'http://example.invalid', | |
| // Opts back in against the job-level RIUS_HEARTBEAT=false: this | |
| // check reads a heartbeat payload. The injected transport keeps | |
| // it off the network. | |
| heartbeat: true, | |
| heartbeatTransport: async (payload) => { captured = payload; }, | |
| }); | |
| setTimeout(async () => { | |
| await client.shutdown(); | |
| if (captured === undefined) { | |
| console.error('no heartbeat payload was captured'); | |
| process.exit(1); | |
| } | |
| if (captured.sdk_version !== pkgVersion) { | |
| console.error('sdk_version resolved to ' + JSON.stringify(captured.sdk_version) + ', expected ' + JSON.stringify(pkgVersion)); | |
| process.exit(1); | |
| } | |
| console.log('sdk_version in dist/index.cjs: ' + captured.sdk_version); | |
| }, 100); | |
| " | |
| - name: anthropic and langchain trace a real call from a CJS consumer | |
| env: | |
| NODE_MAJOR: ${{ matrix.node-version }} | |
| run: | | |
| # The unit tests run under vitest, which resolves @langchain/core | |
| # through its own module graph. @langchain/core ships separate CJS and | |
| # ESM builds, so a copy the SDK patched under one of them would not be | |
| # the copy a plain `require` returns. This asserts a span comes out the | |
| # far end of a packed install, which is the only proof that survives | |
| # that difference. Anthropic is asserted here too because its module | |
| # hook watches CJS require, so this is where it is expected to fire. | |
| # | |
| # @langchain/core 1.x declares engines.node >=20, so the langchain half | |
| # is asserted only from that leg up. Numeric comparison (-lt), not | |
| # string: "8" >= "20" lexicographically but 8 < 20 numerically. | |
| cd /tmp/consumer | |
| WANT_LANGCHAIN=1 | |
| if [ "$NODE_MAJOR" -lt 20 ]; then | |
| echo "skipping the langchain half on Node $NODE_MAJOR (@langchain/core requires >=20)" | |
| WANT_LANGCHAIN=0 | |
| fi | |
| export WANT_LANGCHAIN | |
| npm i @arizeai/openinference-instrumentation-anthropic @anthropic-ai/sdk @opentelemetry/sdk-trace-base >/dev/null | |
| if [ "$WANT_LANGCHAIN" = "1" ]; then | |
| npm i @arizeai/openinference-instrumentation-langchain @langchain/core >/dev/null | |
| fi | |
| node -e " | |
| const http = require('node:http'); | |
| const { init } = require('@glassflow-ai/rius'); | |
| const { InMemorySpanExporter } = require('@opentelemetry/sdk-trace-base'); | |
| // Required BEFORE init(), the way real CJS apps order their | |
| // requires. The entry patches the cached CJS exports directly; a | |
| // require that only happens after init() would get the ESM build | |
| // patched instead (the documented residual gap). | |
| const Anthropic = require('@anthropic-ai/sdk').default; | |
| const exporter = new InMemorySpanExporter(); | |
| const client = init({ spanExporter: exporter }); | |
| const reply = { id: 'm', type: 'message', role: 'assistant', model: 'claude-test', | |
| content: [{ type: 'text', text: '4' }], stop_reason: 'end_turn', | |
| usage: { input_tokens: 5, output_tokens: 1 } }; | |
| const wantLangchain = process.env.WANT_LANGCHAIN === '1'; | |
| client.ready.then(async (enabled) => { | |
| const want = wantLangchain ? ['anthropic', 'langchain'] : ['anthropic']; | |
| for (const name of want) { | |
| if (!enabled.includes(name)) { | |
| throw new Error(name + ' did not load under CJS: ' + JSON.stringify(enabled)); | |
| } | |
| } | |
| if (wantLangchain) { | |
| const { RunnableLambda } = require('@langchain/core/runnables'); | |
| await RunnableLambda.from((x) => 'echo:' + x).withConfig({ runName: 'CiEcho' }).invoke('hi'); | |
| } | |
| const server = http.createServer((request, response) => { | |
| request.resume(); | |
| request.on('end', () => { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(reply)); | |
| }); | |
| }); | |
| await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const anthropic = new Anthropic({ apiKey: 'not-a-real-key', baseURL: 'http://127.0.0.1:' + server.address().port }); | |
| await anthropic.messages.create({ model: 'claude-test', max_tokens: 8, messages: [{ role: 'user', content: '2+2' }] }); | |
| server.close(); | |
| // Read before shutdown(): InMemorySpanExporter.shutdown() clears | |
| // what it collected. flush() drains the batch processor | |
| // deterministically instead of guessing at its schedule delay. | |
| await client.flush(); | |
| const names = exporter.getFinishedSpans().map((span) => span.name); | |
| const expectedNames = wantLangchain ? ['CiEcho', 'Anthropic Messages'] : ['Anthropic Messages']; | |
| for (const expected of expectedNames) { | |
| if (!names.includes(expected)) { | |
| throw new Error('no ' + expected + ' span was exported: ' + JSON.stringify(names)); | |
| } | |
| } | |
| process.exit(0); | |
| }).catch((e) => { console.error(e.message); process.exit(1); }); | |
| " | |
| - name: anthropic traces a real call from a pure-ESM consumer | |
| run: | | |
| # The point of the dual-build patching: a consumer that only ever | |
| # `import`s the provider gets spans. The require hook cannot see ESM | |
| # importers, so before this feature the call below produced nothing — | |
| # while ready still reported the integration enabled. Static imports | |
| # on purpose: they all evaluate before init() runs, which is exactly | |
| # the ordering a real ESM app has. Asserted from a packed install | |
| # because vitest's module graph does not reproduce a real consumer's | |
| # ESM/CJS split. | |
| cd /tmp/consumer | |
| cat > esm-check.mjs <<'EOF' | |
| import http from "node:http"; | |
| import { init } from "@glassflow-ai/rius"; | |
| import Anthropic from "@anthropic-ai/sdk"; | |
| import { InMemorySpanExporter } from "@opentelemetry/sdk-trace-base"; | |
| const exporter = new InMemorySpanExporter(); | |
| const client = init({ spanExporter: exporter }); | |
| const enabled = await client.ready; | |
| if (!enabled.includes("anthropic")) { | |
| throw new Error("anthropic did not load under ESM: " + JSON.stringify(enabled)); | |
| } | |
| const reply = { id: "m", type: "message", role: "assistant", model: "claude-test", | |
| content: [{ type: "text", text: "4" }], stop_reason: "end_turn", | |
| usage: { input_tokens: 5, output_tokens: 1 } }; | |
| const server = http.createServer((request, response) => { | |
| request.resume(); | |
| request.on("end", () => { | |
| response.writeHead(200, { "content-type": "application/json" }); | |
| response.end(JSON.stringify(reply)); | |
| }); | |
| }); | |
| await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); | |
| const anthropic = new Anthropic({ apiKey: "not-a-real-key", baseURL: "http://127.0.0.1:" + server.address().port }); | |
| await anthropic.messages.create({ model: "claude-test", max_tokens: 8, messages: [{ role: "user", content: "2+2" }] }); | |
| server.close(); | |
| // Read before shutdown(): InMemorySpanExporter.shutdown() clears | |
| // what it collected. flush() drains the batch processor | |
| // deterministically instead of guessing at its schedule delay. | |
| await client.flush(); | |
| const names = exporter.getFinishedSpans().map((span) => span.name); | |
| if (!names.includes("Anthropic Messages")) { | |
| throw new Error("no Anthropic Messages span from the ESM consumer: " + JSON.stringify(names)); | |
| } | |
| EOF | |
| node esm-check.mjs | |
| - name: mcp traces a real tool call from CJS and ESM consumers | |
| run: | | |
| # The MCP SDK dual-builds (import -> dist/esm, require -> dist/cjs), | |
| # so the registry's dynamic import patches a DIFFERENT Client class | |
| # from the one a CJS consumer requires. Before the cached-CJS | |
| # patching, this exact leg reproduced ready:["mcp"] with zero spans. | |
| # Asserted from a packed install for the same reason as the provider | |
| # legs: vitest's module graph does not reproduce a real consumer's | |
| # ESM/CJS split. The span-kind assertion is the functional proof; | |
| # "reports enabled" alone has hidden three defects now. | |
| cd /tmp/consumer | |
| npm i @modelcontextprotocol/sdk >/dev/null | |
| cat > mcp-body.js <<'EOF' | |
| module.exports = async function run(init, InMemorySpanExporter, mcpSdk) { | |
| const { Client, McpServer, InMemoryTransport } = mcpSdk; | |
| const exporter = new InMemorySpanExporter(); | |
| const client = init({ spanExporter: exporter }); | |
| const enabled = await client.ready; | |
| if (!enabled.includes("mcp")) { | |
| throw new Error("mcp did not load: " + JSON.stringify(enabled)); | |
| } | |
| const server = new McpServer({ name: "t", version: "1.0.0" }); | |
| server.tool("add", {}, async () => ({ content: [{ type: "text", text: "3" }] })); | |
| const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); | |
| await server.connect(serverTransport); | |
| const mcp = new Client({ name: "c", version: "1.0.0" }); | |
| await mcp.connect(clientTransport); | |
| await mcp.callTool({ name: "add", arguments: {} }); | |
| await client.flush(); | |
| const spans = exporter.getFinishedSpans(); | |
| const tool = spans.find((s) => s.name === "execute_tool add"); | |
| if (!tool) { | |
| throw new Error("no tool span exported: " + JSON.stringify(spans.map((s) => s.name))); | |
| } | |
| if (tool.attributes["openinference.span.kind"] !== "TOOL") { | |
| throw new Error("span is not a TOOL span: " + JSON.stringify(tool.attributes)); | |
| } | |
| }; | |
| EOF | |
| cat > mcp-check.cjs <<'EOF' | |
| const { init } = require("@glassflow-ai/rius"); | |
| const { InMemorySpanExporter } = require("@opentelemetry/sdk-trace-base"); | |
| // Required BEFORE init(), the way real CJS apps order their requires; | |
| // this is the require the cached-CJS patching exists to cover. | |
| const { Client } = require("@modelcontextprotocol/sdk/client/index.js"); | |
| const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js"); | |
| const { InMemoryTransport } = require("@modelcontextprotocol/sdk/inMemory.js"); | |
| const run = require("./mcp-body.js"); | |
| run(init, InMemorySpanExporter, { Client, McpServer, InMemoryTransport }) | |
| .then(() => process.exit(0)) | |
| .catch((e) => { console.error(e.message); process.exit(1); }); | |
| EOF | |
| node mcp-check.cjs | |
| cat > mcp-check.mjs <<'EOF' | |
| import { createRequire } from "node:module"; | |
| import { init } from "@glassflow-ai/rius"; | |
| import { InMemorySpanExporter } from "@opentelemetry/sdk-trace-base"; | |
| import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | |
| import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; | |
| const run = createRequire(import.meta.url)("./mcp-body.js"); | |
| await run(init, InMemorySpanExporter, { Client, McpServer, InMemoryTransport }); | |
| EOF | |
| node mcp-check.mjs | |
| - name: vercel-ai turns a mock generateText into OpenInference spans (CJS consumer) | |
| env: | |
| NODE_MAJOR: ${{ matrix.node-version }} | |
| run: | | |
| # @arizeai/openinference-vercel declares engines.node >=22; only | |
| # install and assert it on legs at or above that. Numeric comparison | |
| # (-lt), not string comparison: "8" >= "22" lexicographically but | |
| # 8 < 22 numerically, and every value here is a plain integer string. | |
| # | |
| # The functional shape on ai v7: the OTel spans come from the | |
| # @ai-sdk/otel telemetry integration (ai itself no longer depends on | |
| # @opentelemetry/api), and our processor entry transforms them with | |
| # OpenInference attributes. Asserting on openinference.span.kind | |
| # proves the PROCESSOR ran; "reports enabled" alone proves nothing. | |
| # The ai package is ESM-only, so the CJS consumer reaches it through | |
| # dynamic import, the way a real CJS app would. | |
| if [ "$NODE_MAJOR" -lt 22 ]; then | |
| echo "skipping vercel-ai assertion on Node $NODE_MAJOR (requires >=22)" | |
| exit 0 | |
| fi | |
| cd /tmp/consumer | |
| npm i @arizeai/openinference-vercel ai @ai-sdk/otel >/dev/null | |
| cat > vercel-check.cjs <<'EOF' | |
| const { init } = require("@glassflow-ai/rius"); | |
| const { InMemorySpanExporter } = require("@opentelemetry/sdk-trace-base"); | |
| const exporter = new InMemorySpanExporter(); | |
| const client = init({ spanExporter: exporter }); | |
| client.ready.then(async (enabled) => { | |
| if (!enabled.includes("vercel-ai")) { | |
| throw new Error("vercel-ai did not load under CJS: " + JSON.stringify(enabled)); | |
| } | |
| const { generateText } = await import("ai"); | |
| const { MockLanguageModelV3 } = await import("ai/test"); | |
| const { OpenTelemetry } = await import("@ai-sdk/otel"); | |
| const mock = new MockLanguageModelV3({ | |
| doGenerate: async () => ({ | |
| content: [{ type: "text", text: "4" }], | |
| finishReason: "stop", | |
| usage: { inputTokens: 5, outputTokens: 1, totalTokens: 6 }, | |
| warnings: [], | |
| }), | |
| }); | |
| await generateText({ | |
| model: mock, | |
| prompt: "2+2", | |
| experimental_telemetry: { isEnabled: true, integrations: [new OpenTelemetry()] }, | |
| }); | |
| await client.flush(); | |
| const spans = exporter.getFinishedSpans(); | |
| const kinds = spans.map((s) => s.attributes["openinference.span.kind"]); | |
| if (!kinds.includes("LLM")) { | |
| throw new Error( | |
| "no OpenInference LLM span; the processor did not transform: " + | |
| JSON.stringify(spans.map((s) => [s.name, s.attributes["openinference.span.kind"]])), | |
| ); | |
| } | |
| process.exit(0); | |
| }).catch((e) => { console.error(e.message); process.exit(1); }); | |
| EOF | |
| node vercel-check.cjs | |
| - name: '@opentelemetry/api is a peer and is not bundled' | |
| run: | | |
| node -e " | |
| const p = require('./package.json'); | |
| if (!p.peerDependencies?.['@opentelemetry/api']) throw new Error('must be a peerDependency'); | |
| if (p.dependencies?.['@opentelemetry/api']) throw new Error('must not be a dependency'); | |
| " | |
| if grep -rq "AsyncLocalStorageContextManager" dist/*.js dist/*.cjs; then | |
| echo "::error::@opentelemetry/api appears bundled into dist"; exit 1 | |
| fi |