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:
@@ -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.
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
parentEnvironmentId: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
|
||||
+22
-155
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+370
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-7
@@ -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,
|
||||
|
||||
+1
@@ -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>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -199,6 +199,7 @@
|
||||
"deploy-environment-variables",
|
||||
"github-actions",
|
||||
"deployment/preview-branches",
|
||||
"deployment/dev-branches",
|
||||
"deployment/atomic-deployment",
|
||||
{
|
||||
"group": "Deployment integrations",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 } : {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user