Skip to content

Commit 79d3f9e

Browse files
authored
fix(webpack): Deduplicate webpack deploys (#875)
Next.js Vercel deploys were creating duplicate deploy records (one per webpack compiler: client, server, edge) - Added a module-level guard (Set<string> keyed by release name) so newDeploy only fires once per release per process - Only guards newDeploy — all other release operations (create, finalize, setCommits) are idempotent and unaffected closes #873
1 parent 62bfaa5 commit 79d3f9e

File tree

2 files changed

+150
-3
lines changed

2 files changed

+150
-3
lines changed

packages/bundler-plugin-core/src/build-plugin-manager.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ import {
2929
import { glob } from "glob";
3030
import { defaultRewriteSourcesHook, prepareBundleForDebugIdUpload } from "./debug-id-upload";
3131

32+
// Module-level guard to prevent duplicate deploy records when multiple bundler plugin
33+
// instances run in the same process (e.g. Next.js creates separate webpack compilers
34+
// for client, server, and edge). Keyed by release name.
35+
const _deployedReleases = new Set<string>();
36+
37+
/** @internal Exported for testing only. */
38+
export function _resetDeployedReleasesForTesting(): void {
39+
_deployedReleases.clear();
40+
}
41+
3242
export type SentryBuildPluginManager = {
3343
/**
3444
* A logger instance that takes the options passed to the build plugin manager into account. (for silencing and log level etc.)
@@ -534,8 +544,9 @@ export function createSentryBuildPluginManager(
534544
await cliInstance.releases.finalize(options.release.name);
535545
}
536546

537-
if (options.release.deploy) {
547+
if (options.release.deploy && !_deployedReleases.has(options.release.name)) {
538548
await cliInstance.releases.newDeploy(options.release.name, options.release.deploy);
549+
_deployedReleases.add(options.release.name);
539550
}
540551
} catch (e) {
541552
sentryScope.captureException('Error in "releaseManagementPlugin" writeBundle hook');

packages/bundler-plugin-core/test/build-plugin-manager.test.ts

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
import { createSentryBuildPluginManager } from "../src/build-plugin-manager";
1+
import {
2+
createSentryBuildPluginManager,
3+
_resetDeployedReleasesForTesting,
4+
} from "../src/build-plugin-manager";
25
import fs from "fs";
36
import { glob } from "glob";
47
import { prepareBundleForDebugIdUpload } from "../src/debug-id-upload";
58

69
const mockCliExecute = jest.fn();
710
const mockCliUploadSourceMaps = jest.fn();
11+
const mockCliNewDeploy = jest.fn();
812

913
jest.mock("@sentry/cli", () => {
1014
return jest.fn().mockImplementation(() => ({
@@ -14,7 +18,7 @@ jest.mock("@sentry/cli", () => {
1418
new: jest.fn(),
1519
finalize: jest.fn(),
1620
setCommits: jest.fn(),
17-
newDeploy: jest.fn(),
21+
newDeploy: mockCliNewDeploy,
1822
},
1923
}));
2024
});
@@ -633,4 +637,136 @@ describe("createSentryBuildPluginManager", () => {
633637
});
634638
});
635639
});
640+
641+
describe("createRelease deploy deduplication", () => {
642+
beforeEach(() => {
643+
jest.clearAllMocks();
644+
_resetDeployedReleasesForTesting();
645+
});
646+
647+
it("should create a deploy record on the first call", async () => {
648+
const manager = createSentryBuildPluginManager(
649+
{
650+
authToken: "test-token",
651+
org: "test-org",
652+
project: "test-project",
653+
release: {
654+
name: "test-release",
655+
deploy: { env: "production" },
656+
},
657+
},
658+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
659+
);
660+
661+
await manager.createRelease();
662+
663+
expect(mockCliNewDeploy).toHaveBeenCalledTimes(1);
664+
expect(mockCliNewDeploy).toHaveBeenCalledWith("test-release", { env: "production" });
665+
});
666+
667+
it("should not create duplicate deploy records when createRelease is called multiple times on the same instance", async () => {
668+
const manager = createSentryBuildPluginManager(
669+
{
670+
authToken: "test-token",
671+
org: "test-org",
672+
project: "test-project",
673+
release: {
674+
name: "test-release",
675+
deploy: { env: "production" },
676+
},
677+
},
678+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
679+
);
680+
681+
await manager.createRelease();
682+
await manager.createRelease();
683+
await manager.createRelease();
684+
685+
expect(mockCliNewDeploy).toHaveBeenCalledTimes(1);
686+
});
687+
688+
it("should not create duplicate deploy records across separate plugin instances with the same release name", async () => {
689+
const managerA = createSentryBuildPluginManager(
690+
{
691+
authToken: "test-token",
692+
org: "test-org",
693+
project: "test-project",
694+
release: {
695+
name: "test-release",
696+
deploy: { env: "production" },
697+
},
698+
},
699+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
700+
);
701+
702+
const managerB = createSentryBuildPluginManager(
703+
{
704+
authToken: "test-token",
705+
org: "test-org",
706+
project: "test-project",
707+
release: {
708+
name: "test-release",
709+
deploy: { env: "production" },
710+
},
711+
},
712+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
713+
);
714+
715+
await managerA.createRelease();
716+
await managerB.createRelease();
717+
718+
expect(mockCliNewDeploy).toHaveBeenCalledTimes(1);
719+
});
720+
721+
it("should allow deploys for different release names", async () => {
722+
const managerA = createSentryBuildPluginManager(
723+
{
724+
authToken: "test-token",
725+
org: "test-org",
726+
project: "test-project",
727+
release: {
728+
name: "release-1",
729+
deploy: { env: "production" },
730+
},
731+
},
732+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
733+
);
734+
735+
const managerB = createSentryBuildPluginManager(
736+
{
737+
authToken: "test-token",
738+
org: "test-org",
739+
project: "test-project",
740+
release: {
741+
name: "release-2",
742+
deploy: { env: "production" },
743+
},
744+
},
745+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
746+
);
747+
748+
await managerA.createRelease();
749+
await managerB.createRelease();
750+
751+
expect(mockCliNewDeploy).toHaveBeenCalledTimes(2);
752+
expect(mockCliNewDeploy).toHaveBeenCalledWith("release-1", { env: "production" });
753+
expect(mockCliNewDeploy).toHaveBeenCalledWith("release-2", { env: "production" });
754+
});
755+
756+
it("should not create a deploy when deploy option is not set", async () => {
757+
const manager = createSentryBuildPluginManager(
758+
{
759+
authToken: "test-token",
760+
org: "test-org",
761+
project: "test-project",
762+
release: { name: "test-release" },
763+
},
764+
{ buildTool: "webpack", loggerPrefix: "[sentry-webpack-plugin]" }
765+
);
766+
767+
await manager.createRelease();
768+
769+
expect(mockCliNewDeploy).not.toHaveBeenCalled();
770+
});
771+
});
636772
});

0 commit comments

Comments
 (0)