fix(webapp): scope development branches to each member (#4323)

## Summary

Allow each organization member to use the same development branch name
without colliding with another member's environment. Fixes #4320.

## Fix

Development branches now use the existing member-scoped project, slug,
and organization-member key for upserts. Preview branches retain their
project-wide shortcode behavior.

New development branches receive distinct shortcodes while keeping their
readable, member-scoped slugs. Existing branches continue to resolve
through the member-scoped key, so this requires no migration or
backfill.
This commit is contained in:
Chris Arderne
2026-07-22 08:20:43 +01:00
committed by GitHub
parent 95307ba33c
commit bb34a2e224
3 changed files with 98 additions and 9 deletions
@@ -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.
@@ -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";
@@ -141,7 +145,31 @@ 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 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;
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 +177,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,
+61 -1
View File
@@ -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,62 @@ 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",
},
});
const [firstRoot, secondRoot] = 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).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);
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 }) => {