From 2db2cf883d95d20c09f92930d8d50ac467600840 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Fri, 17 Oct 2025 18:13:21 +0200 Subject: [PATCH 1/4] feat(core): Allow multi-project sourcemaps upload The `project` option now allows specifiying multiple projects via a string array. Source maps will be uploaded to all specified projects. --- .../src/build-plugin-manager.ts | 28 ++- .../src/options-mapping.ts | 27 ++- .../src/sentry/telemetry.ts | 4 +- packages/bundler-plugin-core/src/types.ts | 10 +- .../test/build-plugin-manager.test.ts | 168 ++++++++++++++++++ .../test/option-mappings.test.ts | 98 ++++++++++ .../src/generate-documentation-table.ts | 3 +- packages/esbuild-plugin/README_TEMPLATE.md | 25 +++ packages/rollup-plugin/README_TEMPLATE.md | 27 +++ packages/vite-plugin/README_TEMPLATE.md | 28 +++ packages/webpack-plugin/README_TEMPLATE.md | 27 +++ 11 files changed, 434 insertions(+), 11 deletions(-) diff --git a/packages/bundler-plugin-core/src/build-plugin-manager.ts b/packages/bundler-plugin-core/src/build-plugin-manager.ts index e41b65d4..39fb741a 100644 --- a/packages/bundler-plugin-core/src/build-plugin-manager.ts +++ b/packages/bundler-plugin-core/src/build-plugin-manager.ts @@ -94,7 +94,8 @@ function createCliInstance(options: NormalizedOptions): SentryCli { return new SentryCli(null, { authToken: options.authToken, org: options.org, - project: options.project, + // Default to the first project if multiple projects are specified + project: Array.isArray(options.project) ? options.project[0] : options.project, silent: options.silent, url: options.url, vcsRemote: options.release.vcsRemote, @@ -360,7 +361,12 @@ export function createSentryBuildPluginManager( if (typeof options.moduleMetadata === "function") { const args = { org: options.org, - project: options.project, + project: Array.isArray(options.project) ? options.project[0] : options.project, + projects: Array.isArray(options.project) + ? options.project + : options.project + ? [options.project] + : undefined, release: options.release.name, }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -444,7 +450,10 @@ export function createSentryBuildPluginManager( getTurborepoEnvPassthroughWarning("SENTRY_ORG") ); return; - } else if (!options.project) { + } else if ( + !options.project || + (Array.isArray(options.project) && options.project.length === 0) + ) { logger.warn( "No project provided. Will not create release. Please set the `project` option to your Sentry project slug." + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") @@ -481,6 +490,9 @@ export function createSentryBuildPluginManager( await cliInstance.releases.uploadSourceMaps(options.release.name, { include: normalizedInclude, dist: options.release.dist, + // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI + // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released + projects: Array.isArray(options.project) ? options.project : [options.project], // We want this promise to throw if the sourcemaps fail to upload so that we know about it. // see: https://github.com/getsentry/sentry-cli/pull/2605 live: "rejectOnError", @@ -625,6 +637,9 @@ export function createSentryBuildPluginManager( }, ], ignore: ignorePaths, + // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI + // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released + projects: Array.isArray(options.project) ? options.project : [options.project], live: "rejectOnError", }); }); @@ -735,6 +750,11 @@ export function createSentryBuildPluginManager( dist: options.release.dist, }, ], + // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI + // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released + projects: Array.isArray(options.project) + ? options.project + : [options.project], live: "rejectOnError", } ); @@ -843,7 +863,7 @@ function canUploadSourceMaps( ); return false; } - if (!options.project) { + if (!options.project || (Array.isArray(options.project) && options.project.length === 0)) { logger.warn( "No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") diff --git a/packages/bundler-plugin-core/src/options-mapping.ts b/packages/bundler-plugin-core/src/options-mapping.ts index 0c79a5ae..ae81f9e4 100644 --- a/packages/bundler-plugin-core/src/options-mapping.ts +++ b/packages/bundler-plugin-core/src/options-mapping.ts @@ -12,7 +12,7 @@ import { determineReleaseName } from "./utils"; export type NormalizedOptions = { org: string | undefined; - project: string | undefined; + project: string | string[] | undefined; authToken: string | undefined; url: string; headers: Record | undefined; @@ -89,7 +89,9 @@ export const SENTRY_SAAS_URL = "https://sentry.io"; export function normalizeUserOptions(userOptions: UserOptions): NormalizedOptions { const options = { org: userOptions.org ?? process.env["SENTRY_ORG"], - project: userOptions.project ?? process.env["SENTRY_PROJECT"], + project: userOptions.project ?? (process.env["SENTRY_PROJECT"]?.includes(',') + ? process.env["SENTRY_PROJECT"].split(',').map(p => p.trim()) + : process.env["SENTRY_PROJECT"]), authToken: userOptions.authToken ?? process.env["SENTRY_AUTH_TOKEN"], url: userOptions.url ?? process.env["SENTRY_URL"] ?? SENTRY_SAAS_URL, headers: userOptions.headers, @@ -209,5 +211,26 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo return false; } + if (options.project) { + if (Array.isArray(options.project)) { + if (options.project.length === 0) { + logger.error( + "The `project` option was specified as an array but is empty.", + "Please provide at least one project slug." + ); + return false; + } + // Check each project is a non-empty string + const invalidProjects = options.project.filter(p => typeof p !== 'string' || p.trim() === ''); + if (invalidProjects.length > 0) { + logger.error( + "The `project` option contains invalid project slugs.", + "All projects must be non-empty strings." + ); + return false; + } + } + } + return true; } diff --git a/packages/bundler-plugin-core/src/sentry/telemetry.ts b/packages/bundler-plugin-core/src/sentry/telemetry.ts index 48c93fb7..347a855f 100644 --- a/packages/bundler-plugin-core/src/sentry/telemetry.ts +++ b/packages/bundler-plugin-core/src/sentry/telemetry.ts @@ -106,7 +106,7 @@ export function setTelemetryDataOnScope( scope.setTags({ organization: org, - project, + project: Array.isArray(project) ? project.join(", ") : project ?? "undefined", bundler: buildTool, }); @@ -129,7 +129,7 @@ export async function allowedToSendTelemetry(options: NormalizedOptions): Promis url, authToken, org, - project, + project: Array.isArray(project) ? project[0] : project, vcsRemote: release.vcsRemote, silent, headers, diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index 1f8539ef..f86aca5e 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -9,9 +9,13 @@ export interface Options { /** * The slug of the Sentry project associated with the app. * + * When uploading source maps, you can specify multiple projects (as an array) to upload + * the same source maps to multiple projects. This is useful in monorepo environments + * where multiple projects share the same release. + * * This value can also be specified via the `SENTRY_PROJECT` environment variable. */ - project?: string; + project?: string | string[]; /** * The authentication token to use for all communication with Sentry. @@ -361,7 +365,8 @@ export interface Options { * Metadata can either be passed directly or alternatively a callback can be provided that will be * called with the following parameters: * - `org`: The organization slug. - * - `project`: The project slug. + * - `project`: The project slug (when multiple projects are configured, this is the first project). + * - `projects`: An array of all project slugs (available when multiple projects are configured). * - `release`: The release name. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -428,6 +433,7 @@ export interface ModuleMetadata { export interface ModuleMetadataCallbackArgs { org?: string; project?: string; + projects?: string[]; release?: string; } diff --git a/packages/bundler-plugin-core/test/build-plugin-manager.test.ts b/packages/bundler-plugin-core/test/build-plugin-manager.test.ts index be6a2f20..d263bd2b 100644 --- a/packages/bundler-plugin-core/test/build-plugin-manager.test.ts +++ b/packages/bundler-plugin-core/test/build-plugin-manager.test.ts @@ -408,6 +408,7 @@ describe("createSentryBuildPluginManager", () => { // Should upload from temp folder expect(mockCliUploadSourceMaps).toHaveBeenCalledWith("some-release-name", { include: [{ paths: ["/tmp/sentry-upload-xyz"], rewrite: false, dist: "1" }], + projects: ["p"], live: "rejectOnError", }); }); @@ -463,4 +464,171 @@ describe("createSentryBuildPluginManager", () => { ); }); }); + + describe("uploadSourcemaps with multiple projects", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGlob.mockResolvedValue(["/path/to/bundle.js"]); + mockPrepareBundleForDebugIdUpload.mockResolvedValue(undefined); + mockCliUploadSourceMaps.mockResolvedValue(undefined); + + // Mock fs operations needed for temp folder upload path + jest.spyOn(fs.promises, "mkdtemp").mockResolvedValue("/tmp/sentry-test"); + jest.spyOn(fs.promises, "readdir").mockResolvedValue([]); + jest.spyOn(fs.promises, "stat").mockResolvedValue({ size: 1000 } as fs.Stats); + jest.spyOn(fs.promises, "rm").mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should pass projects array to uploadSourceMaps when multiple projects configured", async () => { + const buildPluginManager = createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: ["proj-a", "proj-b", "proj-c"], + release: { name: "test-release" }, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"]); + + expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( + "test-release", + expect.objectContaining({ + projects: ["proj-a", "proj-b", "proj-c"], + }) + ); + }); + + it("should pass single project as array to uploadSourceMaps", async () => { + const buildPluginManager = createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: "single-project", + release: { name: "test-release" }, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"]); + + expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( + "test-release", + expect.objectContaining({ + projects: ["single-project"], + }) + ); + }); + + it("should pass projects array in direct upload mode", async () => { + const buildPluginManager = createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: ["proj-a", "proj-b"], + release: { name: "test-release" }, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"], { prepareArtifacts: false }); + + expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( + "test-release", + expect.objectContaining({ + projects: ["proj-a", "proj-b"], + }) + ); + }); + }); + + describe("moduleMetadata callback with multiple projects", () => { + it("should pass project as string and projects as array when multiple projects configured", () => { + const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" }); + + createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: ["proj-a", "proj-b", "proj-c"], + release: { name: "test-release" }, + moduleMetadata: moduleMetadataCallback, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + expect(moduleMetadataCallback).toHaveBeenCalledWith({ + org: "test-org", + project: "proj-a", + projects: ["proj-a", "proj-b", "proj-c"], + release: "test-release", + }); + }); + + it("should pass project as string and projects as array with single project", () => { + const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" }); + + createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + project: "single-project", + release: { name: "test-release" }, + moduleMetadata: moduleMetadataCallback, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + expect(moduleMetadataCallback).toHaveBeenCalledWith({ + org: "test-org", + project: "single-project", + projects: ["single-project"], + release: "test-release", + }); + }); + + it("should pass undefined for projects when no project configured", () => { + const moduleMetadataCallback = jest.fn().mockReturnValue({ custom: "metadata" }); + + createSentryBuildPluginManager( + { + authToken: "test-token", + org: "test-org", + release: { name: "test-release" }, + moduleMetadata: moduleMetadataCallback, + }, + { + buildTool: "webpack", + loggerPrefix: "[sentry-webpack-plugin]", + } + ); + + expect(moduleMetadataCallback).toHaveBeenCalledWith({ + org: "test-org", + project: undefined, + projects: undefined, + release: "test-release", + }); + }); + }); }); diff --git a/packages/bundler-plugin-core/test/option-mappings.test.ts b/packages/bundler-plugin-core/test/option-mappings.test.ts index 76bceb3d..921bcd1e 100644 --- a/packages/bundler-plugin-core/test/option-mappings.test.ts +++ b/packages/bundler-plugin-core/test/option-mappings.test.ts @@ -190,6 +190,68 @@ describe("normalizeUserOptions()", () => { expect(normalizedOptions.release.deploy).toBeUndefined(); }); }); + + describe("multi-project support", () => { + test("should accept project as a string array", () => { + const userOptions: Options = { + org: "my-org", + project: ["project-a", "project-b", "project-c"], + authToken: "my-auth-token", + release: { name: "my-release" }, + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toEqual(["project-a", "project-b", "project-c"]); + }); + + test("should parse comma-separated SENTRY_PROJECT env var", () => { + const originalEnv = process.env; + process.env = { ...originalEnv }; + process.env["SENTRY_PROJECT"] = "proj1,proj2,proj3"; + + const userOptions: Options = { + org: "my-org", + authToken: "my-auth-token", + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toEqual(["proj1", "proj2", "proj3"]); + + process.env = originalEnv; + }); + + test("should trim whitespace from comma-separated projects", () => { + const originalEnv = process.env; + process.env = { ...originalEnv }; + process.env["SENTRY_PROJECT"] = "proj1 , proj2 , proj3"; + + const userOptions: Options = { + org: "my-org", + authToken: "my-auth-token", + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toEqual(["proj1", "proj2", "proj3"]); + + process.env = originalEnv; + }); + + test("should keep single project as string (no comma)", () => { + const originalEnv = process.env; + process.env = { ...originalEnv }; + process.env["SENTRY_PROJECT"] = "single-project"; + + const userOptions: Options = { + org: "my-org", + authToken: "my-auth-token", + }; + + const normalized = normalizeUserOptions(userOptions); + expect(normalized.project).toBe("single-project"); + + process.env = originalEnv; + }); + }); }); describe("validateOptions", () => { @@ -269,4 +331,40 @@ describe("validateOptions", () => { expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(true); expect(mockedLogger.error).not.toHaveBeenCalled(); }); + + describe("multi-project validation", () => { + it("should return `false` if project array is empty", () => { + const options = { project: [] } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/project.*array.*empty/i), + expect.stringMatching(/at least one/i) + ); + }); + + it("should return `false` if project array contains invalid strings", () => { + const options = { project: ["valid", "", " ", "also-valid"] } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(false); + expect(mockedLogger.error).toHaveBeenCalledWith( + expect.stringMatching(/invalid.*project/i), + expect.stringMatching(/non-empty strings/i) + ); + }); + + it("should return `true` for valid project array", () => { + const options = { project: ["proj-a", "proj-b"] } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(true); + expect(mockedLogger.error).not.toHaveBeenCalled(); + }); + + it("should return `true` for valid single project string", () => { + const options = { project: "single-project" } as Partial; + + expect(validateOptions(options as unknown as NormalizedOptions, mockedLogger)).toBe(true); + expect(mockedLogger.error).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/dev-utils/src/generate-documentation-table.ts b/packages/dev-utils/src/generate-documentation-table.ts index 073ba305..6e97771b 100644 --- a/packages/dev-utils/src/generate-documentation-table.ts +++ b/packages/dev-utils/src/generate-documentation-table.ts @@ -17,8 +17,9 @@ const options: OptionDocumentation[] = [ }, { name: "project", + type: "string | string[]", fullDescription: - "The slug of the Sentry project associated with the app.\n\nThis value can also be specified via the `SENTRY_PROJECT` environment variable.", + "The slug of the Sentry project associated with the app. You can also provide an array of project slugs to upload source maps to multiple projects with the same release.\n\nThis value can also be specified via the `SENTRY_PROJECT` environment variable. To specify multiple projects via the environment variable, separate them with commas: `SENTRY_PROJECT=project1,project2,project3`.", }, { name: "authToken", diff --git a/packages/esbuild-plugin/README_TEMPLATE.md b/packages/esbuild-plugin/README_TEMPLATE.md index ca35df29..eef262df 100644 --- a/packages/esbuild-plugin/README_TEMPLATE.md +++ b/packages/esbuild-plugin/README_TEMPLATE.md @@ -53,6 +53,31 @@ require("esbuild").build({ }); ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```js +// esbuild.config.js +const { sentryEsbuildPlugin } = require("@sentry/esbuild-plugin"); + +require("esbuild").build({ + sourcemap: true, + plugins: [ + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +Or via environment variable: +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File diff --git a/packages/rollup-plugin/README_TEMPLATE.md b/packages/rollup-plugin/README_TEMPLATE.md index 53ebf7c8..fe4d3fbd 100644 --- a/packages/rollup-plugin/README_TEMPLATE.md +++ b/packages/rollup-plugin/README_TEMPLATE.md @@ -55,6 +55,33 @@ export default { }; ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```js +// rollup.config.js +import { sentryRollupPlugin } from "@sentry/rollup-plugin"; + +export default { + plugins: [ + sentryRollupPlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], + output: { + sourcemap: true, + }, +}; +``` + +Or via environment variable: +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File diff --git a/packages/vite-plugin/README_TEMPLATE.md b/packages/vite-plugin/README_TEMPLATE.md index ad8d3cbc..9dc88d5a 100644 --- a/packages/vite-plugin/README_TEMPLATE.md +++ b/packages/vite-plugin/README_TEMPLATE.md @@ -60,6 +60,34 @@ export default defineConfig({ }); ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```ts +// vite.config.ts +import { defineConfig } from "vite"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; + +export default defineConfig({ + build: { + sourcemap: true, + }, + plugins: [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +Or via environment variable: +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File diff --git a/packages/webpack-plugin/README_TEMPLATE.md b/packages/webpack-plugin/README_TEMPLATE.md index 2b25c4a2..d80abc6a 100644 --- a/packages/webpack-plugin/README_TEMPLATE.md +++ b/packages/webpack-plugin/README_TEMPLATE.md @@ -56,6 +56,33 @@ module.exports = { }; ``` +### Multi-Project Configuration + +If you want to upload the same source maps to multiple Sentry projects: + +```js +// webpack.config.js +const { sentryWebpackPlugin } = require("@sentry/webpack-plugin"); + +module.exports = { + // ... other config above ... + + devtool: "source-map", + plugins: [ + sentryWebpackPlugin({ + org: process.env.SENTRY_ORG, + project: ["frontend-team-a", "frontend-team-b", "frontend-team-c"], + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}; +``` + +Or via environment variable: +```bash +SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c +``` + #OPTIONS_SECTION_INSERT# ### Configuration File From d9f1c345ab6f552676a49846e6b45309bba598de Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 20 Oct 2025 09:32:34 +0200 Subject: [PATCH 2/4] Fix formatting --- packages/bundler-plugin-core/src/options-mapping.ts | 12 ++++++++---- .../test/build-plugin-manager.test.ts | 4 +++- packages/esbuild-plugin/README_TEMPLATE.md | 1 + packages/rollup-plugin/README_TEMPLATE.md | 1 + packages/vite-plugin/README_TEMPLATE.md | 1 + packages/webpack-plugin/README_TEMPLATE.md | 1 + 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/bundler-plugin-core/src/options-mapping.ts b/packages/bundler-plugin-core/src/options-mapping.ts index ae81f9e4..9b1355eb 100644 --- a/packages/bundler-plugin-core/src/options-mapping.ts +++ b/packages/bundler-plugin-core/src/options-mapping.ts @@ -89,9 +89,11 @@ export const SENTRY_SAAS_URL = "https://sentry.io"; export function normalizeUserOptions(userOptions: UserOptions): NormalizedOptions { const options = { org: userOptions.org ?? process.env["SENTRY_ORG"], - project: userOptions.project ?? (process.env["SENTRY_PROJECT"]?.includes(',') - ? process.env["SENTRY_PROJECT"].split(',').map(p => p.trim()) - : process.env["SENTRY_PROJECT"]), + project: + userOptions.project ?? + (process.env["SENTRY_PROJECT"]?.includes(",") + ? process.env["SENTRY_PROJECT"].split(",").map((p) => p.trim()) + : process.env["SENTRY_PROJECT"]), authToken: userOptions.authToken ?? process.env["SENTRY_AUTH_TOKEN"], url: userOptions.url ?? process.env["SENTRY_URL"] ?? SENTRY_SAAS_URL, headers: userOptions.headers, @@ -221,7 +223,9 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo return false; } // Check each project is a non-empty string - const invalidProjects = options.project.filter(p => typeof p !== 'string' || p.trim() === ''); + const invalidProjects = options.project.filter( + (p) => typeof p !== "string" || p.trim() === "" + ); if (invalidProjects.length > 0) { logger.error( "The `project` option contains invalid project slugs.", diff --git a/packages/bundler-plugin-core/test/build-plugin-manager.test.ts b/packages/bundler-plugin-core/test/build-plugin-manager.test.ts index d263bd2b..1003d57c 100644 --- a/packages/bundler-plugin-core/test/build-plugin-manager.test.ts +++ b/packages/bundler-plugin-core/test/build-plugin-manager.test.ts @@ -545,7 +545,9 @@ describe("createSentryBuildPluginManager", () => { } ); - await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"], { prepareArtifacts: false }); + await buildPluginManager.uploadSourcemaps(["/path/to/bundle.js"], { + prepareArtifacts: false, + }); expect(mockCliUploadSourceMaps).toHaveBeenCalledWith( "test-release", diff --git a/packages/esbuild-plugin/README_TEMPLATE.md b/packages/esbuild-plugin/README_TEMPLATE.md index eef262df..5e1e3e0c 100644 --- a/packages/esbuild-plugin/README_TEMPLATE.md +++ b/packages/esbuild-plugin/README_TEMPLATE.md @@ -74,6 +74,7 @@ require("esbuild").build({ ``` Or via environment variable: + ```bash SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c ``` diff --git a/packages/rollup-plugin/README_TEMPLATE.md b/packages/rollup-plugin/README_TEMPLATE.md index fe4d3fbd..05e88416 100644 --- a/packages/rollup-plugin/README_TEMPLATE.md +++ b/packages/rollup-plugin/README_TEMPLATE.md @@ -78,6 +78,7 @@ export default { ``` Or via environment variable: + ```bash SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c ``` diff --git a/packages/vite-plugin/README_TEMPLATE.md b/packages/vite-plugin/README_TEMPLATE.md index 9dc88d5a..0f34d017 100644 --- a/packages/vite-plugin/README_TEMPLATE.md +++ b/packages/vite-plugin/README_TEMPLATE.md @@ -84,6 +84,7 @@ export default defineConfig({ ``` Or via environment variable: + ```bash SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c ``` diff --git a/packages/webpack-plugin/README_TEMPLATE.md b/packages/webpack-plugin/README_TEMPLATE.md index d80abc6a..c02dc897 100644 --- a/packages/webpack-plugin/README_TEMPLATE.md +++ b/packages/webpack-plugin/README_TEMPLATE.md @@ -79,6 +79,7 @@ module.exports = { ``` Or via environment variable: + ```bash SENTRY_PROJECT=frontend-team-a,frontend-team-b,frontend-team-c ``` From db86ea3824507e405d04217f40fadc40ea21d049 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 20 Oct 2025 14:29:33 +0200 Subject: [PATCH 3/4] Add getProjects helper --- .../src/build-plugin-manager.ts | 27 +++++++++---------- .../src/sentry/telemetry.ts | 3 ++- packages/bundler-plugin-core/src/utils.ts | 15 +++++++++++ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/bundler-plugin-core/src/build-plugin-manager.ts b/packages/bundler-plugin-core/src/build-plugin-manager.ts index 39fb741a..77c517f0 100644 --- a/packages/bundler-plugin-core/src/build-plugin-manager.ts +++ b/packages/bundler-plugin-core/src/build-plugin-manager.ts @@ -19,7 +19,12 @@ import { safeFlushTelemetry, } from "./sentry/telemetry"; import { Options, SentrySDKBuildFlags } from "./types"; -import { arrayify, getTurborepoEnvPassthroughWarning, stripQueryAndHashFromPath } from "./utils"; +import { + arrayify, + getProjects, + getTurborepoEnvPassthroughWarning, + stripQueryAndHashFromPath, +} from "./utils"; import { glob } from "glob"; import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from "./debug-id-upload"; @@ -95,7 +100,7 @@ function createCliInstance(options: NormalizedOptions): SentryCli { authToken: options.authToken, org: options.org, // Default to the first project if multiple projects are specified - project: Array.isArray(options.project) ? options.project[0] : options.project, + project: getProjects(options.project)?.[0], silent: options.silent, url: options.url, vcsRemote: options.release.vcsRemote, @@ -361,12 +366,8 @@ export function createSentryBuildPluginManager( if (typeof options.moduleMetadata === "function") { const args = { org: options.org, - project: Array.isArray(options.project) ? options.project[0] : options.project, - projects: Array.isArray(options.project) - ? options.project - : options.project - ? [options.project] - : undefined, + project: getProjects(options.project)?.[0], + projects: getProjects(options.project), release: options.release.name, }; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -492,7 +493,7 @@ export function createSentryBuildPluginManager( dist: options.release.dist, // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released - projects: Array.isArray(options.project) ? options.project : [options.project], + projects: getProjects(options.project), // We want this promise to throw if the sourcemaps fail to upload so that we know about it. // see: https://github.com/getsentry/sentry-cli/pull/2605 live: "rejectOnError", @@ -639,7 +640,7 @@ export function createSentryBuildPluginManager( ignore: ignorePaths, // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released - projects: Array.isArray(options.project) ? options.project : [options.project], + projects: getProjects(options.project), live: "rejectOnError", }); }); @@ -752,9 +753,7 @@ export function createSentryBuildPluginManager( ], // @ts-expect-error - projects is not a valid option for uploadSourceMaps but is implemented in the CLI // Remove once https://github.com/getsentry/sentry-cli/pull/2856 is released - projects: Array.isArray(options.project) - ? options.project - : [options.project], + projects: getProjects(options.project), live: "rejectOnError", } ); @@ -863,7 +862,7 @@ function canUploadSourceMaps( ); return false; } - if (!options.project || (Array.isArray(options.project) && options.project.length === 0)) { + if (!getProjects(options.project)?.[0]) { logger.warn( "No project provided. Will not upload source maps. Please set the `project` option to your Sentry project slug." + getTurborepoEnvPassthroughWarning("SENTRY_PROJECT") diff --git a/packages/bundler-plugin-core/src/sentry/telemetry.ts b/packages/bundler-plugin-core/src/sentry/telemetry.ts index 347a855f..70c17b3e 100644 --- a/packages/bundler-plugin-core/src/sentry/telemetry.ts +++ b/packages/bundler-plugin-core/src/sentry/telemetry.ts @@ -5,6 +5,7 @@ import { NormalizedOptions, SENTRY_SAAS_URL } from "../options-mapping"; import { Scope } from "@sentry/core"; import { createStackParser, nodeStackLineParser } from "@sentry/utils"; import { makeOptionallyEnabledNodeTransport } from "./transports"; +import { getProjects } from "../utils"; const SENTRY_SAAS_HOSTNAME = "sentry.io"; @@ -129,7 +130,7 @@ export async function allowedToSendTelemetry(options: NormalizedOptions): Promis url, authToken, org, - project: Array.isArray(project) ? project[0] : project, + project: getProjects(project)?.[0], vcsRemote: release.vcsRemote, silent, headers, diff --git a/packages/bundler-plugin-core/src/utils.ts b/packages/bundler-plugin-core/src/utils.ts index 1162e9f6..a1aebf11 100644 --- a/packages/bundler-plugin-core/src/utils.ts +++ b/packages/bundler-plugin-core/src/utils.ts @@ -393,3 +393,18 @@ export function getTurborepoEnvPassthroughWarning(envVarName: string): string { ? `\nYou seem to be using Turborepo, did you forget to put ${envVarName} in \`passThroughEnv\`? https://turbo.build/repo/docs/reference/configuration#passthroughenv` : ""; } + +/** + * Gets the projects from the project option. This might be a single project or an array of projects. + */ +export function getProjects(project: string | string[] | undefined): string[] | undefined { + if (Array.isArray(project)) { + return project; + } + + if (project) { + return [project]; + } + + return undefined; +} From be3b7833ee556d72c353fdd5fae2a72a958f86a3 Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 20 Oct 2025 14:37:21 +0200 Subject: [PATCH 4/4] Flatten project validation --- .../src/options-mapping.ts | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/bundler-plugin-core/src/options-mapping.ts b/packages/bundler-plugin-core/src/options-mapping.ts index 9b1355eb..b017a9d8 100644 --- a/packages/bundler-plugin-core/src/options-mapping.ts +++ b/packages/bundler-plugin-core/src/options-mapping.ts @@ -213,26 +213,22 @@ export function validateOptions(options: NormalizedOptions, logger: Logger): boo return false; } - if (options.project) { - if (Array.isArray(options.project)) { - if (options.project.length === 0) { - logger.error( - "The `project` option was specified as an array but is empty.", - "Please provide at least one project slug." - ); - return false; - } - // Check each project is a non-empty string - const invalidProjects = options.project.filter( - (p) => typeof p !== "string" || p.trim() === "" + if (options.project && Array.isArray(options.project)) { + if (options.project.length === 0) { + logger.error( + "The `project` option was specified as an array but is empty.", + "Please provide at least one project slug." ); - if (invalidProjects.length > 0) { - logger.error( - "The `project` option contains invalid project slugs.", - "All projects must be non-empty strings." - ); - return false; - } + return false; + } + // Check each project is a non-empty string + const invalidProjects = options.project.filter((p) => typeof p !== "string" || p.trim() === ""); + if (invalidProjects.length > 0) { + logger.error( + "The `project` option contains invalid project slugs.", + "All projects must be non-empty strings." + ); + return false; } }