Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1cc4c7d
chore(db): backfill isBranchableEnvironment for existing dev environm…
carderne Jun 19, 2026
8d9b644
feat(webapp): make development environments branchable (API + auth)
carderne Jun 19, 2026
21468dd
feat(webapp): dashboard for dev branches
carderne Jun 19, 2026
e4c7e92
feat(cli): dev branch support
carderne Jun 19, 2026
a2a10a8
use parentEnvironmentId instead of isBranchable for dev
carderne Jun 22, 2026
1a85c2f
Revert "chore(db): backfill isBranchableEnvironment for existing dev …
carderne Jun 22, 2026
c2c6d7e
consistent dev/preview branch differentiation
carderne Jun 22, 2026
009d3c6
add docs
carderne Jun 22, 2026
01298e9
multi presence redis query use mget
carderne Jun 22, 2026
2913e11
remove dev branch upgrade button
carderne Jun 22, 2026
4fa84f2
add tests
carderne Jun 22, 2026
13e41b4
feature flag
carderne Jun 22, 2026
017dbe5
add changeset
carderne Jun 22, 2026
6acd813
out of dev branches error
carderne Jun 22, 2026
8ceba56
some fixes and tests
carderne Jun 22, 2026
6e6ee57
fix dev build namespacing
carderne Jun 22, 2026
4f18510
fix cli dev subcommand
carderne Jun 22, 2026
c9ae913
scope dev branch correctly
carderne Jun 23, 2026
bebc1de
cli guards
carderne Jun 23, 2026
4906967
redis multi
carderne Jun 23, 2026
826236f
clean up branches/dev-branches routes
carderne Jun 23, 2026
3ef63ef
improve default branch handling and errors
carderne Jun 23, 2026
0e5e47a
cleaning up nits
carderne Jun 23, 2026
1bc8710
fix rbac test
carderne Jun 23, 2026
8609d0a
improve backwards compat
carderne Jun 23, 2026
aa0ed21
fix dev command env file resolution
carderne Jun 23, 2026
0195c39
fix bugs from coderabbit
carderne Jun 23, 2026
363f033
simplify new env var logic
carderne Jun 23, 2026
218d393
more env filtering correctness
carderne Jun 23, 2026
545d26c
catch eager prisma connect
carderne Jun 23, 2026
944ff03
fix nits
carderne Jun 23, 2026
f389672
improve var name in rbac
carderne Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dev-branches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---

