From bf82c980d3cb7286305cc685ad6b15ec1f702e9e Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 21 Jul 2026 16:56:01 +0100 Subject: [PATCH 1/4] fix(webapp): scope development branches to each member --- .../app/services/upsertBranch.server.ts | 37 ++++++++--- apps/webapp/test/devBranchServices.test.ts | 61 ++++++++++++++++++- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index fa18101b476..27966be4b06 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -1,4 +1,8 @@ -import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { + type Prisma, + type PrismaClient, + type PrismaClientOrTransaction, +} from "@trigger.dev/database"; import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; @@ -13,6 +17,7 @@ import { logger } from "./logger.server"; import { getCurrentPlan, getLimit } from "./platform.v3.server"; import { type z } from "zod"; import invariant from "tiny-invariant"; +import { nanoid } from "nanoid"; import { type CreateBranchOptions } from "~/utils/branches"; import { applyBillingLimitPauseAfterEnvCreate, @@ -141,7 +146,28 @@ export class UpsertBranchService { const branchSlug = `${slug(`${parentEnvironment.slug}-${sanitizedBranchName}`)}`; const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); - const shortcode = branchSlug; + const isDevelopmentBranch = parentEnvironment.type === "DEVELOPMENT"; + // Dev branch slugs are member-scoped, but shortcodes remain project-scoped. + // Keep the readable slug while giving each member's branch a unique shortcode. + const shortcode = isDevelopmentBranch ? `${branchSlug}-${nanoid()}` : branchSlug; + let branchWhere: Prisma.RuntimeEnvironmentWhereUniqueInput; + if (isDevelopmentBranch) { + invariant(parentEnvironment.orgMemberId, "Development branches require an org member"); + branchWhere = { + projectId_slug_orgMemberId: { + projectId: parentEnvironment.project.id, + slug: branchSlug, + orgMemberId: parentEnvironment.orgMemberId, + }, + }; + } else { + branchWhere = { + projectId_shortcode: { + projectId: parentEnvironment.project.id, + shortcode, + }, + }; + } const billingPause = await getInitialEnvPauseStateForBillingLimit( parentEnvironment.organization.id, parentEnvironment.type @@ -149,12 +175,7 @@ export class UpsertBranchService { const now = new Date(); const branch = await this.#prismaClient.runtimeEnvironment.upsert({ - where: { - projectId_shortcode: { - projectId: parentEnvironment.project.id, - shortcode: shortcode, - }, - }, + where: branchWhere, create: { slug: branchSlug, apiKey, diff --git a/apps/webapp/test/devBranchServices.test.ts b/apps/webapp/test/devBranchServices.test.ts index 06c06f7c7e8..c01f3e7b066 100644 --- a/apps/webapp/test/devBranchServices.test.ts +++ b/apps/webapp/test/devBranchServices.test.ts @@ -4,7 +4,11 @@ import slug from "slug"; import { describe, expect, vi } from "vitest"; import { ArchiveBranchService } from "~/services/archiveBranch.server"; import { UpsertBranchService } from "~/services/upsertBranch.server"; -import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures"; +import { + createTestOrgProjectWithMember, + createTestUser, + uniqueId, +} from "./fixtures/environmentVariablesFixtures"; vi.setConfig({ testTimeout: 60_000 }); @@ -77,6 +81,61 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => { } ); + postgresTest("allows different members to use the same branch name", async ({ prisma }) => { + const { + organization, + project, + user: firstUser, + orgMember: firstMember, + } = await createTestOrgProjectWithMember(prisma); + const secondUser = await createTestUser(prisma); + const secondMember = await prisma.orgMember.create({ + data: { + organizationId: organization.id, + userId: secondUser.id, + role: "MEMBER", + }, + }); + + await Promise.all([ + createDevRoot(prisma, project.id, organization.id, firstMember.id), + createDevRoot(prisma, project.id, organization.id, secondMember.id), + ]); + + const options = { + projectId: project.id, + env: "development" as const, + branchName: "shared-name", + }; + const [firstResult, secondResult] = await Promise.all([ + new UpsertBranchService(prisma).call( + { type: "userMembership", userId: firstUser.id }, + options + ), + new UpsertBranchService(prisma).call( + { type: "userMembership", userId: secondUser.id }, + options + ), + ]); + + expect(firstResult.success && secondResult.success).toBe(true); + if (!firstResult.success || !secondResult.success) return; + expect(firstResult.branch.id).not.toBe(secondResult.branch.id); + expect(firstResult.branch.slug).toBe(secondResult.branch.slug); + expect(firstResult.branch.shortcode).not.toBe(secondResult.branch.shortcode); + expect(firstResult.branch.orgMemberId).toBe(firstMember.id); + expect(secondResult.branch.orgMemberId).toBe(secondMember.id); + + const firstRetry = await new UpsertBranchService(prisma).call( + { type: "userMembership", userId: firstUser.id }, + options + ); + expect(firstRetry.success).toBe(true); + if (!firstRetry.success) return; + expect(firstRetry.alreadyExisted).toBe(true); + expect(firstRetry.branch.id).toBe(firstResult.branch.id); + }); + postgresTest( "rejects an invalid branch name without touching the database", async ({ prisma }) => { From e236e0f6233c9f2942db0bd7c017f1f9d56f8b44 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 21 Jul 2026 17:03:44 +0100 Subject: [PATCH 2/4] docs(webapp): note development branch scoping fix --- .server-changes/dev-branch-member-scoping.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/dev-branch-member-scoping.md diff --git a/.server-changes/dev-branch-member-scoping.md b/.server-changes/dev-branch-member-scoping.md new file mode 100644 index 00000000000..d836c6c8357 --- /dev/null +++ b/.server-changes/dev-branch-member-scoping.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Allow different organization members to use the same development branch name without sharing or colliding with each other's branch environments. From ac8596989dd9655975c8842e8ee5864011592019 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 21 Jul 2026 17:52:36 +0100 Subject: [PATCH 3/4] fix(webapp): make dev branch shortcodes deterministic --- apps/webapp/app/services/upsertBranch.server.ts | 8 +++++--- apps/webapp/test/devBranchServices.test.ts | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 27966be4b06..5674a67c169 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -17,7 +17,6 @@ import { logger } from "./logger.server"; import { getCurrentPlan, getLimit } from "./platform.v3.server"; import { type z } from "zod"; import invariant from "tiny-invariant"; -import { nanoid } from "nanoid"; import { type CreateBranchOptions } from "~/utils/branches"; import { applyBillingLimitPauseAfterEnvCreate, @@ -148,8 +147,11 @@ export class UpsertBranchService { const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); const isDevelopmentBranch = parentEnvironment.type === "DEVELOPMENT"; // Dev branch slugs are member-scoped, but shortcodes remain project-scoped. - // Keep the readable slug while giving each member's branch a unique shortcode. - const shortcode = isDevelopmentBranch ? `${branchSlug}-${nanoid()}` : branchSlug; + // The parent shortcode is already unique within the project, so it gives + // each member's branch a stable, readable shortcode without a migration. + const shortcode = isDevelopmentBranch + ? `${branchSlug}-${parentEnvironment.shortcode}` + : branchSlug; let branchWhere: Prisma.RuntimeEnvironmentWhereUniqueInput; if (isDevelopmentBranch) { invariant(parentEnvironment.orgMemberId, "Development branches require an org member"); diff --git a/apps/webapp/test/devBranchServices.test.ts b/apps/webapp/test/devBranchServices.test.ts index c01f3e7b066..55cf4fcbbc3 100644 --- a/apps/webapp/test/devBranchServices.test.ts +++ b/apps/webapp/test/devBranchServices.test.ts @@ -97,7 +97,7 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => { }, }); - await Promise.all([ + const [firstRoot, secondRoot] = await Promise.all([ createDevRoot(prisma, project.id, organization.id, firstMember.id), createDevRoot(prisma, project.id, organization.id, secondMember.id), ]); @@ -122,7 +122,8 @@ describe("UpsertBranchService — DEVELOPMENT parent", () => { if (!firstResult.success || !secondResult.success) return; expect(firstResult.branch.id).not.toBe(secondResult.branch.id); expect(firstResult.branch.slug).toBe(secondResult.branch.slug); - expect(firstResult.branch.shortcode).not.toBe(secondResult.branch.shortcode); + expect(firstResult.branch.shortcode).toBe(`dev-shared-name-${firstRoot.shortcode}`); + expect(secondResult.branch.shortcode).toBe(`dev-shared-name-${secondRoot.shortcode}`); expect(firstResult.branch.orgMemberId).toBe(firstMember.id); expect(secondResult.branch.orgMemberId).toBe(secondMember.id); From e9abddad167521aab583c0aa98fb88cbc2e11b6f Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 21 Jul 2026 18:05:25 +0100 Subject: [PATCH 4/4] docs(webapp): clarify dev branch identity scoping --- apps/webapp/app/services/upsertBranch.server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 5674a67c169..3364f3f560a 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -146,9 +146,9 @@ export class UpsertBranchService { const apiKey = createApiKeyForEnv(parentEnvironment.type); const pkApiKey = createPkApiKeyForEnv(parentEnvironment.type); const isDevelopmentBranch = parentEnvironment.type === "DEVELOPMENT"; - // Dev branch slugs are member-scoped, but shortcodes remain project-scoped. - // The parent shortcode is already unique within the project, so it gives - // each member's branch a stable, readable shortcode without a migration. + // Dev branches can share a slug across members, so their identity is scoped by + // orgMemberId. Shortcodes remain project-scoped and use the parent's unique + // shortcode to distinguish otherwise identical branch slugs. const shortcode = isDevelopmentBranch ? `${branchSlug}-${parentEnvironment.shortcode}` : branchSlug;