From 3e10204deb4b17c43def45ce02ac92f60fc6dcd7 Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Mon, 21 Sep 2026 21:02:37 +0000 Subject: [PATCH 1/8] feat(AIC-3416): diagnose ESM externalization during initialization Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/__tests__/import-diagnostics.test.ts | 154 ++++++++++++++++++ packages/client/src/import-diagnostics.ts | 87 ++++++++++ packages/client/src/lifecycle.ts | 20 ++- 3 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 packages/client/src/__tests__/import-diagnostics.test.ts create mode 100644 packages/client/src/import-diagnostics.ts diff --git a/packages/client/src/__tests__/import-diagnostics.test.ts b/packages/client/src/__tests__/import-diagnostics.test.ts new file mode 100644 index 00000000..fcb09791 --- /dev/null +++ b/packages/client/src/__tests__/import-diagnostics.test.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + describeImportFailure, + esmExternalizationDiagnostic, + isEsmInteropError, + isModuleNotFoundError, + moduleNameFromError, + resetEsmExternalizationWarning, + warnEsmExternalizationOnce, +} from '../import-diagnostics.js'; + +const MISSING_NODE_SDK = + '[LaunchDarkly] @launchdarkly/node-server-sdk is not installed. ' + + 'Either install it (npm install @launchdarkly/node-server-sdk) or pass a ' + + 'pre-initialized LD client to initClient().'; + +function errorWithCode(message: string, code?: string): Error { + const error = new Error(message); + if (code) Object.assign(error, { code }); + return error; +} + +describe('import diagnostics', () => { + beforeEach(() => { + resetEsmExternalizationWarning(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('isEsmInteropError', () => { + it.each([ + ['ERR_REQUIRE_ESM by code', errorWithCode('boom', 'ERR_REQUIRE_ESM')], + ['ERR_PACKAGE_PATH_NOT_EXPORTED by code', errorWithCode('boom', 'ERR_PACKAGE_PATH_NOT_EXPORTED')], + [ + 'require() of ES Module message', + new Error('require() of ES Module /app/node_modules/@launchdarkly/ai-server/dist/index.js is not supported'), + ], + ['import-only message', new Error('Must use import to load ES Module: /app/node_modules/foo/index.js')], + ['missing exports main', new Error('No "exports" main defined in /app/node_modules/foo/package.json')], + ['subpath not exported', new Error(`Package subpath './dist' is not defined by "exports"`)], + ])('recognizes %s', (_label, error) => { + expect(isEsmInteropError(error)).toBe(true); + }); + + it.each([ + ['a genuinely missing module', errorWithCode("Cannot find module 'foo'", 'MODULE_NOT_FOUND')], + ['an unrelated failure', new TypeError('Reading config of undefined')], + ['a non-error throw', 'something went wrong'], + ])('does not recognize %s', (_label, error) => { + expect(isEsmInteropError(error)).toBe(false); + }); + }); + + describe('isModuleNotFoundError', () => { + it.each(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND'])('recognizes %s', (code) => { + expect(isModuleNotFoundError(errorWithCode('nope', code))).toBe(true); + }); + + it('recognizes the message form when no code is attached', () => { + expect(isModuleNotFoundError(new Error("Cannot find package '@launchdarkly/node-server-sdk'"))).toBe(true); + }); + + it('does not recognize an interoperability failure', () => { + expect(isModuleNotFoundError(errorWithCode('boom', 'ERR_REQUIRE_ESM'))).toBe(false); + }); + }); + + describe('moduleNameFromError', () => { + it('reads the specifier from a missing-module message', () => { + expect(moduleNameFromError(new Error("Cannot find module '@opentelemetry/core'"), 'fallback')).toBe( + '@opentelemetry/core', + ); + }); + + it('reads the package name from a node_modules path', () => { + const error = new Error('require() of ES Module /app/node_modules/@opentelemetry/sdk-trace-node/build/index.js'); + expect(moduleNameFromError(error, 'fallback')).toBe('@opentelemetry/sdk-trace-node'); + }); + + it('falls back when the message names no package', () => { + expect(moduleNameFromError(new Error('boom'), 'an OpenTelemetry SDK package')).toBe( + 'an OpenTelemetry SDK package', + ); + }); + }); + + describe('esmExternalizationDiagnostic', () => { + it('names the module and carries the remediation and recipe link', () => { + const message = esmExternalizationDiagnostic('@launchdarkly/node-server-sdk'); + expect(message).toContain('@launchdarkly/node-server-sdk'); + expect(message).toContain('ESM package was externalized into CommonJS output'); + expect(message).toContain('/^@launchdarkly\\/ai-/'); + expect(message).toContain( + 'https://github.com/launchdarkly/js-ai-sdk/blob/main/packages/ai-node/README.md#commonjs-aws-lambda-and-webpack', + ); + }); + }); + + describe('describeImportFailure', () => { + it('returns the externalization diagnostic with the original error as cause', () => { + const original = errorWithCode('require() of ES Module', 'ERR_REQUIRE_ESM'); + const described = describeImportFailure('@launchdarkly/node-server-sdk', original, MISSING_NODE_SDK); + + expect(described.message).toBe(esmExternalizationDiagnostic('@launchdarkly/node-server-sdk')); + expect(described.cause).toBe(original); + }); + + it('keeps the missing-dependency guidance for a genuine MODULE_NOT_FOUND', () => { + const original = errorWithCode("Cannot find module '@launchdarkly/node-server-sdk'", 'MODULE_NOT_FOUND'); + const described = describeImportFailure('@launchdarkly/node-server-sdk', original, MISSING_NODE_SDK); + + expect(described.message).toBe(MISSING_NODE_SDK); + expect(described.message).not.toContain('externalized'); + expect(described.cause).toBe(original); + }); + + it('passes an unrelated failure through unchanged', () => { + const original = new TypeError('Reading config of undefined'); + expect(describeImportFailure('@launchdarkly/node-server-sdk', original, MISSING_NODE_SDK)).toBe(original); + }); + + it('wraps a non-error throw without labeling it', () => { + const described = describeImportFailure('@launchdarkly/node-server-sdk', 'kaboom', MISSING_NODE_SDK); + expect(described.message).toBe('kaboom'); + }); + }); + + describe('warnEsmExternalizationOnce', () => { + it('warns with the diagnostic and the original error message', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + warnEsmExternalizationOnce('@opentelemetry/sdk-trace-node', new Error('require() of ES Module')); + + const message = warn.mock.calls[0][0] as string; + expect(message).toContain('@opentelemetry/sdk-trace-node'); + expect(message).toContain('ESM package was externalized into CommonJS output'); + expect(message).toContain('Original error: require() of ES Module'); + }); + + it('warns only once until the warning state is reset', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = new Error('require() of ES Module'); + + warnEsmExternalizationOnce('@opentelemetry/core', error); + warnEsmExternalizationOnce('@opentelemetry/core', error); + expect(warn).toHaveBeenCalledTimes(1); + + resetEsmExternalizationWarning(); + warnEsmExternalizationOnce('@opentelemetry/core', error); + expect(warn).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/client/src/import-diagnostics.ts b/packages/client/src/import-diagnostics.ts new file mode 100644 index 00000000..3a87430b --- /dev/null +++ b/packages/client/src/import-diagnostics.ts @@ -0,0 +1,87 @@ +const LD_BUNDLER_RECIPE_URL = + 'https://github.com/launchdarkly/js-ai-sdk/blob/main/packages/ai-node/README.md#commonjs-aws-lambda-and-webpack'; + +const ESM_INTEROP_ERROR_CODES = new Set(['ERR_REQUIRE_ESM', 'ERR_PACKAGE_PATH_NOT_EXPORTED']); + +const ESM_INTEROP_MESSAGE_PATTERNS = [ + /ERR_REQUIRE_ESM/, + /ERR_PACKAGE_PATH_NOT_EXPORTED/, + /require\(\) of ES Module/i, + /Must use import to load ES ?Module/i, + /No "exports" main defined/i, + /is not defined by "exports"/i, +]; + +const MODULE_NOT_FOUND_ERROR_CODES = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND']); + +let esmExternalizationWarned = false; + +function errorCode(error: unknown): string | undefined { + const code = (error as { code?: unknown } | null)?.code; + return typeof code === 'string' ? code : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Recognizes the Node module-interoperability failures produced when a bundler + * downlevels an `await import()` of an ESM-only package into `require()`. + */ +export function isEsmInteropError(error: unknown): boolean { + const code = errorCode(error); + if (code && ESM_INTEROP_ERROR_CODES.has(code)) return true; + const message = errorMessage(error); + return ESM_INTEROP_MESSAGE_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function isModuleNotFoundError(error: unknown): boolean { + const code = errorCode(error); + if (code && MODULE_NOT_FOUND_ERROR_CODES.has(code)) return true; + return /Cannot find (?:module|package)/i.test(errorMessage(error)); +} + +export function moduleNameFromError(error: unknown, fallback: string): string { + const message = errorMessage(error); + const specifier = message.match(/Cannot find (?:module|package) '([^']+)'/); + if (specifier) return specifier[1]; + const fromPath = message.match(/node_modules\/((?:@[^/]+\/)?[^/]+)/); + return fromPath ? fromPath[1] : fallback; +} + +export function esmExternalizationDiagnostic(moduleName: string): string { + return ( + `[LaunchDarkly] Could not load "${moduleName}" during initialization because an ` + + 'ESM package was externalized into CommonJS output, so the bundled require() cannot ' + + 'resolve it. Allowlist the LaunchDarkly AI packages so the bundler inlines them:\n' + + ' externals: [nodeExternals({ allowlist: [/^@launchdarkly\\/ai-/] })]\n' + + ` AWS Lambda + Webpack recipe: ${LD_BUNDLER_RECIPE_URL}` + ); +} + +/** + * Classifies a failed optional dynamic import. ESM-externalization and genuinely + * missing dependencies get actionable guidance; anything else is passed through + * unchanged so unrelated failures are not mislabeled. + */ +export function describeImportFailure(moduleName: string, error: unknown, missingDependencyMessage: string): Error { + if (isEsmInteropError(error)) { + return new Error(esmExternalizationDiagnostic(moduleName), { cause: error }); + } + if (isModuleNotFoundError(error)) { + return new Error(missingDependencyMessage, { cause: error }); + } + return error instanceof Error ? error : new Error(errorMessage(error)); +} + +export function warnEsmExternalizationOnce(moduleName: string, error: unknown): void { + if (esmExternalizationWarned) return; + esmExternalizationWarned = true; + // biome-ignore lint/suspicious/noConsole: intentional warning for a bundler misconfiguration + console.warn(`${esmExternalizationDiagnostic(moduleName)}\n Original error: ${errorMessage(error)}`); +} + +export function resetEsmExternalizationWarning(): void { + esmExternalizationWarned = false; +} diff --git a/packages/client/src/lifecycle.ts b/packages/client/src/lifecycle.ts index 06733bc5..9187172b 100644 --- a/packages/client/src/lifecycle.ts +++ b/packages/client/src/lifecycle.ts @@ -1,6 +1,13 @@ import 'dotenv/config'; import { trace } from '@opentelemetry/api'; import { ConversationIdSpanProcessor } from './conversation.js'; +import { + describeImportFailure, + isEsmInteropError, + moduleNameFromError, + resetEsmExternalizationWarning, + warnEsmExternalizationOnce, +} from './import-diagnostics.js'; import { flushAiSdkInfo, resetAiSdkInfo } from './sdk-info.js'; import type { AiConfigRep, InitBaseClientOptions, LDClientInterface, LDContext, VariationMeta } from './types.js'; import { parseAiConfig } from './types.js'; @@ -65,7 +72,11 @@ async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): P ({ resourceFromAttributes } = await import('@opentelemetry/resources')); ({ AsyncLocalStorageContextManager } = await import('@opentelemetry/context-async-hooks')); ({ CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator } = await import('@opentelemetry/core')); - } catch { + } catch (err) { + if (isEsmInteropError(err)) { + warnEsmExternalizationOnce(moduleNameFromError(err, 'an OpenTelemetry SDK package'), err); + return; + } // biome-ignore lint/suspicious/noConsole: intentional warning when optional OTel peer deps are missing console.warn( '[LaunchDarkly] Telemetry is disabled because one or more OpenTelemetry SDK ' + @@ -131,6 +142,7 @@ export async function waitForTelemetry(timeoutMs = 5000): Promise { * Must be called before process.exit() to ensure all pending spans are exported. */ export async function shutdownTelemetry(): Promise { + resetEsmExternalizationWarning(); if (tracerProvider) { await tracerProvider.shutdown(); tracerProvider = null; @@ -175,8 +187,10 @@ async function initBaseClient(options: InitBaseClientOptions = {}): Promise Date: Mon, 21 Sep 2026 21:17:01 +0000 Subject: [PATCH 2/8] ci(AIC-3417): canary published artifacts in an AWS Lambda Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 114 ++++++++- .gitignore | 1 + biome.json | 3 +- integration-tests/lambda-commonjs/README.md | 88 +++++++ integration-tests/lambda-commonjs/canary.mjs | 218 ++++++++++++++++++ .../lambda-commonjs/canary.test.ts | 93 ++++++++ .../lambda-commonjs/fixture/handler.cjs | 24 ++ .../lambda-commonjs/fixture/serverless.yml | 34 +++ .../fixture/webpack.config.cjs | 19 ++ package.json | 2 +- 10 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 integration-tests/lambda-commonjs/README.md create mode 100644 integration-tests/lambda-commonjs/canary.mjs create mode 100644 integration-tests/lambda-commonjs/canary.test.ts create mode 100644 integration-tests/lambda-commonjs/fixture/handler.cjs create mode 100644 integration-tests/lambda-commonjs/fixture/serverless.yml create mode 100644 integration-tests/lambda-commonjs/fixture/webpack.config.cjs diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index dfdd3803..d99d3e9f 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -10,6 +10,11 @@ # - Allows publishing a specific package to npm # - Supports prerelease and dry-run modes # +# RELEASE CANARY (after a publish, or on demand): +# - Installs the exact published versions into a CommonJS Serverless/Webpack app +# - Deploys, invokes, and removes an ephemeral Node 22 Lambda via GitHub OIDC +# - Requires the AWS_LAMBDA_CANARY_ROLE_ARN repository variable; skipped while it is unset +# name: release-please on: @@ -41,6 +46,11 @@ on: description: 'Is this a dry run. If so no package will be published.' type: boolean required: true + canary_versions: + description: 'Run only the Lambda release canary against these exact published versions, e.g. ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0' + type: string + required: false + default: '' jobs: release-please: @@ -303,7 +313,7 @@ jobs: manual-publish: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' + if: ${{ github.event_name == 'workflow_dispatch' && inputs.canary_versions == '' }} permissions: id-token: write contents: read @@ -319,3 +329,105 @@ jobs: workspace_path: ${{ inputs.workspace_path }} prerelease: ${{ inputs.prerelease }} dry_run: ${{ inputs.dry_run }} + + # --- Published-artifact Lambda release canary --- + # + # Proves the versions that just landed on npm can be installed into a clean CommonJS + # package, bundled by Serverless 3 + Webpack 5 + webpack-node-externals 3, loaded by a real + # Node 22 Lambda, and removed again. It loads packages only — no SDK key, no flag evaluation, + # no model call. See integration-tests/lambda-commonjs/README.md for the required AWS role. + + release-canary: + name: Published-artifact Lambda canary + runs-on: ubuntu-latest + needs: + - release-please + - release-client + - release-ai-node + - release-ai-otel + - release-openai-messages + if: >- + ${{ always() + && !contains(needs.*.result, 'failure') + && !contains(needs.*.result, 'cancelled') + && vars.AWS_LAMBDA_CANARY_ROLE_ARN != '' + && (github.event_name != 'workflow_dispatch' || inputs.canary_versions != '') + && (github.event_name == 'workflow_dispatch' + || needs.release-please.outputs.package-ai-node-released == 'true' + || needs.release-please.outputs.package-ai-otel-released == 'true' + || needs.release-please.outputs.package-openai-messages-released == 'true') }} + permissions: + contents: read + id-token: write + env: + AWS_REGION: ${{ vars.AWS_LAMBDA_CANARY_REGION || 'us-east-1' }} + CANARY_STAGE: c${{ github.run_id }}-${{ github.run_attempt }} + SLS_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Resolve canary versions + id: versions + env: + REQUESTED_VERSIONS: ${{ inputs.canary_versions }} + run: | + set -euo pipefail + if [ -n "${REQUESTED_VERSIONS:-}" ]; then + resolved="$REQUESTED_VERSIONS" + else + resolved=$(node -e "const m=require('./.release-please-manifest.json');process.stdout.write(['ai-node','openai-messages','ai-otel'].map((p)=>p+'='+m['packages/'+p]).join(','))") + fi + echo "Canary versions: $resolved" + echo "resolved=$resolved" >> "$GITHUB_OUTPUT" + + - name: Wait for the versions to be visible on npm + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: node integration-tests/lambda-commonjs/canary.mjs wait + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 + with: + audience: 'https://github.com/launchdarkly' + role-to-assume: ${{ vars.AWS_LAMBDA_CANARY_ROLE_ARN }} + role-session-name: JsAiSdkLambdaCanary + aws-region: ${{ env.AWS_REGION }} + + - name: Build the canary package + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: node integration-tests/lambda-commonjs/canary.mjs build --package --stage="$CANARY_STAGE" + + - name: Deploy the ephemeral Lambda + working-directory: integration-tests/lambda-commonjs/.canary/app + run: npx serverless deploy --stage "$CANARY_STAGE" + + - name: Invoke and assert the capability payload + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: | + set -euo pipefail + aws lambda invoke \ + --function-name "ld-ai-canary-$CANARY_STAGE" \ + --cli-binary-format raw-in-base64-out \ + --payload '{}' \ + response.json + cat response.json + node integration-tests/lambda-commonjs/canary.mjs assert --response=response.json + + - name: Remove the ephemeral Lambda + if: always() + run: | + set -uo pipefail + status=0 + (cd integration-tests/lambda-commonjs/.canary/app && npx serverless remove --stage "$CANARY_STAGE") || status=1 + aws logs delete-log-group --log-group-name "/aws/lambda/ld-ai-canary-$CANARY_STAGE" 2>/dev/null || true + if [ "$status" -ne 0 ]; then + echo "::error::Canary cleanup failed — check for a leftover ld-ai-canary-$CANARY_STAGE stack" + fi + exit "$status" diff --git a/.gitignore b/.gitignore index 7065b80a..52a1ebde 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,6 @@ output/ spec.md tmp/ integration-tests/**/.staging/ +integration-tests/**/.canary/ .claude/ \ No newline at end of file diff --git a/biome.json b/biome.json index 03774cdf..6934fbe2 100644 --- a/biome.json +++ b/biome.json @@ -48,7 +48,8 @@ "!**/node_modules", "!**/*.d.ts", "!**/output", - "!**/.staging" + "!**/.staging", + "!**/.canary" ] }, "overrides": [ diff --git a/integration-tests/lambda-commonjs/README.md b/integration-tests/lambda-commonjs/README.md new file mode 100644 index 00000000..f2512173 --- /dev/null +++ b/integration-tests/lambda-commonjs/README.md @@ -0,0 +1,88 @@ +# Published-artifact Lambda release canary + +Owner: the LaunchDarkly AI SDK team (maintainers of `launchdarkly/js-ai-sdk`). + +This fixture is the post-publish half of the [AIC-3370](https://launchdarkly.atlassian.net/browse/AIC-3370) +CommonJS work. The `integration-tests/commonjs-webpack` suite proves that *packed* artifacts load from a +Webpack CommonJS bundle; this canary proves the same thing for the artifacts that actually reached npm, +running inside a real Node 22 AWS Lambda. + +It installs **exact published versions** — never workspace links, never a dist-tag — bundles them with +Serverless Framework 3, Webpack 5 and `webpack-node-externals` 3 (so the LaunchDarkly packages stay +external and are resolved by Node at runtime, the AIC-3370 failure path), deploys an ephemeral function, +invokes it once, asserts the capability payload, and removes the function and its log group. + +The canary loads packages only. It uses **no SDK key, evaluates no flag, and makes no model call**, so it +costs nothing beyond a single Lambda invocation. + +## What runs + +`canary.mjs` is the whole driver. It takes one exact version per package, either as flags or as +`LD_AI_CANARY_VERSIONS='ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'`, and refuses anything that is +not an exact semver. + +| Command | What it does | +| --- | --- | +| `wait` | Polls `npm view` until all three versions are visible (npm publishes are not immediately readable). | +| `build` | Stages `.canary/app` from `fixture/`, installs the exact versions with `--save-exact`, verifies the resolved tree, and optionally runs `serverless package`. | +| `assert` | Validates an invoke payload: `ok`, a `v22.x` runtime, the expected capability symbols, and the exact versions the Lambda loaded. | + +The deployed function is `ld-ai-canary-`, where the workflow uses a stage unique to the run. + +## Local dry run (no AWS credentials needed) + +```bash +cd integration-tests/lambda-commonjs +node canary.mjs build --ai-node=0.2.0 --openai-messages=0.2.0 --ai-otel=0.1.1 --package --stage=local +unzip -l .canary/app/.serverless/ld-ai-canary.zip | grep '@launchdarkly' +``` + +Serverless 3 does not know the `nodejs22.x` runtime in its config schema, so packaging prints an +`Invalid configuration encountered` warning for `provider.runtime`. The fixture sets +`configValidationMode: warn` so this stays a warning; CloudFormation accepts the runtime and the deployed +function is Node 22 (the canary asserts `process.version` to prove it). + +`yarn test:integration` runs `canary.test.ts`, which covers the assertion logic and the exact-version +guard without touching the network or AWS. + +## AWS prerequisite + +The workflow job authenticates with GitHub OIDC only — **there are no long-lived AWS keys in this repo and +none may be added.** It stays skipped until these repository variables exist: + +| Variable | Purpose | +| --- | --- | +| `AWS_LAMBDA_CANARY_ROLE_ARN` | Role assumed via OIDC. Required; the job is skipped while unset. | +| `AWS_LAMBDA_CANARY_REGION` | Optional, defaults to `us-east-1`. | + +The role's trust policy should allow `token.actions.githubusercontent.com` with +`sub` restricted to `repo:launchdarkly/js-ai-sdk:*` and `aud` of `https://github.com/launchdarkly`. + +Least-privilege permissions needed by deploy, invoke and cleanup, all scoped to the canary's own +resources (`ld-ai-canary-*`): + +- `cloudformation:CreateStack`, `UpdateStack`, `DeleteStack`, `DescribeStacks`, `DescribeStackEvents`, + `DescribeStackResource(s)`, `ValidateTemplate`, `GetTemplate`, `ListStackResources` +- `s3:CreateBucket`, `DeleteBucket`, `ListBucket`, `GetObject`, `PutObject`, `DeleteObject`, + `GetBucketLocation`, `GetBucketPolicy`, `PutBucketPolicy`, `PutEncryptionConfiguration` on the + Serverless deployment bucket +- `lambda:CreateFunction`, `DeleteFunction`, `GetFunction`, `GetFunctionConfiguration`, + `UpdateFunctionCode`, `UpdateFunctionConfiguration`, `InvokeFunction`, `ListVersionsByFunction`, + `TagResource` +- `logs:CreateLogGroup`, `DeleteLogGroup`, `DescribeLogGroups`, `PutRetentionPolicy` +- `iam:CreateRole`, `DeleteRole`, `GetRole`, `PassRole`, `PutRolePolicy`, `DeleteRolePolicy`, + `AttachRolePolicy`, `DetachRolePolicy` for the function's execution role + +## When it fails + +A failure means a published artifact cannot be loaded from a CommonJS Lambda bundle — the AIC-3370 bug, +reintroduced. Reproduce locally with the dry run above against the published versions, then invoke the +generated bundle directly: + +```bash +cd integration-tests/lambda-commonjs/.canary/app +node -e "require('./.webpack/service/handler.js').handler().then(console.log, console.error)" +``` + +Cleanup always runs. If it still fails, the workflow annotates the run with the leftover stage name; delete +the `ld-ai-canary-` CloudFormation stack and the `/aws/lambda/ld-ai-canary-` log group by hand. diff --git a/integration-tests/lambda-commonjs/canary.mjs b/integration-tests/lambda-commonjs/canary.mjs new file mode 100644 index 00000000..0a0f70b8 --- /dev/null +++ b/integration-tests/lambda-commonjs/canary.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +// Release canary driver: stages a clean CommonJS Serverless app that installs the exact +// published versions of the LaunchDarkly AI packages, then packages, waits on npm, or asserts +// the capability payload a deployed Lambda returned. +// +// Usage: +// node canary.mjs wait --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 +// node canary.mjs build --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 [--package] [--stage=local] +// node canary.mjs assert --response= --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 +// +// Versions may also be supplied as LD_AI_CANARY_VERSIONS='ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'. + +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +export const appDir = join(here, '.canary', 'app'); + +/** Short name -> published package name. One core wrapper, one message handler, the OTel bundle. */ +const PACKAGES = { + 'ai-node': '@launchdarkly/ai-node', + 'openai-messages': '@launchdarkly/ai-openai-messages', + 'ai-otel': '@launchdarkly/ai-otel', +}; + +/** Pinned to the Serverless 3 + Webpack 5 toolchain reported in AIC-3370. */ +const TOOLCHAIN = { + serverless: '3.40.0', + 'serverless-webpack': '5.15.4', + webpack: '5.104.1', + 'webpack-cli': '6.0.1', + 'webpack-node-externals': '3.0.0', +}; + +const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/; + +const NPM_WAIT_ATTEMPTS = 30; +const NPM_WAIT_INTERVAL_MS = 10_000; + +function fail(message) { + process.stderr.write(`${message}\n`); + process.exit(1); +} + +function run(command, args, cwd, options = {}) { + const result = spawnSync(command, args, { cwd, encoding: 'utf8', stdio: 'inherit', ...options }); + if (result.error) { + throw result.error; + } + if (!options.allowFailure && result.status !== 0) { + fail(`${command} ${args.join(' ')} failed with status ${result.status}`); + } + return result; +} + +function capture(command, args, cwd) { + return spawnSync(command, args, { cwd, encoding: 'utf8' }); +} + +function parseArgs(argv) { + const args = {}; + for (const arg of argv) { + const match = /^--([^=]+)(?:=(.*))?$/.exec(arg); + if (!match) { + fail(`Unrecognized argument: ${arg}`); + } + args[match[1]] = match[2] ?? 'true'; + } + return args; +} + +/** Resolves one exact version per package. Ranges, dist-tags, and workspace links are rejected. */ +function resolveVersions(args) { + const fromEnv = {}; + for (const pair of (process.env.LD_AI_CANARY_VERSIONS ?? '').split(',')) { + const [name, version] = pair.split('=').map((part) => part.trim()); + if (name && version) { + fromEnv[name] = version; + } + } + + const versions = {}; + for (const [shortName, packageName] of Object.entries(PACKAGES)) { + const version = args[shortName] ?? fromEnv[shortName]; + if (!version) { + fail(`Missing version for ${packageName}. Pass --${shortName}= or set LD_AI_CANARY_VERSIONS.`); + } + if (!EXACT_VERSION.test(version)) { + fail(`${packageName}@${version} is not an exact version. The canary never installs ranges or dist-tags.`); + } + versions[packageName] = version; + } + return versions; +} + +async function waitForNpm(versions) { + for (const [packageName, version] of Object.entries(versions)) { + let visible = false; + for (let attempt = 1; attempt <= NPM_WAIT_ATTEMPTS; attempt += 1) { + const result = capture('npm', ['view', `${packageName}@${version}`, 'version'], here); + if (result.status === 0 && result.stdout.trim() === version) { + visible = true; + process.stdout.write(`${packageName}@${version} is visible on npm\n`); + break; + } + process.stdout.write(`${packageName}@${version} not on npm yet (attempt ${attempt}/${NPM_WAIT_ATTEMPTS})\n`); + await sleep(NPM_WAIT_INTERVAL_MS); + } + if (!visible) { + fail(`${packageName}@${version} never became visible on npm.`); + } + } +} + +function writeApp(versions) { + rmSync(join(here, '.canary'), { recursive: true, force: true }); + mkdirSync(appDir, { recursive: true }); + + for (const file of ['handler.cjs', 'serverless.yml', 'webpack.config.cjs']) { + cpSync(join(here, 'fixture', file), join(appDir, file)); + } + + const manifest = { + name: 'ld-ai-lambda-canary-app', + version: '0.0.0', + private: true, + description: 'Ephemeral CommonJS Lambda that loads the published LaunchDarkly AI packages.', + license: 'Apache-2.0', + dependencies: versions, + devDependencies: TOOLCHAIN, + }; + writeFileSync(join(appDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); +} + +/** Asserts npm installed the exact versions requested, and records them for the handler to echo. */ +function recordInstalledVersions(versions) { + const listed = capture('npm', ['ls', '--depth=0', '--json'], appDir); + const installed = JSON.parse(listed.stdout || '{}').dependencies ?? {}; + + const resolved = {}; + for (const [packageName, expected] of Object.entries(versions)) { + const actual = installed[packageName]?.version; + if (actual !== expected) { + fail(`${packageName} resolved to ${actual ?? 'nothing'}, expected ${expected}.`); + } + resolved[packageName] = actual; + } + writeFileSync(join(appDir, 'versions.json'), `${JSON.stringify(resolved, null, 2)}\n`); +} + +function build(versions, { packageApp, stage }) { + writeApp(versions); + run('npm', ['install', '--save-exact', '--no-audit', '--no-fund'], appDir); + recordInstalledVersions(versions); + + if (packageApp) { + run('npx', ['serverless', 'package', '--stage', stage], appDir, { + env: { ...process.env, SLS_TELEMETRY_DISABLED: '1' }, + }); + } +} + +function assertResponse(responsePath, versions) { + const payload = JSON.parse(readFileSync(responsePath, 'utf8')); + + if (payload.errorType || payload.errorMessage) { + fail(`Lambda returned an error: ${payload.errorType ?? ''} ${payload.errorMessage ?? ''}`); + } + if (payload.ok !== true) { + fail(`Capability payload did not report ok: ${JSON.stringify(payload)}`); + } + if (!String(payload.runtime ?? '').startsWith('v22.')) { + fail(`Expected a Node 22 runtime, got ${payload.runtime}.`); + } + + for (const [symbol, type] of Object.entries(payload.capabilities ?? {})) { + if (type !== 'function' && type !== 'object') { + fail(`Capability ${symbol} resolved to ${type}.`); + } + } + for (const symbol of ['config', 'initClient', 'openaiMessages']) { + if (!(symbol in (payload.capabilities ?? {}))) { + fail(`Capability payload is missing ${symbol}: ${JSON.stringify(payload)}`); + } + } + for (const [packageName, expected] of Object.entries(versions)) { + if (payload.versions?.[packageName] !== expected) { + fail(`Lambda loaded ${packageName}@${payload.versions?.[packageName]}, expected ${expected}.`); + } + } + + process.stdout.write(`Canary passed on ${payload.runtime}: ${JSON.stringify(payload.versions)}\n`); +} + +const [command, ...rest] = process.argv.slice(2); +const args = parseArgs(rest); +const versions = resolveVersions(args); + +switch (command) { + case 'wait': + await waitForNpm(versions); + break; + case 'build': + build(versions, { packageApp: args.package === 'true', stage: args.stage ?? 'local' }); + break; + case 'assert': + if (!args.response) { + fail('assert requires --response='); + } + assertResponse(args.response, versions); + break; + default: + fail(`Unknown command: ${command ?? '(none)'}. Expected wait, build, or assert.`); +} diff --git a/integration-tests/lambda-commonjs/canary.test.ts b/integration-tests/lambda-commonjs/canary.test.ts new file mode 100644 index 00000000..6060a483 --- /dev/null +++ b/integration-tests/lambda-commonjs/canary.test.ts @@ -0,0 +1,93 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const here = dirname(fileURLToPath(import.meta.url)); +const driver = join(here, 'canary.mjs'); + +const VERSIONS = ['--ai-node=0.3.0', '--openai-messages=0.3.0', '--ai-otel=0.2.0']; + +const PAYLOAD = { + ok: true, + runtime: 'v22.11.0', + versions: { + '@launchdarkly/ai-node': '0.3.0', + '@launchdarkly/ai-openai-messages': '0.3.0', + '@launchdarkly/ai-otel': '0.2.0', + }, + capabilities: { + config: 'function', + initClient: 'function', + shutdown: 'function', + openaiMessages: 'function', + aiOtel: 'object', + }, +}; + +function writePayload(payload: unknown): string { + const file = join(mkdtempSync(join(tmpdir(), 'ld-canary-')), 'response.json'); + writeFileSync(file, JSON.stringify(payload)); + return file; +} + +function assertPayload(payload: unknown, versions: string[] = VERSIONS) { + return spawnSync('node', [driver, 'assert', `--response=${writePayload(payload)}`, ...versions], { + encoding: 'utf8', + }); +} + +describe('release canary driver', () => { + it('accepts a capability payload from the expected versions on Node 22', () => { + const result = assertPayload(PAYLOAD); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('rejects versions that are not exact', () => { + const result = assertPayload(PAYLOAD, ['--ai-node=latest', '--openai-messages=0.3.0', '--ai-otel=0.2.0']); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('not an exact version'); + }); + + it('rejects a payload built from different versions than were published', () => { + const result = assertPayload({ + ...PAYLOAD, + versions: { ...PAYLOAD.versions, '@launchdarkly/ai-node': '0.2.0' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('expected 0.3.0'); + }); + + it('rejects a payload from a runtime other than Node 22', () => { + const result = assertPayload({ ...PAYLOAD, runtime: 'v20.11.0' }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Expected a Node 22 runtime'); + }); + + it('rejects a capability the Lambda could not resolve — the AIC-3370 failure mode', () => { + const result = assertPayload({ + ...PAYLOAD, + capabilities: { ...PAYLOAD.capabilities, config: 'undefined' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Capability config resolved to undefined'); + }); + + it('reports a Lambda invocation error instead of passing', () => { + const result = assertPayload({ + errorType: 'Error', + errorMessage: 'No "exports" main defined in @launchdarkly/ai-node/package.json', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Lambda returned an error'); + }); +}); diff --git a/integration-tests/lambda-commonjs/fixture/handler.cjs b/integration-tests/lambda-commonjs/fixture/handler.cjs new file mode 100644 index 00000000..c5a63b28 --- /dev/null +++ b/integration-tests/lambda-commonjs/fixture/handler.cjs @@ -0,0 +1,24 @@ +'use strict'; + +// Lambda-shaped CommonJS entry point for the release canary. It only reports which symbols the +// published packages resolved — no LaunchDarkly SDK key, no flag evaluation, no model call. +const versions = require('./versions.json'); + +exports.handler = async () => { + const aiNode = await import('@launchdarkly/ai-node'); + const openaiMessages = await import('@launchdarkly/ai-openai-messages'); + const aiOtel = await import('@launchdarkly/ai-otel'); + + return { + ok: true, + runtime: process.version, + versions, + capabilities: { + config: typeof aiNode.config, + initClient: typeof aiNode.initClient, + shutdown: typeof aiNode.shutdown, + openaiMessages: typeof openaiMessages.openaiMessages, + aiOtel: typeof aiOtel, + }, + }; +}; diff --git a/integration-tests/lambda-commonjs/fixture/serverless.yml b/integration-tests/lambda-commonjs/fixture/serverless.yml new file mode 100644 index 00000000..4b3d2a3b --- /dev/null +++ b/integration-tests/lambda-commonjs/fixture/serverless.yml @@ -0,0 +1,34 @@ +service: ld-ai-canary +frameworkVersion: '3' + +# Serverless 3's schema predates nodejs22.x; CloudFormation accepts it. +configValidationMode: warn + +provider: + name: aws + runtime: nodejs22.x + region: ${env:AWS_REGION, 'us-east-1'} + stage: ${opt:stage, 'local'} + memorySize: 512 + timeout: 30 + logRetentionInDays: 1 + architecture: x86_64 + stackTags: + owner: launchdarkly-ai-sdk + purpose: js-ai-sdk-release-canary + ephemeral: 'true' + +plugins: + - serverless-webpack + +custom: + webpack: + webpackConfig: webpack.config.cjs + includeModules: true + packager: npm + +functions: + capability: + name: ld-ai-canary-${sls:stage} + handler: handler.handler + description: Loads the published LaunchDarkly AI packages and reports what resolved. diff --git a/integration-tests/lambda-commonjs/fixture/webpack.config.cjs b/integration-tests/lambda-commonjs/fixture/webpack.config.cjs new file mode 100644 index 00000000..710b0dca --- /dev/null +++ b/integration-tests/lambda-commonjs/fixture/webpack.config.cjs @@ -0,0 +1,19 @@ +'use strict'; + +const path = require('node:path'); +const nodeExternals = require('webpack-node-externals'); +const slsw = require('serverless-webpack'); + +// No allowlist: the LaunchDarkly AI packages stay external and are loaded through `require()` +// from the deployment package, which is the resolution path AIC-3370 broke. +module.exports = { + mode: 'none', + target: 'node22', + entry: slsw.lib.entries, + externals: [nodeExternals()], + output: { + path: path.join(__dirname, '.webpack'), + filename: '[name].js', + libraryTarget: 'commonjs2', + }, +}; diff --git a/package.json b/package.json index 9ab0f38c..24283f4c 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "yarn workspace @launchdarkly/ai-server run build && yarn workspaces run build", "clean": "yarn workspaces run clean", "test": "vitest run examples/utils.test.ts && yarn workspaces run test", - "test:integration": "vitest run integration-tests/commonjs-webpack/commonjs-webpack.test.ts", + "test:integration": "vitest run integration-tests/commonjs-webpack/commonjs-webpack.test.ts integration-tests/lambda-commonjs/canary.test.ts", "typecheck": "yarn workspaces run typecheck", "format": "biome format --write .", "format:check": "biome format --check .", From 77a106fafbdea5e1957eab5da7ee7f7ca4939f26 Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Tue, 22 Sep 2026 13:52:31 +0000 Subject: [PATCH 3/8] docs(AIC-3417): rewrite the canary docs in plain layered English Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 9 +- integration-tests/lambda-commonjs/README.md | 115 ++++++++++++------- integration-tests/lambda-commonjs/canary.mjs | 6 +- 3 files changed, 81 insertions(+), 49 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d99d3e9f..9500d1a7 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -332,10 +332,11 @@ jobs: # --- Published-artifact Lambda release canary --- # - # Proves the versions that just landed on npm can be installed into a clean CommonJS - # package, bundled by Serverless 3 + Webpack 5 + webpack-node-externals 3, loaded by a real - # Node 22 Lambda, and removed again. It loads packages only — no SDK key, no flag evaluation, - # no model call. See integration-tests/lambda-commonjs/README.md for the required AWS role. + # This job installs the versions that npm now serves into a clean CommonJS package. + # Serverless 3, Webpack 5, and webpack-node-externals 3 bundle that package. + # A real Node 22 Lambda then loads it, and the job deletes the Lambda again. + # The job loads packages only. It uses no SDK key, no flag evaluation, and no model call. + # See integration-tests/lambda-commonjs/README.md for the AWS role that this job needs. release-canary: name: Published-artifact Lambda canary diff --git a/integration-tests/lambda-commonjs/README.md b/integration-tests/lambda-commonjs/README.md index f2512173..f8984ca8 100644 --- a/integration-tests/lambda-commonjs/README.md +++ b/integration-tests/lambda-commonjs/README.md @@ -1,35 +1,58 @@ # Published-artifact Lambda release canary -Owner: the LaunchDarkly AI SDK team (maintainers of `launchdarkly/js-ai-sdk`). +Owner: the LaunchDarkly AI SDK team. The team maintains `launchdarkly/js-ai-sdk`. -This fixture is the post-publish half of the [AIC-3370](https://launchdarkly.atlassian.net/browse/AIC-3370) -CommonJS work. The `integration-tests/commonjs-webpack` suite proves that *packed* artifacts load from a -Webpack CommonJS bundle; this canary proves the same thing for the artifacts that actually reached npm, -running inside a real Node 22 AWS Lambda. +## Plain version -It installs **exact published versions** — never workspace links, never a dist-tag — bundles them with -Serverless Framework 3, Webpack 5 and `webpack-node-externals` 3 (so the LaunchDarkly packages stay -external and are resolved by Node at runtime, the AIC-3370 failure path), deploys an ephemeral function, -invokes it once, asserts the capability payload, and removes the function and its log group. +We publish packages to npm. A customer then installs them and runs them on AWS Lambda. -The canary loads packages only. It uses **no SDK key, evaluates no flag, and makes no model call**, so it -costs nothing beyond a single Lambda invocation. +This test does the same thing after each release. It installs the published packages. +It runs them in a real AWS Lambda function. It then deletes that function. -## What runs +If the test fails, the packages are broken for customers. We learn this in minutes, not weeks. -`canary.mjs` is the whole driver. It takes one exact version per package, either as flags or as -`LD_AI_CANARY_VERSIONS='ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'`, and refuses anything that is -not an exact semver. +The test only loads the packages. It uses no SDK key. It evaluates no flag. It calls no model. +So the test costs almost nothing. + +## What it means for the code (junior eng) + +A canary is a small test that runs against production artifacts after a release. +This canary is the second half of the work in +[AIC-3370](https://launchdarkly.atlassian.net/browse/AIC-3370). + +The suite in `integration-tests/commonjs-webpack` packs the workspace with `npm pack`. +That suite tests the tarball that we plan to publish. +This canary tests the artifact that npm actually serves. + +The canary installs exact published versions. It never uses a workspace link. +It never uses a dist-tag such as `latest`. + +The fixture bundles the app with Serverless Framework 3 and Webpack 5. +It also uses `webpack-node-externals` 3 with no allowlist. +So Node resolves the LaunchDarkly packages at runtime. +That path is the exact path that failed in AIC-3370. + +The workflow then deploys one function. It invokes the function one time. +It checks the JSON answer. It then deletes the function and the log group. + +### The driver + +`canary.mjs` holds all of the logic. It takes one exact version for each package. +You pass the versions as flags. You can also set +`LD_AI_CANARY_VERSIONS='ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'`. +The driver rejects a version that is not an exact semver. | Command | What it does | | --- | --- | -| `wait` | Polls `npm view` until all three versions are visible (npm publishes are not immediately readable). | -| `build` | Stages `.canary/app` from `fixture/`, installs the exact versions with `--save-exact`, verifies the resolved tree, and optionally runs `serverless package`. | -| `assert` | Validates an invoke payload: `ok`, a `v22.x` runtime, the expected capability symbols, and the exact versions the Lambda loaded. | +| `wait` | Calls `npm view` until npm serves all three versions. A new publish is not readable at once. | +| `build` | Copies `fixture/` into `.canary/app`. Installs the exact versions with `--save-exact`. Checks the installed tree. Runs `serverless package` when you pass `--package`. | +| `assert` | Reads the invoke payload. Checks `ok`, a `v22.x` runtime, the capability names, and the loaded versions. | + +The function name is `ld-ai-canary-`. The workflow gives each run a unique stage. -The deployed function is `ld-ai-canary-`, where the workflow uses a stage unique to the run. +### Local dry run -## Local dry run (no AWS credentials needed) +This run needs no AWS credentials. ```bash cd integration-tests/lambda-commonjs @@ -37,29 +60,34 @@ node canary.mjs build --ai-node=0.2.0 --openai-messages=0.2.0 --ai-otel=0.1.1 -- unzip -l .canary/app/.serverless/ld-ai-canary.zip | grep '@launchdarkly' ``` -Serverless 3 does not know the `nodejs22.x` runtime in its config schema, so packaging prints an -`Invalid configuration encountered` warning for `provider.runtime`. The fixture sets -`configValidationMode: warn` so this stays a warning; CloudFormation accepts the runtime and the deployed -function is Node 22 (the canary asserts `process.version` to prove it). +The config schema of Serverless 3 does not know the `nodejs22.x` runtime. +So the package step prints an `Invalid configuration encountered` warning for `provider.runtime`. +The fixture sets `configValidationMode: warn`, and the message stays a warning. +CloudFormation accepts the runtime. The function runs on Node 22. +The canary reads `process.version` and fails on any other runtime. -`yarn test:integration` runs `canary.test.ts`, which covers the assertion logic and the exact-version -guard without touching the network or AWS. +`yarn test:integration` runs `canary.test.ts`. +That file tests the assert logic and the exact-version guard. +It uses no network and no AWS account. -## AWS prerequisite +### AWS prerequisite -The workflow job authenticates with GitHub OIDC only — **there are no long-lived AWS keys in this repo and -none may be added.** It stays skipped until these repository variables exist: +The workflow job uses GitHub OIDC only. +OIDC lets the job get short-lived AWS credentials from a role. +This repository holds no long-lived AWS keys. Please do not add any. + +The job stays skipped until these repository variables exist. | Variable | Purpose | | --- | --- | -| `AWS_LAMBDA_CANARY_ROLE_ARN` | Role assumed via OIDC. Required; the job is skipped while unset. | -| `AWS_LAMBDA_CANARY_REGION` | Optional, defaults to `us-east-1`. | +| `AWS_LAMBDA_CANARY_ROLE_ARN` | The role that the job assumes. The job is skipped while this variable is empty. | +| `AWS_LAMBDA_CANARY_REGION` | Optional. The default is `us-east-1`. | -The role's trust policy should allow `token.actions.githubusercontent.com` with -`sub` restricted to `repo:launchdarkly/js-ai-sdk:*` and `aud` of `https://github.com/launchdarkly`. +The trust policy of the role must allow `token.actions.githubusercontent.com`. +Limit `sub` to `repo:launchdarkly/js-ai-sdk:*`. +Set `aud` to `https://github.com/launchdarkly`. -Least-privilege permissions needed by deploy, invoke and cleanup, all scoped to the canary's own -resources (`ld-ai-canary-*`): +The role needs these permissions. Scope each one to the `ld-ai-canary-*` resources. - `cloudformation:CreateStack`, `UpdateStack`, `DeleteStack`, `DescribeStacks`, `DescribeStackEvents`, `DescribeStackResource(s)`, `ValidateTemplate`, `GetTemplate`, `ListStackResources` @@ -71,18 +99,21 @@ resources (`ld-ai-canary-*`): `TagResource` - `logs:CreateLogGroup`, `DeleteLogGroup`, `DescribeLogGroups`, `PutRetentionPolicy` - `iam:CreateRole`, `DeleteRole`, `GetRole`, `PassRole`, `PutRolePolicy`, `DeleteRolePolicy`, - `AttachRolePolicy`, `DetachRolePolicy` for the function's execution role + `AttachRolePolicy`, `DetachRolePolicy` for the execution role of the function + +### When the canary fails -## When it fails +A failure means that a published package does not load from a CommonJS Lambda bundle. +That is the AIC-3370 bug again. -A failure means a published artifact cannot be loaded from a CommonJS Lambda bundle — the AIC-3370 bug, -reintroduced. Reproduce locally with the dry run above against the published versions, then invoke the -generated bundle directly: +Run the local dry run above with the published versions. +Then invoke the generated bundle directly. ```bash cd integration-tests/lambda-commonjs/.canary/app node -e "require('./.webpack/service/handler.js').handler().then(console.log, console.error)" ``` -Cleanup always runs. If it still fails, the workflow annotates the run with the leftover stage name; delete -the `ld-ai-canary-` CloudFormation stack and the `/aws/lambda/ld-ai-canary-` log group by hand. +The cleanup step always runs. The step prints an error when it cannot delete the stack. +Then you delete the `ld-ai-canary-` CloudFormation stack by hand. +You also delete the `/aws/lambda/ld-ai-canary-` log group by hand. diff --git a/integration-tests/lambda-commonjs/canary.mjs b/integration-tests/lambda-commonjs/canary.mjs index 0a0f70b8..27e8128b 100644 --- a/integration-tests/lambda-commonjs/canary.mjs +++ b/integration-tests/lambda-commonjs/canary.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node -// Release canary driver: stages a clean CommonJS Serverless app that installs the exact -// published versions of the LaunchDarkly AI packages, then packages, waits on npm, or asserts -// the capability payload a deployed Lambda returned. +// Release canary driver. It stages a clean CommonJS Serverless app. +// The app installs the exact published versions of the LaunchDarkly AI packages. +// The driver waits on npm, builds the app, or checks the payload from a deployed Lambda. // // Usage: // node canary.mjs wait --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 From 63fe94e34c1b07ac7ea08e3adf721cdcc1ca0a8f Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Tue, 22 Sep 2026 13:58:08 +0000 Subject: [PATCH 4/8] fix(AIC-3417): check exact export types and fail on log group cleanup errors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 11 ++++++++-- integration-tests/lambda-commonjs/canary.mjs | 21 ++++++++++++------- .../lambda-commonjs/canary.test.ts | 18 ++++++++++++++++ 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 9500d1a7..8527ed51 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -427,8 +427,15 @@ jobs: set -uo pipefail status=0 (cd integration-tests/lambda-commonjs/.canary/app && npx serverless remove --stage "$CANARY_STAGE") || status=1 - aws logs delete-log-group --log-group-name "/aws/lambda/ld-ai-canary-$CANARY_STAGE" 2>/dev/null || true + log_group="/aws/lambda/ld-ai-canary-$CANARY_STAGE" + if ! delete_output=$(aws logs delete-log-group --log-group-name "$log_group" 2>&1); then + # Stack removal already deletes the log group, so a missing group is a success. + if ! printf '%s' "$delete_output" | grep -q 'ResourceNotFoundException'; then + echo "$delete_output" + status=1 + fi + fi if [ "$status" -ne 0 ]; then - echo "::error::Canary cleanup failed — check for a leftover ld-ai-canary-$CANARY_STAGE stack" + echo "::error::Canary cleanup failed. Look for a leftover ld-ai-canary-$CANARY_STAGE stack or $log_group log group." fi exit "$status" diff --git a/integration-tests/lambda-commonjs/canary.mjs b/integration-tests/lambda-commonjs/canary.mjs index 27e8128b..2f41fab5 100644 --- a/integration-tests/lambda-commonjs/canary.mjs +++ b/integration-tests/lambda-commonjs/canary.mjs @@ -36,6 +36,15 @@ const TOOLCHAIN = { 'webpack-node-externals': '3.0.0', }; +/** The exact type each export must have inside the Lambda. */ +const EXPECTED_CAPABILITIES = { + config: 'function', + initClient: 'function', + shutdown: 'function', + openaiMessages: 'function', + aiOtel: 'object', +}; + const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?$/; const NPM_WAIT_ATTEMPTS = 30; @@ -177,14 +186,10 @@ function assertResponse(responsePath, versions) { fail(`Expected a Node 22 runtime, got ${payload.runtime}.`); } - for (const [symbol, type] of Object.entries(payload.capabilities ?? {})) { - if (type !== 'function' && type !== 'object') { - fail(`Capability ${symbol} resolved to ${type}.`); - } - } - for (const symbol of ['config', 'initClient', 'openaiMessages']) { - if (!(symbol in (payload.capabilities ?? {}))) { - fail(`Capability payload is missing ${symbol}: ${JSON.stringify(payload)}`); + for (const [symbol, expectedType] of Object.entries(EXPECTED_CAPABILITIES)) { + const actualType = payload.capabilities?.[symbol]; + if (actualType !== expectedType) { + fail(`Capability ${symbol} resolved to ${actualType ?? 'nothing'}, expected ${expectedType}.`); } } for (const [packageName, expected] of Object.entries(versions)) { diff --git a/integration-tests/lambda-commonjs/canary.test.ts b/integration-tests/lambda-commonjs/canary.test.ts index 6060a483..5de1d195 100644 --- a/integration-tests/lambda-commonjs/canary.test.ts +++ b/integration-tests/lambda-commonjs/canary.test.ts @@ -81,6 +81,24 @@ describe('release canary driver', () => { expect(result.stderr).toContain('Capability config resolved to undefined'); }); + it('rejects a callable export that resolves to the wrong type', () => { + const result = assertPayload({ + ...PAYLOAD, + capabilities: { ...PAYLOAD.capabilities, initClient: 'object' }, + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Capability initClient resolved to object, expected function'); + }); + + it('rejects a payload that drops an expected export', () => { + const { shutdown: _dropped, ...capabilities } = PAYLOAD.capabilities; + const result = assertPayload({ ...PAYLOAD, capabilities }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Capability shutdown resolved to nothing'); + }); + it('reports a Lambda invocation error instead of passing', () => { const result = assertPayload({ errorType: 'Error', From 898a02e181530c7cf6ab7dae0ef7eac91285f6c9 Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Wed, 23 Sep 2026 20:32:14 +0000 Subject: [PATCH 5/8] fix(AIC-3417): pin and canary the published core package Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 5 +++-- integration-tests/lambda-commonjs/README.md | 11 ++++++++--- integration-tests/lambda-commonjs/canary.mjs | 14 +++++++++----- integration-tests/lambda-commonjs/canary.test.ts | 11 +++++++++-- .../lambda-commonjs/fixture/handler.cjs | 2 ++ 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 8527ed51..7945e1ff 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,7 +47,7 @@ on: type: boolean required: true canary_versions: - description: 'Run only the Lambda release canary against these exact published versions, e.g. ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0' + description: 'Run only the Lambda release canary against these exact published versions, e.g. ai-server=0.4.0,ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0' type: string required: false default: '' @@ -354,6 +354,7 @@ jobs: && vars.AWS_LAMBDA_CANARY_ROLE_ARN != '' && (github.event_name != 'workflow_dispatch' || inputs.canary_versions != '') && (github.event_name == 'workflow_dispatch' + || needs.release-please.outputs.package-client-released == 'true' || needs.release-please.outputs.package-ai-node-released == 'true' || needs.release-please.outputs.package-ai-otel-released == 'true' || needs.release-please.outputs.package-openai-messages-released == 'true') }} @@ -381,7 +382,7 @@ jobs: if [ -n "${REQUESTED_VERSIONS:-}" ]; then resolved="$REQUESTED_VERSIONS" else - resolved=$(node -e "const m=require('./.release-please-manifest.json');process.stdout.write(['ai-node','openai-messages','ai-otel'].map((p)=>p+'='+m['packages/'+p]).join(','))") + resolved=$(node -e "const m=require('./.release-please-manifest.json');const d={'ai-server':'packages/client','ai-node':'packages/ai-node','openai-messages':'packages/openai-messages','ai-otel':'packages/ai-otel'};process.stdout.write(Object.entries(d).map(([n,p])=>n+'='+m[p]).join(','))") fi echo "Canary versions: $resolved" echo "resolved=$resolved" >> "$GITHUB_OUTPUT" diff --git a/integration-tests/lambda-commonjs/README.md b/integration-tests/lambda-commonjs/README.md index f8984ca8..a6c495e7 100644 --- a/integration-tests/lambda-commonjs/README.md +++ b/integration-tests/lambda-commonjs/README.md @@ -39,12 +39,17 @@ It checks the JSON answer. It then deletes the function and the log group. `canary.mjs` holds all of the logic. It takes one exact version for each package. You pass the versions as flags. You can also set -`LD_AI_CANARY_VERSIONS='ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'`. +`LD_AI_CANARY_VERSIONS='ai-server=0.4.0,ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'`. The driver rejects a version that is not an exact semver. +The list includes `@launchdarkly/ai-server`, the core package. +`@launchdarkly/ai-node` depends on the core package with `*`. +So npm picks any visible core version when the canary does not pin it. +The canary installs the core version directly, and a core-only release also runs the canary. + | Command | What it does | | --- | --- | -| `wait` | Calls `npm view` until npm serves all three versions. A new publish is not readable at once. | +| `wait` | Calls `npm view` until npm serves all four versions. A new publish is not readable at once. | | `build` | Copies `fixture/` into `.canary/app`. Installs the exact versions with `--save-exact`. Checks the installed tree. Runs `serverless package` when you pass `--package`. | | `assert` | Reads the invoke payload. Checks `ok`, a `v22.x` runtime, the capability names, and the loaded versions. | @@ -56,7 +61,7 @@ This run needs no AWS credentials. ```bash cd integration-tests/lambda-commonjs -node canary.mjs build --ai-node=0.2.0 --openai-messages=0.2.0 --ai-otel=0.1.1 --package --stage=local +node canary.mjs build --ai-server=0.3.0 --ai-node=0.2.0 --openai-messages=0.2.0 --ai-otel=0.1.1 --package --stage=local unzip -l .canary/app/.serverless/ld-ai-canary.zip | grep '@launchdarkly' ``` diff --git a/integration-tests/lambda-commonjs/canary.mjs b/integration-tests/lambda-commonjs/canary.mjs index 2f41fab5..0b4298fd 100644 --- a/integration-tests/lambda-commonjs/canary.mjs +++ b/integration-tests/lambda-commonjs/canary.mjs @@ -5,11 +5,12 @@ // The driver waits on npm, builds the app, or checks the payload from a deployed Lambda. // // Usage: -// node canary.mjs wait --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 -// node canary.mjs build --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 [--package] [--stage=local] -// node canary.mjs assert --response= --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 +// node canary.mjs wait --ai-server=0.4.0 --ai-node=0.3.0 --openai-messages=0.3.0 --ai-otel=0.2.0 +// node canary.mjs build [--package] [--stage=local] +// node canary.mjs assert --response= // -// Versions may also be supplied as LD_AI_CANARY_VERSIONS='ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'. +// Versions may also be supplied as +// LD_AI_CANARY_VERSIONS='ai-server=0.4.0,ai-node=0.3.0,openai-messages=0.3.0,ai-otel=0.2.0'. import { spawnSync } from 'node:child_process'; import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -20,8 +21,10 @@ import { fileURLToPath } from 'node:url'; const here = dirname(fileURLToPath(import.meta.url)); export const appDir = join(here, '.canary', 'app'); -/** Short name -> published package name. One core wrapper, one message handler, the OTel bundle. */ +// Short name -> published package name. The core package is installed directly, because +// ai-node depends on it with `*` and would otherwise resolve to any visible core version. const PACKAGES = { + 'ai-server': '@launchdarkly/ai-server', 'ai-node': '@launchdarkly/ai-node', 'openai-messages': '@launchdarkly/ai-openai-messages', 'ai-otel': '@launchdarkly/ai-otel', @@ -38,6 +41,7 @@ const TOOLCHAIN = { /** The exact type each export must have inside the Lambda. */ const EXPECTED_CAPABILITIES = { + aiServer: 'object', config: 'function', initClient: 'function', shutdown: 'function', diff --git a/integration-tests/lambda-commonjs/canary.test.ts b/integration-tests/lambda-commonjs/canary.test.ts index 5de1d195..5a97e152 100644 --- a/integration-tests/lambda-commonjs/canary.test.ts +++ b/integration-tests/lambda-commonjs/canary.test.ts @@ -8,17 +8,19 @@ import { describe, expect, it } from 'vitest'; const here = dirname(fileURLToPath(import.meta.url)); const driver = join(here, 'canary.mjs'); -const VERSIONS = ['--ai-node=0.3.0', '--openai-messages=0.3.0', '--ai-otel=0.2.0']; +const VERSIONS = ['--ai-server=0.4.0', '--ai-node=0.3.0', '--openai-messages=0.3.0', '--ai-otel=0.2.0']; const PAYLOAD = { ok: true, runtime: 'v22.11.0', versions: { + '@launchdarkly/ai-server': '0.4.0', '@launchdarkly/ai-node': '0.3.0', '@launchdarkly/ai-openai-messages': '0.3.0', '@launchdarkly/ai-otel': '0.2.0', }, capabilities: { + aiServer: 'object', config: 'function', initClient: 'function', shutdown: 'function', @@ -48,7 +50,12 @@ describe('release canary driver', () => { }); it('rejects versions that are not exact', () => { - const result = assertPayload(PAYLOAD, ['--ai-node=latest', '--openai-messages=0.3.0', '--ai-otel=0.2.0']); + const result = assertPayload(PAYLOAD, [ + '--ai-server=0.4.0', + '--ai-node=latest', + '--openai-messages=0.3.0', + '--ai-otel=0.2.0', + ]); expect(result.status).toBe(1); expect(result.stderr).toContain('not an exact version'); diff --git a/integration-tests/lambda-commonjs/fixture/handler.cjs b/integration-tests/lambda-commonjs/fixture/handler.cjs index c5a63b28..321219c8 100644 --- a/integration-tests/lambda-commonjs/fixture/handler.cjs +++ b/integration-tests/lambda-commonjs/fixture/handler.cjs @@ -5,6 +5,7 @@ const versions = require('./versions.json'); exports.handler = async () => { + const aiServer = await import('@launchdarkly/ai-server'); const aiNode = await import('@launchdarkly/ai-node'); const openaiMessages = await import('@launchdarkly/ai-openai-messages'); const aiOtel = await import('@launchdarkly/ai-otel'); @@ -14,6 +15,7 @@ exports.handler = async () => { runtime: process.version, versions, capabilities: { + aiServer: typeof aiServer, config: typeof aiNode.config, initClient: typeof aiNode.initClient, shutdown: typeof aiNode.shutdown, From bd31003176977d62a09326eae043fb33e62cb3ef Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Mon, 19 Oct 2026 17:17:12 +0000 Subject: [PATCH 6/8] fix(AIC-3417): harden the canary AWS role guidance and step order Limit the OIDC subject to main. Drop the attach-policy actions. Require a permissions boundary. Set the AWS credentials after the build. Point the debug command at the packaged zip. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 20 ++++++++++++----- integration-tests/lambda-commonjs/README.md | 22 +++++++++++++++---- .../lambda-commonjs/fixture/serverless.yml | 3 +++ 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7945e1ff..594365c1 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -392,6 +392,14 @@ jobs: LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} run: node integration-tests/lambda-commonjs/canary.mjs wait + # The build runs npm install for the Serverless toolchain, and every install script + # inherits this environment. The build needs no AWS access, so the credentials + # start after it. + - name: Build the canary package + env: + LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} + run: node integration-tests/lambda-commonjs/canary.mjs build --package --stage="$CANARY_STAGE" + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: @@ -400,13 +408,10 @@ jobs: role-session-name: JsAiSdkLambdaCanary aws-region: ${{ env.AWS_REGION }} - - name: Build the canary package - env: - LD_AI_CANARY_VERSIONS: ${{ steps.versions.outputs.resolved }} - run: node integration-tests/lambda-commonjs/canary.mjs build --package --stage="$CANARY_STAGE" - - name: Deploy the ephemeral Lambda working-directory: integration-tests/lambda-commonjs/.canary/app + env: + LD_AI_CANARY_PERMISSIONS_BOUNDARY: ${{ vars.AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY }} run: npx serverless deploy --stage "$CANARY_STAGE" - name: Invoke and assert the capability payload @@ -427,7 +432,10 @@ jobs: run: | set -uo pipefail status=0 - (cd integration-tests/lambda-commonjs/.canary/app && npx serverless remove --stage "$CANARY_STAGE") || status=1 + app_dir=integration-tests/lambda-commonjs/.canary/app + if [ -d "$app_dir" ]; then + (cd "$app_dir" && npx serverless remove --stage "$CANARY_STAGE") || status=1 + fi log_group="/aws/lambda/ld-ai-canary-$CANARY_STAGE" if ! delete_output=$(aws logs delete-log-group --log-group-name "$log_group" 2>&1); then # Stack removal already deletes the log group, so a missing group is a success. diff --git a/integration-tests/lambda-commonjs/README.md b/integration-tests/lambda-commonjs/README.md index a6c495e7..f26cf9a2 100644 --- a/integration-tests/lambda-commonjs/README.md +++ b/integration-tests/lambda-commonjs/README.md @@ -89,7 +89,9 @@ The job stays skipped until these repository variables exist. | `AWS_LAMBDA_CANARY_REGION` | Optional. The default is `us-east-1`. | The trust policy of the role must allow `token.actions.githubusercontent.com`. -Limit `sub` to `repo:launchdarkly/js-ai-sdk:*`. +Limit `sub` to `repo:launchdarkly/js-ai-sdk:ref:refs/heads/main`. +The release push runs from `main`, and you start a canary dispatch from `main` too. +A wider `sub` lets a workflow on any branch assume the role. Set `aud` to `https://github.com/launchdarkly`. The role needs these permissions. Scope each one to the `ld-ai-canary-*` resources. @@ -103,8 +105,16 @@ The role needs these permissions. Scope each one to the `ld-ai-canary-*` resourc `UpdateFunctionCode`, `UpdateFunctionConfiguration`, `InvokeFunction`, `ListVersionsByFunction`, `TagResource` - `logs:CreateLogGroup`, `DeleteLogGroup`, `DescribeLogGroups`, `PutRetentionPolicy` -- `iam:CreateRole`, `DeleteRole`, `GetRole`, `PassRole`, `PutRolePolicy`, `DeleteRolePolicy`, - `AttachRolePolicy`, `DetachRolePolicy` for the execution role of the function +- `iam:CreateRole`, `DeleteRole`, `GetRole`, `PassRole`, `PutRolePolicy`, `DeleteRolePolicy` + for the execution role of the function + +The generated stack puts inline `Policies` on the execution role only. +So the role needs no `iam:AttachRolePolicy` and no `iam:DetachRolePolicy`. + +The `iam:CreateRole` and `iam:PutRolePolicy` actions need an `iam:PermissionsBoundary` condition. +That condition stops the role from granting itself more rights than the boundary allows. +The fixture reads the boundary from `LD_AI_CANARY_PERMISSIONS_BOUNDARY`. +Set that variable to the same boundary policy ARN that the condition names. ### When the canary fails @@ -114,9 +124,13 @@ That is the AIC-3370 bug again. Run the local dry run above with the published versions. Then invoke the generated bundle directly. +The `serverless-webpack` plugin deletes `.webpack/` after it writes the zip. +So you unzip the package first, and then you load `handler.js` from there. + ```bash cd integration-tests/lambda-commonjs/.canary/app -node -e "require('./.webpack/service/handler.js').handler().then(console.log, console.error)" +unzip -o .serverless/ld-ai-canary.zip -d .unzipped +node -e "require('./.unzipped/handler.js').handler().then(console.log, console.error)" ``` The cleanup step always runs. The step prints an error when it cannot delete the stack. diff --git a/integration-tests/lambda-commonjs/fixture/serverless.yml b/integration-tests/lambda-commonjs/fixture/serverless.yml index 4b3d2a3b..b38aa9fc 100644 --- a/integration-tests/lambda-commonjs/fixture/serverless.yml +++ b/integration-tests/lambda-commonjs/fixture/serverless.yml @@ -13,6 +13,9 @@ provider: timeout: 30 logRetentionInDays: 1 architecture: x86_64 + iam: + role: + permissionsBoundary: ${env:LD_AI_CANARY_PERMISSIONS_BOUNDARY, null} stackTags: owner: launchdarkly-ai-sdk purpose: js-ai-sdk-release-canary From 41e0fdf6104cec2d66b464d9b4b3fbca7a379912 Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Mon, 19 Oct 2026 17:24:01 +0000 Subject: [PATCH 7/8] ci(AIC-3417): skip the canary cleanup when the credentials step does not run Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 594365c1..16fa7e74 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -401,6 +401,7 @@ jobs: run: node integration-tests/lambda-commonjs/canary.mjs build --package --stage="$CANARY_STAGE" - name: Configure AWS credentials + id: aws-credentials uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1 with: audience: 'https://github.com/launchdarkly' @@ -427,8 +428,10 @@ jobs: cat response.json node integration-tests/lambda-commonjs/canary.mjs assert --response=response.json + # A failed build stops before the credentials step. Then no AWS resource exists, + # and the cleanup has no credentials to delete one. - name: Remove the ephemeral Lambda - if: always() + if: always() && steps.aws-credentials.outcome == 'success' run: | set -uo pipefail status=0 From 11acd8d13353a1e789d15d839985d15233484b27 Mon Sep 17 00:00:00 2001 From: Tony Trinh Date: Tue, 20 Oct 2026 15:29:47 +0000 Subject: [PATCH 8/8] docs(AIC-3417): name the boundary repository variable and gate the job on it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/release-please.yml | 6 ++---- integration-tests/lambda-commonjs/README.md | 6 ++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 16fa7e74..f14155a9 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -352,6 +352,7 @@ jobs: && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && vars.AWS_LAMBDA_CANARY_ROLE_ARN != '' + && vars.AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY != '' && (github.event_name != 'workflow_dispatch' || inputs.canary_versions != '') && (github.event_name == 'workflow_dispatch' || needs.release-please.outputs.package-client-released == 'true' @@ -435,10 +436,7 @@ jobs: run: | set -uo pipefail status=0 - app_dir=integration-tests/lambda-commonjs/.canary/app - if [ -d "$app_dir" ]; then - (cd "$app_dir" && npx serverless remove --stage "$CANARY_STAGE") || status=1 - fi + (cd integration-tests/lambda-commonjs/.canary/app && npx serverless remove --stage "$CANARY_STAGE") || status=1 log_group="/aws/lambda/ld-ai-canary-$CANARY_STAGE" if ! delete_output=$(aws logs delete-log-group --log-group-name "$log_group" 2>&1); then # Stack removal already deletes the log group, so a missing group is a success. diff --git a/integration-tests/lambda-commonjs/README.md b/integration-tests/lambda-commonjs/README.md index f26cf9a2..e7386056 100644 --- a/integration-tests/lambda-commonjs/README.md +++ b/integration-tests/lambda-commonjs/README.md @@ -86,6 +86,7 @@ The job stays skipped until these repository variables exist. | Variable | Purpose | | --- | --- | | `AWS_LAMBDA_CANARY_ROLE_ARN` | The role that the job assumes. The job is skipped while this variable is empty. | +| `AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY` | The boundary policy ARN. It must match the ARN in the role's `iam:PermissionsBoundary` condition. The job is skipped while this variable is empty. | | `AWS_LAMBDA_CANARY_REGION` | Optional. The default is `us-east-1`. | The trust policy of the role must allow `token.actions.githubusercontent.com`. @@ -113,8 +114,9 @@ So the role needs no `iam:AttachRolePolicy` and no `iam:DetachRolePolicy`. The `iam:CreateRole` and `iam:PutRolePolicy` actions need an `iam:PermissionsBoundary` condition. That condition stops the role from granting itself more rights than the boundary allows. -The fixture reads the boundary from `LD_AI_CANARY_PERMISSIONS_BOUNDARY`. -Set that variable to the same boundary policy ARN that the condition names. +The workflow passes `vars.AWS_LAMBDA_CANARY_PERMISSIONS_BOUNDARY` to the fixture. +The fixture reads it as `LD_AI_CANARY_PERMISSIONS_BOUNDARY`. +Set that repository variable to the same boundary policy ARN that the condition names. ### When the canary fails