Add support for dev branches to the webapp and CLI. This allows humans (and agents) to run multiple local dev servers simultaneously, with a separate dashboard for each one.
18 changes: 11 additions & 7 deletions apps/webapp/app/components/BlankStatePanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import { type BranchableEnvironmentToken } from "~/utils/branchableEnvironment";
import { NewBranchPanel } from "~/routes/resources.branches.create";
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
import {
docsPath,
Expand Down Expand Up @@ -488,24 +489,27 @@ export function BranchesNoBranchableEnvironment({ showSelfServe }: { showSelfSer
}

export function BranchesNoBranches({
parentEnvironment,
env,
limits,
canUpgrade,
showSelfServe,
}: {
parentEnvironment: { id: string };
env: BranchableEnvironmentToken;
limits: { used: number; limit: number };
canUpgrade: boolean;
showSelfServe: boolean;
}) {
const organization = useOrganization();

const envTextClassName = env === "preview" ? "text-preview" : "text-dev";
const branchesLabel = env === "preview" ? "preview branches" : "dev branches";

if (limits.used >= limits.limit) {
return (
<InfoPanel
title="Upgrade to get preview branches"
title={`Upgrade to get ${branchesLabel}`}
icon={BranchEnvironmentIconSmall}
iconClassName="text-preview"
iconClassName={envTextClassName}
panelClassName="max-w-full"
accessory={
showSelfServe && canUpgrade ? (
Expand Down Expand Up @@ -536,7 +540,7 @@ export function BranchesNoBranches({
<InfoPanel
title="Create your first branch"
icon={BranchEnvironmentIconSmall}
iconClassName="text-preview"
iconClassName={envTextClassName}
panelClassName="max-w-full"
accessory={
<NewBranchPanel
Expand All @@ -549,7 +553,7 @@ export function BranchesNoBranches({
New branch
</Button>
}
parentEnvironment={parentEnvironment}
env={env}
/>
}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/webapp/app/components/DevPresence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro

// Only subscribe to event source if enabled is true
const streamedEvents = useEventSource(
`/resources/orgs/${organization.slug}/projects/${project.slug}/dev/presence`,
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/presence`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dev presence is now env-specific

{
event: "presence",
disabled: !enabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export function environmentFullTitle(environment: Environment) {
}
}

export function environmentTextClassName(environment: Environment) {
export function environmentTextClassName(environment: { type: Environment["type"] }) {
switch (environment.type) {
case "PRODUCTION":
return "text-prod";
Expand Down
105 changes: 65 additions & 40 deletions apps/webapp/app/components/navigation/EnvironmentSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { isBranchableEnvironment } from "~/utils/branchableEnvironment";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
Expand All @@ -9,8 +11,8 @@ import { useFeatures } from "~/hooks/useFeatures";
import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
import { branchesPath, branchesDevPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle, environmentTextClassName } from "../environments/EnvironmentLabel";
import { ButtonContent } from "../primitives/Buttons";
import { Header2 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
Expand Down Expand Up @@ -50,6 +52,7 @@ export function EnvironmentSelector({
}, [navigation.location?.pathname]);

const hasStaging = project.environments.some((env) => env.type === "STAGING");
const devBranchesEnabled = Boolean(organization.featureFlags?.devBranchesEnabled);

return (
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
Expand Down Expand Up @@ -104,34 +107,40 @@ export function EnvironmentSelector({
>
<div className="flex flex-col gap-1 p-1">
{project.environments
.filter((env) => env.branchName === null)
.filter((env) => env.parentEnvironmentId === null)
.map((env) => {
switch (env.isBranchableEnvironment) {
case true: {
const branchEnvironments = project.environments.filter(
(e) => e.parentEnvironmentId === env.id
);
return (
<Branches
key={env.id}
parentEnvironment={env}
branchEnvironments={branchEnvironments}
currentEnvironment={environment}
/>
);
}
case false:
return (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={
<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />
}
isSelected={env.id === environment.id}
/>
);
// DEVELOPMENT is only branchable in the UI when the org has the
// multi-branch dev flag on. Without it, dev renders as a plain
// selector button (the original behavior). PREVIEW is unaffected.
const renderAsBranchable =
isBranchableEnvironment(env) &&
(env.type !== "DEVELOPMENT" || devBranchesEnabled);

if (renderAsBranchable) {
const branchEnvironments = project.environments.filter(
(e) => e.parentEnvironmentId === env.id
);
const allBranchEnvironments = env.type === "DEVELOPMENT" ? [env, ...branchEnvironments] : branchEnvironments;
return (
<Branches
key={env.id}
parentEnvironment={env}
branchEnvironments={allBranchEnvironments}
currentEnvironment={environment}
/>
);
}

return (
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={
<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />
}
isSelected={env.id === environment.id}
/>
);
})}
</div>
{!hasStaging && isManagedCloud && (
Expand Down Expand Up @@ -226,7 +235,14 @@ function Branches({
? "no-active-branches"
: "has-branches";

const currentBranchIsArchived = environment.archivedAt !== null;
// Only surface the active environment's archived-branch item in the submenu it
// actually belongs to. Both Development and Preview render this component, so
// without the parent check an archived dev branch would leak into the Preview
// submenu (and vice-versa).
const currentBranchIsArchived =
environment.archivedAt !== null && environment.parentEnvironmentId === parentEnvironment.id;

const envTextClassName = environmentTextClassName(parentEnvironment);

return (
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
Expand Down Expand Up @@ -260,11 +276,11 @@ function Branches({
to={urlForEnvironment(environment)}
title={
<>
<span className="block w-full text-preview">{environment.branchName}</span>
<span className={cn("block w-full", envTextClassName)}>{environment.branchName}</span>
<Badge variant="extra-small">Archived</Badge>
</>
}
icon={<BranchEnvironmentIconSmall className="size-4 shrink-0 text-preview" />}
icon={<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />}
isSelected={environment.id === currentEnvironment.id}
/>
)}
Expand All @@ -276,16 +292,16 @@ function Branches({
<PopoverMenuItem
key={env.id}
to={urlForEnvironment(env)}
title={<span className="block w-full text-preview">{env.branchName}</span>}
icon={<BranchEnvironmentIconSmall className="size-4 shrink-0 text-preview" />}
title={<span className={cn("block w-full", envTextClassName)}>{env.branchName ?? DEFAULT_DEV_BRANCH}</span>}
icon={<BranchEnvironmentIconSmall className={cn("size-4 shrink-0", envTextClassName)} />}
isSelected={env.id === currentEnvironment.id}
/>
))}
</>
) : state === "no-branches" ? (
<div className="flex max-w-sm flex-col gap-1 p-2">
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall className="size-4 text-preview" />
<BranchEnvironmentIconSmall className={cn("size-4", envTextClassName)} />
<Header2>Create your first branch</Header2>
</div>
<Paragraph spacing variant="small">
Expand All @@ -305,12 +321,21 @@ function Branches({
)}
</div>
<div className="border-t border-charcoal-700 p-1">
<PopoverMenuItem
to={branchesPath(organization, project, environment)}
title="Manage branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
{parentEnvironment.type === "DEVELOPMENT" ? (
<PopoverMenuItem
to={branchesDevPath(organization, project, environment)}
title="Manage dev branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
) : (
<PopoverMenuItem
to={branchesPath(organization, project, environment)}
title="Manage preview branches"
icon={<Cog8ToothIcon className="size-4 text-text-dimmed" />}
leadingIconClassName="text-text-dimmed"
/>
)}
</div>
</PopoverContent>
</div>
Expand Down
14 changes: 10 additions & 4 deletions apps/webapp/app/db.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,11 @@ function getClient() {
queryPerformanceMonitor.onQuery("writer", log);
});

// connect eagerly
client.$connect();
// Connect eagerly, catch any exception and log

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NB change to db.server.ts

This was causing my tests to fail. This eager connect was trying to connect to localhost:5432 which doesn't exist in the CI env.

This will still emit the error in prod without unnecessarily crashing processes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ericallam thoughts on this one?

// Prisma will connect on use anyway
client.$connect().catch((error) => {
logger.error("Failed to eagerly connect prisma client (writer)", { error });
});

console.log(`🔌 prisma client connected`);

Expand Down Expand Up @@ -378,8 +381,11 @@ function getReplicaClient() {
queryPerformanceMonitor.onQuery("replica", log);
});

// connect eagerly
replicaClient.$connect();
// Connect eagerly, catch any exception and log
// Prisma will connect on use anyway
replicaClient.$connect().catch((error) => {
logger.error("Failed to eagerly connect prisma client (replica)", { error });
});

console.log(`🔌 read replica connected`);

Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/models/member.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ export async function acceptInvite({
organization: invite.organization,
project,
type: "DEVELOPMENT",
isBranchableEnvironment: false,
// We set this true but no backfill (yet!?) so never used

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NB

// for dev environments
isBranchableEnvironment: true,
member,
prismaClient: tx,
});
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/models/project.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ export async function createProject(
organization,
project,
type: "DEVELOPMENT",
isBranchableEnvironment: false,
// We set this true but no backfill (yet!?) so never used
// for dev environments
isBranchableEnvironment: true,
member,
});
}
Expand Down
44 changes: 33 additions & 11 deletions apps/webapp/app/models/runtimeEnvironment.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { logger } from "~/services/logger.server";
import { getUsername } from "~/utils/username";
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";

export type { RuntimeEnvironment };

Expand Down Expand Up @@ -94,21 +94,24 @@ export function toAuthenticated(

export async function findEnvironmentByApiKey(
apiKey: string,
branchName: string | undefined
branchName: string | undefined,
tx: PrismaClientOrTransaction = $replica
): Promise<AuthenticatedEnvironment | null> {
const branch = sanitizeBranchName(branchName) ?? undefined;

const include = {
...authIncludeBase,
childEnvironments: branchName
childEnvironments: branch
? {
where: {
branchName: sanitizeBranchName(branchName),
archivedAt: null,
},
}
where: {
branchName: branch,
archivedAt: null,
},
}
: undefined,
} satisfies Prisma.RuntimeEnvironmentInclude;

let environment = await $replica.runtimeEnvironment.findFirst({
let environment = await tx.runtimeEnvironment.findFirst({
where: {
apiKey,
},
Expand All @@ -117,7 +120,7 @@ export async function findEnvironmentByApiKey(

// Fall back to keys that were revoked within the grace window
if (!environment) {
const revokedApiKey = await $replica.revokedApiKey.findFirst({
const revokedApiKey = await tx.revokedApiKey.findFirst({
where: {
apiKey,
expiresAt: { gt: new Date() },
Expand All @@ -140,7 +143,7 @@ export async function findEnvironmentByApiKey(
}

if (environment.type === "PREVIEW") {
if (!branchName) {
if (!branch) {
logger.warn("findEnvironmentByApiKey(): Preview env with no branch name provided", {
environmentId: environment.id,
});
Expand All @@ -163,6 +166,25 @@ export async function findEnvironmentByApiKey(
return null;
}

// If there is a named DEV branch (other than default), return it
if (environment.type === "DEVELOPMENT" && branch !== undefined && !isDefaultDevBranch(branch)) {
const childEnvironment = environment.childEnvironments.at(0);

if (childEnvironment) {
return toAuthenticated({
...childEnvironment,
apiKey: environment.apiKey,
orgMember: environment.orgMember,
organization: environment.organization,
project: environment.project,
});
}

//A branch was specified but no child environment was found
return null;

}

return toAuthenticated(environment);
}

Expand Down
Loading
Loading