Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
52d9910
chore(db): backfill isBranchableEnvironment for existing dev environm…
carderne Jun 19, 2026
a57f206
feat(webapp): make development environments branchable (API + auth)
carderne Jun 19, 2026
de6c9a8
feat(webapp): dashboard for dev branches
carderne Jun 19, 2026
9a0a1a2
feat(cli): dev branch support
carderne Jun 19, 2026
0c63420
use parentEnvironmentId instead of isBranchable for dev
carderne Jun 22, 2026
851b6dc
Revert "chore(db): backfill isBranchableEnvironment for existing dev …
carderne Jun 22, 2026
51b0065
consistent dev/preview branch differentiation
carderne Jun 22, 2026
1d9a18b
add docs
carderne Jun 22, 2026
09e766e
multi presence redis query use mget
carderne Jun 22, 2026
9d1bfdc
remove dev branch upgrade button
carderne Jun 22, 2026
ab144c5
add tests
carderne Jun 22, 2026
346431a
feature flag
carderne Jun 22, 2026
5721646
add changeset
carderne Jun 22, 2026
3967019
out of dev branches error
carderne Jun 22, 2026
775d918
some fixes and tests
carderne Jun 22, 2026
b178629
fix dev build namespacing
carderne Jun 22, 2026
0c1d6cc
fix cli dev subcommand
carderne Jun 22, 2026
745efa6
scope dev branch correctly
carderne Jun 23, 2026
f6b427d
cli guards
carderne Jun 23, 2026
e02787a
redis multi
carderne Jun 23, 2026
9b992c8
clean up branches/dev-branches routes
carderne Jun 23, 2026
c3a7529
improve default branch handling and errors
carderne Jun 23, 2026
7b8bdc2
cleaning up nits
carderne Jun 23, 2026
8bbd4c8
fix rbac test
carderne Jun 23, 2026
d53fcce
improve backwards compat
carderne Jun 23, 2026
8feb4c8
fix dev command env file resolution
carderne Jun 23, 2026
534fffa
fix bugs from coderabbit
carderne Jun 23, 2026
55ccb3d
skip flaky e2e tests
carderne Jun 23, 2026
54f96ed
simplify new env var logic
carderne Jun 23, 2026
28314d3
more env filtering correctness
carderne Jun 23, 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`,
{
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
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
// 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