feat: multi dev branches (#4023)

Closes this feature request:
[https://triggerdev.featurebase.app/p/isolated-dev-sessions-for-multiple-local-trigger-dev-instances](https://triggerdev.featurebase.app/p/isolated-dev-sessions-for-multiple-local-trigger-dev-instances)

### Feature notes:
- CLI `trigger dev` works as before
- `trigger dev --branch my-branch` to create a new branch and run
against it.
- `trigger dev archive --branch my-branch` to archive (or in webapp).
- New webapp page to manage and archive dev branches, currently feature
flagged.

### Implementation details:
- No changes to data model, no backfill. `isBranchableEnvironment`
column is ignored for dev branches, we use `parentEnvironmentId IS NULL`
instead.
- `x-trigger-branch` overloaded for preview and dev branches
- New `TRIGGER_DEV_BRANCH` env var available locally.
`TRIGGER_PREVIEW_BRANCH` overloaded for child runs.
- Lots of new glue code to sanitise the branch checks.

### Rollout
- Deploy webapp/API changes (all backwards compatible)
- Manual tests on some orgs
- Deploy docs, release CLI, flip feature flag for webapp feature

### NB
- `api.v1.projects.$projectRef.environments.ts` will return
`isBranchableEnvironment: true` for all dev environments.

### Prerequisites
- [x] Typecheck will not pass until we make a new release of
`@trigger.dev/platform` and bump it here
This commit is contained in:
Chris Arderne
2026-06-26 09:01:37 +01:00
committed by GitHub
parent bc605eedaf
commit df78ef96d9
74 changed files with 2589 additions and 454 deletions
+6
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Adds support for dev branches similar to the preview branches already supported.
@@ -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,
@@ -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 ? (
@@ -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
@@ -549,7 +553,7 @@ export function BranchesNoBranches({
New branch
</Button>
}
parentEnvironment={parentEnvironment}
env={env}
/>
}
>
+1 -1
View File
@@ -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,
@@ -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";
@@ -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";
@@ -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";
@@ -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}>
@@ -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 && (
@@ -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}>
@@ -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}
/>
)}
@@ -276,8 +292,8 @@ 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}
/>
))}
@@ -285,7 +301,7 @@ function Branches({
) : 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">
@@ -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>
+16 -4
View File
@@ -255,8 +255,14 @@ function getClient() {
queryPerformanceMonitor.onQuery("writer", log);
});
// connect eagerly
client.$connect();
// Connect eagerly; Prisma will connect on use anyway.
// Swallow the error when testing (DB likely unavailable)
const connectPromise = client.$connect();
if (env.NODE_ENV === "test") {
connectPromise.catch((error) => {
logger.warn("Failed to eagerly connect prisma client (writer)", { error });
});
}
console.log(`🔌 prisma client connected`);
@@ -378,8 +384,14 @@ function getReplicaClient() {
queryPerformanceMonitor.onQuery("replica", log);
});
// connect eagerly
replicaClient.$connect();
// Connect eagerly; Prisma will connect on use anyway.
// Swallow the error when testing (DB likely unavailable)
const connectPromise = replicaClient.$connect();
if (env.NODE_ENV === "test") {
connectPromise.catch((error) => {
logger.warn("Failed to eagerly connect prisma client (replica)", { error });
});
}
console.log(`🔌 read replica connected`);
+3 -1
View File
@@ -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,
});
+3 -1
View File
@@ -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,
});
}
@@ -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 };
@@ -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,
},
@@ -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() },
@@ -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,
});
@@ -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);
}
@@ -4,15 +4,13 @@ import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { type UserFromSession } from "~/services/session.server";
import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder";
import {
SelectBestEnvironmentPresenter,
type MinimumEnvironment,
} from "./SelectBestEnvironmentPresenter.server";
import { SelectBestEnvironmentPresenter } from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { env } from "~/env.server";
import { flags } from "~/v3/featureFlags.server";
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -83,6 +81,7 @@ export class OrganizationsPresenter {
branchName: true,
parentEnvironmentId: true,
archivedAt: true,
updatedAt: true,
orgMember: {
select: {
userId: true,
@@ -102,10 +101,15 @@ export class OrganizationsPresenter {
throw redirect(newProjectPath(organization));
}
const environments = fullProject.
environments.filter((env) => env.type !== "DEVELOPMENT" || env.orgMember?.userId === user.id);
const environmentsWithActivity = await hydrateEnvsWithActivity(user.id, fullProject.id, environments);
const environment = this.#getEnvironment({
user,
projectId: fullProject.id,
environments: fullProject.environments,
environments,
environmentSlug,
});
@@ -115,13 +119,7 @@ export class OrganizationsPresenter {
project: {
...fullProject,
createdAt: fullProject.createdAt,
environments: sortEnvironments(
fullProject.environments.filter((env) => {
if (env.type !== "DEVELOPMENT") return true;
if (env.orgMember?.userId === user.id) return true;
return false;
})
),
environments: sortEnvironments(environmentsWithActivity),
},
environment,
};
@@ -245,7 +243,10 @@ export class OrganizationsPresenter {
//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
(env) =>
env.type === "DEVELOPMENT" &&
env.parentEnvironmentId === null &&
env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
@@ -1,7 +1,7 @@
import {
type RuntimeEnvironment,
type PrismaClient,
RuntimeEnvironmentType,
type RuntimeEnvironmentType,
} from "@trigger.dev/database";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
@@ -49,6 +49,7 @@ export class SelectBestEnvironmentPresenter {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
paused: true,
orgMember: {
select: {
@@ -73,6 +74,7 @@ export class SelectBestEnvironmentPresenter {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
paused: true,
orgMember: {
select: {
@@ -140,7 +142,13 @@ export class SelectBestEnvironmentPresenter {
}
async selectBestEnvironment<
T extends { id: string; type: RuntimeEnvironmentType; orgMember: { userId: string } | null }
T extends {
id: string;
type: RuntimeEnvironmentType;
slug: string;
parentEnvironmentId: string | null;
orgMember: { userId: string } | null;
}
>(projectId: string, user: UserFromSession, environments: T[]): Promise<T> {
//try get current environment from prefs
const currentEnvironmentId: string | undefined =
@@ -153,7 +161,11 @@ export class SelectBestEnvironmentPresenter {
//otherwise show their dev environment
const yourDevEnvironment = environments.find(
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
// Return the default dev environment (the root, no parent), not a branch
(env) =>
env.type === "DEVELOPMENT" &&
env.parentEnvironmentId === null &&
env.orgMember?.userId === user.id
);
if (yourDevEnvironment) {
return yourDevEnvironment;
@@ -1,17 +1,56 @@
import { GitMeta } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
import { type z } from "zod";
import { type Prisma, type PrismaClient, prisma } from "~/db.server";
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { type BranchesOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import { type BranchesOptions } from "~/utils/branches";
import { getCurrentPlan, getPlans } from "~/services/platform.v3.server";
import { checkBranchLimit } from "~/services/upsertBranch.server";
import { devPresence } from "./DevPresence.server";
import { sortEnvironments } from "~/utils/environmentSort";
import {
type BranchableEnvironmentToken,
type BranchableEnvironmentType,
toBranchableEnvironmentType,
} from "~/utils/branchableEnvironment";
type Result = Awaited<ReturnType<BranchesPresenter["call"]>>;
export type Branch = Result["branches"][number];
const BRANCHES_PER_PAGE = 25;
/**
* Prisma `where` fragment that scopes the branches list by branch name, keyed by
* environment type. Spread it into the query's `where` (it contributes either a
* `branchName` constraint or a top-level `OR`).
*
* The default DEV branch is the root dev env, stored with `branchName: null`, so
* for DEVELOPMENT we always include the null-branchName root (and still match it
* when searching — hence the top-level `OR`, since a scalar field filter can't
* express "matches search OR is null"). PREVIEW only ever lists real branches, so
* its root (null) is excluded. Passing no `search` yields the "all branches of
* this type" fragment.
*/
function branchNameFilter(
envType: BranchableEnvironmentType,
search?: string
): Prisma.RuntimeEnvironmentWhereInput {
switch (envType) {
case "DEVELOPMENT":
return search
? { OR: [{ branchName: { contains: search, mode: "insensitive" } }, { branchName: null }] }
: {};
case "PREVIEW":
return search
? { branchName: { contains: search, mode: "insensitive" } }
: { branchName: { not: null } };
default:
throw new Error(`branchNameFilter: unsupported environment type "${envType}"`);
}
}
type Options = z.infer<typeof BranchesOptions>;
export type GitMetaLinks = {
@@ -58,12 +97,14 @@ export class BranchesPresenter {
public async call({
userId,
projectSlug,
env,
showArchived = false,
search,
page = 1,
}: {
userId: User["id"];
projectSlug: Project["slug"];
env: BranchableEnvironmentToken;
} & Options) {
const project = await this.#prismaClient.project.findFirst({
select: {
@@ -86,19 +127,29 @@ export class BranchesPresenter {
throw new Error("Project not found");
}
const envType = toBranchableEnvironmentType(env);
const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: project.id,
isBranchableEnvironment: true,
type: envType,
// The branchable parent is the root env (no parent). For dev that's
// derivable; for preview we trust the isBranchableEnvironment column.
...(envType === "DEVELOPMENT"
? { parentEnvironmentId: null, orgMember: { userId } }
: { isBranchableEnvironment: true }),
},
});
const hasFilters = !!showArchived || (search !== undefined && search !== "");
if (!branchableEnvironment) {
if (envType === "DEVELOPMENT") {
throw new Error("No branchable environment in development environment");
}
return {
branchableEnvironment: null,
currentPage: page,
@@ -119,23 +170,26 @@ export class BranchesPresenter {
};
}
const branchNameWhere = branchNameFilter(envType, search);
const orgMemberWhere = envType === "DEVELOPMENT" ? { orgMember: { userId } } : {};
const visibleCount = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
branchName: search
? {
contains: search,
mode: "insensitive",
}
: {
not: null,
},
type: envType,
...branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
});
// Limits
const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id);
const limits = await checkBranchLimit({
prisma: this.#prismaClient,
organizationId: project.organizationId,
projectId: project.id,
userId,
type: envType,
});
const [currentPlan, plans] = await Promise.all([
getCurrentPlan(project.organizationId),
@@ -154,21 +208,18 @@ export class BranchesPresenter {
id: true,
slug: true,
branchName: true,
parentEnvironmentId: true,
type: true,
archivedAt: true,
createdAt: true,
updatedAt: true,
git: true,
},
where: {
projectId: project.id,
branchName: search
? {
contains: search,
mode: "insensitive",
}
: {
not: null,
},
type: envType,
...branchNameWhere,
...orgMemberWhere,
...(showArchived ? {} : { archivedAt: null }),
},
orderBy: {
@@ -181,32 +232,29 @@ export class BranchesPresenter {
const totalBranches = await this.#prismaClient.runtimeEnvironment.count({
where: {
projectId: project.id,
branchName: {
not: null,
},
type: envType,
...branchNameFilter(envType),
...orgMemberWhere,
},
});
const branchesFiltered = branches
.filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null)
.map((branch) => ({
...branch,
git: processGitMetadata(branch.git),
branchName: branch.branchName ?? DEFAULT_DEV_BRANCH,
}));
const branchesWithActivity = await hydrateEnvsWithActivity(userId, project.id, branchesFiltered);
const branchesSorted = sortEnvironments(branchesWithActivity);
return {
branchableEnvironment,
currentPage: page,
totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE),
hasBranches: totalBranches > 0,
branches: branches.flatMap((branch) => {
if (branch.branchName === null) {
return [];
}
const git = processGitMetadata(branch.git);
return [
{
...branch,
branchName: branch.branchName,
git,
} as const,
];
}),
branches: branchesSorted,
hasFilters,
limits,
canPurchaseBranches,
@@ -218,6 +266,32 @@ export class BranchesPresenter {
}
}
export async function hydrateEnvsWithActivity<
T extends { type: RuntimeEnvironmentType; id: string }
>(
userId: string,
projectId: string,
environments: T[]
): Promise<Array<T & { lastActivity: Date | undefined; isConnected: boolean | undefined }>> {
const recentDevBranchIds = await devPresence.getRecentBranchIds(userId, projectId);
const devEnvIds = environments
.filter((env) => env.type === "DEVELOPMENT" && recentDevBranchIds.has(env.id))
.map((env) => env.id);
const connectedMap = await devPresence.isConnectedMany(devEnvIds);
return environments.map((env) => {
if (env.type !== "DEVELOPMENT") {
return { ...env, lastActivity: undefined, isConnected: undefined };
}
const devHit = recentDevBranchIds.get(env.id);
const lastActivity = devHit === undefined ? undefined : devHit;
const isConnected = devHit === undefined ? undefined : (connectedMap.get(env.id) ?? false);
return { ...env, lastActivity, isConnected };
});
}
export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null {
if (!data) return null;
@@ -1,8 +1,11 @@
import Redis, { type RedisOptions } from "ioredis";
import { defaultReconnectOnError } from "@internal/redis";
import { env } from "~/env.server";
import { subDays } from "date-fns";
const PRESENCE_KEY_PREFIX = "dev-presence:connection:";
const DEV_RECENT_DEBOUNCE_SEC = 60;
const DEV_RECENT_TTL = 7 * 24 * 60 * 60; // 7 days
const RECENCY_DAYS = 3;
export class DevPresence {
private redis: Redis;
@@ -17,13 +20,56 @@ export class DevPresence {
return !!presenceValue;
}
async setConnected(environmentId: string, ttl: number) {
async isConnectedMany(environmentIds: string[]): Promise<Map<string, boolean>> {
if (environmentIds.length === 0) return new Map();
const keys = environmentIds.map((id) => this.getPresenceKey(id));
const values = await this.redis.mget(keys);
return new Map(environmentIds.map((id, i) => [id, !!values[i]]));
}
async setConnected({ userId, projectId, environmentId, ttl }: { userId: string; projectId: string; environmentId: string; ttl: number; }) {
const presenceKey = this.getPresenceKey(environmentId);
await this.redis.setex(presenceKey, ttl, new Date().toISOString());
const touchKey = this.getTouchKey(environmentId);
const acquired = await this.redis.set(touchKey, "1", "EX", DEV_RECENT_DEBOUNCE_SEC, "NX");
if (acquired !== null) {
const recentKey = this.getRecentKey(userId, projectId);
const now = new Date();
const threeDaysAgo = subDays(now, RECENCY_DAYS);
await this.redis
.multi()
.zadd(recentKey, now.getTime(), environmentId)
.zremrangebyscore(recentKey, 0, threeDaysAgo.getTime())
.zremrangebyrank(recentKey, 0, -51)
.expire(recentKey, DEV_RECENT_TTL)
.exec();
}
}
async getRecentBranchIds(userId: string, projectId: string) {
const recentKey = this.getRecentKey(userId, projectId);
const threeDaysAgo = subDays(Date.now(), RECENCY_DAYS);
const raw = await this.redis.zrevrangebyscore(recentKey, "+inf", threeDaysAgo.getTime(), "WITHSCORES");
const branches = new Map<string, Date>();
for (let i = 0; i < raw.length; i += 2) {
branches.set(raw[i], new Date(Number(raw[i + 1])));
}
return branches;
}
private getPresenceKey(environmentId: string) {
return `${PRESENCE_KEY_PREFIX}${environmentId}`;
return `dev-presence:connection:${environmentId}`;
}
private getRecentKey(userId: string, projectId: string) {
return `dev-recent:${userId}:${projectId}`;
}
private getTouchKey(environmentId: string) {
return `dev-recent-touch:${environmentId}`;
}
}
@@ -51,6 +51,7 @@ export class EditSchedulePresenter {
},
},
branchName: true,
parentEnvironmentId: true,
},
},
},
@@ -87,15 +88,14 @@ export class EditSchedulePresenter {
: [];
const possibleEnvironments = filterOrphanedEnvironments(project.environments)
// Exclude the branchable PREVIEW parent (it has no parent of its own);
// only actual preview branches are schedulable.
.filter((environment) => !(environment.type === "PREVIEW" && environment.parentEnvironmentId === null))
.map((environment) => {
return {
...displayableEnvironment(environment, userId),
branchName: environment.branchName ?? undefined,
};
})
.filter((env) => {
if (env.type === "PREVIEW" && !env.branchName) return false;
return true;
});
return {
@@ -82,7 +82,8 @@ export class ManageConcurrencyPresenter extends BasePresenter {
const projectEnvironments: EnvironmentWithConcurrency[] = [];
for (const environment of environments) {
// Don't count parent environments
if (environment.isBranchableEnvironment) continue;
// We don't use isBranchableEnvironment() here as it will include the root dev env
if (environment.type === "PREVIEW" && environment.isBranchableEnvironment) continue;
// Don't count deleted projects
if (environment.project.deletedAt) continue;
@@ -7,6 +7,7 @@ export type EnvironmentVariablesEnvironment = {
type: RuntimeEnvironmentType;
isBranchableEnvironment: boolean;
branchName: string | null;
parentEnvironmentId: string | null;
};
export type EnvironmentVariablesEnvironmentsResult = {
@@ -47,6 +48,7 @@ export async function loadEnvironmentVariablesEnvironments(
type: true,
isBranchableEnvironment: true,
branchName: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
@@ -69,6 +71,7 @@ export async function loadEnvironmentVariablesEnvironments(
type: environment.type,
isBranchableEnvironment: environment.isBranchableEnvironment,
branchName: environment.branchName,
parentEnvironmentId: environment.parentEnvironmentId,
})),
hasStaging: environments.some((environment) => environment.type === "STAGING"),
};
@@ -20,6 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
@@ -3,9 +3,9 @@ import { parse } from "@conform-to/zod";
import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useActionData, useFetcher, useLocation, useSearchParams } from "@remix-run/react";
import { useFetcher, useSearchParams } from "@remix-run/react";
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { GitMeta, tryCatch } from "@trigger.dev/core/v3";
import { tryCatch } from "@trigger.dev/core/v3";
import { useCallback, useEffect, useState } from "react";
import { SearchInput } from "~/components/primitives/SearchInput";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
@@ -16,7 +16,6 @@ import { Feedback } from "~/components/Feedback";
import { GitMetadata } from "~/components/GitMetadata";
import { V4Title } from "~/components/V4Badge";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { InlineCode } from "~/components/code/InlineCode";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
@@ -33,8 +32,6 @@ import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Header3 } from "~/components/primitives/Headers";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
import { Label } from "~/components/primitives/Label";
@@ -62,12 +59,11 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { findProjectBySlug } from "~/models/project.server";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
import { logger } from "~/services/logger.server";
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
import { cn } from "~/utils/cn";
import {
branchesPath,
@@ -80,13 +76,21 @@ import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
import { SetBranchesAddOnService } from "~/v3/services/setBranchesAddOn.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { ArchiveButton } from "../resources.branches.archive";
import { NewBranchPanel } from "~/routes/resources.branches.create";
import { BranchesOptions } from "~/utils/branches";
import { IconArrowBearRight2 } from "@tabler/icons-react";
export const BranchesOptions = z.object({
search: z.string().optional(),
showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(),
page: z.preprocess((val) => Number(val), z.number()).optional(),
});
const PurchaseSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("purchase"),
amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"),
}),
z.object({
action: z.literal("quota-increase"),
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
@@ -101,6 +105,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const result = await presenter.call({
userId,
projectSlug: projectParam,
env: "preview",
...options,
});
@@ -114,31 +119,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
}
};
export const CreateBranchOptions = z.object({
parentEnvironmentId: z.string(),
branchName: z.string().min(1),
git: GitMeta.optional(),
});
export type CreateBranchOptions = z.infer<typeof CreateBranchOptions>;
export const schema = CreateBranchOptions.and(
z.object({
failurePath: z.string(),
})
);
const PurchaseSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("purchase"),
amount: z.coerce.number().int("Must be a whole number").min(0, "Amount must be 0 or more"),
}),
z.object({
action: z.literal("quota-increase"),
amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"),
}),
]);
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
@@ -202,37 +182,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ ok: true } as const);
}
const submission = parse(formData, { schema });
if (!submission.value) {
return redirectWithErrorMessage("/", request, "Invalid form data");
}
const upsertBranchService = new UpsertBranchService();
const result = await upsertBranchService.call(
{ type: "userMembership", userId },
submission.value
);
if (result.success) {
if (result.alreadyExisted) {
submission.error = {
branchName: [
`Branch "${result.branch.branchName}" already exists. You can archive it and create a new one with the same name.`,
],
};
return json(submission);
}
return redirectWithSuccessMessage(
`${branchesPath(result.organization, result.project, result.branch)}?dialogClosed=true`,
request,
`Branch "${result.branch.branchName}" created`
);
}
submission.error = { branchName: [result.error] };
return json(submission);
// Branch creation is handled by the `resources.branches.create` resource
// route; this action only services the purchase flow above.
return json({ ok: false, error: "Unsupported action" } as const, { status: 400 });
}
export default function Page() {
@@ -328,7 +280,7 @@ export default function Page() {
New branch
</Button>
}
parentEnvironment={branchableEnvironment}
env="preview"
/>
)}
</PageAccessories>
@@ -338,7 +290,7 @@ export default function Page() {
{!hasBranches ? (
<MainCenteredContainer className="max-w-md">
<BranchesNoBranches
parentEnvironment={branchableEnvironment}
env="preview"
limits={limits}
canUpgrade={canUpgrade ?? false}
showSelfServe={showSelfServe}
@@ -922,88 +874,3 @@ function updateBranchState({
return "increase";
}
export function NewBranchPanel({
button,
parentEnvironment,
}: {
button: React.ReactNode;
parentEnvironment: { id: string };
}) {
const lastSubmission = useActionData<typeof action>();
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [form, { parentEnvironmentId, branchName, failurePath }] = useForm({
id: "create-branch",
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema });
},
shouldRevalidate: "onInput",
});
useEffect(() => {
if (searchParams.has("dialogClosed")) {
setSearchParams((s) => {
s.delete("dialogClosed");
return s;
});
setIsOpen(false);
}
}, [searchParams, setSearchParams]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogContent>
<DialogHeader>New branch</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<Form method="post" {...form.props} className="w-full">
<Fieldset className="max-w-full gap-y-3">
<input
value={parentEnvironment.id}
{...conform.input(parentEnvironmentId, { type: "hidden" })}
/>
<input
value={location.pathname}
{...conform.input(failurePath, { type: "hidden" })}
/>
<InputGroup className="max-w-full">
<Label>Branch name</Label>
<Input {...conform.input(branchName)} />
<Hint>
Must not contain: spaces <InlineCode variant="extra-small">~</InlineCode>{" "}
<InlineCode variant="extra-small">^</InlineCode>{" "}
<InlineCode variant="extra-small">:</InlineCode>{" "}
<InlineCode variant="extra-small">?</InlineCode>{" "}
<InlineCode variant="extra-small">*</InlineCode>{" "}
<InlineCode variant="extra-small">{"["}</InlineCode>{" "}
<InlineCode variant="extra-small">\</InlineCode>{" "}
<InlineCode variant="extra-small">//</InlineCode>{" "}
<InlineCode variant="extra-small">..</InlineCode>{" "}
<InlineCode variant="extra-small">{"@{"}</InlineCode>{" "}
<InlineCode variant="extra-small">.lock</InlineCode>
</Hint>
<FormError id={branchName.errorId}>{branchName.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium">
Create branch
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,370 @@
import { CheckIcon, PlusIcon } from "@heroicons/react/20/solid";
import { BookOpenIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { useCallback } from "react";
import { SearchInput } from "~/components/primitives/SearchInput";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { V4Title } from "~/components/V4Badge";
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Badge } from "~/components/primitives/Badge";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import * as Property from "~/components/primitives/PropertyTable";
import { Switch } from "~/components/primitives/Switch";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableCellMenu,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { branchesDevPath, docsPath, ProjectParamSchema } from "~/utils/pathBuilder";
import { ArchiveButton } from "../resources.branches.archive";
import { NewBranchPanel } from "~/routes/resources.branches.create";
import { BranchesOptions } from "~/utils/branches";
import { IconArrowBearRight2 } from "@tabler/icons-react";
import { useAutoRevalidate } from "~/hooks/useAutoRevalidate";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam } = ProjectParamSchema.parse(params);
const searchParams = new URL(request.url).searchParams;
const parsedSearchParams = BranchesOptions.safeParse(Object.fromEntries(searchParams));
const options = parsedSearchParams.success ? parsedSearchParams.data : {};
try {
const presenter = new BranchesPresenter();
const result = await presenter.call({
userId,
projectSlug: projectParam,
env: "development",
...options,
});
return typedjson(result);
} catch (error) {
logger.error("Error loading dev branches page", { error });
throw new Response(undefined, {
status: 400,
statusText: "Something went wrong, if this problem persists please contact support.",
});
}
};
export default function Page() {
const {
branches,
limits,
currentPage,
totalPages,
} = useTypedLoaderData<typeof loader>();
useAutoRevalidate({ interval: 5000 });
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const atBranchLimit = limits.used >= limits.limit;
const usageRatio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
return (
<PageContainer>
<NavBar>
<PageTitle title={<V4Title>Dev branches</V4Title>} />
<PageAccessories>
<AdminDebugTooltip>
<Property.Table>
{branches.map((branch) => (
<Property.Item key={branch.id}>
<Property.Label>{branch.branchName}</Property.Label>
<Property.Value>{branch.id}</Property.Value>
</Property.Item>
))}
</Property.Table>
</AdminDebugTooltip>
<LinkButton
variant={"docs/small"}
LeadingIcon={BookOpenIcon}
to={docsPath("deployment/dev-branches")}
>
Dev branches docs
</LinkButton>
{limits.isAtLimit ? (
<BranchLimitReachedDialog limits={limits} />
) : (
<NewBranchPanel
button={
<Button
variant="primary/small"
shortcut={{ key: "n" }}
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
fullWidth
textAlignLeft
>
New branch
</Button>
}
env="development"
/>
)}
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
<div className="flex items-center justify-between gap-x-1.5 p-2">
<BranchFilters />
<PaginationControls
currentPage={currentPage}
totalPages={totalPages}
showPageNumbers={false}
/>
</div>
<div className="grid max-h-full min-h-full grid-rows-[1fr] overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Branch</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Last active</TableHeaderCell>
<TableHeaderCell>Archived</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{branches.length === 0 ? (
<TableBlankRow colSpan={5}>
<Paragraph>There are no matches for your filters</Paragraph>
</TableBlankRow>
) : (
branches.map((branch) => {
const path = branchesDevPath(organization, project, branch);
const cellClass = branch.archivedAt ? "opacity-50" : "";
const isSelected = branch.id === environment.id;
return (
<TableRow key={branch.id}>
<TableCell isTabbableCell className={cellClass}>
<div className="flex items-center gap-1">
<BranchEnvironmentIconSmall
className={cn("size-4", isSelected && "text-dev")}
/>
<CopyableText
value={branch.branchName}
className={cn(isSelected && "text-dev")}
/>
{isSelected && <Badge variant="extra-small">Current</Badge>}
</div>
</TableCell>
<TableCell className={cellClass}>
<DateTime date={branch.createdAt} />
</TableCell>
<TableCell className={cellClass}>
{branch.isConnected ? (
<>Online now</>
) : branch.lastActivity ? (
<DateTime date={branch.lastActivity} />
) : null}
</TableCell>
<TableCell className={cellClass}>
{branch.archivedAt ? (
<CheckIcon className="size-4 text-charcoal-400" />
) : (
""
)}
</TableCell>
<TableCellMenu
className="pl-32"
isSticky
hiddenButtons={
isSelected ? null : (
<LinkButton
to={path}
variant="secondary/small"
LeadingIcon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-2"
className="pl-1.5"
>
Switch to branch
</LinkButton>
)
}
popoverContent={
!isSelected || !branch.archivedAt ? (
<>
{isSelected ? null : (
<PopoverMenuItem
to={path}
icon={IconArrowBearRight2}
leadingIconClassName="text-blue-500 -mr-0.5 -ml-1"
title="Switch to branch"
/>
)}
{!branch.archivedAt ? (
<ArchiveButton
environment={branch}
// The root dev env (no parent) is the default
// branch and can't be archived — matches the
// guard in ArchiveBranchService.
disabled={!branch.parentEnvironmentId}
/>
) : null}
</>
) : null
}
/>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${atBranchLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${usageRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${Math.round(usageRatio * 100)}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{atBranchLimit ? (
<Header3 className="text-error">
You've used all {limits.limit} of your branches. Archive one to free up
space.
</Header3>
) : (
<div className="flex items-center gap-1">
<Header3>
You've used {limits.used}/{limits.limit} of your branches
</Header3>
<InfoIconTooltip content="Archived branches don't count towards your limit." />
</div>
)}
</div>
</div>
</div>
</div>
</PageBody>
</PageContainer>
);
}
export function BranchFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries()));
const handleArchivedChange = useCallback((checked: boolean) => {
setSearchParams((s) => {
if (checked) {
s.set("showArchived", "true");
} else {
s.delete("showArchived");
}
s.delete("page");
return s;
});
}, []);
return (
<div className="flex w-full items-center justify-between gap-2">
<SearchInput placeholder="Search branch name…" resetParams={["page"]} />
<Switch
checked={showArchived ?? false}
onCheckedChange={handleArchivedChange}
label="Show archived"
variant="secondary/small"
/>
</div>
);
}
function BranchLimitReachedDialog({
limits,
}: {
limits: {
used: number;
limit: number;
};
}) {
return (
<Dialog>
<DialogTrigger asChild>
<Button
LeadingIcon={PlusIcon}
leadingIconClassName="text-white"
variant="primary/small"
shortcut={{ key: "n" }}
>
New branch
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your limit</DialogHeader>
<div className="mt-2">
<Paragraph spacing>
You've used {limits.used}/{limits.limit} of your branches.
</Paragraph>
<Paragraph>You can archive a branch to free up space.</Paragraph>
</div>
</DialogContent>
</Dialog>
);
}
@@ -220,12 +220,12 @@ export default function Page() {
const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState<Set<string>>(new Set());
const [selectedBranchId, setSelectedBranchId] = useState<string | undefined>(undefined);
const branchEnvironments = environments.filter((env) => env.branchName);
const nonBranchEnvironments = environments.filter((env) => !env.branchName);
// TODO for no we only support branch-specific env vars for Preview environments
// Mostly to keep the UX for setting consistent env-vars across Dev/Staging/Prod easier
const previewBranches = environments.filter((env) => env.type === "PREVIEW" && env.parentEnvironmentId !== null);
const nonBranchEnvironments = environments.filter((env) => env.parentEnvironmentId === null);
const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id));
const previewIsSelected = selectedEnvironments.some(
(env) => env.branchName !== null || env.type === "PREVIEW"
);
const previewIsSelected = selectedEnvironments.some((env) => env.type === "PREVIEW");
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
@@ -406,7 +406,7 @@ export default function Page() {
value={selectedBranchId ?? "all"}
setValue={handleBranchChange}
placeholder="All branches"
items={[{ id: "all", branchName: "All branches" }, ...branchEnvironments]}
items={[{ id: "all", branchName: "All branches" }, ...previewBranches]}
className="w-fit min-w-52"
filter={{
keys: [
@@ -414,7 +414,7 @@ export default function Page() {
],
}}
text={(val) =>
val ? branchEnvironments.find((b) => b.id === val)?.branchName : null
val ? previewBranches.find((b) => b.id === val)?.branchName : null
}
dropdownIcon
>
@@ -5,6 +5,7 @@ import { z } from "zod";
import {
authenticatedEnvironmentForAuthentication,
authenticateRequest,
branchNameFromRequest,
type AuthenticationResult,
} from "~/services/apiAuth.server";
import { env as appEnv } from "~/env.server";
@@ -69,7 +70,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
const { projectRef, env } = parsedParams.data;
const triggerBranch = request.headers.get("x-trigger-branch") ?? undefined;
const triggerBranch = branchNameFromRequest(request);
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
authenticationResult,
@@ -10,6 +10,7 @@ import {
type AuthenticationResult,
authenticatedEnvironmentForAuthentication,
authenticateRequest,
branchNameFromRequest,
} from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
@@ -19,10 +20,6 @@ const ParamsSchema = z.object({
env: z.enum(["dev", "staging", "prod", "preview"]),
});
const HeadersSchema = z.object({
"x-trigger-branch": z.string().optional(),
});
type ParamsSchema = z.infer<typeof ParamsSchema>;
export async function loader({ request, params }: LoaderFunctionArgs) {
@@ -60,10 +57,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
}
const { projectRef, env } = parsedParams.data;
const parsedHeaders = HeadersSchema.safeParse(Object.fromEntries(request.headers));
const triggerBranch = parsedHeaders.success
? parsedHeaders.data["x-trigger-branch"]
: undefined;
const triggerBranch = branchNameFromRequest(request);
const runtimeEnv = await authenticatedEnvironmentForAuthentication(
authenticationResult,
@@ -5,12 +5,15 @@ import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
import { ArchiveBranchService } from "~/services/archiveBranch.server";
import { logger } from "~/services/logger.server";
import { toBranchableEnvironmentType } from "~/utils/branchableEnvironment";
const ParamsSchema = z.object({
projectRef: z.string(),
});
const BodySchema = z.object({
// Defaults to "preview" so existing CLIs that don't send `env` keep working.
env: z.enum(["preview", "development"]).default("preview"),
branch: z.string(),
});
@@ -49,6 +52,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: parsed.error.message }, { status: 400 });
}
const { env, branch } = parsed.data;
const environmentType = toBranchableEnvironmentType(env);
const environments = await prisma.runtimeEnvironment.findMany({
select: {
id: true,
@@ -59,16 +65,22 @@ export async function action({ request, params }: ActionFunctionArgs) {
authenticationResult.type === "organizationAccessToken"
? { id: authenticationResult.result.organizationId }
: {
members: {
some: {
userId: authenticationResult.result.userId,
},
members: {
some: {
userId: authenticationResult.result.userId,
},
},
},
// Dev branches are per-org-member: only the owner may archive their own.
...(authenticationResult.type !== "organizationAccessToken" &&
environmentType === "DEVELOPMENT"
? { orgMember: { userId: authenticationResult.result.userId } }
: {}),
project: {
externalRef: projectRef,
},
branchName: parsed.data.branch,
type: environmentType,
branchName: branch,
},
});
@@ -76,7 +88,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Branch not found" }, { status: 404 });
}
const environment = environments.find((env) => env.archivedAt === null);
const activeEnvironments = environments.filter((env) => env.archivedAt === null);
if (
authenticationResult.type === "organizationAccessToken" &&
environmentType === "DEVELOPMENT" &&
activeEnvironments.length > 1
) {
return json(
{ error: "Branch name is ambiguous for development environments. Use a personal access token scoped to the branch owner." },
{ status: 409 }
);
}
const environment = activeEnvironments[0];
if (!environment) {
return json({ error: "Branch already archived" }, { status: 400 });
}
@@ -1,5 +1,7 @@
import { json, type LoaderFunctionArgs, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch, UpsertBranchRequestBody } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH, isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
import invariant from "tiny-invariant";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateRequest } from "~/services/apiAuth.server";
@@ -69,24 +71,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: parsed.error.message }, { status: 400 });
}
const previewEnvironment = await prisma.runtimeEnvironment.findFirst({
select: {
id: true,
},
where: {
projectId: project.id,
slug: "preview",
},
});
const { branch, env, git } = parsed.data;
if (!previewEnvironment) {
if (env === "development" && authenticationResult.type === "organizationAccessToken") {
return json(
{ error: "You don't have preview branches setup. Go to the dashboard to enable them." },
{ error: "Cannot create dev branches with organization access tokens." },
{ status: 400 }
);
}
const { branch, env, git } = parsed.data;
if (env === "development" && isDefaultDevBranch(branch)) {
return json(
{ error: `Cannot create dev branch with name '${DEFAULT_DEV_BRANCH}'.` },
{ status: 400 }
);
}
const service = new UpsertBranchService();
const result = await service.call(
@@ -94,8 +93,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
? { type: "orgId", organizationId: authenticationResult.result.organizationId }
: { type: "userMembership", userId: authenticationResult.result.userId },
{
env,
branchName: branch,
parentEnvironmentId: previewEnvironment.id,
projectId: project.id,
git,
}
);
@@ -5,6 +5,7 @@ import { $replica } from "~/db.server";
import { findProjectByRef } from "~/models/project.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { isBranchableEnvironment } from "~/utils/branchableEnvironment";
const ParamsSchema = z.object({
projectRef: z.string(),
@@ -52,6 +53,7 @@ export const loader = createLoaderPATApiRoute(
slug: true,
type: true,
isBranchableEnvironment: true,
parentEnvironmentId: true,
branchName: true,
paused: true,
},
@@ -61,7 +63,7 @@ export const loader = createLoaderPATApiRoute(
id: env.id,
slug: env.slug,
type: env.type,
isBranchableEnvironment: env.isBranchableEnvironment,
isBranchableEnvironment: isBranchableEnvironment(env),
branchName: env.branchName,
paused: env.paused,
}));
@@ -12,11 +12,19 @@ export const loader = createSSELoader({
handler: async ({ id, controller, debug, request }) => {
const authentication = await authenticateApiRequestWithFailure(request);
if (!authentication.ok) {
throw json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const environmentId = authentication.environment.id;
const projectId = authentication.environment.projectId;
const userId = authentication.environment.orgMember?.userId;
if (!userId) {
throw json({ error: "Not a dev environment" }, { status: 400 });
}
const ttl = env.DEV_PRESENCE_TTL_MS / 1000;
return {
@@ -27,11 +35,11 @@ export const loader = createSSELoader({
},
initStream: async ({ send }) => {
// Set initial presence with more context
await devPresence.setConnected(environmentId, ttl);
await devPresence.setConnected({ userId, projectId, environmentId, ttl });
send({ event: "start", data: `Started ${id}` });
},
iterator: async ({ send, date }) => {
await devPresence.setConnected(environmentId, ttl);
await devPresence.setConnected({ userId, projectId, environmentId, ttl });
send({ event: "time", data: new Date().toISOString() });
},
cleanup: async () => {},
@@ -20,6 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
@@ -20,6 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
@@ -20,6 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
@@ -20,6 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
id: true,
type: true,
slug: true,
parentEnvironmentId: true,
orgMember: {
select: {
userId: true,
@@ -13,7 +13,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { ArchiveBranchService } from "~/services/archiveBranch.server";
import { requireUserId } from "~/services/session.server";
import { branchesPath, v3EnvironmentPath } from "~/utils/pathBuilder";
import { branchesDevPath, branchesPath } from "~/utils/pathBuilder";
const ArchiveBranchOptions = z.object({
environmentId: z.string(),
@@ -46,7 +46,9 @@ export async function action({ request }: ActionFunctionArgs) {
if (result.success) {
return redirectWithSuccessMessage(
branchesPath(result.organization, result.project, result.branch),
result.branch.type === "DEVELOPMENT" ?
branchesDevPath(result.organization, result.project, result.branch)
: branchesPath(result.organization, result.project, result.branch),
request,
`Branch "${result.branch.branchName}" archived`
);
@@ -57,8 +59,10 @@ export async function action({ request }: ActionFunctionArgs) {
export function ArchiveButton({
environment,
disabled,
}: {
environment: { id: string; branchName: string };
disabled?: boolean;
}) {
const lastSubmission = useActionData<typeof action>();
const location = useLocation();
@@ -82,6 +86,7 @@ export function ArchiveButton({
fullWidth
textAlignLeft
className="w-full px-1.5 py-[0.9rem]"
disabled={disabled}
>
Archive branch
</Button>
@@ -0,0 +1,160 @@
import { conform, useForm } from "@conform-to/react";
import { parse } from "@conform-to/zod";
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useLocation, useSearchParams } from "@remix-run/react";
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { useEffect, useState } from "react";
import { InlineCode } from "~/components/code/InlineCode";
import { Button } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { Fieldset } from "~/components/primitives/Fieldset";
import { FormButtons } from "~/components/primitives/FormButtons";
import { FormError } from "~/components/primitives/FormError";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { useProject } from "~/hooks/useProject";
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
import { requireUserId } from "~/services/session.server";
import { UpsertBranchService } from "~/services/upsertBranch.server";
import { type BranchableEnvironmentToken } from "~/utils/branchableEnvironment";
import { CreateBranchFormSchema } from "~/utils/branches";
import { branchesDevPath, branchesPath } from "~/utils/pathBuilder";
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const submission = parse(formData, { schema: CreateBranchFormSchema });
if (!submission.value) {
return redirectWithErrorMessage("/", request, "Invalid form data");
}
const upsertBranchService = new UpsertBranchService();
const result = await upsertBranchService.call(
{ type: "userMembership", userId },
submission.value
);
if (result.success) {
if (result.alreadyExisted) {
submission.error = {
branchName: [
`Branch "${result.branch.branchName}" already exists. You can archive it and create a new one with the same name.`,
],
};
return json(submission);
}
// Branches of both types are created through here; route the success
// redirect to the matching list page based on the created branch's type.
const path =
result.branch.type === "DEVELOPMENT"
? branchesDevPath(result.organization, result.project, result.branch)
: branchesPath(result.organization, result.project, result.branch);
return redirectWithSuccessMessage(
`${path}?dialogClosed=true`,
request,
`Branch "${result.branch.branchName}" created`
);
}
submission.error = { branchName: [result.error] };
return json(submission);
}
export function NewBranchPanel({
button,
env,
}: {
button: React.ReactNode;
env: BranchableEnvironmentToken;
}) {
const project = useProject();
// Posts to this resource route (not the host page), so read the submission
// result off the fetcher rather than the page's `useActionData`.
const fetcher = useFetcher<typeof action>();
const lastSubmission = fetcher.data;
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [form, { projectId, env: envField, branchName, failurePath }] = useForm({
id: "create-branch",
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
return parse(formData, { schema: CreateBranchFormSchema });
},
shouldRevalidate: "onInput",
});
useEffect(() => {
if (searchParams.has("dialogClosed")) {
setSearchParams((s) => {
s.delete("dialogClosed");
return s;
});
setIsOpen(false);
}
}, [searchParams, setSearchParams]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{button}</DialogTrigger>
<DialogContent>
<DialogHeader>New branch</DialogHeader>
<div className="mt-2 flex flex-col gap-4">
<fetcher.Form
method="post"
action="/resources/branches/create"
{...form.props}
className="w-full"
>
<Fieldset className="max-w-full gap-y-3">
<input value={project.id} {...conform.input(projectId, { type: "hidden" })} />
<input value={env} {...conform.input(envField, { type: "hidden" })} />
<input
value={location.pathname}
{...conform.input(failurePath, { type: "hidden" })}
/>
<InputGroup className="max-w-full">
<Label>Branch name</Label>
<Input {...conform.input(branchName)} />
<Hint>
Must not contain: spaces <InlineCode variant="extra-small">~</InlineCode>{" "}
<InlineCode variant="extra-small">^</InlineCode>{" "}
<InlineCode variant="extra-small">:</InlineCode>{" "}
<InlineCode variant="extra-small">?</InlineCode>{" "}
<InlineCode variant="extra-small">*</InlineCode>{" "}
<InlineCode variant="extra-small">{"["}</InlineCode>{" "}
<InlineCode variant="extra-small">\</InlineCode>{" "}
<InlineCode variant="extra-small">//</InlineCode>{" "}
<InlineCode variant="extra-small">..</InlineCode>{" "}
<InlineCode variant="extra-small">{"@{"}</InlineCode>{" "}
<InlineCode variant="extra-small">.lock</InlineCode>
</Hint>
<FormError id={branchName.errorId}>{branchName.error}</FormError>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium">
Create branch
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Fieldset>
</fetcher.Form>
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,7 +3,7 @@ import { env } from "~/env.server";
import { devPresence } from "~/presenters/v3/DevPresence.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ProjectParamSchema } from "~/utils/pathBuilder";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { createSSELoader, type SendFunction } from "~/utils/sse";
export const loader = createSSELoader({
@@ -12,16 +12,18 @@ export const loader = createSSELoader({
debug: false,
handler: async ({ id, controller, debug, request, params }) => {
const userId = await requireUserId(request);
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
type: "DEVELOPMENT",
slug: envParam,
orgMember: {
userId,
},
project: {
slug: projectParam,
organization: { slug: organizationSlug },
},
},
});
@@ -71,6 +71,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
type: true,
slug: true,
branchName: true,
parentEnvironmentId: true,
orgMember: {
select: {
user: true,
@@ -145,6 +146,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
type: true,
slug: true,
branchName: true,
parentEnvironmentId: true,
orgMember: { select: { user: true } },
},
where: {
@@ -248,7 +250,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
},
environments: sortEnvironments(
run.project.environments
.filter((env) => env.type !== "PREVIEW" || env.branchName)
.filter((env) => env.type !== "PREVIEW" || env.parentEnvironmentId !== null)
.map((env) => ({
...displayableEnvironment(env, userId),
branchName: env.branchName ?? undefined,
+36 -18
View File
@@ -25,7 +25,7 @@ import {
isOrganizationAccessToken,
} from "./organizationAccessToken.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
@@ -282,8 +282,14 @@ function isSecretApiKey(key: string) {
return key.startsWith("tr_");
}
/**
* Reads the branch off the `x-trigger-branch` header and sanitizes it.
* Every server-side reader should go through here so sanitization is applied uniformly.
* The dev `"default"` sentinel is intentionally NOT resolved here:
* that translation is environment type-dependent.
*/
export function branchNameFromRequest(request: Request): string | undefined {
return request.headers.get("x-trigger-branch") ?? undefined;
return sanitizeBranchName(request.headers.get("x-trigger-branch")) ?? undefined;
}
function getApiKeyFromRequest(request: Request): {
@@ -454,6 +460,14 @@ export async function authenticatedEnvironmentForAuthentication(
slug = "stg";
}
// Normalize the requested branch once: sanitize it, then collapse the dev
// `"default"` sentinel to "no branch" so it resolves to the root dev env
// rather than a (non-existent) branch literally named "default".
// TODO this slug check is brittle
const sanitizedBranch = sanitizeBranchName(branch);
const resolvedBranch =
slug === "dev" && isDefaultDevBranch(sanitizedBranch) ? null : sanitizedBranch;
switch (auth.type) {
case "apiKey": {
if (!auth.result.ok) {
@@ -470,7 +484,7 @@ export async function authenticatedEnvironmentForAuthentication(
);
}
if (auth.result.environment.slug !== slug && auth.result.environment.branchName !== branch) {
if (auth.result.environment.slug !== slug && auth.result.environment.branchName !== resolvedBranch) {
throw json(
{
error:
@@ -499,19 +513,17 @@ export async function authenticatedEnvironmentForAuthentication(
throw json({ error: "Project not found" }, { status: 404 });
}
const sanitizedBranch = sanitizeBranchName(branch);
if (!sanitizedBranch) {
if (!resolvedBranch) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
orgMember: {
userId: user.id,
},
}
: {}),
},
include: authIncludeBase,
@@ -527,8 +539,15 @@ export async function authenticatedEnvironmentForAuthentication(
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
type: "PREVIEW",
branchName: sanitizedBranch,
type: slug === "dev" ? "DEVELOPMENT" : "PREVIEW",
branchName: resolvedBranch,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
: {}),
archivedAt: null,
},
include: authIncludeWithParent,
@@ -539,10 +558,10 @@ export async function authenticatedEnvironmentForAuthentication(
}
if (!environment.parentEnvironment) {
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
throw json({ error: "Branch not associated with a parent environment" }, { status: 400 });
}
// PREVIEW envs reuse the parent's apiKey for downstream auth flows
// PREVIEW envs (and DEVELOPMENT branches) reuse the parent's apiKey for downstream auth flows
// (signed JWTs, internal-fetch helpers). Override before mapping so
// the slim shape carries the parent's key.
return toAuthenticated({
@@ -572,9 +591,7 @@ export async function authenticatedEnvironmentForAuthentication(
throw json({ error: "Project not found" }, { status: 404 });
}
const sanitizedBranch = sanitizeBranchName(branch);
if (!sanitizedBranch) {
if (!resolvedBranch) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
@@ -593,8 +610,9 @@ export async function authenticatedEnvironmentForAuthentication(
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
// No Development branches for OAT
type: "PREVIEW",
branchName: sanitizedBranch,
branchName: resolvedBranch,
archivedAt: null,
},
include: authIncludeWithParent,
@@ -38,6 +38,17 @@ export class ArchiveBranchService {
},
}
: { id: orgFilter.organizationId },
// Dev branches are per-org-member, so org membership alone isn't enough:
// only the owner may archive their own dev branch. Non-dev branches (e.g.
// preview) remain scoped by org membership only.
...(orgFilter.type === "userMembership"
? {
OR: [
{ type: { not: "DEVELOPMENT" as const } },
{ orgMember: { userId: orgFilter.userId } },
],
}
: {}),
},
include: {
organization: {
@@ -56,10 +67,16 @@ export class ArchiveBranchService {
},
});
// A branch is defined by having a parent; any root (dev/preview parent,
// prod, staging) has none and can't be archived. For dev, that root is
// the default branch, so give the clearer message.
if (!environment.parentEnvironmentId) {
return {
success: false as const,
error: "This isn't a branch, and cannot be archived.",
error:
environment.type === "DEVELOPMENT"
? "The default development branch cannot be archived."
: "This isn't a branch, and cannot be archived.",
};
}
+80 -23
View File
@@ -2,10 +2,20 @@ import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/
import slug from "slug";
import { prisma } from "~/db.server";
import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server";
import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import {
type BranchableEnvironmentType,
isBranchableEnvironment,
rootEnvironmentWhere,
toBranchableEnvironmentType,
} from "~/utils/branchableEnvironment";
import { logger } from "./logger.server";
import { getCurrentPlan, getLimit } from "./platform.v3.server";
import { type z } from "zod";
import invariant from "tiny-invariant";
import { type CreateBranchOptions } from "~/utils/branches";
type CreateBranchOptions = z.infer<typeof CreateBranchOptions>;
export class UpsertBranchService {
#prismaClient: PrismaClient;
@@ -22,8 +32,13 @@ export class UpsertBranchService {
orgFilter:
| { type: "userMembership"; userId: string }
| { type: "orgId"; organizationId: string },
{ parentEnvironmentId, branchName, git }: CreateBranchOptions
{ projectId, env, branchName, git }: CreateBranchOptions
) {
const parentEnvType = toBranchableEnvironmentType(env);
// Dev branch creation is always user-scoped (org tokens are rejected upstream),
// so we can disambiguate the per-member dev root by userId.
const userId = orgFilter.type === "userMembership" ? orgFilter.userId : undefined;
const sanitizedBranchName = sanitizeBranchName(branchName);
if (!sanitizedBranchName) {
return {
@@ -42,7 +57,10 @@ export class UpsertBranchService {
try {
const parentEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({
where: {
id: parentEnvironmentId,
projectId,
// Locate the branchable parent structurally (root env of this type),
// not by its magic slug. Branchability is asserted below.
...rootEnvironmentWhere(parentEnvType, { userId }),
organization:
orgFilter.type === "userMembership"
? {
@@ -71,31 +89,48 @@ export class UpsertBranchService {
},
});
// Dev environments are scoped per org member, so a dev branch must inherit
// its parent's orgMemberId. Preview parents have no orgMember (orgMemberId is null).
if (!parentEnvironment) {
// This should never happen
if (env === "development") {
return {
success: false as const,
error: "Error: No default dev runtime environment setup.",
};
}
return {
success: false as const,
error: "You don't have preview branches setup. Go to the dashboard to enable them.",
};
}
if (!parentEnvironment.isBranchableEnvironment) {
if (!isBranchableEnvironment(parentEnvironment)) {
return {
success: false as const,
error: "Your preview environment is not branchable",
error: `Your ${env} environment is not branchable`,
};
}
const limits = await checkBranchLimit(
this.#prismaClient,
parentEnvironment.organization.id,
parentEnvironment.project.id,
sanitizedBranchName
);
const limits = await checkBranchLimit({
prisma: this.#prismaClient,
organizationId: parentEnvironment.organization.id,
projectId: parentEnvironment.project.id,
type: parentEnvType,
userId,
newBranchName: sanitizedBranchName,
});
if (limits.isAtLimit) {
// DEVELOPMENT has no upgrade path, so only PREVIEW mentions upgrading.
const remediation =
parentEnvType === "PREVIEW"
? "Use the CLI to view your existing branches and archive any you no longer need, or upgrade to get more."
: "Use the CLI to view your existing branches and archive any you no longer need.";
return {
success: false as const,
error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. Upgrade to get more branches or archive some.`,
error: `You've used all ${limits.used} of ${limits.limit} branches for your plan. ${remediation}`,
};
}
@@ -105,7 +140,6 @@ export class UpsertBranchService {
const shortcode = branchSlug;
const now = new Date();
const branch = await this.#prismaClient.runtimeEnvironment.upsert({
where: {
projectId_shortcode: {
@@ -132,6 +166,9 @@ export class UpsertBranchService {
parentEnvironment: {
connect: { id: parentEnvironment.id },
},
orgMember: parentEnvironment.orgMemberId
? { connect: { id: parentEnvironment.orgMemberId } }
: undefined,
git: git ?? undefined,
},
update: {
@@ -158,18 +195,35 @@ export class UpsertBranchService {
}
}
export async function checkBranchLimit(
prisma: PrismaClientOrTransaction,
organizationId: string,
projectId: string,
newBranchName?: string
) {
export async function checkBranchLimit({
prisma,
organizationId,
projectId,
userId,
type,
newBranchName,
}: {
prisma: PrismaClientOrTransaction;
organizationId: string;
projectId: string;
userId?: string;
type: BranchableEnvironmentType;
newBranchName?: string;
}) {
let orgMemberWhere = {};
if (type === "DEVELOPMENT") {
invariant(userId, "Cannot use org access for dev server");
orgMemberWhere = { orgMember: { userId } };
}
const usedEnvs = await prisma.runtimeEnvironment.findMany({
where: {
projectId,
branchName: {
not: null,
},
type,
// For PREVIEW, count only branches (exclude the branchable parent). For
// DEVELOPMENT, the root env counts toward the limit alongside its branches.
...(type === "PREVIEW" ? { parentEnvironmentId: { not: null } } : {}),
...orgMemberWhere,
archivedAt: null,
},
});
@@ -177,9 +231,12 @@ export async function checkBranchLimit(
const count = newBranchName
? usedEnvs.filter((env) => env.branchName !== newBranchName).length
: usedEnvs.length;
const baseLimit = await getLimit(organizationId, "branches", 100_000_000);
const limitName = type === "PREVIEW" ? "branches" : "branchesDev";
const baseLimit = await getLimit(organizationId, limitName, 100_000_000);
const currentPlan = await getCurrentPlan(organizationId);
const purchasedBranches = currentPlan?.v3Subscription?.addOns?.branches?.purchased ?? 0;
// We deliberately include purchased PREVIEW branches in DEV limits... (not documented anywhere)
const limit = baseLimit + purchasedBranches;
return {
@@ -0,0 +1,72 @@
import { type Prisma, type RuntimeEnvironmentType } from "@trigger.dev/database";
type BranchableEnvironmentInput = {
type: RuntimeEnvironmentType;
parentEnvironmentId: string | null;
isBranchableEnvironment: boolean;
};
export type BranchableEnvironmentType = Extract<
RuntimeEnvironmentType,
"PREVIEW" | "DEVELOPMENT"
>;
/**
* The wire/form token for a branchable environment kind, as sent by the CLI and
* dashboard forms.
*/
export type BranchableEnvironmentToken = "preview" | "development";
export function toBranchableEnvironmentType(
env: BranchableEnvironmentToken
): BranchableEnvironmentType {
switch (env) {
case "preview": return "PREVIEW";
case "development": return "DEVELOPMENT";
}
}
/**
* Prisma `where` fragment matching the *root* environment of a type the
* branchable parent, never a branch (branches always carry a `parentEnvironmentId`).
* DEVELOPMENT roots are per-org-member, so pass `userId` to disambiguate between
* members' dev environments.
*
* Use this instead of locating roots by their magic slug (`"dev"` / `"preview"`),
* which is an instance identifier, not a reliable type discriminator. Whether the
* matched root is actually branchable is a separate concern gate it with
* {@link isBranchableEnvironment} after the lookup.
*/
export function rootEnvironmentWhere(
type: RuntimeEnvironmentType,
opts?: { userId?: string }
): Prisma.RuntimeEnvironmentWhereInput {
return {
type,
parentEnvironmentId: null,
...(type === "DEVELOPMENT" && opts?.userId ? { orgMember: { userId: opts.userId } } : {}),
};
}
/**
* Whether an environment is a branchable parent (i.e. branches can be created
* under it), as opposed to a branch itself or a non-branchable environment.
*
* Branchability is split by type:
* - A branch (any env with a `parentEnvironmentId`) is never itself branchable.
* - DEVELOPMENT roots are always branchable it's derivable from the structure,
* so we don't trust the `isBranchableEnvironment` column for dev.
* - PREVIEW roots use the `isBranchableEnvironment` column, which is the
* long-standing source of truth (and may hold legacy non-branchable rows).
* - STAGING / PRODUCTION are never branchable.
*
* The `parentEnvironmentId === null` guard is load-bearing: dev *branches* are
* also `type === "DEVELOPMENT"`, so checking the type alone would misclassify
* them. Always go through this helper rather than inlining the rule.
*/
export function isBranchableEnvironment(env: BranchableEnvironmentInput): boolean {
if (env.parentEnvironmentId !== null) return false;
if (env.type === "DEVELOPMENT") return true;
if (env.type === "PREVIEW") return env.isBranchableEnvironment;
return false;
}
+24
View File
@@ -0,0 +1,24 @@
import { GitMeta } from "@trigger.dev/core/v3";
import { z } from "zod";
/** Search/filter params for the branches list pages. */
export const BranchesOptions = z.object({
search: z.string().optional(),
showArchived: z.preprocess((val) => val === "true" || val === true, z.boolean()).optional(),
page: z.preprocess((val) => Number(val), z.number()).optional(),
});
/** Payload accepted by the create-branch service/action. */
export const CreateBranchOptions = z.object({
projectId: z.string(),
env: z.enum(["preview", "development"]),
branchName: z.string().min(1),
git: GitMeta.optional(),
});
/** The create-branch form schema (payload + the form's failure redirect path). */
export const CreateBranchFormSchema = CreateBranchOptions.and(
z.object({
failurePath: z.string(),
})
);
+16 -1
View File
@@ -10,6 +10,8 @@ const environmentSortOrder: RuntimeEnvironmentType[] = [
type SortType = {
type: RuntimeEnvironmentType;
userName?: string | null;
lastActivity?: Date | undefined;
updatedAt?: Date | undefined;
};
export function sortEnvironments<T extends SortType>(
@@ -24,7 +26,20 @@ export function sortEnvironments<T extends SortType>(
const difference = aIndex - bIndex;
if (difference === 0) {
//same environment so sort by name
if (a.type === "DEVELOPMENT" && b.type === "DEVELOPMENT") {
// Within the same env type, order by recency: most-recent dev activity
// first, falling back to updatedAt when there's no recorded activity,
// then to username when we have no timestamps at all.
const aTime = (a.lastActivity ?? a.updatedAt)?.getTime();
const bTime = (b.lastActivity ?? b.updatedAt)?.getTime();
if (aTime !== undefined && bTime !== undefined) {
return bTime - aTime;
}
if (aTime !== undefined) return -1;
if (bTime !== undefined) return 1;
}
const usernameA = a.userName || "";
const usernameB = b.userName || "";
return usernameA.localeCompare(usernameB);
+9
View File
@@ -725,6 +725,15 @@ export function branchesPath(
return `${v3EnvironmentPath(organization, project, environment)}/branches`;
}
export function branchesDevPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/dev-branches`;
}
export function concurrencyPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -1114,6 +1114,17 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment
]);
}
// Dev branches set branchName too, so carry it to the task via the same
// TRIGGER_PREVIEW_BRANCH var the prod path uses.
if (runtimeEnvironment.branchName) {
result = result.concat([
{
key: "TRIGGER_PREVIEW_BRANCH",
value: runtimeEnvironment.branchName,
},
]);
}
const commonVariables = await resolveCommonBuiltInVariables(runtimeEnvironment);
return [...result, ...commonVariables];
+3
View File
@@ -17,6 +17,7 @@ export const FEATURE_FLAG = {
computeMigrationFreePercentage: "computeMigrationFreePercentage",
computeMigrationPaidPercentage: "computeMigrationPaidPercentage",
computeMigrationRequireTemplate: "computeMigrationRequireTemplate",
devBranchesEnabled: "devBranchesEnabled",
} as const;
export const FeatureFlagCatalog = {
@@ -47,6 +48,8 @@ export const FeatureFlagCatalog = {
// When on, migrated orgs build their compute template in required mode at deploy
// (fails the deploy on error) instead of shadow. Strict boolean (see above).
[FEATURE_FLAG.computeMigrationRequireTemplate]: z.boolean(),
// Per-org access to development branches. Off unless enabled for the org.
[FEATURE_FLAG.devBranchesEnabled]: z.coerce.boolean(),
};
export type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
+1 -1
View File
@@ -134,7 +134,7 @@
"@trigger.dev/rbac": "workspace:*",
"@trigger.dev/sso": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.28",
"@trigger.dev/platform": "1.0.29",
"@trigger.dev/redis-worker": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@types/pg": "8.6.6",
@@ -0,0 +1,113 @@
import { describe, expect, it } from "vitest";
import {
isBranchableEnvironment,
rootEnvironmentWhere,
toBranchableEnvironmentType,
} from "~/utils/branchableEnvironment";
describe("toBranchableEnvironmentType", () => {
it("maps the wire tokens to the canonical Prisma enum", () => {
expect(toBranchableEnvironmentType("preview")).toBe("PREVIEW");
expect(toBranchableEnvironmentType("development")).toBe("DEVELOPMENT");
});
});
describe("isBranchableEnvironment", () => {
it("treats any DEVELOPMENT root as branchable, ignoring the column", () => {
// The dev migration dropped the column-based approach: branchability for
// dev is derived structurally, so a root with the column unset is still
// branchable.
expect(
isBranchableEnvironment({
type: "DEVELOPMENT",
parentEnvironmentId: null,
isBranchableEnvironment: false,
})
).toBe(true);
});
it("never treats a dev BRANCH (one with a parent) as branchable", () => {
// Load-bearing guard: dev branches are also type DEVELOPMENT, so checking
// the type alone would misclassify them. The parentEnvironmentId guard is
// what prevents branches-of-branches.
expect(
isBranchableEnvironment({
type: "DEVELOPMENT",
parentEnvironmentId: "env_parent",
isBranchableEnvironment: true,
})
).toBe(false);
});
it("honors the column for PREVIEW roots (the long-standing source of truth)", () => {
expect(
isBranchableEnvironment({
type: "PREVIEW",
parentEnvironmentId: null,
isBranchableEnvironment: true,
})
).toBe(true);
expect(
isBranchableEnvironment({
type: "PREVIEW",
parentEnvironmentId: null,
isBranchableEnvironment: false,
})
).toBe(false);
});
it("never treats a preview branch as branchable, even with the column set", () => {
expect(
isBranchableEnvironment({
type: "PREVIEW",
parentEnvironmentId: "env_parent",
isBranchableEnvironment: true,
})
).toBe(false);
});
it("is false for STAGING and PRODUCTION", () => {
for (const type of ["STAGING", "PRODUCTION"] as const) {
expect(
isBranchableEnvironment({
type,
parentEnvironmentId: null,
isBranchableEnvironment: true,
})
).toBe(false);
}
});
});
describe("rootEnvironmentWhere", () => {
it("matches the root env of the type (never a branch)", () => {
expect(rootEnvironmentWhere("PREVIEW")).toEqual({
type: "PREVIEW",
parentEnvironmentId: null,
});
});
it("scopes DEVELOPMENT roots by org member when a userId is given", () => {
// Dev roots are per-org-member, so the same project has one root per user.
expect(rootEnvironmentWhere("DEVELOPMENT", { userId: "user_123" })).toEqual({
type: "DEVELOPMENT",
parentEnvironmentId: null,
orgMember: { userId: "user_123" },
});
});
it("omits the org-member filter for DEVELOPMENT when no userId is given", () => {
expect(rootEnvironmentWhere("DEVELOPMENT")).toEqual({
type: "DEVELOPMENT",
parentEnvironmentId: null,
});
});
it("ignores userId for non-development types", () => {
expect(rootEnvironmentWhere("PREVIEW", { userId: "user_123" })).toEqual({
type: "PREVIEW",
parentEnvironmentId: null,
});
});
});
+134
View File
@@ -0,0 +1,134 @@
import { postgresTest } from "@internal/testcontainers";
import { type PrismaClient } from "@trigger.dev/database";
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";
vi.setConfig({ testTimeout: 60_000 });
async function createDevRoot(
prisma: PrismaClient,
projectId: string,
organizationId: string,
orgMemberId: string
) {
return prisma.runtimeEnvironment.create({
data: {
slug: "dev",
apiKey: uniqueId("tr_dev"),
pkApiKey: uniqueId("pk_dev"),
shortcode: uniqueId("sc"),
projectId,
organizationId,
type: "DEVELOPMENT",
orgMemberId,
maximumConcurrencyLimit: 17,
},
});
}
describe("UpsertBranchService — DEVELOPMENT parent", () => {
postgresTest("creates a child branch that inherits the parent's ownership", async ({ prisma }) => {
const { organization, project, user, orgMember } = await createTestOrgProjectWithMember(prisma);
const devRoot = await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const result = await new UpsertBranchService(prisma).call(
{ type: "userMembership", userId: user.id },
{ projectId: project.id, env: "development", branchName: "my-feature" }
);
expect(result.success).toBe(true);
if (!result.success) return;
const { branch } = result;
expect(branch.type).toBe("DEVELOPMENT");
expect(branch.parentEnvironmentId).toBe(devRoot.id);
expect(branch.branchName).toBe("my-feature");
// The key dev-vs-preview divergence: dev branches MUST copy the parent's
// orgMemberId (preview parents have none).
expect(branch.orgMemberId).toBe(orgMember.id);
// Children inherit the parent's concurrency limit at creation.
expect(branch.maximumConcurrencyLimit).toBe(17);
expect(branch.slug).toBe(slug(`${devRoot.slug}-my-feature`));
});
postgresTest("is idempotent — upserting the same branch returns the existing row", async ({ prisma }) => {
const { organization, project, user, orgMember } = await createTestOrgProjectWithMember(prisma);
await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const orgFilter = { type: "userMembership" as const, userId: user.id };
const options = { projectId: project.id, env: "development" as const, branchName: "dup" };
const first = await new UpsertBranchService(prisma).call(orgFilter, options);
const second = await new UpsertBranchService(prisma).call(orgFilter, options);
expect(first.success && second.success).toBe(true);
if (!first.success || !second.success) return;
expect(second.alreadyExisted).toBe(true);
expect(second.branch.id).toBe(first.branch.id);
});
postgresTest("rejects an invalid branch name without touching the database", async ({ prisma }) => {
const { organization, project, user, orgMember } = await createTestOrgProjectWithMember(prisma);
await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const result = await new UpsertBranchService(prisma).call(
{ type: "userMembership", userId: user.id },
{ projectId: project.id, env: "development", branchName: "bad branch name!" }
);
expect(result.success).toBe(false);
});
});
describe("ArchiveBranchService — DEVELOPMENT", () => {
postgresTest("archives a dev branch and frees its slug/shortcode for reuse", async ({ prisma }) => {
const { organization, project, user, orgMember } = await createTestOrgProjectWithMember(prisma);
await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const orgFilter = { type: "userMembership" as const, userId: user.id };
const created = await new UpsertBranchService(prisma).call(orgFilter, {
projectId: project.id,
env: "development",
branchName: "reuse-me",
});
expect(created.success).toBe(true);
if (!created.success) return;
const originalSlug = created.branch.slug;
const archived = await new ArchiveBranchService(prisma).call(orgFilter, {
environmentId: created.branch.id,
});
expect(archived.success).toBe(true);
if (!archived.success) return;
expect(archived.branch.archivedAt).not.toBeNull();
// Slug + shortcode are randomized on archive so the name can be reused.
expect(archived.branch.slug).not.toBe(originalSlug);
// The same branch name can now be created again (new row, deterministic slug).
const recreated = await new UpsertBranchService(prisma).call(orgFilter, {
projectId: project.id,
env: "development",
branchName: "reuse-me",
});
expect(recreated.success).toBe(true);
if (!recreated.success) return;
expect(recreated.branch.id).not.toBe(created.branch.id);
expect(recreated.branch.slug).toBe(originalSlug);
});
postgresTest("refuses to archive the default branch (the dev root)", async ({ prisma }) => {
const { organization, project, user, orgMember } = await createTestOrgProjectWithMember(prisma);
const devRoot = await createDevRoot(prisma, project.id, organization.id, orgMember.id);
const result = await new ArchiveBranchService(prisma).call(
{ type: "userMembership", userId: user.id },
{ environmentId: devRoot.id }
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBe("The default development branch cannot be archived.");
});
});
+128
View File
@@ -0,0 +1,128 @@
import { redisTest } from "@internal/testcontainers";
import { subDays } from "date-fns";
import Redis from "ioredis";
import { describe, expect, vi } from "vitest";
import { DevPresence } from "~/presenters/v3/DevPresence.server";
vi.setConfig({ testTimeout: 30_000 });
let seq = 0;
function ids() {
seq += 1;
return { userId: `user_${seq}`, projectId: `proj_${seq}` };
}
const recentKey = (userId: string, projectId: string) => `dev-recent:${userId}:${projectId}`;
describe("DevPresence — recency ZSET", () => {
redisTest("getRecentBranchIds returns an empty map when nothing has pinged", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const { userId, projectId } = ids();
const result = await presence.getRecentBranchIds(userId, projectId);
expect(result.size).toBe(0);
});
redisTest("a ping records the branch as recently active", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const { userId, projectId } = ids();
await presence.setConnected({ userId, projectId, environmentId: "env_a", ttl: 60 });
const result = await presence.getRecentBranchIds(userId, projectId);
expect([...result.keys()]).toEqual(["env_a"]);
expect(result.get("env_a")).toBeInstanceOf(Date);
});
redisTest("debounces to at most one ZADD per env per minute", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
// First ping records env_a.
await presence.setConnected({ userId, projectId, environmentId: "env_a", ttl: 60 });
// Simulate the entry being removed (e.g. another reader pruned it) while the
// 60s debounce touch key is still live.
await redis.zrem(recentKey(userId, projectId), "env_a");
// A second ping within the debounce window must NOT re-add it.
await presence.setConnected({ userId, projectId, environmentId: "env_a", ttl: 60 });
const result = await presence.getRecentBranchIds(userId, projectId);
expect(result.has("env_a")).toBe(false);
await redis.quit();
});
redisTest("does not return entries older than the recency window", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
const key = recentKey(userId, projectId);
const fourDaysAgo = subDays(Date.now(), 4).getTime();
const oneHourAgo = Date.now() - 60 * 60 * 1000;
await redis.zadd(key, fourDaysAgo, "env_stale", oneHourAgo, "env_fresh");
const result = await presence.getRecentBranchIds(userId, projectId);
expect([...result.keys()]).toEqual(["env_fresh"]);
await redis.quit();
});
redisTest("physically prunes stale entries on the next ping", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
const key = recentKey(userId, projectId);
await redis.zadd(key, subDays(Date.now(), 4).getTime(), "env_stale");
// A fresh ping triggers the ZREMRANGEBYSCORE cleanup for this user/project.
await presence.setConnected({ userId, projectId, environmentId: "env_fresh", ttl: 60 });
expect(await redis.zscore(key, "env_stale")).toBeNull();
expect(await redis.zcard(key)).toBe(1);
await redis.quit();
});
redisTest("caps cardinality at 50 even under a flood of distinct branches", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
// 60 distinct envs, each with its own debounce key, so each performs a ZADD.
for (let i = 0; i < 60; i++) {
// eslint-disable-next-line no-await-in-loop
await presence.setConnected({ userId, projectId, environmentId: `env_${i}`, ttl: 60 });
}
expect(await redis.zcard(recentKey(userId, projectId))).toBe(50);
await redis.quit();
});
redisTest("returns recent branches in most-recent-first order", async ({ redisOptions }) => {
const presence = new DevPresence(redisOptions);
const redis = new Redis(redisOptions);
const { userId, projectId } = ids();
const key = recentKey(userId, projectId);
const now = Date.now();
await redis.zadd(
key,
now - 3000,
"env_oldest",
now - 2000,
"env_middle",
now - 1000,
"env_newest"
);
const result = await presence.getRecentBranchIds(userId, projectId);
expect([...result.keys()]).toEqual(["env_newest", "env_middle", "env_oldest"]);
await redis.quit();
});
});
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
exceptDevEnvironments,
filterOrphanedEnvironments,
onlyDevEnvironments,
sortEnvironments,
} from "~/utils/environmentSort";
describe("sortEnvironments", () => {
it("orders by environment type first (dev, staging, preview, prod)", () => {
const sorted = sortEnvironments([
{ type: "PRODUCTION" },
{ type: "PREVIEW" },
{ type: "DEVELOPMENT" },
{ type: "STAGING" },
]);
expect(sorted.map((e) => e.type)).toEqual([
"DEVELOPMENT",
"STAGING",
"PREVIEW",
"PRODUCTION",
]);
});
it("sorts same-type rows by lastActivity desc when both have it", () => {
const older = new Date("2026-06-01T00:00:00Z");
const newer = new Date("2026-06-20T00:00:00Z");
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "a", lastActivity: older },
{ type: "DEVELOPMENT", userName: "b", lastActivity: newer },
]);
// Most recently active branch first.
expect(sorted.map((e) => e.userName)).toEqual(["b", "a"]);
});
it("falls back to updatedAt desc when neither row has lastActivity", () => {
const older = new Date("2026-06-01T00:00:00Z");
const newer = new Date("2026-06-20T00:00:00Z");
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "a", updatedAt: older },
{ type: "DEVELOPMENT", userName: "b", updatedAt: newer },
]);
// Most recently updated branch first.
expect(sorted.map((e) => e.userName)).toEqual(["b", "a"]);
});
it("uses a row's lastActivity over its own stale updatedAt", () => {
const staleUpdate = new Date("2026-06-01T00:00:00Z");
const recentActivity = new Date("2026-06-26T00:00:00Z");
const otherUpdate = new Date("2026-06-10T00:00:00Z");
// 'a' has a stale updatedAt but recent dev activity; 'b' has only a (more
// recent than a's update) updatedAt. If activity weren't preferred, a's
// stale 06-01 would lose to b's 06-10; instead a's 06-26 activity wins.
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "b", updatedAt: otherUpdate },
{ type: "DEVELOPMENT", userName: "a", updatedAt: staleUpdate, lastActivity: recentActivity },
]);
expect(sorted.map((e) => e.userName)).toEqual(["a", "b"]);
});
it("orders rows with any timestamp ahead of rows with none", () => {
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "no-timestamp" },
{ type: "DEVELOPMENT", userName: "has-update", updatedAt: new Date("2026-06-10T00:00:00Z") },
]);
expect(sorted.map((e) => e.userName)).toEqual(["has-update", "no-timestamp"]);
});
it("falls back to username order when lastActivity is absent (the ZSET-missing case)", () => {
// When the recency ZSET is missing/evicted, lastActivity is undefined for
// every branch, and the list must still render in a stable order.
const sorted = sortEnvironments([
{ type: "DEVELOPMENT", userName: "charlie" },
{ type: "DEVELOPMENT", userName: "alice" },
{ type: "DEVELOPMENT", userName: "bob" },
]);
expect(sorted.map((e) => e.userName)).toEqual(["alice", "bob", "charlie"]);
});
});
describe("filterOrphanedEnvironments", () => {
it("drops DEVELOPMENT envs with no owning org member", () => {
const result = filterOrphanedEnvironments([
{ type: "DEVELOPMENT", orgMemberId: "om_1" },
{ type: "DEVELOPMENT", orgMemberId: undefined },
{ type: "PRODUCTION" } as any,
]);
expect(result).toEqual([
{ type: "DEVELOPMENT", orgMemberId: "om_1" },
{ type: "PRODUCTION" },
]);
});
it("keeps DEVELOPMENT envs whose orgMember relation is loaded", () => {
const result = filterOrphanedEnvironments([
{ type: "DEVELOPMENT", orgMember: { id: "om_1" } },
{ type: "DEVELOPMENT", orgMember: undefined } as any,
]);
expect(result).toEqual([{ type: "DEVELOPMENT", orgMember: { id: "om_1" } }]);
});
it("never filters non-development environments", () => {
const envs = [{ type: "PREVIEW" }, { type: "STAGING" }, { type: "PRODUCTION" }] as any[];
expect(filterOrphanedEnvironments(envs)).toEqual(envs);
});
});
describe("onlyDevEnvironments / exceptDevEnvironments", () => {
const envs = [{ type: "DEVELOPMENT" }, { type: "PREVIEW" }, { type: "PRODUCTION" }] as const;
it("partitions on the development type", () => {
expect(onlyDevEnvironments([...envs])).toEqual([{ type: "DEVELOPMENT" }]);
expect(exceptDevEnvironments([...envs])).toEqual([
{ type: "PREVIEW" },
{ type: "PRODUCTION" },
]);
});
});
@@ -0,0 +1,155 @@
import { postgresTest } from "@internal/testcontainers";
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { findEnvironmentByApiKey } from "~/models/runtimeEnvironment.server";
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
type EnvOverrides = {
type: "DEVELOPMENT" | "PREVIEW" | "PRODUCTION";
orgMemberId?: string | null;
parentEnvironmentId?: string | null;
branchName?: string | null;
isBranchableEnvironment?: boolean;
archivedAt?: Date | null;
};
async function createEnv(
prisma: PrismaClient,
projectId: string,
organizationId: string,
overrides: EnvOverrides
) {
return prisma.runtimeEnvironment.create({
data: {
slug: uniqueId("env"),
apiKey: uniqueId("tr"),
pkApiKey: uniqueId("pk"),
shortcode: uniqueId("sc"),
projectId,
organizationId,
type: overrides.type,
orgMemberId: overrides.orgMemberId ?? null,
parentEnvironmentId: overrides.parentEnvironmentId ?? null,
branchName: overrides.branchName ?? null,
isBranchableEnvironment: overrides.isBranchableEnvironment ?? false,
archivedAt: overrides.archivedAt ?? null,
},
});
}
describe("findEnvironmentByApiKey — DEVELOPMENT branch resolution", () => {
postgresTest("resolves the full dev auth matrix from the parent's api key", async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
// The existing per-member dev env IS the default branch (no branchName).
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const namedBranch = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "my-feature",
});
await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "archived-feature",
archivedAt: new Date(),
});
// No header → the root dev env (unchanged, day-one behaviour).
const noHeader = await findEnvironmentByApiKey(devRoot.apiKey, undefined, prisma);
expect(noHeader?.id).toBe(devRoot.id);
// "default" sentinel → also the root dev env.
const defaultHeader = await findEnvironmentByApiKey(devRoot.apiKey, "default", prisma);
expect(defaultHeader?.id).toBe(devRoot.id);
// A named branch that exists → the child env...
const child = await findEnvironmentByApiKey(devRoot.apiKey, "my-feature", prisma);
expect(child?.id).toBe(namedBranch.id);
expect(child?.branchName).toBe("my-feature");
// ...but carrying the PARENT's api key and ownership, not the child's own key.
expect(child?.apiKey).toBe(devRoot.apiKey);
expect(child?.orgMemberId).toBe(orgMember.id);
// A named branch that doesn't exist → null (not a silent fall-through to root).
const missing = await findEnvironmentByApiKey(devRoot.apiKey, "does-not-exist", prisma);
expect(missing).toBeNull();
// An archived branch → null (archivedAt filter on the child include).
const archived = await findEnvironmentByApiKey(devRoot.apiKey, "archived-feature", prisma);
expect(archived).toBeNull();
});
postgresTest("a branch name is sanitized before lookup", async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const namedBranch = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "feature/login",
});
// refs/heads/ prefix is stripped to match the stored branch name.
const resolved = await findEnvironmentByApiKey(
devRoot.apiKey,
"refs/heads/feature/login",
prisma
);
expect(resolved?.id).toBe(namedBranch.id);
});
});
describe("findEnvironmentByApiKey — PREVIEW (regression guard)", () => {
postgresTest("preview still requires a branch and never resolves the parent", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const previewParent = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
isBranchableEnvironment: true,
});
const previewBranch = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
parentEnvironmentId: previewParent.id,
branchName: "pr-123",
});
// No header on a preview key → null (preview has no default).
const noHeader = await findEnvironmentByApiKey(previewParent.apiKey, undefined, prisma);
expect(noHeader).toBeNull();
// With a branch → the child, carrying the parent's api key.
const resolved = await findEnvironmentByApiKey(previewParent.apiKey, "pr-123", prisma);
expect(resolved?.id).toBe(previewBranch.id);
expect(resolved?.apiKey).toBe(previewParent.apiKey);
});
});
describe("findEnvironmentByApiKey — non-branchable", () => {
postgresTest("a production key ignores the branch header and returns itself", async ({ prisma }) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const prod = await createEnv(prisma, project.id, organization.id, { type: "PRODUCTION" });
const resolved = await findEnvironmentByApiKey(prod.apiKey, "some-branch", prisma);
expect(resolved?.id).toBe(prod.id);
});
postgresTest("an unknown api key returns null", async ({ prisma }) => {
const resolved = await findEnvironmentByApiKey("tr_dev_nonexistent", undefined, prisma);
expect(resolved).toBeNull();
});
});
+173
View File
@@ -0,0 +1,173 @@
import { postgresTest } from "@internal/testcontainers";
import plugin from "@trigger.dev/rbac";
import { type PrismaClient } from "@trigger.dev/database";
import { describe, expect, vi } from "vitest";
import { createTestOrgProjectWithMember, uniqueId } from "./fixtures/environmentVariablesFixtures";
vi.setConfig({ testTimeout: 60_000 });
// Exercises the RBAC *fallback* controller's bearer-auth branch pivot — the
// "new auth path" used by createLoaderApiRoute / createActionApiRoute. It
// mirrors findEnvironmentByApiKey, but is a separate implementation, so it
// needs its own coverage. forceFallback skips loading the closed-source plugin
// and uses the in-repo fallback directly.
function makeController(prisma: PrismaClient) {
return plugin.create({ primary: prisma, replica: prisma }, { forceFallback: true });
}
function bearerRequest(apiKey: string, branch?: string) {
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (branch !== undefined) {
headers["x-trigger-branch"] = branch;
}
return new Request("https://api.trigger.dev/api/v1/test", { headers });
}
type EnvOverrides = {
type: "DEVELOPMENT" | "PREVIEW" | "PRODUCTION";
orgMemberId?: string | null;
parentEnvironmentId?: string | null;
branchName?: string | null;
isBranchableEnvironment?: boolean;
archivedAt?: Date | null;
};
async function createEnv(
prisma: PrismaClient,
projectId: string,
organizationId: string,
overrides: EnvOverrides
) {
return prisma.runtimeEnvironment.create({
data: {
slug: uniqueId("env"),
apiKey: uniqueId("tr"),
pkApiKey: uniqueId("pk"),
shortcode: uniqueId("sc"),
projectId,
organizationId,
type: overrides.type,
orgMemberId: overrides.orgMemberId ?? null,
parentEnvironmentId: overrides.parentEnvironmentId ?? null,
branchName: overrides.branchName ?? null,
isBranchableEnvironment: overrides.isBranchableEnvironment ?? false,
archivedAt: overrides.archivedAt ?? null,
},
});
}
describe("RBAC fallback — DEVELOPMENT branch pivot", () => {
postgresTest("pivots to the named branch, carrying the parent's api key", async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const namedBranch = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "my-feature",
});
const result = await rbac.authenticateBearer(bearerRequest(devRoot.apiKey, "my-feature"));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.environment.id).toBe(namedBranch.id);
expect(result.environment.branchName).toBe("my-feature");
// The pivoted env adopts the parent's api key, not the child's own.
expect(result.environment.apiKey).toBe(devRoot.apiKey);
});
postgresTest("the 'default' sentinel resolves the root dev env (no pivot)", async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
parentEnvironmentId: devRoot.id,
branchName: "my-feature",
});
const result = await rbac.authenticateBearer(bearerRequest(devRoot.apiKey, "default"));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.environment.id).toBe(devRoot.id);
});
postgresTest("no branch header resolves the root dev env", async ({ prisma }) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const result = await rbac.authenticateBearer(bearerRequest(devRoot.apiKey));
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.environment.id).toBe(devRoot.id);
});
postgresTest("a named branch that doesn't exist is rejected (not a fall-through)", async ({
prisma,
}) => {
const { organization, project, orgMember } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const devRoot = await createEnv(prisma, project.id, organization.id, {
type: "DEVELOPMENT",
orgMemberId: orgMember.id,
});
const result = await rbac.authenticateBearer(bearerRequest(devRoot.apiKey, "nope"));
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(401);
});
});
describe("RBAC fallback — branch header guards", () => {
// The "default" sentinel is DEVELOPMENT-only: it maps the dev root env to its
// (branchless) self. For PREVIEW, "default" is an ordinary branch name, so a
// PREVIEW branch literally named "default" is reachable and the request pivots
// to it like any other branch. (Preview branch names are normally PR refs, so
// a branch named "default" is unusual — but it's supported, not a collision.)
postgresTest("preview + 'default' pivots to the branch named 'default' (sentinel is dev-only)", async ({
prisma,
}) => {
const { organization, project } = await createTestOrgProjectWithMember(prisma);
const rbac = makeController(prisma);
const previewParent = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
isBranchableEnvironment: true,
});
const previewDefaultBranch = await createEnv(prisma, project.id, organization.id, {
type: "PREVIEW",
parentEnvironmentId: previewParent.id,
branchName: "default",
});
const result = await rbac.authenticateBearer(bearerRequest(previewParent.apiKey, "default"));
expect(result.ok).toBe(true);
if (!result.ok) return;
// Pivots to the branch named "default", carrying the parent's api key.
expect(result.environment.id).toBe(previewDefaultBranch.id);
expect(result.environment.id).not.toBe(previewParent.id);
expect(result.environment.apiKey).toBe(previewParent.apiKey);
});
});
+30 -1
View File
@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import {
DEFAULT_DEV_BRANCH,
isDefaultDevBranch,
isValidGitBranchName,
sanitizeBranchName,
} from "@trigger.dev/core/v3/utils/gitBranch";
describe("isValidGitBranchName", () => {
it("returns true for a valid branch name", async () => {
@@ -105,4 +110,28 @@ describe("branchNameFromRef", () => {
const result = sanitizeBranchName("");
expect(result).toBeNull();
});
it("returns null for null/undefined", async () => {
expect(sanitizeBranchName(null)).toBeNull();
expect(sanitizeBranchName(undefined)).toBeNull();
});
});
describe("isDefaultDevBranch", () => {
it("is true only for the reserved sentinel", () => {
expect(isDefaultDevBranch(DEFAULT_DEV_BRANCH)).toBe(true);
expect(isDefaultDevBranch("default")).toBe(true);
});
it("is false for any named branch", () => {
expect(isDefaultDevBranch("my-feature")).toBe(false);
// Case matters — the sentinel is an exact wire value.
expect(isDefaultDevBranch("Default")).toBe(false);
expect(isDefaultDevBranch("default-ish")).toBe(false);
});
it("is false for null/undefined (no header means no branch, resolved elsewhere)", () => {
expect(isDefaultDevBranch(null)).toBe(false);
expect(isDefaultDevBranch(undefined)).toBe(false);
});
});
+92
View File
@@ -0,0 +1,92 @@
---
title: "Development branches"
sidebarTitle: "Dev branches"
description: "Run multiple local dev sessions in isolation by giving each one its own development branch. Use branches to keep parallel work (in separate worktrees, directories, or agents) from clashing."
---
Every project starts with a single development environment called `default`. A **dev branch** is an isolated environment that lives under development, with its own runs, schedules, and concurrency.
Branches are useful when you run more than one local dev session at a time. Give each session its own branch so their runs don't collide:
- Run several [git worktrees](https://git-scm.com/docs/git-worktree) or copies of your project in parallel, one branch each.
- Let multiple coding agents each work in their own branch without stepping on one another.
When you're done with a branch, you can archive it to free up a slot or just re-use it.
## Run a dev session on a branch
Log in with the CLI first:
<CodeGroup>
```bash npm
npx trigger.dev@latest login
```
```bash pnpm
pnpm dlx trigger.dev@latest login
```
```bash bun
bunx trigger.dev@latest login
```
</CodeGroup>
Then start a dev session on a branch with the `--branch` flag. If the branch doesn't exist yet, it's created:
<CodeGroup>
```bash npm
npx trigger.dev@latest dev --branch my-feature
```
```bash pnpm
pnpm dlx trigger.dev@latest dev --branch my-feature
```
```bash bun
bunx trigger.dev@latest dev --branch my-feature
```
</CodeGroup>
Without `--branch`, the session runs on the `default` branch.
<Tip>
You can also set the branch with the `TRIGGER_DEV_BRANCH` environment variable instead of the flag.
</Tip>
## Archive a branch
Archive a branch from the CLI when you no longer need it. The CLI detects your local git branch, or you can name one with `--branch`:
<CodeGroup>
```bash npm
npx trigger.dev@latest dev archive --branch my-feature
```
```bash pnpm
pnpm dlx trigger.dev@latest dev archive --branch my-feature
```
```bash bun
bunx trigger.dev@latest dev archive --branch my-feature
```
</CodeGroup>
You can also create and archive branches from the **Dev branches** page in the dashboard.
## Limits on active branches
Each branch has its own concurrency, so we limit how many can be active per project. Archive a branch at any time to unlock another slot.
| Plan | Active dev branches |
| ----- | ------------------- |
| Free | 25 |
| Hobby | 25 |
| Pro | 25 |
Need more? [Get in touch](https://trigger.dev/contact) and we'll raise the limit.
+1
View File
@@ -199,6 +199,7 @@
"deploy-environment-variables",
"github-actions",
"deployment/preview-branches",
"deployment/dev-branches",
"deployment/atomic-deployment",
{
"group": "Deployment integrations",
+3 -3
View File
@@ -119,7 +119,7 @@ await envvars.update("proj_1234", "preview", "DATABASE_URL", {
</Tab>
<Tab title="cURL">
To target a specific preview branch, include the `x-trigger-branch` header in your API requests with the branch name as the value:
To target a specific preview or development branch, include the `x-trigger-branch` header in your API requests with the branch name as the value:
```bash
curl --request PUT \
@@ -137,8 +137,8 @@ curl --request PUT \
This will set the `DATABASE_URL` environment variable specifically for the `feature-xyz` preview branch.
<Note>
The `x-trigger-branch` header is only relevant when working with the `preview` environment (`{env}
` parameter set to `preview`). It has no effect when working with `dev`, `staging`, or `prod`
The `x-trigger-branch` header is only relevant when working with the `preview` or `dev` environments (`{env}
` parameter set to `preview` or `development`). It has no effect when working with `staging`, or `prod`
environments.
</Note>
@@ -324,6 +324,7 @@ model RuntimeEnvironment {
// Preview branches
/// If true, this environment has branches and is treated differently in the dashboard/API
/// NB: this flag is NOT used for Development branches, instead (type, parentEnvironmentId) = (DEVELOPMENT, NULL) is used
isBranchableEnvironment Boolean @default(false)
branchName String?
parentEnvironment RuntimeEnvironment? @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+31 -28
View File
@@ -17,7 +17,7 @@ import { isUserActorToken, verifyUserActorToken } from "@trigger.dev/plugins";
import { createHash } from "node:crypto";
import type { PrismaClient } from "@trigger.dev/database";
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js";
export type FallbackPrismaClients = {
@@ -146,7 +146,7 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
};
}
// PREVIEW envs are parents — operating "on a branch" means routing
// PREVIEW (and DEVELOPMENT) envs are parents — operating "on a branch" means routing
// to a child env keyed by branchName. The customer authenticates
// with the parent's apiKey + an `x-trigger-branch` header. Mirror
// findEnvironmentByApiKey: include the matching child env so the
@@ -192,34 +192,37 @@ class RoleBaseAccessFallbackController implements RoleBaseAccessController {
return { ok: false, status: 401, error: "Invalid API key" };
}
// PREVIEW env requires a branch header; pivot to the child env so
// downstream code operates on the branch (its own id, but the
// parent's apiKey/orgMember/organization/project — exactly what
// findEnvironmentByApiKey does for the legacy auth path).
if (env.type === "PREVIEW") {
if (!branchName) {
return {
ok: false,
status: 401,
error: "x-trigger-branch header required for preview env",
if (env.type === "PREVIEW" && !branchName) {
return {
ok: false,
status: 401,
error: "x-trigger-branch header required for preview env",
};
}
if (env.type === "PREVIEW" || env.type === "DEVELOPMENT") {
// The "default" root branch is DEVELOPMENT-only: it maps to the dev root env
// (which carries no branch), so we skip the pivot there. For PREVIEW,
// "default" is an ordinary branch name and must still pivot to its child.
const isDevAndDefault = env.type === "DEVELOPMENT" && isDefaultDevBranch(branchName);
if (branchName !== null && !isDevAndDefault) {
const child = env.childEnvironments?.[0];
if (!child) {
return { ok: false, status: 401, error: "No matching branch env" };
}
// Pivot to the child env: child's id/type/branchName, parent's
// apiKey/orgMember/organization/project. parentEnvironment is set
// explicitly here so the slim shape stays internally consistent.
env = {
...child,
apiKey: env.apiKey,
orgMember: env.orgMember,
organization: env.organization,
project: env.project,
parentEnvironment: { id: env.id, apiKey: env.apiKey },
childEnvironments: [],
};
}
const child = env.childEnvironments?.[0];
if (!child) {
return { ok: false, status: 401, error: "No matching branch env" };
}
// Pivot to the child env: child's id/type/branchName, parent's
// apiKey/orgMember/organization/project. parentEnvironment is set
// explicitly here so the slim shape stays internally consistent.
env = {
...child,
apiKey: env.apiKey,
orgMember: env.orgMember,
organization: env.organization,
project: env.project,
parentEnvironment: { id: env.id, apiKey: env.apiKey },
childEnvironments: [],
};
}
const subject: RbacSubject = {
+20 -8
View File
@@ -320,7 +320,11 @@ export class CliApiClient {
);
}
async archiveBranch(projectRef: string, branch: string) {
async archiveBranch(
projectRef: string,
env: UpsertBranchRequestBody["env"],
branch: string
) {
if (!this.accessToken) {
throw new Error("archiveBranch: No access token");
}
@@ -331,7 +335,7 @@ export class CliApiClient {
{
method: "POST",
headers: this.getHeaders(),
body: JSON.stringify({ branch }),
body: JSON.stringify({ env, branch }),
}
);
}
@@ -694,6 +698,7 @@ export class CliApiClient {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
...this.getBranchHeader(),
},
});
}
@@ -714,6 +719,7 @@ export class CliApiClient {
headers: {
...init?.headers,
Authorization: `Bearer ${this.accessToken}`,
...this.getBranchHeader(),
},
}),
});
@@ -766,6 +772,7 @@ export class CliApiClient {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
...this.getBranchHeader(),
},
body: JSON.stringify(body),
});
@@ -783,6 +790,7 @@ export class CliApiClient {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
...this.getBranchHeader(),
},
body: JSON.stringify(body),
});
@@ -802,6 +810,7 @@ export class CliApiClient {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
...this.getBranchHeader(),
},
body: JSON.stringify(body),
});
@@ -818,6 +827,7 @@ export class CliApiClient {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
...this.getBranchHeader(),
},
}
);
@@ -837,6 +847,7 @@ export class CliApiClient {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
"Content-Type": "application/json",
...this.getBranchHeader(),
},
body: JSON.stringify(body),
}
@@ -855,6 +866,7 @@ export class CliApiClient {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
...this.getBranchHeader(),
},
//no body at the moment, but we'll probably add things soon
body: JSON.stringify({}),
@@ -875,6 +887,7 @@ export class CliApiClient {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: "application/json",
...this.getBranchHeader(),
},
body: JSON.stringify(body),
}
@@ -886,16 +899,15 @@ export class CliApiClient {
}
private getHeaders() {
const headers: Record<string, string> = {
return {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
"x-trigger-source": this.source,
...this.getBranchHeader(),
};
}
if (this.branch) {
headers["x-trigger-branch"] = this.branch;
}
return headers;
private getBranchHeader(): Record<string, string> {
return this.branch ? { "x-trigger-branch": this.branch } : {};
}
}
+168 -13
View File
@@ -1,8 +1,13 @@
import { intro } from "@clack/prompts";
import { resolve } from "node:path";
import { spinner } from "../utilities/windows.js";
import { loadConfig } from "../config.js";
import { verifyDirectory } from "./deploy.js";
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { Command, Option as CommandOption } from "commander";
import { z } from "zod";
import { CliApiClient } from "../apiClient.js";
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
import { CommonCommandOptions, commonOptions, handleTelemetry, wrapCommandAction } from "../cli/common.js";
import { watchConfig } from "../config.js";
import { DevSessionInstance, startDevSession } from "../dev/devSession.js";
import { createLockFile } from "../dev/lock.js";
@@ -27,11 +32,22 @@ import { installMcpServer } from "./install-mcp.js";
import { tryCatch } from "@trigger.dev/core/utils";
import { VERSION } from "@trigger.dev/core";
import { initiateSkillsInstallWizard } from "./skills.js";
import { getDevBranch } from "@trigger.dev/core/v3";
const DevArchiveCommandOptions = CommonCommandOptions.extend({
branch: z.string().optional(),
config: z.string().optional(),
projectRef: z.string().optional(),
skipUpdateCheck: z.boolean().default(false),
});
type DevArchiveCommandOptions = z.infer<typeof DevArchiveCommandOptions>;
const DevCommandOptions = CommonCommandOptions.extend({
debugOtel: z.boolean().default(false),
config: z.string().optional(),
projectRef: z.string().optional(),
branch: z.string().optional(),
skipUpdateCheck: z.boolean().default(false),
skipPlatformNotifications: z.boolean().default(false),
envFile: z.string().optional(),
@@ -48,15 +64,23 @@ const DevCommandOptions = CommonCommandOptions.extend({
export type DevCommandOptions = z.infer<typeof DevCommandOptions>;
export function configureDevCommand(program: Command) {
return commonOptions(
program
.command("dev")
// `dev` is the root command that defaults to the `start` subcommand,
// maintains existing behaviour for `trigger dev` but `trigger dev --help` a bit different
const devBase = program.command("dev").description("Run your Trigger.dev tasks locally");
commonOptions(
devBase
.command("start", { isDefault: true })
.description("Run your Trigger.dev tasks locally")
.option("-c, --config <config file>", "The name of the config file")
.option(
"-p, --project-ref <project ref>",
"The project ref. Required if there is no config file."
)
.option(
"-b, --branch <branch>",
"The dev branch to use. If not provided, we'll use the default branch."
)
.option(
"--env-file <env file>",
"Path to the .env file to use for the dev session. Defaults to .env in the project directory."
@@ -100,6 +124,32 @@ export function configureDevCommand(program: Command) {
await devCommand(opts);
});
});
commonOptions(
devBase
.command("archive")
.description("Archive a dev branch")
.argument("[path]", "The path to the project", ".")
.option(
"-b, --branch <branch>",
"The dev branch to archive. Defaults to the TRIGGER_DEV_BRANCH environment variable if set."
)
.option("--skip-update-check", "Skip checking for @trigger.dev package updates")
.option("-c, --config <config file>", "The name of the config file, found at [path]")
.option(
"-p, --project-ref <project ref>",
"The project ref. Required if there is no config file. This will override the project specified in the config file."
)
.option(
"--env-file <env file>",
"Path to the .env file to load into the CLI process. Defaults to .env in the project directory."
)
).action(async (path, options) => {
await handleTelemetry(async () => {
await printStandloneInitialBanner(true, options.profile);
await devArchiveCommand(path, options);
});
});
}
export async function devCommand(options: DevCommandOptions) {
@@ -164,8 +214,7 @@ export async function devCommand(options: DevCommandOptions) {
);
} else {
logger.log(
`${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.\n\n${
authorization.error
`${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.\n\n${authorization.error
}`
);
}
@@ -192,18 +241,21 @@ async function startDev(options: StartDevOptions) {
logger.debug("Starting dev CLI", { options });
let watcher: Awaited<ReturnType<typeof watchConfig>> | undefined;
let removeLockFile: (() => void) | undefined;
try {
if (options.logLevel) {
logger.loggerLevel = options.logLevel;
}
const apiClient = new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken);
const notificationPromise = options.skipPlatformNotifications
? undefined
: fetchPlatformNotification({
apiClient: new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken),
projectRef: options.projectRef,
});
apiClient,
projectRef: options.projectRef,
});
await printStandloneInitialBanner(true, options.profile);
@@ -215,13 +267,15 @@ async function startDev(options: StartDevOptions) {
displayedUpdateMessage = await updateTriggerPackages(options.cwd, { ...options }, true, true);
}
const removeLockFile = await createLockFile(options.cwd);
const envVars = resolveLocalEnvVars(options.envFile);
const branch = getDevBranch({ specified: options.branch ?? envVars.TRIGGER_DEV_BRANCH });
removeLockFile = await createLockFile(options.cwd, branch);
let devInstance: DevSessionInstance | undefined;
printDevBanner(displayedUpdateMessage);
const envVars = resolveLocalEnvVars(options.envFile);
if (envVars.TRIGGER_PROJECT_REF) {
logger.debug("Using project ref from env", { ref: envVars.TRIGGER_PROJECT_REF });
@@ -246,6 +300,18 @@ async function startDev(options: StartDevOptions) {
logger.debug("Initial config", watcher.config);
if (branch) {
const upsertResult = await apiClient.upsertBranch(watcher.config.project, {
branch,
env: "development",
});
if (!upsertResult.success) {
logger.error(`Failed to use branch "${branch}": ${upsertResult.error}`);
process.exit(1);
}
}
// eslint-disable-next-line no-inner-declarations
async function bootDevSession(configParam: ResolvedConfig) {
const projectClient = await getProjectClient({
@@ -253,6 +319,7 @@ async function startDev(options: StartDevOptions) {
apiUrl: options.login.auth.apiUrl,
projectRef: configParam.project,
env: "dev",
branch,
profile: options.profile,
});
@@ -262,6 +329,7 @@ async function startDev(options: StartDevOptions) {
return startDevSession({
name: projectClient.name,
branch,
rawArgs: options,
rawConfig: configParam,
client: projectClient.client,
@@ -274,19 +342,106 @@ async function startDev(options: StartDevOptions) {
devInstance = await bootDevSession(watcher.config);
const waitUntilExit = async () => {};
const waitUntilExit = async () => { };
return {
watcher,
stop: async () => {
devInstance?.stop();
await watcher?.stop();
removeLockFile();
removeLockFile?.();
},
waitUntilExit,
};
} catch (error) {
removeLockFile?.();
await watcher?.stop();
throw error;
}
}
async function devArchiveCommand(dir: string, options: unknown) {
return await wrapCommandAction(
"devArchiveCommand",
DevArchiveCommandOptions,
options,
async (opts) => {
return await archiveDevBranchCommand(dir, opts);
}
);
}
async function archiveDevBranchCommand(dir: string, options: DevArchiveCommandOptions) {
intro(`Archiving dev branch`);
if (!options.skipUpdateCheck) {
await updateTriggerPackages(dir, { ...options }, true, true);
}
const cwd = process.cwd();
const projectPath = resolve(cwd, dir);
verifyDirectory(dir, projectPath);
const authorization = await login({
embedded: true,
defaultApiUrl: options.apiUrl,
profile: options.profile,
});
if (!authorization.ok) {
if (authorization.error === "fetch failed") {
throw new Error(
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
);
} else {
throw new Error(
`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
);
}
}
const resolvedConfig = await loadConfig({
cwd: projectPath,
overrides: { project: options.projectRef },
configFile: options.config,
});
logger.debug("Resolved config", resolvedConfig);
const branch = getDevBranch({ specified: options.branch });
// getDevBranch returns undefined for the default branch (the root dev env),
// which can't be archived. Require the user to name a real branch instead.
if (!branch) {
throw new Error(
"You need to specify which dev branch to archive (the default branch can't be archived). Use --branch <branch>."
);
}
const $buildSpinner = spinner();
$buildSpinner.start(`Archiving "${branch}"`);
const result = await archiveDevBranch(authorization, branch, resolvedConfig.project);
$buildSpinner.stop(
result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".`
);
return result;
}
async function archiveDevBranch(
authorization: LoginResultOk,
branch: string,
project: string
) {
const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
const result = await apiClient.archiveBranch(project, "development", branch);
if (result.success) {
return true;
} else {
logger.error(result.error);
return false;
}
}
+1 -1
View File
@@ -135,7 +135,7 @@ export async function archivePreviewBranch(
) {
const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken);
const result = await apiClient.archiveBranch(project, branch);
const result = await apiClient.archiveBranch(project, "preview", branch);
if (result.success) {
return true;
+4 -2
View File
@@ -1,4 +1,5 @@
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch";
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import {
createTaskMetadataFailedErrorStack,
@@ -31,13 +32,14 @@ import { analyzeWorker } from "../utilities/analyze.js";
export type DevOutputOptions = {
name: string | undefined;
branch?: string;
dashboardUrl: string;
config: ResolvedConfig;
args: DevCommandOptions;
};
export function startDevOutput(options: DevOutputOptions) {
const { dashboardUrl, config } = options;
const { branch, dashboardUrl, config } = options;
const baseUrl = `${dashboardUrl}/projects/v3/${config.project}`;
@@ -90,7 +92,7 @@ export function startDevOutput(options: DevOutputOptions) {
const runsLink = chalkLink(cliLink("View runs", runsUrl));
const runtime = chalkGrey(`[${worker.build.runtime}]`);
const workerStarted = chalkGrey("Local worker ready");
const workerStarted = chalkGrey(`Local worker ready on branch: ${branch ?? DEFAULT_DEV_BRANCH}`);
const workerVersion = chalkWorker(worker.serverWorker!.version);
logParts.push(workerStarted, runtime, arrow, workerVersion);
+8 -4
View File
@@ -33,6 +33,7 @@ import { join } from "node:path";
export type DevSessionOptions = {
name: string | undefined;
branch?: string;
dashboardUrl: string;
initialMode: "local";
showInteractiveDevSession: boolean | undefined;
@@ -50,18 +51,20 @@ export type DevSessionInstance = {
export async function startDevSession({
rawConfig,
name,
branch,
rawArgs,
client,
dashboardUrl,
keepTmpFiles,
}: DevSessionOptions): Promise<DevSessionInstance> {
clearTmpDirs(rawConfig.workingDir);
const destination = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles);
clearTmpDirs(rawConfig.workingDir, branch);
const destination = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles, branch);
// Create shared store directory for deduplicating chunk files across rebuilds
const storeDir = getStoreDir(rawConfig.workingDir, keepTmpFiles);
const storeDir = getStoreDir(rawConfig.workingDir, keepTmpFiles, branch);
const runtime = await startWorkerRuntime({
name,
branch,
config: rawConfig,
args: rawArgs,
client,
@@ -81,6 +84,7 @@ export async function startDevSession({
const stopOutput = startDevOutput({
name,
branch,
dashboardUrl,
config: rawConfig,
args: rawArgs,
@@ -187,7 +191,7 @@ export async function startDevSession({
return;
}
const workerDir = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles);
const workerDir = getTmpDir(rawConfig.workingDir, "build", keepTmpFiles, branch);
await updateBuild(result, workerDir);
});
},
+12 -3
View File
@@ -31,9 +31,12 @@ import { resolveLocalEnvVars } from "../utilities/localEnvVars.js";
import type { Metafile } from "esbuild";
import { TaskRunProcessPool } from "./taskRunProcessPool.js";
import { tryCatch } from "@trigger.dev/core/utils";
import { devBranchPathSegment } from "../utilities/devBranch.js";
import { getTmpRoot } from "../utilities/tempDirectories.js";
export type WorkerRuntimeOptions = {
name: string | undefined;
branch?: string;
config: ResolvedConfig;
args: DevCommandOptions;
client: CliApiClient;
@@ -206,8 +209,14 @@ class DevSupervisor implements WorkerRuntime {
mkdirSync(triggerDir, { recursive: true });
}
this.activeRunsPath = join(triggerDir, "active-runs.json");
this.watchdogPidPath = join(triggerDir, "watchdog.pid");
// Namespace watchdog state per branch so concurrent dev sessions on
// different branches don't share a single watchdog instance (the
// single-instance guard would otherwise kill the other branch's watchdog).
const safeBranch = devBranchPathSegment(this.options.branch);
const suffix = safeBranch ? `-${safeBranch}` : "";
this.activeRunsPath = join(triggerDir, `active-runs${suffix}.json`);
this.watchdogPidPath = join(triggerDir, `watchdog${suffix}.pid`);
// Write empty active-runs file
this.#updateActiveRunsFile();
@@ -232,7 +241,7 @@ class DevSupervisor implements WorkerRuntime {
WATCHDOG_API_KEY: this.options.client.accessToken ?? "",
WATCHDOG_ACTIVE_RUNS: this.activeRunsPath,
WATCHDOG_PID_FILE: this.watchdogPidPath,
WATCHDOG_TMP_DIR: join(triggerDir, "tmp"),
WATCHDOG_TMP_DIR: getTmpRoot(this.options.config.workingDir, this.options.branch),
},
});
+13 -2
View File
@@ -1,6 +1,7 @@
import path from "node:path";
import { readFile } from "../utilities/fileSystem.js";
import { tryCatch } from "@trigger.dev/core/utils";
import { devBranchPathSegment } from "../utilities/devBranch.js";
import { logger } from "../utilities/logger.js";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync, unlinkSync } from "node:fs";
@@ -8,9 +9,19 @@ import { onExit } from "signal-exit";
const LOCK_FILE_NAME = "dev.lock";
export async function createLockFile(cwd: string) {
/**
* Builds the lock file name for a given branch. The default branch keeps the
* original `dev.lock` name (backwards compatible).
*/
function lockFileName(branch?: string) {
const safeBranch = devBranchPathSegment(branch);
if (!safeBranch) return LOCK_FILE_NAME;
return `dev.${safeBranch}.lock`;
}
export async function createLockFile(cwd: string, branch?: string) {
const currentPid = process.pid;
const lockFilePath = path.join(cwd, ".trigger", LOCK_FILE_NAME);
const lockFilePath = path.join(cwd, ".trigger", lockFileName(branch));
logger.debug("Checking for lockfile", { lockFilePath, currentPid });
+3 -1
View File
@@ -54,7 +54,9 @@ export const CommonProjectsInput = z.object({
.default("dev"),
branch: z
.string()
.describe("The branch to get tasks for, only used for preview environments")
.describe(
"The branch to get tasks for, only used for preview environments and branchable development environments"
)
.optional(),
});
+3 -2
View File
@@ -207,8 +207,9 @@ function formatSize(bytes: number): string {
}
function normalizePath(path: string): string {
// Remove .trigger/tmp/build-<hash>/ prefix
return path.replace(/(^|\/).trigger\/tmp\/build-[^/]+\//, "");
// Remove .trigger/tmp/build-<hash>/ prefix (tmp root may be branch-scoped,
// e.g. .trigger/tmp-feature-foo/build-<hash>/)
return path.replace(/(^|\/)\.trigger\/tmp(-[^/]+)?\/build-[^/]+\//, "$1");
}
interface BundleTreeData {
@@ -0,0 +1,22 @@
import { createHash } from "node:crypto";
import { isDefaultDevBranch } from "@trigger.dev/core/v3/utils/gitBranch";
/**
* Derives a filesystem-safe path segment for a dev branch, used to namespace
* on-disk artifacts (lock files, the `.trigger/tmp` build tree, watchdog state)
* so concurrent `trigger dev` sessions on different branches in the same project
* don't clobber each other.
*
* Returns `undefined` for the default branch (or no branch) so callers keep
* their original, branch-less paths for backwards compatibility.
*/
export function devBranchPathSegment(branch?: string): string | undefined {
if (!branch || isDefaultDevBranch(branch)) {
return undefined;
}
// Branch names can contain filesystem-unsafe characters (e.g. "/"), so sanitize.
const sanitized = branch.replace(/[^a-zA-Z0-9-_]/g, "-");
const branchHash = createHash("sha1").update(branch).digest("hex").slice(0, 8);
return `${sanitized}-${branchHash}`;
}
@@ -1,6 +1,21 @@
import fs from "node:fs";
import path from "node:path";
import { onExit } from "signal-exit";
import { devBranchPathSegment } from "./devBranch.js";
/**
* Resolves the `.trigger/tmp` root for a dev session, scoped to the branch so
* concurrent sessions on different branches don't share (and clobber) a build
* tree. The default branch keeps the original `.trigger/tmp` path; branches get
* a sibling root (e.g. `.trigger/tmp-feature-foo`) so a default-branch
* `clearTmpDirs` can't reach into a branch's tree, and vice versa.
*/
export function getTmpRoot(projectRoot: string | undefined, branch?: string): string {
projectRoot ??= process.cwd();
const safeBranch = devBranchPathSegment(branch);
const tmpDirName = safeBranch ? `tmp-${safeBranch}` : "tmp";
return path.join(projectRoot, ".trigger", tmpDirName);
}
/**
* A short-lived directory. Automatically removed when the process exits, but
@@ -21,10 +36,10 @@ export interface EphemeralDirectory {
export function getTmpDir(
projectRoot: string | undefined,
prefix: string,
keep: boolean = false
keep: boolean = false,
branch?: string
): EphemeralDirectory {
projectRoot ??= process.cwd();
const tmpRoot = path.join(projectRoot, ".trigger", "tmp");
const tmpRoot = getTmpRoot(projectRoot, branch);
fs.mkdirSync(tmpRoot, { recursive: true });
const tmpPrefix = path.join(tmpRoot, `${prefix}-`);
@@ -48,9 +63,8 @@ export function getTmpDir(
};
}
export function clearTmpDirs(projectRoot: string | undefined) {
projectRoot ??= process.cwd();
const tmpRoot = path.join(projectRoot, ".trigger", "tmp");
export function clearTmpDirs(projectRoot: string | undefined, branch?: string) {
const tmpRoot = getTmpRoot(projectRoot, branch);
try {
fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -65,9 +79,12 @@ export function clearTmpDirs(projectRoot: string | undefined) {
* identical chunk files between build versions.
* Automatically cleaned up when the process exits.
*/
export function getStoreDir(projectRoot: string | undefined, keep: boolean = false): string {
projectRoot ??= process.cwd();
const storeDir = path.join(projectRoot, ".trigger", "tmp", "store");
export function getStoreDir(
projectRoot: string | undefined,
keep: boolean = false,
branch?: string
): string {
const storeDir = path.join(getTmpRoot(projectRoot, branch), "store");
fs.mkdirSync(storeDir, { recursive: true });
// Register exit handler to clean up the store directory
@@ -1,5 +1,6 @@
import { GitMeta } from "../schemas/index.js";
import { getEnvVar } from "../utils/getEnv.js";
import { isDefaultDevBranch } from "../utils/gitBranch.js";
export function getBranch({
specified,
@@ -31,3 +32,20 @@ export function getBranch({
return undefined;
}
export function getDevBranch({
specified,
}: {
specified?: string;
}): string | undefined {
// For development we don't look at git/Vercel — only the flag and our env var.
const branch = specified ?? getEnvVar("TRIGGER_DEV_BRANCH");
// No branch and the "default" sentinel both mean the root dev env, which
// carries no branch. Collapse to undefined so callers send no branch
if (!branch || isDefaultDevBranch(branch)) {
return undefined;
}
return branch;
}
+2
View File
@@ -215,6 +215,8 @@ export class ApiClient {
constructor(
baseUrl: string,
accessToken: string,
// Carries the branch for any branchable env (preview or dev) — both ride the
// x-trigger-branch header, and the server disambiguates by the token's env.
previewBranch?: string,
requestOptions: ApiRequestOptions = {},
futureFlags: ApiClientFutureFlags = {}
+19 -1
View File
@@ -1,11 +1,22 @@
import { ApiClient } from "../apiClient/index.js";
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js";
import { getEnvVar } from "../utils/getEnv.js";
import { isDefaultDevBranch } from "../utils/gitBranch.js";
import { sdkScope } from "../sdkScope/index.js";
import { ApiClientConfiguration } from "./types.js";
const API_NAME = "api-client";
/**
* Read the dev-side branch carrier env var, collapsing the `"default"` sentinel
* to `undefined` so it never leaks into the `x-trigger-branch` header (the
* sentinel refers to the root dev env, which carries no branch).
*/
function getDevBranchEnvVar(): string | undefined {
const value = getEnvVar("TRIGGER_DEV_BRANCH");
return value && !isDefaultDevBranch(value) ? value : undefined;
}
export class ApiClientMissingError extends Error {
constructor(message: string) {
super(message);
@@ -56,6 +67,9 @@ export class APIClientManagerAPI {
get branchName(): string | undefined {
const scoped = sdkScope.getStore();
if (scoped) {
// previewBranch carries the branch for any branchable env (preview or dev) —
// they share the x-trigger-branch header. resolveApiClientConfig folds in the
// dev-side TRIGGER_DEV_BRANCH carrier when building the scoped config.
const value = scoped.apiClientConfig.previewBranch;
return value ? value : undefined;
}
@@ -64,6 +78,9 @@ export class APIClientManagerAPI {
config?.previewBranch ??
getEnvVar("TRIGGER_PREVIEW_BRANCH") ??
getEnvVar("VERCEL_GIT_COMMIT_REF") ??
// Dev branches share the x-trigger-branch header; TRIGGER_DEV_BRANCH is the
// dev-side carrier. Never read the "default" sentinel.
getDevBranchEnvVar() ??
undefined;
return value ? value : undefined;
}
@@ -80,7 +97,8 @@ export class APIClientManagerAPI {
previewBranch:
partial.previewBranch ??
getEnvVar("TRIGGER_PREVIEW_BRANCH") ??
getEnvVar("VERCEL_GIT_COMMIT_REF"),
getEnvVar("VERCEL_GIT_COMMIT_REF") ??
getDevBranchEnvVar(),
requestOptions: partial.requestOptions,
future: partial.future,
};
+1 -1
View File
@@ -605,7 +605,7 @@ export type DeploymentTriggeredVia = z.infer<typeof DeploymentTriggeredVia>;
export const UpsertBranchRequestBody = z.object({
git: GitMeta.optional(),
env: z.enum(["preview"]),
env: z.enum(["preview", "development"]),
branch: z.string(),
});
+20
View File
@@ -1,3 +1,23 @@
/**
* The sentinel branch name the CLI/SDK sends for a `trigger dev` session that
* isn't targeting a named dev branch. On the server the "root" development
* environment is stored with `branchName: null`, so this value never matches a
* real row call sites translate it to "no branch" via {@link isDefaultDevBranch}.
*
* It's a wire value: any client (the CLI, a custom frontend) can send it in the
* `x-trigger-branch` header, so the server must always interpret it, never
* assume the CLI stripped it.
*/
export const DEFAULT_DEV_BRANCH = "default";
/**
* Whether a branch name is the {@link DEFAULT_DEV_BRANCH} sentinel, i.e. it
* refers to the root development environment rather than a named dev branch.
*/
export function isDefaultDevBranch(branchName: string | null | undefined): boolean {
return branchName === DEFAULT_DEV_BRANCH;
}
export function isValidGitBranchName(branch: string): boolean {
if (!branch) return false;
+5 -5
View File
@@ -576,8 +576,8 @@ importers:
specifier: workspace:*
version: link:../../internal-packages/otlp-importer
'@trigger.dev/platform':
specifier: 1.0.28
version: 1.0.28
specifier: 1.0.29
version: 1.0.29
'@trigger.dev/plugins':
specifier: workspace:*
version: link:../../packages/plugins
@@ -8520,8 +8520,8 @@ packages:
react: 18.3.1
react-dom: 18.3.1
'@trigger.dev/platform@1.0.28':
resolution: {integrity: sha512-UVb7FlGljThZH5FJtXv5BvQQTGot510OAMZR98UJ1TLFuTvTwKo+NITf0yimYiDW5YaP768p6v1nVERx8MwpNw==}
'@trigger.dev/platform@1.0.29':
resolution: {integrity: sha512-75lsz0igwY9tqWfT6U7Huj+94VWic3//B4Cux4muCzH/ZC8Hz22O9fsMe+R7JtQy7HsemG42R+Zwy5ITnSgFYg==}
'@types/acorn@4.0.6':
resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==}
@@ -25914,7 +25914,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@trigger.dev/platform@1.0.28':
'@trigger.dev/platform@1.0.29':
dependencies:
zod: 3.23.8