diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts new file mode 100644 index 00000000..7788a4ef --- /dev/null +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/__tests__/deploy-source-headers.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import pkg from '../../package.json' with { type: 'json' }; +import { deploySourceHeaders } from '../credentials.ts'; + +describe('deploySourceHeaders', () => { + const originalEnv = process.env['GITHUB_ACTIONS']; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env['GITHUB_ACTIONS']; + } else { + process.env['GITHUB_ACTIONS'] = originalEnv; + } + }); + + test('uses "github-action" as source when GITHUB_ACTIONS is "true"', () => { + process.env['GITHUB_ACTIONS'] = 'true'; + expect(deploySourceHeaders()['x-prisma-deploy-source']).toBe('github-action'); + }); + + test('uses "composer" as source outside GitHub Actions', () => { + delete process.env['GITHUB_ACTIONS']; + expect(deploySourceHeaders()['x-prisma-deploy-source']).toBe('composer'); + }); + + test('uses "composer" as source when GITHUB_ACTIONS has another value', () => { + process.env['GITHUB_ACTIONS'] = 'false'; + expect(deploySourceHeaders()['x-prisma-deploy-source']).toBe('composer'); + }); + + test('always sets x-prisma-client-name to "composer"', () => { + expect(deploySourceHeaders()['x-prisma-client-name']).toBe('composer'); + }); + + test('sets x-prisma-client-version to the package version', () => { + expect(pkg.version.length).toBeGreaterThan(0); + expect(deploySourceHeaders()['x-prisma-client-version']).toBe(pkg.version); + }); +}); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts index ac6f9490..2cbee49f 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/client.ts @@ -4,7 +4,7 @@ import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as Redacted from 'effect/Redacted'; -import { managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; +import { deploySourceHeaders, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; export type ManagementApiClient = ReturnType; @@ -31,6 +31,10 @@ export const layer = (options?: { Effect.gen(function* () { const { token } = yield* PrismaCredentials; const baseUrl = options?.apiOrigin ?? (yield* managementApiBaseUrl()); - return createManagementApiClient({ token: Redacted.value(token), baseUrl }); + return createManagementApiClient({ + token: Redacted.value(token), + baseUrl, + headers: deploySourceHeaders(), + }); }), ); diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts index e462d981..cd114b76 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/credentials.ts @@ -3,6 +3,7 @@ import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import type * as Redacted from 'effect/Redacted'; +import pkg from '../package.json' with { type: 'json' }; /** * The Prisma service token used to authenticate Management API calls. Kept @@ -64,6 +65,17 @@ const normalizeBaseUrl = (value: string): Effect.Effect => : new Error(`Invalid Prisma Management API URL: ${String(cause)}`), }); +/** + * Headers the Management API records in its deploy analytics: which tool sent + * the request, and whether it ran in GitHub Actions. The API behaves the same + * without them. + */ +export const deploySourceHeaders = (): Record => ({ + 'x-prisma-deploy-source': process.env['GITHUB_ACTIONS'] === 'true' ? 'github-action' : 'composer', + 'x-prisma-client-name': 'composer', + 'x-prisma-client-version': pkg.version, +}); + /** * The Management API origin every Prisma-Cloud client in this package uses — * Composer's own SDK client AND upstream alchemy's postgres providers resolve diff --git a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts index 20d0fa0c..58bcef87 100644 --- a/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts +++ b/packages/1-prisma-cloud/0-lowering/lowering/src/providers.ts @@ -3,8 +3,15 @@ import * as Prisma from 'alchemy/Prisma'; import * as Provider from 'alchemy/Provider'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; +import * as HttpClient from 'effect/unstable/http/HttpClient'; +import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest'; import * as client from './client.ts'; -import { fromEnv, managementApiBaseUrl, PrismaCredentials } from './credentials.ts'; +import { + deploySourceHeaders, + fromEnv, + managementApiBaseUrl, + PrismaCredentials, +} from './credentials.ts'; /** The collection of Prisma resource providers. */ export class Providers extends Provider.ProviderCollection()('PrismaComposer') {} @@ -32,6 +39,19 @@ const prismaEnvironment = () => }), ); +/** + * A node:http transport that adds deploy-source headers to every request. + * Used privately by upstreamPrismaProviders — not exposed as the ambient + * HttpClient, so the artifact-upload path is unaffected. + */ +const prismaManagementHttpLayer = Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const base = yield* HttpClient.HttpClient; + return HttpClient.mapRequest(base, HttpClientRequest.setHeaders(deploySourceHeaders())); + }), +).pipe(Layer.provide(NodeHttpClient.layerNodeHttp)); + /** * Upstream alchemy's live providers for the postgres family (Project, * Database, Connection), the compute family (App, Deployment, @@ -56,10 +76,10 @@ const upstreamPrismaProviders = () => Prisma.BucketAccessKeyProvider(), ).pipe( Layer.provideMerge(Prisma.PrismaClientLive), - // Provide (NOT provideMerge) the node transport privately — mirrors + // Provide (NOT provideMerge) the transport privately — mirrors // upstream's Providers.ts: it must serve only the Prisma management // client, never override the ambient HttpClient of other providers. - Layer.provide(NodeHttpClient.layerNodeHttp), + Layer.provide(prismaManagementHttpLayer), Layer.provideMerge(prismaEnvironment()), );