diff --git a/.changeset/silly-cows-serve.md b/.changeset/silly-cows-serve.md new file mode 100644 index 0000000000..d655dbed80 --- /dev/null +++ b/.changeset/silly-cows-serve.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Added support for Preview branches in v4 projects diff --git a/CHANGESETS.md b/CHANGESETS.md index 12022fa72a..cf66007661 100644 --- a/CHANGESETS.md +++ b/CHANGESETS.md @@ -30,28 +30,14 @@ Please follow the best-practice of adding changesets in the same commit as the c ## Snapshot instructions -!MAKE SURE TO UPDATE THE TAG IN THE INSTRUCTIONS BELOW! +1. Delete the `.changeset/pre.json` file (if it exists) -1. Add changesets as usual +2. Do a temporary commit (do NOT push this, you should undo it after) -```sh -pnpm run changeset:add -``` +3. Copy the `GITHUB_TOKEN` line from the .env file -2. Create a snapshot version (replace "prerelease" with your tag) +4. Run `GITHUB_TOKEN=github_pat_12345 ./scripts/publish-prerelease.sh re2` -```sh -pnpm exec changeset version --snapshot prerelease -``` +Make sure to replace the token with yours. `re2` is the tag that will be used for the pre-release. -3. Build the packages: - -```sh -pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev" -``` - -4. Publish the snapshot (replace "dev" with your tag) - -```sh -pnpm exec changeset publish --no-git-tag --snapshot --tag prerelease -``` +5. Undo the commit where you deleted the pre.json file. diff --git a/apps/webapp/app/assets/icons/ArchiveIcon.tsx b/apps/webapp/app/assets/icons/ArchiveIcon.tsx new file mode 100644 index 0000000000..1d910ba750 --- /dev/null +++ b/apps/webapp/app/assets/icons/ArchiveIcon.tsx @@ -0,0 +1,44 @@ +export function ArchiveIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} + +export function UnarchiveIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EnvironmentIcons.tsx b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx index 02fa27c546..bc74ab10bc 100644 --- a/apps/webapp/app/assets/icons/EnvironmentIcons.tsx +++ b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx @@ -75,7 +75,7 @@ export function ProdEnvironmentIcon({ className }: { className?: string }) { ); } + +export function PreviewEnvironmentIconSmall({ className }: { className?: string }) { + return ; +} + +export function BranchEnvironmentIconSmall({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/IntegrationIcon.tsx b/apps/webapp/app/assets/icons/IntegrationIcon.tsx deleted file mode 100644 index fd1839228f..0000000000 --- a/apps/webapp/app/assets/icons/IntegrationIcon.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LogoIcon } from "~/components/LogoIcon"; - -export function IntegrationIcon() { - return ; -} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index ea7a20764d..f3a4b3faa5 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -18,6 +18,7 @@ import { useProject } from "~/hooks/useProject"; import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server"; import { docsPath, + v3BillingPath, v3EnvironmentPath, v3EnvironmentVariablesPath, v3NewProjectAlertPath, @@ -36,6 +37,11 @@ import { TextLink } from "./primitives/TextLink"; import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; import { StepContentContainer } from "./StepContentContainer"; import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { useFeatures } from "~/hooks/useFeatures"; +import { DialogContent, DialogTrigger, Dialog } from "./primitives/Dialog"; +import { V4Badge } from "./V4Badge"; +import { NewBranchPanel } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; export function HasNoTasksDev() { return ( @@ -431,7 +437,120 @@ export function NoWaitpointTokens() { ); } -function SwitcherPanel() { +export function BranchesNoBranchableEnvironment() { + const { isManagedCloud } = useFeatures(); + const organization = useOrganization(); + + if (!isManagedCloud) { + return ( + + + To add branches you need to have a RuntimeEnvironment where{" "} + isBranchableEnvironment is true. We recommend creating a + dedicated one using the "PREVIEW" type. + + + ); + } + + return ( + + Upgrade + + } + > + + Preview branches in Trigger.dev create isolated environments for testing new features before + production. + + + ); +} + +export function BranchesNoBranches({ + parentEnvironment, + limits, + canUpgrade, +}: { + parentEnvironment: { id: string }; + limits: { used: number; limit: number }; + canUpgrade: boolean; +}) { + const organization = useOrganization(); + + if (limits.used >= limits.limit) { + return ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + ) + } + > + + You've reached the limit ({limits.used}/{limits.limit}) of branches for your plan. Upgrade + to get branches. + + + ); + } + + return ( + + New branch + + } + parentEnvironment={parentEnvironment} + /> + } + > + + Branches are a way to test new features in isolation before merging them into the main + environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn more. + + + ); +} + +export function SwitcherPanel({ title = "Switch to a deployed environment" }: { title?: string }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -439,7 +558,7 @@ function SwitcherPanel() { return (
- Switch to a deployed environment + {title} + {git.pullRequestUrl && git.pullRequestNumber && } + {git.shortSha && } + {git.branchUrl && } + + ); +} + +export function GitMetadataBranch({ + git, +}: { + git: Pick; +}) { + return ( + } + iconSpacing="gap-x-1" + to={git.branchUrl} + className="pl-1" + > + {git.branchName} + + } + content="Jump to GitHub branch" + /> + ); +} + +export function GitMetadataCommit({ + git, +}: { + git: Pick; +}) { + return ( + } + iconSpacing="gap-x-1" + className="pl-1" + > + {`${git.shortSha} / ${git.commitMessage}`} + + } + content="Jump to GitHub commit" + /> + ); +} + +export function GitMetadataPullRequest({ + git, +}: { + git: Pick; +}) { + if (!git.pullRequestUrl || !git.pullRequestNumber) return null; + + return ( + } + iconSpacing="gap-x-1" + className="pl-1" + > + #{git.pullRequestNumber} {git.pullRequestTitle} + + } + content="Jump to GitHub pull request" + /> + ); +} diff --git a/apps/webapp/app/components/V4Badge.tsx b/apps/webapp/app/components/V4Badge.tsx new file mode 100644 index 0000000000..c92baabac8 --- /dev/null +++ b/apps/webapp/app/components/V4Badge.tsx @@ -0,0 +1,26 @@ +import { cn } from "~/utils/cn"; +import { Badge } from "./primitives/Badge"; +import { SimpleTooltip } from "./primitives/Tooltip"; + +export function V4Badge({ inline = false, className }: { inline?: boolean; className?: string }) { + return ( + + V4 + + } + content="This feature is only available in V4 and above." + disableHoverableContent + /> + ); +} + +export function V4Title({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 9d3d3bb8b0..3afa861cf3 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -1,4 +1,5 @@ import { + BranchEnvironmentIconSmall, DeployedEnvironmentIconSmall, DevEnvironmentIconSmall, ProdEnvironmentIconSmall, @@ -6,7 +7,7 @@ import { import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server"; import { cn } from "~/utils/cn"; -type Environment = Pick; +type Environment = Pick & { branchName?: string | null }; export function EnvironmentIcon({ environment, @@ -15,6 +16,14 @@ export function EnvironmentIcon({ environment: Environment; className?: string; }) { + if (environment.branchName) { + return ( + + ); + } + switch (environment.type) { case "DEVELOPMENT": return ( @@ -39,13 +48,15 @@ export function EnvironmentIcon({ export function EnvironmentCombo({ environment, className, + iconClassName, }: { environment: Environment; className?: string; + iconClassName?: string; }) { return ( - + ); @@ -60,12 +71,16 @@ export function EnvironmentLabel({ }) { return ( - {environmentFullTitle(environment)} + {environment.branchName ? environment.branchName : environmentFullTitle(environment)} ); } export function environmentTitle(environment: Environment, username?: string) { + if (environment.branchName) { + return environment.branchName; + } + switch (environment.type) { case "PRODUCTION": return "Prod"; @@ -79,6 +94,10 @@ export function environmentTitle(environment: Environment, username?: string) { } export function environmentFullTitle(environment: Environment) { + if (environment.branchName) { + return environment.branchName; + } + switch (environment.type) { case "PRODUCTION": return "Production"; diff --git a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx index c041683e04..130c1c4acc 100644 --- a/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx +++ b/apps/webapp/app/components/environments/RegenerateApiKeyModal.tsx @@ -28,13 +28,7 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) { return ( - diff --git a/apps/webapp/app/components/navigation/EnvironmentBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx new file mode 100644 index 0000000000..2a34b9e434 --- /dev/null +++ b/apps/webapp/app/components/navigation/EnvironmentBanner.tsx @@ -0,0 +1,83 @@ +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { useLocation } from "@remix-run/react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment"; +import { useOptionalOrganization, useOrganization } from "~/hooks/useOrganizations"; +import { useOptionalProject, useProject } from "~/hooks/useProject"; +import { v3QueuesPath } from "~/utils/pathBuilder"; +import { environmentFullTitle } from "../environments/EnvironmentLabel"; +import { LinkButton } from "../primitives/Buttons"; +import { Icon } from "../primitives/Icon"; +import { Paragraph } from "../primitives/Paragraph"; + +export function EnvironmentBanner() { + const organization = useOptionalOrganization(); + const project = useOptionalProject(); + const environment = useOptionalEnvironment(); + + const isPaused = organization && project && environment && environment.paused; + const isArchived = organization && project && environment && environment.archivedAt; + + return ( + + {isArchived ? : isPaused ? : null} + + ); +} + +function PausedBanner() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const location = useLocation(); + const hideButton = location.pathname.endsWith("/queues"); + + return ( + +
+ + + {environmentFullTitle(environment)} environment paused. No new runs will be dequeued and + executed. + +
+ {hideButton ? null : ( +
+ + Manage + +
+ )} +
+ ); +} + +function ArchivedBranchBanner() { + const environment = useEnvironment(); + + return ( + +
+ + + "{environment.branchName}" branch is archived and is read-only. No new runs will be + dequeued and executed. + +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx b/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx deleted file mode 100644 index bc0501a210..0000000000 --- a/apps/webapp/app/components/navigation/EnvironmentPausedBanner.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; -import { useLocation } from "@remix-run/react"; -import { AnimatePresence, motion } from "framer-motion"; -import { useOptionalEnvironment } from "~/hooks/useEnvironment"; -import { useOptionalOrganization } from "~/hooks/useOrganizations"; -import { useOptionalProject } from "~/hooks/useProject"; -import { v3QueuesPath } from "~/utils/pathBuilder"; -import { environmentFullTitle } from "../environments/EnvironmentLabel"; -import { LinkButton } from "../primitives/Buttons"; -import { Icon } from "../primitives/Icon"; -import { Paragraph } from "../primitives/Paragraph"; - -export function EnvironmentPausedBanner() { - const organization = useOptionalOrganization(); - const project = useOptionalProject(); - const environment = useOptionalEnvironment(); - const location = useLocation(); - - const hideButton = location.pathname.endsWith("/queues"); - - return ( - - {organization && project && environment && environment.paused ? ( - -
- - - {environmentFullTitle(environment)} environment paused. No new runs will be dequeued - and executed. - -
- {hideButton ? null : ( -
- - Manage - -
- )} -
- ) : null} -
- ); -} - -export function useShowEnvironmentPausedBanner() { - const environment = useOptionalEnvironment(); - const shouldShow = environment?.paused ?? false; - return { shouldShow }; -} diff --git a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx index a56d0ec8ab..def3996f85 100644 --- a/apps/webapp/app/components/navigation/EnvironmentSelector.tsx +++ b/apps/webapp/app/components/navigation/EnvironmentSelector.tsx @@ -1,19 +1,30 @@ +import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid"; import { useNavigation } from "@remix-run/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { useEnvironment } from "~/hooks/useEnvironment"; import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher"; import { useFeatures } from "~/hooks/useFeatures"; -import { type MatchedOrganization } from "~/hooks/useOrganizations"; +import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; -import { v3BillingPath } from "~/utils/pathBuilder"; +import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder"; import { EnvironmentCombo } from "../environments/EnvironmentLabel"; +import { ButtonContent } from "../primitives/Buttons"; +import { Header2 } from "../primitives/Headers"; +import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverArrowTrigger, PopoverContent, PopoverMenuItem, PopoverSectionHeader, + PopoverTrigger, } from "../primitives/Popover"; +import { TextLink } from "../primitives/TextLink"; +import { V4Badge } from "../V4Badge"; import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu"; +import { Badge } from "../primitives/Badge"; export function EnvironmentSelector({ organization, @@ -53,14 +64,36 @@ export function EnvironmentSelector({ style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }} >
- {project.environments.map((env) => ( - } - isSelected={env.id === environment.id} - /> - ))} + {project.environments + .filter((env) => env.branchName === null) + .map((env) => { + switch (env.isBranchableEnvironment) { + case true: { + const branchEnvironments = project.environments.filter( + (e) => e.parentEnvironmentId === env.id + ); + return ( + + ); + } + case false: + return ( + + } + isSelected={env.id === environment.id} + /> + ); + } + })}
{!hasStaging && isManagedCloud && ( <> @@ -80,6 +113,20 @@ export function EnvironmentSelector({ } isSelected={false} /> + + + Upgrade +
+ } + isSelected={false} + /> )} @@ -87,3 +134,147 @@ export function EnvironmentSelector({ ); } + +function Branches({ + parentEnvironment, + branchEnvironments, + currentEnvironment, +}: { + parentEnvironment: SideMenuEnvironment; + branchEnvironments: SideMenuEnvironment[]; + currentEnvironment: SideMenuEnvironment; +}) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const { urlForEnvironment } = useEnvironmentSwitcher(); + const navigation = useNavigation(); + const [isMenuOpen, setMenuOpen] = useState(false); + const timeoutRef = useRef(null); + + // Clear timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + useEffect(() => { + setMenuOpen(false); + }, [navigation.location?.pathname]); + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setMenuOpen(true); + }; + + const handleMouseLeave = () => { + // Small delay before closing to allow moving to the content + timeoutRef.current = setTimeout(() => { + setMenuOpen(false); + }, 150); + }; + + const activeBranches = branchEnvironments.filter((env) => env.archivedAt === null); + const state = + branchEnvironments.length === 0 + ? "no-branches" + : activeBranches.length === 0 + ? "no-active-branches" + : "has-branches"; + + const currentBranchIsArchived = environment.archivedAt !== null; + + return ( + setMenuOpen(open)} open={isMenuOpen}> +
+ + + + + + +
+ {currentBranchIsArchived && ( + + {environment.branchName} + Archived + + } + icon={} + isSelected={environment.id === currentEnvironment.id} + /> + )} + {state === "has-branches" ? ( + <> + {branchEnvironments + .filter((env) => env.archivedAt === null) + .map((env) => ( + {env.branchName}} + icon={} + isSelected={env.id === currentEnvironment.id} + /> + ))} + + ) : state === "no-branches" ? ( +
+
+ + Create your first branch +
+ + Branches are a way to test new features in isolation before merging them into the + main environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn + more. + +
+ ) : ( +
+ All branches are archived. +
+ )} +
+
+ } + leadingIconClassName="text-text-dimmed" + /> +
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index a788e74233..93f2843de5 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -21,7 +21,8 @@ import { Icon } from "../primitives/Icon"; import { Paragraph } from "../primitives/Paragraph"; import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover"; import { StepNumber } from "../primitives/StepNumber"; -import { MenuCount, SideMenuItem } from "./SideMenuItem"; +import { SideMenuItem } from "./SideMenuItem"; +import { Badge } from "../primitives/Badge"; export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) { const [isHelpMenuOpen, setHelpMenuOpen] = useState(false); @@ -109,7 +110,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: >
Join our Slack… - + + Pro +
diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index f4d67bfac4..694ac87560 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -21,6 +21,7 @@ import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { Paragraph } from "../primitives/Paragraph"; +import { Badge } from "../primitives/Badge"; export function OrganizationSettingsSideMenu({ organization, @@ -69,9 +70,9 @@ export function OrganizationSettingsSideMenu({ to={v3BillingPath(organization)} data-action="billing" badge={ - currentPlan?.v3Subscription?.isPaying - ? currentPlan?.v3Subscription?.plan?.title - : undefined + currentPlan?.v3Subscription?.isPaying ? ( + {currentPlan?.v3Subscription?.plan?.title} + ) : undefined } /> )} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 4dab36078e..ca79b07d64 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -38,6 +38,7 @@ import { cn } from "~/utils/cn"; import { accountPath, adminPath, + branchesPath, logoutPath, newOrganizationPath, newProjectPath, @@ -84,6 +85,8 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { SideMenuHeader } from "./SideMenuHeader"; import { SideMenuItem } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { V4Badge } from "../V4Badge"; type SideMenuUser = Pick & { isImpersonating: boolean }; export type SideMenuProject = Pick< @@ -263,6 +266,7 @@ export function SideMenu({ icon={WaitpointTokenIcon} activeIconColor="text-sky-500" to={v3WaitpointTokensPath(organization, project, environment)} + badge={} /> @@ -288,6 +292,14 @@ export function SideMenu({ to={v3ProjectAlertsPath(organization, project, environment)} data-action="alerts" /> + } + /> ["target"]; }) { const pathName = usePathName(); @@ -46,18 +46,8 @@ export function SideMenuItem({ >
{name} -
- {badge !== undefined && } -
+
{badge !== undefined && badge}
); } - -export function MenuCount({ count }: { count: number | string }) { - return ( -
- {count} -
- ); -} diff --git a/apps/webapp/app/components/primitives/Accordion.tsx b/apps/webapp/app/components/primitives/Accordion.tsx new file mode 100644 index 0000000000..beb61b2012 --- /dev/null +++ b/apps/webapp/app/components/primitives/Accordion.tsx @@ -0,0 +1,57 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import { cn } from "~/utils/cn"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index ed94d46f95..bafd772b0a 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -299,9 +299,11 @@ export const Button = forwardRef( if (props.shortcut) { useShortcutKeys({ shortcut: props.shortcut, - action: () => { + action: (e) => { if (innerRef.current) { innerRef.current.click(); + e.preventDefault(); + e.stopPropagation(); } }, disabled, diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index 767aa3fab4..7855e241e3 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -5,7 +5,7 @@ import { UpgradePrompt, useShowUpgradePrompt } from "../billing/UpgradePrompt"; import { BreadcrumbIcon } from "./BreadcrumbIcon"; import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; -import { EnvironmentPausedBanner } from "../navigation/EnvironmentPausedBanner"; +import { EnvironmentBanner } from "../navigation/EnvironmentBanner"; type WithChildren = { children: React.ReactNode; @@ -25,11 +25,7 @@ export function NavBar({ children }: WithChildren) {
{children}
- {showUpgradePrompt.shouldShow && organization ? ( - - ) : ( - - )} + {showUpgradePrompt.shouldShow && organization ? : } ); } diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index ce7d72eb0b..22156927f3 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -59,6 +59,7 @@ function PopoverMenuItem({ isSelected, variant = { variant: "small-menu-item" }, leadingIconClassName, + className, }: { to: string; icon?: RenderIcon; @@ -66,6 +67,7 @@ function PopoverMenuItem({ isSelected?: boolean; variant?: ButtonContentPropsType; leadingIconClassName?: string; + className?: string; }) { return ( {title} diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index db889586cc..207217504d 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -131,6 +131,12 @@ function ReplayForm({ dropdownIcon variant="tertiary/medium" className="w-fit pl-1" + filter={{ + keys: [ + (item) => item.type.replace(/\//g, " ").replace(/_/g, " "), + (item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "", + ], + }} text={(value) => { const env = environments.find((env) => env.id === value)!; return ( diff --git a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts index 9c0d02a7b6..5c9aa2059b 100644 --- a/apps/webapp/app/hooks/useEnvironmentSwitcher.ts +++ b/apps/webapp/app/hooks/useEnvironmentSwitcher.ts @@ -1,7 +1,5 @@ import { type Path, useMatches } from "@remix-run/react"; -import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server"; -import { useEnvironment } from "./useEnvironment"; -import { useEnvironments } from "./useEnvironments"; +import { type RuntimeEnvironment } from "@trigger.dev/database"; import { useOptimisticLocation } from "./useOptimisticLocation"; /** @@ -12,7 +10,7 @@ export function useEnvironmentSwitcher() { const matches = useMatches(); const location = useOptimisticLocation(); - const urlForEnvironment = (newEnvironment: MinimumEnvironment) => { + const urlForEnvironment = (newEnvironment: Pick) => { return routeForEnvironmentSwitch({ location, matchId: matches[matches.length - 1].id, @@ -86,7 +84,8 @@ export function routeForEnvironmentSwitch({ * Replace the /env// in the path so it's /env/ */ function replaceEnvInPath(path: string, environmentSlug: string) { - return path.replace(/env\/([a-z0-9-]+)/, `env/${environmentSlug}`); + //allow anything except / + return path.replace(/env\/([^/]+)/, `env/${environmentSlug}`); } function fullPath(location: Path) { diff --git a/apps/webapp/app/models/api-key.server.ts b/apps/webapp/app/models/api-key.server.ts index c70fd7510f..86609cc01d 100644 --- a/apps/webapp/app/models/api-key.server.ts +++ b/apps/webapp/app/models/api-key.server.ts @@ -84,7 +84,7 @@ export function createPkApiKeyForEnv(envType: RuntimeEnvironment["type"]) { return `pk_${envSlug(envType)}_${apiKeyId(20)}`; } -export type EnvSlug = "dev" | "stg" | "prod" | "prev"; +export type EnvSlug = "dev" | "stg" | "prod" | "preview"; export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug { switch (environmentType) { @@ -98,11 +98,11 @@ export function envSlug(environmentType: RuntimeEnvironment["type"]): EnvSlug { return "stg"; } case "PREVIEW": { - return "prev"; + return "preview"; } } } export function isEnvSlug(maybeSlug: string): maybeSlug is EnvSlug { - return ["dev", "stg", "prod", "prev"].includes(maybeSlug); + return ["dev", "stg", "prod", "preview"].includes(maybeSlug); } diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index c2e05248be..86ae5d371d 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -174,7 +174,14 @@ export async function acceptInvite({ userId, inviteId }: { userId: string; invit // 3. Create an environment for each project for (const project of invite.organization.projects) { - await createEnvironment(invite.organization, project, "DEVELOPMENT", member, tx); + await createEnvironment({ + organization: invite.organization, + project, + type: "DEVELOPMENT", + isBranchableEnvironment: false, + member, + prismaClient: tx, + }); } // 4. Check for other invites diff --git a/apps/webapp/app/models/organization.server.ts b/apps/webapp/app/models/organization.server.ts index b9cd102d79..61dc99ecd7 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -6,12 +6,12 @@ import type { User, } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; -import slug from "slug"; -import { prisma, PrismaClientOrTransaction } from "~/db.server"; import { generate } from "random-words"; -import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; +import slug from "slug"; +import { prisma, type PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { featuresForUrl } from "~/features.server"; +import { createApiKeyForEnv, createPkApiKeyForEnv, envSlug } from "./api-key.server"; export type { Organization }; @@ -76,13 +76,21 @@ export async function createOrganization( return { ...organization }; } -export async function createEnvironment( - organization: Pick, - project: Pick, - type: RuntimeEnvironment["type"], - member?: OrgMember, - prismaClient: PrismaClientOrTransaction = prisma -) { +export async function createEnvironment({ + organization, + project, + type, + isBranchableEnvironment = false, + member, + prismaClient = prisma, +}: { + organization: Pick; + project: Pick; + type: RuntimeEnvironment["type"]; + isBranchableEnvironment?: boolean; + member?: OrgMember; + prismaClient?: PrismaClientOrTransaction; +}) { const slug = envSlug(type); const apiKey = createApiKeyForEnv(type); const pkApiKey = createPkApiKeyForEnv(type); @@ -108,6 +116,7 @@ export async function createEnvironment( }, orgMember: member ? { connect: { id: member.id } } : undefined, type, + isBranchableEnvironment, }, }); } diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 581c08ed7f..3e4dc2cf8d 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -88,10 +88,21 @@ export async function createProject( }); // Create the dev and prod environments - await createEnvironment(organization, project, "PRODUCTION"); + await createEnvironment({ + organization, + project, + type: "PRODUCTION", + isBranchableEnvironment: false, + }); for (const member of project.organization.members) { - await createEnvironment(organization, project, "DEVELOPMENT", member); + await createEnvironment({ + organization, + project, + type: "DEVELOPMENT", + isBranchableEnvironment: false, + member, + }); } await projectCreated(organization, project); diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index d9f616f6cf..80820fa910 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -1,12 +1,15 @@ import type { AuthenticatedEnvironment } from "@internal/run-engine"; import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@trigger.dev/database"; import { prisma } from "~/db.server"; +import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; +import { sanitizeBranchName } from "~/v3/gitBranch"; export type { RuntimeEnvironment }; export async function findEnvironmentByApiKey( - apiKey: string + apiKey: string, + branchName: string | undefined ): Promise { const environment = await prisma.runtimeEnvironment.findFirst({ where: { @@ -16,6 +19,14 @@ export async function findEnvironmentByApiKey( project: true, organization: true, orgMember: true, + childEnvironments: branchName + ? { + where: { + branchName: sanitizeBranchName(branchName), + archivedAt: null, + }, + } + : undefined, }, }); @@ -24,11 +35,37 @@ export async function findEnvironmentByApiKey( return null; } + if (environment.type === "PREVIEW") { + if (!branchName) { + logger.error("findEnvironmentByApiKey(): Preview env with no branch name provided", { + environmentId: environment.id, + }); + return null; + } + + const childEnvironment = environment?.childEnvironments.at(0); + + if (childEnvironment) { + return { + ...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 environment; } +/** @deprecated We don't use public api keys anymore */ export async function findEnvironmentByPublicApiKey( - apiKey: string + apiKey: string, + branchName: string | undefined ): Promise { const environment = await prisma.runtimeEnvironment.findFirst({ where: { @@ -49,7 +86,9 @@ export async function findEnvironmentByPublicApiKey( return environment; } -export async function findEnvironmentById(id: string): Promise { +export async function findEnvironmentById( + id: string +): Promise<(AuthenticatedEnvironment & { parentEnvironment: { apiKey: string } | null }) | null> { const environment = await prisma.runtimeEnvironment.findFirst({ where: { id, @@ -58,6 +97,11 @@ export async function findEnvironmentById(id: string): Promise & { + orgMember: null | { + userId: string | undefined; + }; + })[]; }) { if (environmentSlug) { const env = environments.find((e) => e.slug === environmentSlug); diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts index e269f3fc2c..1ff560586a 100644 --- a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -1,7 +1,7 @@ +import { type RuntimeEnvironment } from "@trigger.dev/database"; import { type PrismaClient, prisma } from "~/db.server"; import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; -import { sortEnvironments } from "~/utils/environmentSort"; export class ApiKeysPresenter { #prismaClient: PrismaClient; @@ -10,12 +10,19 @@ export class ApiKeysPresenter { this.#prismaClient = prismaClient; } - public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { - const environments = await this.#prismaClient.runtimeEnvironment.findMany({ + public async call({ + userId, + projectSlug, + environmentSlug, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + environmentSlug: RuntimeEnvironment["slug"]; + }) { + const environment = await this.#prismaClient.runtimeEnvironment.findFirst({ select: { id: true, apiKey: true, - pkApiKey: true, type: true, slug: true, updatedAt: true, @@ -24,13 +31,11 @@ export class ApiKeysPresenter { userId: true, }, }, - backgroundWorkers: { + branchName: true, + parentEnvironment: { select: { - version: true, - }, - take: 1, - orderBy: { - version: "desc", + id: true, + apiKey: true, }, }, }, @@ -45,30 +50,25 @@ export class ApiKeysPresenter { }, }, }, + slug: environmentSlug, + orgMember: + environmentSlug === "dev" + ? { + userId, + } + : undefined, }, }); - //filter out environments the only development ones belong to the current user - const filtered = environments.filter((environment) => { - if (environment.type === "DEVELOPMENT") { - return environment.orgMember?.userId === userId; - } - return true; - }); + if (!environment) { + throw new Error("Environment not found"); + } return { - environments: sortEnvironments( - filtered.map((environment) => ({ - id: environment.id, - apiKey: environment.apiKey, - pkApiKey: environment.pkApiKey, - type: environment.type, - slug: environment.slug, - updatedAt: environment.updatedAt, - latestVersion: environment.backgroundWorkers.at(0)?.version, - })) - ), - hasStaging: environments.some((environment) => environment.type === "STAGING"), + environment: { + ...environment, + apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey, + }, }; } } diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts new file mode 100644 index 0000000000..9951fe4b40 --- /dev/null +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -0,0 +1,243 @@ +import { GitMeta } from "@trigger.dev/core/v3"; +import { type z } from "zod"; +import { 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 { checkBranchLimit } from "~/services/upsertBranch.server"; + +type Result = Awaited>; +export type Branch = Result["branches"][number]; + +const BRANCHES_PER_PAGE = 25; + +type Options = z.infer; + +export type GitMetaLinks = { + /** The cleaned repository URL without any username/password */ + repositoryUrl: string; + /** The branch name */ + branchName: string; + /** Link to the specific branch */ + branchUrl: string; + /** Link to the specific commit */ + commitUrl: string; + /** Link to the pull request (if available) */ + pullRequestUrl?: string; + /** The pull request number (if available) */ + pullRequestNumber?: number; + /** The pull request title (if available) */ + pullRequestTitle?: string; + /** Link to compare this branch with main */ + compareUrl: string; + /** Shortened commit SHA (first 7 characters) */ + shortSha: string; + /** Whether the branch has uncommitted changes */ + isDirty: boolean; + /** The commit message */ + commitMessage: string; + /** The commit author */ + commitAuthor: string; +}; + +export class BranchesPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ + userId, + projectSlug, + showArchived = false, + search, + page = 1, + }: { + userId: User["id"]; + projectSlug: Project["slug"]; + } & Options) { + const project = await this.#prismaClient.project.findFirst({ + select: { + id: true, + organizationId: true, + }, + where: { + slug: projectSlug, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ + select: { + id: true, + }, + where: { + projectId: project.id, + isBranchableEnvironment: true, + }, + }); + + const hasFilters = !!showArchived || (search !== undefined && search !== ""); + + if (!branchableEnvironment) { + return { + branchableEnvironment: null, + currentPage: page, + totalPages: 0, + hasBranches: false, + branches: [], + hasFilters: false, + limits: { + used: 0, + limit: 0, + isAtLimit: true, + }, + }; + } + + const visibleCount = await this.#prismaClient.runtimeEnvironment.count({ + where: { + projectId: project.id, + branchName: search + ? { + contains: search, + mode: "insensitive", + } + : { + not: null, + }, + ...(showArchived ? {} : { archivedAt: null }), + }, + }); + + // Limits + const limits = await checkBranchLimit(this.#prismaClient, project.organizationId, project.id); + + const branches = await this.#prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + slug: true, + branchName: true, + type: true, + archivedAt: true, + createdAt: true, + git: true, + }, + where: { + projectId: project.id, + branchName: search + ? { + contains: search, + mode: "insensitive", + } + : { + not: null, + }, + ...(showArchived ? {} : { archivedAt: null }), + }, + orderBy: { + branchName: "asc", + }, + skip: (page - 1) * BRANCHES_PER_PAGE, + take: BRANCHES_PER_PAGE, + }); + + const totalBranches = await this.#prismaClient.runtimeEnvironment.count({ + where: { + projectId: project.id, + branchName: { + not: null, + }, + }, + }); + + 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, + ]; + }), + hasFilters, + limits, + }; + } +} + +export function processGitMetadata(data: Prisma.JsonValue): GitMetaLinks | null { + if (!data) return null; + + const parsed = GitMeta.safeParse(data); + if (!parsed.success) { + return null; + } + + if (!parsed.data.remoteUrl) { + return null; + } + + // Clean the remote URL by removing any username/password and ensuring it's a proper GitHub URL + const cleanRemoteUrl = (() => { + try { + const url = new URL(parsed.data.remoteUrl); + // Remove any username/password from the URL + url.username = ""; + url.password = ""; + // Ensure we're using https + url.protocol = "https:"; + // Remove any trailing .git + return url.toString().replace(/\.git$/, ""); + } catch (e) { + // If URL parsing fails, try to clean it manually + return parsed.data.remoteUrl + .replace(/^git@github\.com:/, "https://github.com/") + .replace(/^https?:\/\/[^@]+@/, "https://") + .replace(/\.git$/, ""); + } + })(); + + if (!parsed.data.commitRef || !parsed.data.commitSha) return null; + + const shortSha = parsed.data.commitSha.slice(0, 7); + + return { + repositoryUrl: cleanRemoteUrl, + branchName: parsed.data.commitRef, + branchUrl: `${cleanRemoteUrl}/tree/${parsed.data.commitRef}`, + commitUrl: `${cleanRemoteUrl}/commit/${parsed.data.commitSha}`, + pullRequestUrl: parsed.data.pullRequestNumber + ? `${cleanRemoteUrl}/pull/${parsed.data.pullRequestNumber}` + : undefined, + pullRequestNumber: parsed.data.pullRequestNumber, + pullRequestTitle: parsed.data.pullRequestTitle, + compareUrl: `${cleanRemoteUrl}/compare/main...${parsed.data.commitRef}`, + shortSha, + isDirty: parsed.data.dirty ?? false, + commitMessage: parsed.data.commitMessage ?? "", + commitAuthor: parsed.data.commitAuthorName ?? "", + }; +} diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index b044cbe070..c63bb3f5b0 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -1,9 +1,14 @@ -import { type WorkerDeploymentStatus, type WorkerInstanceGroupType } from "@trigger.dev/database"; +import { + Prisma, + type WorkerDeploymentStatus, + type WorkerInstanceGroupType, +} from "@trigger.dev/database"; import { sqlDatabaseSchema, type PrismaClient, prisma } from "~/db.server"; import { type Organization } from "~/models/organization.server"; import { type Project } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; +import { processGitMetadata } from "./BranchesPresenter.server"; const pageSize = 20; @@ -102,6 +107,7 @@ export class DeploymentListPresenter { userDisplayName: string | null; userAvatarUrl: string | null; type: WorkerInstanceGroupType; + git: Prisma.JsonValue | null; }[] >` SELECT @@ -117,7 +123,8 @@ export class DeploymentListPresenter { u."avatarUrl" AS "userAvatarUrl", wd."builtAt", wd."deployedAt", - wd."type" + wd."type", + wd."git" FROM ${sqlDatabaseSchema}."WorkerDeployment" as wd INNER JOIN @@ -164,6 +171,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; avatarUrl: deployment.userAvatarUrl, } : undefined, + git: processGitMetadata(deployment.git), }; }), }; diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index 7c29aecbe4..b8e2c00a68 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -10,6 +10,7 @@ import { type Project } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { type User } from "~/models/user.server"; import { getUsername } from "~/utils/username"; +import { processGitMetadata } from "./BranchesPresenter.server"; export type ErrorData = { name: string; @@ -98,6 +99,7 @@ export class DeploymentPresenter { builtAt: true, deployedAt: true, createdAt: true, + git: true, promotions: { select: { label: true, @@ -162,6 +164,7 @@ export class DeploymentPresenter { errorData: DeploymentPresenter.prepareErrorData(deployment.errorData), isBuilt: !!deployment.builtAt, type: deployment.type, + git: processGitMetadata(deployment.git), }, }; } diff --git a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts index a1d9573c98..5b0881b6d1 100644 --- a/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EditSchedulePresenter.server.ts @@ -50,6 +50,7 @@ export class EditSchedulePresenter { }, }, }, + branchName: true, }, }, }, @@ -85,11 +86,17 @@ export class EditSchedulePresenter { }) : []; - const possibleEnvironments = filterOrphanedEnvironments(project.environments).map( - (environment) => { - return displayableEnvironment(environment, userId); - } - ); + const possibleEnvironments = filterOrphanedEnvironments(project.environments) + .map((environment) => { + return { + ...displayableEnvironment(environment, userId), + branchName: environment.branchName ?? undefined, + }; + }) + .filter((env) => { + if (env.type === "PREVIEW" && !env.branchName) return false; + return true; + }); return { possibleTasks: possibleTasks.map((task) => task.slug).sort(), diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index f03121ae83..730591f4eb 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -71,6 +71,8 @@ export class EnvironmentVariablesPresenter { select: { id: true, type: true, + isBranchableEnvironment: true, + branchName: true, orgMember: { select: { userId: true, @@ -81,6 +83,7 @@ export class EnvironmentVariablesPresenter { project: { slug: projectSlug, }, + archivedAt: null, }, }); @@ -109,7 +112,7 @@ export class EnvironmentVariablesPresenter { { id: environmentVariable.id, key: environmentVariable.key, - environment: { type: env.type, id: env.id }, + environment: { type: env.type, id: env.id, branchName: env.branchName }, value: isSecret ? "" : val.value, isSecret, }, @@ -120,6 +123,8 @@ export class EnvironmentVariablesPresenter { environments: sortedEnvironments.map((environment) => ({ id: environment.id, type: environment.type, + isBranchableEnvironment: environment.isBranchableEnvironment, + branchName: environment.branchName, })), hasStaging: environments.some((environment) => environment.type === "STAGING"), }; diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index 7279156391..bd210351ca 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -35,6 +35,7 @@ export type ScheduleListItem = { id: string; type: RuntimeEnvironmentType; userName?: string; + branchName?: string; }[]; }; export type ScheduleList = Awaited>; @@ -67,6 +68,7 @@ export class ScheduleListPresenter extends BasePresenter { id: true, type: true, slug: true, + branchName: true, orgMember: { select: { user: { @@ -269,7 +271,10 @@ export class ScheduleListPresenter extends BasePresenter { ); } - return displayableEnvironment(environment, userId); + return { + ...displayableEnvironment(environment, userId), + branchName: environment.branchName ?? undefined, + }; }), }; }); diff --git a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts index cd157651be..9491e85130 100644 --- a/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ViewSchedulePresenter.server.ts @@ -53,6 +53,7 @@ export class ViewSchedulePresenter { }, }, }, + branchName: true, }, }, }, @@ -91,7 +92,10 @@ export class ViewSchedulePresenter { runs, environments: schedule.instances.map((instance) => { const environment = instance.environment; - return displayableEnvironment(environment, userId); + return { + ...displayableEnvironment(environment, userId), + branchName: environment.branchName ?? undefined, + }; }), }, }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx index 01c296fb22..acbf29c4f3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx @@ -1,33 +1,41 @@ -import { ArrowUpCircleIcon, BookOpenIcon, InformationCircleIcon } from "@heroicons/react/20/solid"; +import { BookOpenIcon } from "@heroicons/react/20/solid"; import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { CodeBlock } from "~/components/code/CodeBlock"; import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentCombo, environmentFullTitle } from "~/components/environments/EnvironmentLabel"; +import { + EnvironmentCombo, + environmentFullTitle, + environmentTextClassName, +} from "~/components/environments/EnvironmentLabel"; import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "~/components/primitives/Accordion"; import { LinkButton } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; import { ClipboardField } from "~/components/primitives/ClipboardField"; -import { DateTime } from "~/components/primitives/DateTime"; -import { InfoPanel } from "~/components/primitives/InfoPanel"; +import { Header2 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { - Table, - TableBody, - TableCell, - TableCellMenu, - TableHeader, - TableHeaderCell, - TableRow, -} from "~/components/primitives/Table"; -import { TextLink } from "~/components/primitives/TextLink"; import { useOrganization } from "~/hooks/useOrganizations"; import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; export const meta: MetaFunction = () => { return [ @@ -39,18 +47,18 @@ export const meta: MetaFunction = () => { export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { projectParam } = ProjectParamSchema.parse(params); + const { projectParam, envParam } = EnvironmentParamSchema.parse(params); try { const presenter = new ApiKeysPresenter(); - const { environments, hasStaging } = await presenter.call({ + const { environment } = await presenter.call({ userId, projectSlug: projectParam, + environmentSlug: envParam, }); return typedjson({ - environments, - hasStaging, + environment, }); } catch (error) { console.error(error); @@ -62,9 +70,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { environments, hasStaging } = useTypedLoaderData(); + const { environment } = useTypedLoaderData(); const organization = useOrganization(); + if (!environment) { + throw new Response(undefined, { + status: 404, + statusText: "Environment not found", + }); + } + + let envBlock = `TRIGGER_SECRET_KEY="${environment.apiKey}"`; + if (environment.branchName) { + envBlock += `\nTRIGGER_PREVIEW_BRANCH="${environment.branchName}"`; + } + return ( @@ -72,12 +92,10 @@ export default function Page() { - {environments.map((environment) => ( - - {environment.slug} - {environment.id} - - ))} + + {environment.slug} + {environment.id} + @@ -90,83 +108,86 @@ export default function Page() { - -
- - - - Environment - Secret key - Key generated - Latest version - Actions - - - - {environments.map((environment) => ( - - - - - - - - - - - {environment.latestVersion ?? "–"} - - } - > - - ))} - {!hasStaging && ( - - - - - - - Upgrade to get staging environment - - - - - - + + +
+ -
+ > + + API keys + +
+
+ +
+ + +
+ + + Set this as your TRIGGER_SECRET_KEY{" "} + env var in your backend. + +
+ {environment.branchName && ( + + + + + Set this as your{" "} + TRIGGER_PREVIEW_BRANCH env var in + your backend. + + + )} + {environment.type === "DEVELOPMENT" && ( + + Every team member gets their own dev Secret key. Make sure you're using the one + above otherwise you will trigger runs on your team member's machine. + + )} -
- - - Set your Secret keys in your backend - by adding TRIGGER_SECRET_KEY env var in order to{" "} - trigger tasks. - - + + + How to set these environment variables + +
+
+ You need to set these environment variables in your backend. This allows the + SDK to authenticate with Trigger.dev. +
+ +
+
+
+
-
+
); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx new file mode 100644 index 0000000000..7726e6a9b1 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route.tsx @@ -0,0 +1,613 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { + ArrowRightIcon, + ArrowUpCircleIcon, + CheckIcon, + MagnifyingGlassIcon, + PlusIcon, +} from "@heroicons/react/20/solid"; +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useActionData, useLocation, useSearchParams } from "@remix-run/react"; +import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { GitMeta } from "@trigger.dev/core/v3"; +import { useCallback, useEffect, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { BranchesNoBranchableEnvironment, BranchesNoBranches } from "~/components/BlankStatePanels"; +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"; +import { CopyableText } from "~/components/primitives/CopyableText"; +import { DateTime } from "~/components/primitives/DateTime"; +import { + Dialog, + DialogContent, + DialogFooter, + 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 { Header3 } from "~/components/primitives/Headers"; +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 { 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 { useThrottle } from "~/hooks/useThrottle"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { BranchesPresenter } from "~/presenters/v3/BranchesPresenter.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { UpsertBranchService } from "~/services/upsertBranch.server"; +import { cn } from "~/utils/cn"; +import { branchesPath, docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { ArchiveButton } from "../resources.branches.archive"; + +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(), +}); + +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, + ...options, + }); + + return typedjson(result); + } catch (error) { + logger.error("Error loading preview branches page", { error }); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export const CreateBranchOptions = z.object({ + parentEnvironmentId: z.string(), + branchName: z.string().min(1), + git: GitMeta.optional(), +}); + +export type CreateBranchOptions = z.infer; + +export const schema = CreateBranchOptions.and( + z.object({ + failurePath: z.string(), + }) +); + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return redirectWithErrorMessage("/", request, "Invalid form data"); + } + + const upsertBranchService = new UpsertBranchService(); + const result = await upsertBranchService.call(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); +} + +export default function Page() { + const { + branchableEnvironment, + branches, + hasFilters, + limits, + currentPage, + totalPages, + hasBranches, + } = useTypedLoaderData(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const plan = useCurrentPlan(); + const requiresUpgrade = + plan?.v3Subscription?.plan && + limits.used >= plan.v3Subscription.plan.limits.branches.number && + !plan.v3Subscription.plan.limits.branches.canExceed; + const canUpgrade = + plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.branches.canExceed; + + if (!branchableEnvironment) { + return ( + + + Preview branches} /> + + + + + + + + ); + } + + return ( + + + Preview branches} /> + + + + {branches.map((branch) => ( + + {branch.branchName} + {branch.id} + + ))} + + + + + Branches docs + + + {limits.isAtLimit ? ( + + ) : ( + + New branch + + } + parentEnvironment={branchableEnvironment} + /> + )} + + + +
+ {!hasBranches ? ( + + + + ) : ( + <> +
+ +
+ +
+
+ +
1 ? "grid-rows-[1fr_auto]" : "grid-rows-[1fr]" + )} + > + + + + Branch + Created + Git + Archived + + Actions + + + + + {branches.length === 0 ? ( + + There are no matches for your filters + + ) : ( + branches.map((branch) => { + const path = branchesPath(organization, project, branch); + const cellClass = branch.archivedAt ? "opacity-50" : ""; + const isSelected = branch.id === environment.id; + + return ( + + +
+ + + {isSelected && Current} +
+
+ + + + +
+ +
+
+ + {branch.archivedAt ? ( + + ) : ( + "–" + )} + + + ) + } + popoverContent={ + !isSelected || !branch.archivedAt ? ( + <> + {isSelected ? null : ( + + )} + {!branch.archivedAt ? ( + + ) : null} + + ) : null + } + /> +
+ ); + }) + )} +
+
+
1 && "justify-end border-t border-grid-dimmed px-2 py-3" + )} + > + +
+
+ +
+
+ + + + + +
+ } + content={`${Math.round((limits.used / limits.limit) * 100)}%`} + /> +
+ {requiresUpgrade ? ( + + You've used all {limits.limit} of your branches. Archive one or upgrade your + plan to enable more. + + ) : ( +
+ + You've used {limits.used}/{limits.limit} of your branches + + +
+ )} + + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} +
+
+
+ + )} + +
+
+ ); +} + +export function BranchFilters() { + const [searchParams, setSearchParams] = useSearchParams(); + const { search, showArchived, page } = BranchesOptions.parse( + Object.fromEntries(searchParams.entries()) + ); + + const handleFilterChange = useCallback((filterType: string, value: string | undefined) => { + setSearchParams((s) => { + if (value) { + searchParams.set(filterType, value); + } else { + searchParams.delete(filterType); + } + searchParams.delete("page"); + return searchParams; + }); + }, []); + + const handleArchivedChange = useCallback((checked: boolean) => { + handleFilterChange("showArchived", checked ? "true" : undefined); + }, []); + + const handleSearchChange = useThrottle((value: string) => { + handleFilterChange("search", value.length === 0 ? undefined : value); + }, 300); + + return ( +
+ handleSearchChange(e.target.value)} + /> + + +
+ ); +} + +function UpgradePanel({ + limits, + canUpgrade, +}: { + limits: { + used: number; + limit: number; + }; + canUpgrade: boolean; +}) { + const organization = useOrganization(); + + return ( + + + + + + You've exceeded your limit +
+ + You've used {limits.used}/{limits.limit} of your branches. + + You can archive one or upgrade your plan for more. +
+ + {canUpgrade ? ( + + Upgrade + + ) : ( + Request more} + defaultValue="help" + /> + )} + +
+
+ ); +} + +export function NewBranchPanel({ + button, + parentEnvironment, +}: { + button: React.ReactNode; + parentEnvironment: { id: string }; +}) { + const lastSubmission = useActionData(); + 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 ( + + {button} + + New branch +
+
+
+ + + + + + + Must not contain: spaces ~{" "} + ^{" "} + :{" "} + ?{" "} + *{" "} + {"["}{" "} + \{" "} + //{" "} + ..{" "} + {"@{"}{" "} + .lock + + {branchName.error} + + {form.error} + + Create branch + + } + cancelButton={ + + + + } + /> +
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 1ee6599513..a245e8d9d8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -2,6 +2,7 @@ import { Link, useLocation } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { GitMetadata } from "~/components/GitMetadata"; import { UserAvatar } from "~/components/UserProfilePhoto"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; @@ -198,6 +199,15 @@ export default function Page() { )} + + + Git + +
+ +
+
+
Deployed by diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index ef8ababe36..701e6843f9 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -53,6 +53,7 @@ import { createSearchParams } from "~/utils/searchParams"; import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus"; import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions"; import { useEffect } from "react"; +import { GitMetadata } from "~/components/GitMetadata"; export const meta: MetaFunction = () => { return [ @@ -193,6 +194,7 @@ export default function Page() { Tasks Deployed at Deployed by + Git Go to page @@ -256,6 +258,11 @@ export default function Page() { "–" )} + +
+ +
+
{ const userId = await requireUserId(request); @@ -186,6 +187,15 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState>(new Set()); + const [selectedBranchId, setSelectedBranchId] = useState(undefined); + + const branchEnvironments = environments.filter((env) => env.branchName); + const nonBranchEnvironments = environments.filter((env) => !env.branchName); + const selectedEnvironments = environments.filter((env) => selectedEnvironmentIds.has(env.id)); + const previewIsSelected = selectedEnvironments.some( + (env) => env.branchName !== null || env.type === "PREVIEW" + ); const isLoading = navigation.state !== "idle" && navigation.formMethod === "post"; @@ -202,6 +212,45 @@ export default function Page() { }, }); + const handleEnvironmentChange = ( + environmentId: string, + isChecked: boolean, + environmentType?: string + ) => { + setSelectedEnvironmentIds((prev) => { + const newSet = new Set(prev); + + if (isChecked) { + if (environmentType === "PREVIEW") { + // If PREVIEW is checked, clear all other selections including branches + newSet.clear(); + + newSet.add(environmentId); + } else { + // If a non-PREVIEW environment is checked, remove PREVIEW if it's selected + const previewEnv = environments.find((env) => env.type === "PREVIEW"); + if (previewEnv) { + newSet.delete(previewEnv.id); + } + newSet.add(environmentId); + setSelectedBranchId(undefined); + } + } else { + newSet.delete(environmentId); + } + + return newSet; + }); + }; + + const handleBranchChange = (branchId: string) => { + if (branchId === "all") { + setSelectedBranchId(undefined); + } else { + setSelectedBranchId(branchId); + } + }; + const [revealAll, setRevealAll] = useState(true); useEffect(() => { @@ -223,36 +272,70 @@ export default function Page() {
+ {selectedBranchId ? ( + + ) : ( + Array.from(selectedEnvironmentIds).map((id) => ( + + )) + )}
- {environments.map((environment) => ( + {nonBranchEnvironments.map((environment) => ( + handleEnvironmentChange(environment.id, isChecked, environment.type) + } label={} variant="button" /> ))} {!hasStaging && ( - - - - - - - - - - - Upgrade your plan to add a Staging environment. - - - + <> + + + + + + + + + + + Upgrade your plan to add a Staging environment. + + + + + + + + + + + + + + Upgrade your plan to add Preview branches. + + + + )}
{environmentIds.error} @@ -261,6 +344,49 @@ export default function Page() { file when running locally.
+ + {previewIsSelected && ( + + +
+ + {selectedBranchId !== "all" && selectedBranchId !== undefined && ( +
+ Select a branch to override variables in the Preview environment. +
+ )} + - onChange({ ...value, key: e.currentTarget.value })} - autoFocus={index === 0} - onPaste={onPaste} - /> -
+
onChange({ ...value, value: e.currentTarget.value })} + id={`${formId}-${baseFieldName}.key`} + name={`${baseFieldName}.key`} + placeholder="e.g. CLIENT_KEY" + value={value.key} + onChange={(e) => onChange({ ...value, key: e.currentTarget.value })} + autoFocus={index === 0} + onPaste={onPaste} /> + {fields.key.error} +
+
+
+ onChange({ ...value, value: e.currentTarget.value })} + /> + {fields.value.error} +
{showDeleteButton && (
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 1258c58d4d..edef3d3060 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -4,6 +4,7 @@ import { BookOpenIcon, InformationCircleIcon, LockClosedIcon, + MagnifyingGlassIcon, PencilSquareIcon, PlusIcon, TrashIcon, @@ -18,7 +19,6 @@ import { import { useMemo, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { InlineCode } from "~/components/code/InlineCode"; import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -34,6 +34,7 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; import { Switch } from "~/components/primitives/Switch"; import { Table, @@ -47,6 +48,7 @@ import { import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { prisma } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; +import { useFuzzyFilter } from "~/hooks/useFuzzyFilter"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { redirectWithSuccessMessage } from "~/models/message.server"; @@ -67,6 +69,7 @@ import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/enviro import { DeleteEnvironmentVariableValue, EditEnvironmentVariableValue, + EnvironmentVariable, } from "~/v3/environmentVariables/repository"; export const meta: MetaFunction = () => { @@ -198,6 +201,11 @@ export default function Page() { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); + const { filterText, setFilterText, filteredItems } = + useFuzzyFilter({ + items: environmentVariables, + keys: ["key", "value"], + }); // Add isFirst and isLast to each environment variable // They're set based on if they're the first or last time that `key` has been seen in the list @@ -206,7 +214,7 @@ export default function Page() { const keyOccurrences = new Map(); // First pass: count total occurrences of each key - environmentVariables.forEach((variable) => { + filteredItems.forEach((variable) => { keyOccurrences.set(variable.key, (keyOccurrences.get(variable.key) || 0) + 1); }); @@ -214,7 +222,7 @@ export default function Page() { const seenKeys = new Set(); const currentOccurrences = new Map(); - return environmentVariables.map((variable) => { + return filteredItems.map((variable) => { // Track current occurrence number for this key const currentCount = (currentOccurrences.get(variable.key) || 0) + 1; currentOccurrences.set(variable.key, currentCount); @@ -234,7 +242,7 @@ export default function Page() { occurences: totalOccurrences, }; }); - }, [environmentVariables]); + }, [filteredItems]); return ( @@ -253,24 +261,35 @@ export default function Page() {
{environmentVariables.length > 0 && ( -
- setRevealAll(e.valueOf())} +
+ setFilterText(e.target.value)} + autoFocus /> - - Add new - +
+ setRevealAll(e.valueOf())} + /> + + Add new + +
)} - +
Key @@ -354,17 +373,23 @@ export default function Page() { ) : ( -
- You haven't set any environment variables yet. - - Add new - -
+ {environmentVariables.length === 0 ? ( +
+ You haven't set any environment variables yet. + + Add new + +
+ ) : ( +
+ No variables match your search. +
+ )}
)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx index 24446a7eea..8ab4b24ba4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues/route.tsx @@ -168,6 +168,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const redirectPath = `/orgs/${organizationSlug}/projects/${projectParam}/env/${envParam}/queues?page=${page}`; + if (environment.archivedAt) { + return redirectWithErrorMessage(redirectPath, request, "This branch is archived"); + } + switch (action) { case "environment-pause": const pauseService = new PauseEnvironmentService(); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx index 24d9cbe670..db6f641f5d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.settings/route.tsx @@ -1,6 +1,6 @@ import { conform, useForm } from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { FolderIcon } from "@heroicons/react/20/solid"; +import { ExclamationTriangleIcon, FolderIcon, TrashIcon } from "@heroicons/react/20/solid"; import { Form, type MetaFunction, useActionData, useNavigation } from "@remix-run/react"; import { type ActionFunction, json } from "@remix-run/server-runtime"; import { z } from "zod"; @@ -272,7 +272,7 @@ export default function Page() { {projectSlug.error} {deleteForm.error} @@ -287,7 +287,7 @@ export default function Page() { + + + Archive "{environment.branchName}" +
+
+ + + + This will permanently make this branch{" "} + read-only. You won't be able to trigger + runs, execute runs, or use the API for this branch. + + + You will still be able to view the branch and its associated runs. + + + Once archived you can create a new branch with the same name. + + {form.error} + + Archive branch + + } + cancelButton={ + + + + } + /> + +
+
+ + ); +} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index 1d96293d0d..4871a2d0b7 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -195,6 +195,10 @@ const pricingDefinitions = { content: "Realtime allows you to send the live status and data from your runs to your frontend. This is the number of simultaneous Realtime connections that can be made.", }, + branches: { + title: "Branches", + content: "The number of preview branches that can be active (you can archive old ones).", + }, }; type PricingPlansProps = { @@ -495,6 +499,7 @@ export function TierFree({ + @@ -609,7 +614,9 @@ export function TierHobby({ tasks - + + + @@ -726,6 +733,7 @@ export function TierPro({ + @@ -938,7 +946,7 @@ function TeamMembers({ limits }: { limits: Limits }) { function Environments({ limits }: { limits: Limits }) { return ( - {limits.hasStagingEnvironment ? "Dev, Staging and Prod" : "Dev and Prod"}{" "} + {limits.hasStagingEnvironment ? "Dev, Preview and Prod" : "Dev and Prod"}{" "} ); } + +function Branches({ limits }: { limits: Limits }) { + return ( + 0}> + {limits.branches.number} + {limits.branches.canExceed ? "+ " : " "} + + preview branches + + + ); +} diff --git a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts index 0fcaf0981f..431cc9f34c 100644 --- a/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts +++ b/apps/webapp/app/routes/resources.taskruns.$runParam.replay.ts @@ -32,6 +32,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { id: true, type: true, slug: true, + branchName: true, orgMember: { select: { user: true, @@ -39,6 +40,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }, }, where: { + archivedAt: null, OR: [ { type: { @@ -72,10 +74,21 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return typedjson({ payload: await prettyPrintPacket(run.payload, run.payloadType), payloadType: run.payloadType, - environment: displayableEnvironment(environment, userId), + environment: { + ...displayableEnvironment(environment, userId), + branchName: environment.branchName ?? undefined, + }, environments: sortEnvironments( - run.project.environments.map((environment) => displayableEnvironment(environment, userId)) - ), + run.project.environments.map((environment) => { + return { + ...displayableEnvironment(environment, userId), + branchName: environment.branchName ?? undefined, + }; + }) + ).filter((env) => { + if (env.type === "PREVIEW" && !env.branchName) return false; + return true; + }), }); } diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 03c5f128ee..bed78809b6 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -1,23 +1,23 @@ import { json } from "@remix-run/server-runtime"; -import { Prettify } from "@trigger.dev/core"; +import { type Prettify } from "@trigger.dev/core"; import { SignJWT, errors, jwtVerify } from "jose"; import { z } from "zod"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import { findProjectByRef } from "~/models/project.server"; import { - RuntimeEnvironment, findEnvironmentByApiKey, findEnvironmentByPublicApiKey, } from "~/models/runtimeEnvironment.server"; +import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; import { - PersonalAccessTokenAuthenticationResult, + type PersonalAccessTokenAuthenticationResult, authenticateApiRequestWithPersonalAccessToken, isPersonalAccessToken, } from "./personalAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; -import { RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { sanitizeBranchName } from "~/v3/gitBranch"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -57,13 +57,13 @@ export async function authenticateApiRequest( request: Request, options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} ): Promise { - const apiKey = getApiKeyFromRequest(request); + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } - const authentication = await authenticateApiKey(apiKey, options); + const authentication = await authenticateApiKey(apiKey, { ...options, branchName }); return authentication; } @@ -76,7 +76,7 @@ export async function authenticateApiRequestWithFailure( request: Request, options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} ): Promise { - const apiKey = getApiKeyFromRequest(request); + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return { @@ -85,7 +85,7 @@ export async function authenticateApiRequestWithFailure( }; } - const authentication = await authenticateApiKeyWithFailure(apiKey, options); + const authentication = await authenticateApiKeyWithFailure(apiKey, { ...options, branchName }); return authentication; } @@ -95,7 +95,7 @@ export async function authenticateApiRequestWithFailure( */ export async function authenticateApiKey( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} + options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -113,7 +113,7 @@ export async function authenticateApiKey( switch (result.type) { case "PUBLIC": { - const environment = await findEnvironmentByPublicApiKey(result.apiKey); + const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName); if (!environment) { return; } @@ -125,7 +125,7 @@ export async function authenticateApiKey( }; } case "PRIVATE": { - const environment = await findEnvironmentByApiKey(result.apiKey); + const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName); if (!environment) { return; } @@ -160,9 +160,9 @@ export async function authenticateApiKey( * This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined. * It should be used from now on to ensure that the API key is always validated and provide a failure result. */ -export async function authenticateApiKeyWithFailure( +async function authenticateApiKeyWithFailure( apiKey: string, - options: { allowPublicKey?: boolean; allowJWT?: boolean } = {} + options: { allowPublicKey?: boolean; allowJWT?: boolean; branchName?: string } = {} ): Promise { const result = getApiKeyResult(apiKey); @@ -189,7 +189,7 @@ export async function authenticateApiKeyWithFailure( switch (result.type) { case "PUBLIC": { - const environment = await findEnvironmentByPublicApiKey(result.apiKey); + const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName); if (!environment) { return { ok: false, @@ -204,7 +204,7 @@ export async function authenticateApiKeyWithFailure( }; } case "PRIVATE": { - const environment = await findEnvironmentByApiKey(result.apiKey); + const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName); if (!environment) { return { ok: false, @@ -254,19 +254,29 @@ export async function authenticateAuthorizationHeader( return authenticateApiKey(apiKey, { allowPublicKey, allowJWT }); } -export function isPublicApiKey(key: string) { +function isPublicApiKey(key: string) { return key.startsWith("pk_"); } -export function isSecretApiKey(key: string) { +function isSecretApiKey(key: string) { return key.startsWith("tr_"); } -export function getApiKeyFromRequest(request: Request) { - return getApiKeyFromHeader(request.headers.get("Authorization")); +export function branchNameFromRequest(request: Request): string | undefined { + return request.headers.get("x-trigger-branch") ?? undefined; } -export function getApiKeyFromHeader(authorization?: string | null) { +function getApiKeyFromRequest(request: Request): { + apiKey: string | undefined; + branchName: string | undefined; +} { + const apiKey = getApiKeyFromHeader(request.headers.get("Authorization")); + const branchName = branchNameFromRequest(request); + + return { apiKey, branchName }; +} + +function getApiKeyFromHeader(authorization?: string | null) { if (typeof authorization !== "string" || !authorization) { return; } @@ -275,7 +285,7 @@ export function getApiKeyFromHeader(authorization?: string | null) { return apiKey; } -export function getApiKeyResult(apiKey: string): { +function getApiKeyResult(apiKey: string): { apiKey: string; type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT"; } { @@ -302,7 +312,7 @@ export type DualAuthenticationResult = export async function authenticateProjectApiKeyOrPersonalAccessToken( request: Request ): Promise { - const apiKey = getApiKeyFromRequest(request); + const { apiKey, branchName } = getApiKeyFromRequest(request); if (!apiKey) { return; } @@ -320,7 +330,7 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken( }; } - const result = await authenticateApiKey(apiKey, { allowPublicKey: false }); + const result = await authenticateApiKey(apiKey, { allowPublicKey: false, branchName }); if (!result) { return; @@ -335,7 +345,8 @@ export async function authenticateProjectApiKeyOrPersonalAccessToken( export async function authenticatedEnvironmentForAuthentication( auth: DualAuthenticationResult, projectRef: string, - slug: string + slug: string, + branch?: string ): Promise { if (slug === "staging") { slug = "stg"; @@ -357,7 +368,7 @@ export async function authenticatedEnvironmentForAuthentication( ); } - if (auth.result.environment.slug !== slug) { + if (auth.result.environment.slug !== slug && auth.result.environment.branchName !== branch) { throw json( { error: @@ -386,22 +397,53 @@ export async function authenticatedEnvironmentForAuthentication( throw json({ error: "Project not found" }, { status: 404 }); } + if (!branch) { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + projectId: project.id, + slug: slug, + }, + include: { + project: true, + organization: true, + }, + }); + + if (!environment) { + throw json({ error: "Environment not found" }, { status: 404 }); + } + + return environment; + } + const environment = await prisma.runtimeEnvironment.findFirst({ where: { projectId: project.id, slug: slug, + branchName: sanitizeBranchName(branch), + archivedAt: null, }, include: { project: true, organization: true, + parentEnvironment: true, }, }); if (!environment) { - throw json({ error: "Environment not found" }, { status: 404 }); + throw json({ error: "Branch not found" }, { status: 404 }); } - return environment; + if (!environment.parentEnvironment) { + throw json({ error: "Branch not associated with a preview environment" }, { status: 400 }); + } + + return { + ...environment, + apiKey: environment.parentEnvironment.apiKey, + organization: environment.organization, + project: environment.project, + }; } } } diff --git a/apps/webapp/app/services/archiveBranch.server.ts b/apps/webapp/app/services/archiveBranch.server.ts new file mode 100644 index 0000000000..e0ff0e1174 --- /dev/null +++ b/apps/webapp/app/services/archiveBranch.server.ts @@ -0,0 +1,72 @@ +import { type PrismaClient } from "@trigger.dev/database"; +import { prisma } from "~/db.server"; +import { logger } from "./logger.server"; +import { nanoid } from "nanoid"; + +export class ArchiveBranchService { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call(userId: string, { environmentId }: { environmentId: string }) { + try { + const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({ + where: { + id: environmentId, + organization: { + members: { + some: { + userId: userId, + }, + }, + }, + }, + include: { + organization: { + select: { + id: true, + slug: true, + maximumConcurrencyLimit: true, + }, + }, + project: { + select: { + id: true, + slug: true, + }, + }, + }, + }); + + if (!environment.parentEnvironmentId) { + return { + success: false as const, + error: "This isn't a branch, and cannot be archived.", + }; + } + + const slug = `${environment.slug}-${nanoid(6)}`; + const shortcode = slug; + + const updatedBranch = await this.#prismaClient.runtimeEnvironment.update({ + where: { id: environmentId }, + data: { archivedAt: new Date(), slug, shortcode }, + }); + + return { + success: true as const, + branch: updatedBranch, + organization: environment.organization, + project: environment.project, + }; + } catch (e) { + logger.error("ArchiveBranchService error", { environmentId, error: e }); + return { + success: false as const, + error: "Failed to archive branch", + }; + } + } +} diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index c4638fbff1..f12270c1e6 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -346,13 +346,25 @@ export async function getEntitlement(organizationId: string) { } export async function projectCreated(organization: Organization, project: Project) { - if (project.version === "V2" || !isCloud()) { - await createEnvironment(organization, project, "STAGING"); + if (!isCloud()) { + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); } else { //staging is only available on certain plans const plan = await getCurrentPlan(organization.id); if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) { - await createEnvironment(organization, project, "STAGING"); + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); } } } diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts index 9a6082f588..d5950d99d3 100644 --- a/apps/webapp/app/services/realtime/jwtAuth.server.ts +++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts @@ -33,7 +33,10 @@ export async function validatePublicJwtKey(token: string): Promise= limit, + }; +} diff --git a/apps/webapp/app/utils/cn.ts b/apps/webapp/app/utils/cn.ts index dc64e862cf..d33fe61b52 100644 --- a/apps/webapp/app/utils/cn.ts +++ b/apps/webapp/app/utils/cn.ts @@ -10,6 +10,7 @@ const customTwMerge = extendTailwindMerge({ "xs", "sm", "2sm", + "base", "md", "lg", "xl", diff --git a/apps/webapp/app/utils/environmentSort.ts b/apps/webapp/app/utils/environmentSort.ts index 4ed749bf64..00c7a33580 100644 --- a/apps/webapp/app/utils/environmentSort.ts +++ b/apps/webapp/app/utils/environmentSort.ts @@ -2,8 +2,8 @@ import { type RuntimeEnvironmentType } from "@trigger.dev/database"; const environmentSortOrder: RuntimeEnvironmentType[] = [ "DEVELOPMENT", - "PREVIEW", "STAGING", + "PREVIEW", "PRODUCTION", ]; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index c36204f7a1..4a48b3a4b8 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -415,6 +415,14 @@ export function v3DeploymentVersionPath( return `${v3DeploymentsPath(organization, project, environment)}?version=${version}`; } +export function branchesPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/branches`; +} + export function v3BillingPath(organization: OrgForPath, message?: string) { return `${organizationPath(organization)}/settings/billing${ message ? `?message=${encodeURIComponent(message)}` : "" diff --git a/apps/webapp/app/v3/deduplicateVariableArray.server.ts b/apps/webapp/app/v3/deduplicateVariableArray.server.ts new file mode 100644 index 0000000000..886ec7a440 --- /dev/null +++ b/apps/webapp/app/v3/deduplicateVariableArray.server.ts @@ -0,0 +1,14 @@ +import { type EnvironmentVariable } from "./environmentVariables/repository"; + +/** Later variables override earlier ones */ +export function deduplicateVariableArray(variables: EnvironmentVariable[]) { + const result: EnvironmentVariable[] = []; + // Process array in reverse order so later variables override earlier ones + for (const variable of [...variables].reverse()) { + if (!result.some((v) => v.key === variable.key)) { + result.push(variable); + } + } + // Reverse back to maintain original order but with later variables taking precedence + return result.reverse(); +} diff --git a/apps/webapp/app/v3/environmentVariableRules.server.ts b/apps/webapp/app/v3/environmentVariableRules.server.ts new file mode 100644 index 0000000000..ddaee2b249 --- /dev/null +++ b/apps/webapp/app/v3/environmentVariableRules.server.ts @@ -0,0 +1,40 @@ +import { type EnvironmentVariable } from "./environmentVariables/repository"; + +type VariableRule = + | { type: "exact"; key: string } + | { type: "prefix"; prefix: string } + | { type: "whitelist"; key: string }; + +const blacklistedVariables: VariableRule[] = [ + { type: "exact", key: "TRIGGER_SECRET_KEY" }, + { type: "exact", key: "TRIGGER_API_URL" }, + { type: "prefix", prefix: "OTEL_" }, + { type: "whitelist", key: "OTEL_LOG_LEVEL" }, +]; + +export function removeBlacklistedVariables( + variables: EnvironmentVariable[] +): EnvironmentVariable[] { + return variables.filter((v) => { + const whitelisted = blacklistedVariables.find( + (bv) => bv.type === "whitelist" && bv.key === v.key + ); + if (whitelisted) { + return true; + } + + const exact = blacklistedVariables.find((bv) => bv.type === "exact" && bv.key === v.key); + if (exact) { + return false; + } + + const prefix = blacklistedVariables.find( + (bv) => bv.type === "prefix" && v.key.startsWith(bv.prefix) + ); + if (prefix) { + return false; + } + + return true; + }); +} diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index fdcbdc7e08..4462a7e15d 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -1,25 +1,22 @@ -import { - Prisma, - PrismaClient, - RuntimeEnvironment, - RuntimeEnvironmentType, -} from "@trigger.dev/database"; +import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { z } from "zod"; -import { environmentFullTitle, environmentTitle } from "~/components/environments/EnvironmentLabel"; +import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { $transaction, prisma } from "~/db.server"; import { env } from "~/env.server"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { - CreateResult, - DeleteEnvironmentVariable, - DeleteEnvironmentVariableValue, - EnvironmentVariable, - EnvironmentVariableWithSecret, - ProjectEnvironmentVariable, - Repository, - Result, + type CreateResult, + type DeleteEnvironmentVariable, + type DeleteEnvironmentVariableValue, + type EnvironmentVariable, + type EnvironmentVariableWithSecret, + type ProjectEnvironmentVariable, + type Repository, + type Result, } from "./repository"; +import { removeBlacklistedVariables } from "../environmentVariableRules.server"; +import { deduplicateVariableArray } from "../deduplicateVariableArray.server"; function secretKeyProjectPrefix(projectId: string) { return `environmentvariable:${projectId}:`; @@ -94,14 +91,18 @@ export class EnvironmentVariablesRepository implements Repository { } // Remove `TRIGGER_SECRET_KEY` or `TRIGGER_API_URL` - let values = options.variables.filter( - (v) => v.key !== "TRIGGER_SECRET_KEY" && v.key !== "TRIGGER_API_URL" - ); + let values = removeBlacklistedVariables(options.variables); + const removedBlacklisted = values.length !== options.variables.length; //get rid of empty variables values = values.filter((v) => v.key.trim() !== "" && v.value.trim() !== ""); if (values.length === 0) { - return { success: false as const, error: `You must set at least one value` }; + return { + success: false as const, + error: `You must set at least one valid variable.${ + removedBlacklisted ? " All the variables submitted are not allowed." : "" + }`, + }; } //check if any of them exist in an environment we're setting @@ -512,14 +513,17 @@ export class EnvironmentVariablesRepository implements Repository { async getEnvironmentWithRedactedSecrets( projectId: string, - environmentId: string + environmentId: string, + parentEnvironmentId?: string ): Promise { - const variables = await this.getEnvironment(projectId, environmentId); + const variables = await this.getEnvironment(projectId, environmentId, parentEnvironmentId); // Get the keys of all secret variables const secretValues = await this.prismaClient.environmentVariableValue.findMany({ where: { - environmentId, + environmentId: parentEnvironmentId + ? { in: [environmentId, parentEnvironmentId] } + : environmentId, isSecret: true, }, select: { @@ -532,7 +536,7 @@ export class EnvironmentVariablesRepository implements Repository { }); const secretVarKeys = secretValues.map((r) => r.variable.key); - // Filter out secret variables if includeSecrets is false + // Filter out secret variables return variables.map((v) => { if (secretVarKeys.includes(v.key)) { return { @@ -546,7 +550,11 @@ export class EnvironmentVariablesRepository implements Repository { }); } - async getEnvironment(projectId: string, environmentId: string): Promise { + async getEnvironment( + projectId: string, + environmentId: string, + parentEnvironmentId?: string + ): Promise { const project = await this.prismaClient.project.findFirst({ where: { id: projectId, @@ -568,36 +576,57 @@ export class EnvironmentVariablesRepository implements Repository { return []; } - return this.getEnvironmentVariables(projectId, environmentId); + return this.getEnvironmentVariables(projectId, environmentId, parentEnvironmentId); } async #getSecretEnvironmentVariables( projectId: string, - environmentId: string + environmentId: string, + parentEnvironmentId?: string ): Promise { const secretStore = getSecretStore("DATABASE", { prismaClient: this.prismaClient, }); - const secrets = await secretStore.getSecrets( + const parentSecrets = parentEnvironmentId + ? await secretStore.getSecrets( + SecretValue, + secretKeyEnvironmentPrefix(projectId, parentEnvironmentId) + ) + : []; + + const childSecrets = await secretStore.getSecrets( SecretValue, secretKeyEnvironmentPrefix(projectId, environmentId) ); - return secrets.map((secret) => { - const { key } = parseSecretKey(secret.key); + // Merge the secrets, we want child ones to override parent ones + const mergedSecrets = new Map(); + for (const secret of parentSecrets) { + const { key: parsedKey } = parseSecretKey(secret.key); + mergedSecrets.set(parsedKey, secret.value.secret); + } + for (const secret of childSecrets) { + const { key: parsedKey } = parseSecretKey(secret.key); + mergedSecrets.set(parsedKey, secret.value.secret); + } + + const merged = Array.from(mergedSecrets.entries()).map(([key, value]) => { return { key, - value: secret.value.secret, + value, }; }); + + return removeBlacklistedVariables(merged); } async getEnvironmentVariables( projectId: string, - environmentId: string + environmentId: string, + parentEnvironmentId?: string ): Promise { - return this.#getSecretEnvironmentVariables(projectId, environmentId); + return this.#getSecretEnvironmentVariables(projectId, environmentId, parentEnvironmentId); } async delete(projectId: string, options: DeleteEnvironmentVariable): Promise { @@ -780,6 +809,7 @@ export const RuntimeEnvironmentForEnvRepoPayload = { projectId: true, apiKey: true, organizationId: true, + branchName: true, }, } as const; @@ -790,11 +820,13 @@ export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload< export const environmentVariablesRepository = new EnvironmentVariablesRepository(); export async function resolveVariablesForEnvironment( - runtimeEnvironment: RuntimeEnvironmentForEnvRepo + runtimeEnvironment: RuntimeEnvironmentForEnvRepo, + parentEnvironment?: RuntimeEnvironmentForEnvRepo ) { const projectSecrets = await environmentVariablesRepository.getEnvironmentVariables( runtimeEnvironment.projectId, - runtimeEnvironment.id + runtimeEnvironment.id, + parentEnvironment?.id ); const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment); @@ -802,9 +834,13 @@ export async function resolveVariablesForEnvironment( const builtInVariables = runtimeEnvironment.type === "DEVELOPMENT" ? await resolveBuiltInDevVariables(runtimeEnvironment) - : await resolveBuiltInProdVariables(runtimeEnvironment); + : await resolveBuiltInProdVariables(runtimeEnvironment, parentEnvironment); - return [...overridableTriggerVariables, ...projectSecrets, ...builtInVariables]; + return deduplicateVariableArray([ + ...overridableTriggerVariables, + ...projectSecrets, + ...builtInVariables, + ]); } async function resolveOverridableTriggerVariables( @@ -882,11 +918,14 @@ async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment return [...result, ...commonVariables]; } -async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmentForEnvRepo) { +async function resolveBuiltInProdVariables( + runtimeEnvironment: RuntimeEnvironmentForEnvRepo, + parentEnvironment?: RuntimeEnvironmentForEnvRepo +) { let result: Array = [ { key: "TRIGGER_SECRET_KEY", - value: runtimeEnvironment.apiKey, + value: parentEnvironment?.apiKey ?? runtimeEnvironment.apiKey, }, { key: "TRIGGER_API_URL", @@ -906,6 +945,15 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen }, ]; + if (runtimeEnvironment.branchName) { + result = result.concat([ + { + key: "TRIGGER_PREVIEW_BRANCH", + value: runtimeEnvironment.branchName, + }, + ]); + } + if (env.PROD_OTEL_BATCH_PROCESSING_ENABLED === "1") { result = result.concat([ { diff --git a/apps/webapp/app/v3/gitBranch.ts b/apps/webapp/app/v3/gitBranch.ts new file mode 100644 index 0000000000..109de645b9 --- /dev/null +++ b/apps/webapp/app/v3/gitBranch.ts @@ -0,0 +1,44 @@ +export function isValidGitBranchName(branch: string): boolean { + // Must not be empty + if (!branch) return false; + + // Disallowed characters: space, ~, ^, :, ?, *, [, \ + if (/[ \~\^:\?\*\[\\]/.test(branch)) return false; + + // Disallow ASCII control characters (0-31) and DEL (127) + for (let i = 0; i < branch.length; i++) { + const code = branch.charCodeAt(i); + if ((code >= 0 && code <= 31) || code === 127) return false; + } + + // Cannot start or end with a slash + if (branch.startsWith("/") || branch.endsWith("/")) return false; + + // Cannot have consecutive slashes + if (branch.includes("//")) return false; + + // Cannot contain '..' + if (branch.includes("..")) return false; + + // Cannot contain '@{' + if (branch.includes("@{")) return false; + + // Cannot end with '.lock' + if (branch.endsWith(".lock")) return false; + + return true; +} + +export function sanitizeBranchName(ref: string): string | null { + if (!ref) return null; + if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); + if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); + if (ref.startsWith("refs/tags/")) return ref.substring("refs/tags/".length); + if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length); + if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length); + if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length); + //unknown ref format, so reject + if (ref.startsWith("refs/")) return null; + + return ref; +} diff --git a/apps/webapp/app/v3/services/checkSchedule.server.ts b/apps/webapp/app/v3/services/checkSchedule.server.ts index eff6dcd3ad..d5249f6b58 100644 --- a/apps/webapp/app/v3/services/checkSchedule.server.ts +++ b/apps/webapp/app/v3/services/checkSchedule.server.ts @@ -14,7 +14,7 @@ type Schedule = { }; export class CheckScheduleService extends BaseService { - public async call(projectId: string, schedule: Schedule) { + public async call(projectId: string, schedule: Schedule, environmentIds: string[]) { //validate the cron expression try { CronPattern.parse(schedule.cron); @@ -61,28 +61,34 @@ export class CheckScheduleService extends BaseService { ); } - //if creating a schedule, check they're under the limits - if (!schedule.friendlyId) { - //check they're within their limit - const project = await this._prisma.project.findFirst({ - where: { - id: projectId, - }, - select: { - organizationId: true, - environments: { - select: { - id: true, - type: true, - }, + //check they're within their limit + const project = await this._prisma.project.findFirst({ + where: { + id: projectId, + }, + select: { + organizationId: true, + environments: { + select: { + id: true, + type: true, + archivedAt: true, }, }, - }); + }, + }); - if (!project) { - throw new ServiceValidationError("Project not found"); - } + if (!project) { + throw new ServiceValidationError("Project not found"); + } + const environments = project.environments.filter((env) => environmentIds.includes(env.id)); + if (environments.some((env) => env.archivedAt)) { + throw new ServiceValidationError("Can't add or edit a schedule for an archived branch"); + } + + //if creating a schedule, check they're under the limits + if (!schedule.friendlyId) { const limit = await getLimit(project.organizationId, "schedules", 100_000_000); const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({ prisma: this._prisma, diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 9ad4ba254b..3fa78a6c22 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -523,12 +523,16 @@ export async function syncDeclarativeSchedules( ); //this throws errors if the schedule is invalid - await checkSchedule.call(environment.projectId, { - cron: task.schedule.cron, - timezone: task.schedule.timezone, - taskIdentifier: task.id, - friendlyId: existingSchedule?.friendlyId, - }); + await checkSchedule.call( + environment.projectId, + { + cron: task.schedule.cron, + timezone: task.schedule.timezone, + taskIdentifier: task.id, + friendlyId: existingSchedule?.friendlyId, + }, + [environment.id] + ); if (existingSchedule) { const schedule = await prisma.taskSchedule.update({ diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index e99473ca9c..dfcfb3c4f8 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -1,14 +1,14 @@ -import { InitializeDeploymentRequestBody } from "@trigger.dev/core/v3"; +import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3"; +import { WorkerDeploymentType } from "@trigger.dev/database"; import { customAlphabet } from "nanoid"; -import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { env } from "~/env.server"; +import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild } from "../remoteImageBuilder.server"; import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; -import { env } from "~/env.server"; -import { WorkerDeploymentType } from "@trigger.dev/database"; -import { logger } from "~/services/logger.server"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); @@ -107,6 +107,7 @@ export class InitializeDeploymentService extends BaseService { triggeredById: triggeredBy?.id, type: payload.type, imageReference: isManaged ? undefined : unmanagedImageTag, + git: payload.gitMeta ?? undefined, }, }); diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index a521c4f435..5b7ca7098b 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -27,6 +27,10 @@ export class ReplayTaskRunService extends BaseService { return; } + if (authenticatedEnvironment.archivedAt) { + throw new Error("Can't replay a run on an archived environment"); + } + logger.info("Replaying task run", { taskRunId: existingTaskRun.id, taskRunFriendlyId: existingTaskRun.friendlyId, diff --git a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts index f20b04c3db..52e0cfab63 100644 --- a/apps/webapp/app/v3/services/triggerScheduledTask.server.ts +++ b/apps/webapp/app/v3/services/triggerScheduledTask.server.ts @@ -53,6 +53,16 @@ export class TriggerScheduledTaskService extends BaseService { return; } + if (instance.environment.archivedAt) { + logger.debug("Environment is archived, disabling schedule", { + instanceId, + scheduleId: instance.taskSchedule.friendlyId, + environmentId: instance.environment.id, + }); + + return; + } + try { let shouldTrigger = true; diff --git a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts index 46cc23fdf9..9f27bb4c70 100644 --- a/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts +++ b/apps/webapp/app/v3/services/upsertTaskSchedule.server.ts @@ -29,7 +29,7 @@ export class UpsertTaskScheduleService extends BaseService { public async call(projectId: string, schedule: UpsertTaskScheduleServiceOptions) { //this throws errors if the schedule is invalid const checkSchedule = new CheckScheduleService(this._prisma); - await checkSchedule.call(projectId, schedule); + await checkSchedule.call(projectId, schedule, schedule.environments); const deduplicationKey = typeof schedule.deduplicationKey === "string" && schedule.deduplicationKey !== "" diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts index 5c3a8d1cce..ecdb49724e 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts @@ -73,7 +73,7 @@ export class WorkerGroupTokenService extends WithRunEngine { } async rotateToken({ workerGroupId }: { workerGroupId: string }) { - const workerGroup = await this._prisma.workerInstanceGroup.findUnique({ + const workerGroup = await this._prisma.workerInstanceGroup.findFirst({ where: { id: workerGroupId, }, @@ -266,12 +266,10 @@ export class WorkerGroupTokenService extends WithRunEngine { return await $transaction(this._prisma, async (tx) => { const resourceIdentifier = deploymentId ? `${deploymentId}:${instanceName}` : instanceName; - const workerInstance = await tx.workerInstance.findUnique({ + const workerInstance = await tx.workerInstance.findFirst({ where: { - workerGroupId_resourceIdentifier: { - workerGroupId: workerGroup.id, - resourceIdentifier, - }, + workerGroupId: workerGroup.id, + resourceIdentifier, }, include: { deployment: true, @@ -315,12 +313,10 @@ export class WorkerGroupTokenService extends WithRunEngine { // Unique constraint violation if (error.code === "P2002") { try { - const existingWorkerInstance = await tx.workerInstance.findUnique({ + const existingWorkerInstance = await tx.workerInstance.findFirst({ where: { - workerGroupId_resourceIdentifier: { - workerGroupId: workerGroup.id, - resourceIdentifier, - }, + workerGroupId: workerGroup.id, + resourceIdentifier, }, include: { deployment: true, @@ -363,7 +359,7 @@ export class WorkerGroupTokenService extends WithRunEngine { // Unmanaged workers instances are locked to a specific deployment version - const deployment = await tx.workerDeployment.findUnique({ + const deployment = await tx.workerDeployment.findFirst({ where: { ...(deploymentId.startsWith("deployment_") ? { @@ -457,7 +453,11 @@ export class WorkerGroupTokenService extends WithRunEngine { }, include: { deployment: true, - environment: true, + environment: { + include: { + parentEnvironment: true, + }, + }, }, }); @@ -481,6 +481,10 @@ export class WorkerGroupTokenService extends WithRunEngine { export const WorkerInstanceEnv = z.enum(["dev", "staging", "prod"]).default("prod"); export type WorkerInstanceEnv = z.infer; +type EnvironmentWithParent = RuntimeEnvironment & { + parentEnvironment?: RuntimeEnvironment | null; +}; + export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<{ type: WorkerInstanceGroupType; name: string; @@ -491,7 +495,7 @@ export type AuthenticatedWorkerInstanceOptions = WithRunEngineOptions<{ deploymentId?: string; backgroundWorkerId?: string; runnerId?: string; - environment: RuntimeEnvironment | null; + environment: EnvironmentWithParent | null; }>; export class AuthenticatedWorkerInstance extends WithRunEngine { @@ -501,7 +505,7 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { readonly workerInstanceId: string; readonly runnerId?: string; readonly masterQueue: string; - readonly environment: RuntimeEnvironment | null; + readonly environment: EnvironmentWithParent | null; readonly deploymentId?: string; readonly backgroundWorkerId?: string; @@ -682,17 +686,21 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { const environment = this.environment ?? - (await this._prisma.runtimeEnvironment.findUnique({ + (await this._prisma.runtimeEnvironment.findFirst({ where: { id: engineResult.execution.environment.id, }, + include: { + parentEnvironment: true, + }, })); const envVars = environment ? await this.getEnvVars( environment, engineResult.run.id, - engineResult.execution.machine ?? defaultMachinePreset + engineResult.execution.machine ?? defaultMachinePreset, + environment.parentEnvironment ?? undefined ) : {}; @@ -797,9 +805,10 @@ export class AuthenticatedWorkerInstance extends WithRunEngine { private async getEnvVars( environment: RuntimeEnvironment, runId: string, - machinePreset: MachinePreset + machinePreset: MachinePreset, + parentEnvironment?: RuntimeEnvironment ): Promise> { - const variables = await resolveVariablesForEnvironment(environment); + const variables = await resolveVariablesForEnvironment(environment, parentEnvironment); const jwt = await generateJWTTokenForEnvironment(environment, { run_id: runId, diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 99867164e3..88435c4127 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -45,18 +45,19 @@ "@codemirror/view": "^6.5.0", "@conform-to/react": "0.9.2", "@conform-to/zod": "0.9.2", - "@depot/sdk-node": "^1.0.0", "@depot/cli": "0.0.1-cli.2.80.0", + "@depot/sdk-node": "^1.0.0", "@electric-sql/react": "^0.3.5", "@headlessui/react": "^1.7.8", "@heroicons/react": "^2.0.12", + "@internal/redis": "workspace:*", "@internal/run-engine": "workspace:*", + "@internal/tracing": "workspace:*", "@internal/zod-worker": "workspace:*", - "@internal/redis": "workspace:*", - "@trigger.dev/redis-worker": "workspace:*", "@internationalized/date": "^3.5.1", "@lezer/highlight": "^1.1.6", "@opentelemetry/api": "1.9.0", + "@opentelemetry/api-logs": "0.52.1", "@opentelemetry/core": "1.25.1", "@opentelemetry/exporter-logs-otlp-http": "0.52.1", "@opentelemetry/exporter-trace-otlp-http": "0.52.1", @@ -69,9 +70,9 @@ "@opentelemetry/sdk-trace-base": "1.25.1", "@opentelemetry/sdk-trace-node": "1.25.1", "@opentelemetry/semantic-conventions": "1.25.1", - "@opentelemetry/api-logs": "0.52.1", "@popperjs/core": "^2.11.8", "@prisma/instrumentation": "^5.11.0", + "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.0.4", "@radix-ui/react-dialog": "^1.0.3", "@radix-ui/react-label": "^2.0.1", @@ -103,9 +104,9 @@ "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", - "@trigger.dev/platform": "1.0.14", + "@trigger.dev/platform": "1.0.15", + "@trigger.dev/redis-worker": "workspace:*", "@trigger.dev/sdk": "workspace:*", - "@internal/tracing": "workspace:*", "@types/pg": "8.6.6", "@uiw/react-codemirror": "^4.19.5", "@unkey/cache": "^1.5.0", @@ -146,8 +147,8 @@ "non.geist": "^1.0.2", "ohash": "^1.1.3", "openai": "^4.33.1", - "parse-duration": "^2.1.0", "p-limit": "^6.2.0", + "parse-duration": "^2.1.0", "posthog-js": "^1.93.3", "posthog-node": "4.17.1", "prism-react-renderer": "^2.3.1", @@ -194,9 +195,9 @@ "zod-validation-error": "^1.5.0" }, "devDependencies": { - "@internal/testcontainers": "workspace:*", - "@internal/replication": "workspace:*", "@internal/clickhouse": "workspace:*", + "@internal/replication": "workspace:*", + "@internal/testcontainers": "workspace:*", "@remix-run/dev": "2.1.0", "@remix-run/eslint-config": "2.1.0", "@remix-run/testing": "^2.1.0", diff --git a/apps/webapp/prisma/seed.ts b/apps/webapp/prisma/seed.ts index f9e418f25e..009f9278b5 100644 --- a/apps/webapp/prisma/seed.ts +++ b/apps/webapp/prisma/seed.ts @@ -1,5 +1,3 @@ -/* eslint-disable turbo/no-undeclared-env-vars */ - import { seedCloud } from "./seedCloud"; import { prisma } from "../app/db.server"; import { createEnvironment } from "~/models/organization.server"; @@ -48,7 +46,14 @@ async function runStagingEnvironmentMigration() { `Creating staging environment for project ${project.slug} on org ${project.organization.slug}` ); - await createEnvironment(project.organization, project, "STAGING", undefined, tx); + await createEnvironment({ + organization: project.organization, + project, + type: "STAGING", + isBranchableEnvironment: false, + member: undefined, + prismaClient: tx, + }); } catch (error) { console.error(error); } diff --git a/apps/webapp/tailwind.config.js b/apps/webapp/tailwind.config.js index 5b41bcafd8..1e47ecf75b 100644 --- a/apps/webapp/tailwind.config.js +++ b/apps/webapp/tailwind.config.js @@ -149,8 +149,8 @@ const pending = colors.blue[500]; const warning = colors.amber[500]; const error = colors.rose[600]; const devEnv = colors.pink[500]; -const stagingEnv = colors.amber[400]; -const previewEnv = colors.amber[400]; +const stagingEnv = colors.orange[400]; +const previewEnv = colors.yellow[400]; const prodEnv = mint[500]; /** Icon colors */ @@ -310,9 +310,14 @@ module.exports = { }, width: { 0.75: "0.1875rem", + 4.5: "1.125rem", }, height: { 0.75: "0.1875rem", + 4.5: "1.125rem", + }, + size: { + 4.5: "1.125rem", }, }, }, diff --git a/apps/webapp/test/environmentVariableDeduplication.test.ts b/apps/webapp/test/environmentVariableDeduplication.test.ts new file mode 100644 index 0000000000..d47da457a0 --- /dev/null +++ b/apps/webapp/test/environmentVariableDeduplication.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository"; +import { deduplicateVariableArray } from "~/v3/deduplicateVariableArray.server"; + +describe("Deduplicate variables", () => { + it("should keep later variables when there are duplicates", () => { + const variables: EnvironmentVariable[] = [ + { key: "API_KEY", value: "old_value" }, + { key: "API_KEY", value: "new_value" }, + ]; + + const result = deduplicateVariableArray(variables); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ key: "API_KEY", value: "new_value" }); + }); + + it("should preserve order of unique variables", () => { + const variables: EnvironmentVariable[] = [ + { key: "FIRST", value: "first" }, + { key: "SECOND", value: "second" }, + { key: "THIRD", value: "third" }, + ]; + + const result = deduplicateVariableArray(variables); + + expect(result).toHaveLength(3); + expect(result[0].key).toBe("FIRST"); + expect(result[1].key).toBe("SECOND"); + expect(result[2].key).toBe("THIRD"); + }); + + it("should handle multiple duplicates with later values taking precedence", () => { + const variables: EnvironmentVariable[] = [ + { key: "DB_URL", value: "old_db" }, + { key: "API_KEY", value: "old_key" }, + { key: "DB_URL", value: "new_db" }, + { key: "API_KEY", value: "new_key" }, + ]; + + const result = deduplicateVariableArray(variables); + + expect(result).toHaveLength(2); + expect(result.find((v) => v.key === "DB_URL")?.value).toBe("new_db"); + expect(result.find((v) => v.key === "API_KEY")?.value).toBe("new_key"); + }); + + it("should handle empty array", () => { + const result = deduplicateVariableArray([]); + expect(result).toEqual([]); + }); + + it("should handle array with no duplicates", () => { + const variables: EnvironmentVariable[] = [ + { key: "VAR1", value: "value1" }, + { key: "VAR2", value: "value2" }, + ]; + + const result = deduplicateVariableArray(variables); + + expect(result).toHaveLength(2); + expect(result).toEqual(variables); + }); +}); diff --git a/apps/webapp/test/environmentVariableRules.test.ts b/apps/webapp/test/environmentVariableRules.test.ts new file mode 100644 index 0000000000..af27dd3c7c --- /dev/null +++ b/apps/webapp/test/environmentVariableRules.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import type { EnvironmentVariable } from "../app/v3/environmentVariables/repository"; +import { removeBlacklistedVariables } from "~/v3/environmentVariableRules.server"; + +describe("removeBlacklistedVariables", () => { + it("should remove exact match blacklisted variables", () => { + const variables: EnvironmentVariable[] = [ + { key: "TRIGGER_SECRET_KEY", value: "secret123" }, + { key: "TRIGGER_API_URL", value: "https://api.example.com" }, + { key: "NORMAL_VAR", value: "normal" }, + ]; + + const result = removeBlacklistedVariables(variables); + + expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]); + }); + + it("should remove variables with blacklisted prefixes", () => { + const variables: EnvironmentVariable[] = [ + { key: "OTEL_SERVICE_NAME", value: "my-service" }, + { key: "OTEL_TRACE_SAMPLER", value: "always_on" }, + { key: "NORMAL_VAR", value: "normal" }, + ]; + + const result = removeBlacklistedVariables(variables); + + expect(result).toEqual([{ key: "NORMAL_VAR", value: "normal" }]); + }); + + it("should keep whitelisted variables even if they match a blacklisted prefix", () => { + const variables: EnvironmentVariable[] = [ + { key: "OTEL_LOG_LEVEL", value: "debug" }, + { key: "OTEL_SERVICE_NAME", value: "my-service" }, + { key: "NORMAL_VAR", value: "normal" }, + ]; + + const result = removeBlacklistedVariables(variables); + + expect(result).toEqual([ + { key: "OTEL_LOG_LEVEL", value: "debug" }, + { key: "NORMAL_VAR", value: "normal" }, + ]); + }); + + it("should handle empty input array", () => { + const variables: EnvironmentVariable[] = []; + + const result = removeBlacklistedVariables(variables); + + expect(result).toEqual([]); + }); + + it("should handle mixed case variables", () => { + const variables: EnvironmentVariable[] = [ + { key: "trigger_secret_key", value: "secret123" }, // Different case + { key: "OTEL_LOG_LEVEL", value: "debug" }, + { key: "otel_service_name", value: "my-service" }, // Different case + { key: "NORMAL_VAR", value: "normal" }, + ]; + + const result = removeBlacklistedVariables(variables); + + // Should keep only the whitelisted OTEL_LOG_LEVEL and NORMAL_VAR + // Note: The function is case-sensitive, so different case variables should pass through + expect(result).toEqual([ + { key: "trigger_secret_key", value: "secret123" }, + { key: "OTEL_LOG_LEVEL", value: "debug" }, + { key: "otel_service_name", value: "my-service" }, + { key: "NORMAL_VAR", value: "normal" }, + ]); + }); + + it("should handle variables with empty values", () => { + const variables: EnvironmentVariable[] = [ + { key: "TRIGGER_SECRET_KEY", value: "" }, + { key: "OTEL_SERVICE_NAME", value: "" }, + { key: "OTEL_LOG_LEVEL", value: "" }, + { key: "NORMAL_VAR", value: "" }, + ]; + + const result = removeBlacklistedVariables(variables); + + expect(result).toEqual([ + { key: "OTEL_LOG_LEVEL", value: "" }, + { key: "NORMAL_VAR", value: "" }, + ]); + }); + + it("should handle all types of rules in a single array", () => { + const variables: EnvironmentVariable[] = [ + // Exact matches (should be removed) + { key: "TRIGGER_SECRET_KEY", value: "secret123" }, + { key: "TRIGGER_API_URL", value: "https://api.example.com" }, + // Prefix matches (should be removed) + { key: "OTEL_SERVICE_NAME", value: "my-service" }, + { key: "OTEL_TRACE_SAMPLER", value: "always_on" }, + // Whitelist exception (should be kept) + { key: "OTEL_LOG_LEVEL", value: "debug" }, + // Normal variables (should be kept) + { key: "NORMAL_VAR", value: "normal" }, + { key: "DATABASE_URL", value: "postgres://..." }, + ]; + + const result = removeBlacklistedVariables(variables); + + expect(result).toEqual([ + { key: "OTEL_LOG_LEVEL", value: "debug" }, + { key: "NORMAL_VAR", value: "normal" }, + { key: "DATABASE_URL", value: "postgres://..." }, + ]); + }); +}); diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts new file mode 100644 index 0000000000..28f4056c46 --- /dev/null +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; + +describe("isValidGitBranchName", () => { + it("returns true for a valid branch name", async () => { + expect(isValidGitBranchName("feature/valid-branch")).toBe(true); + }); + + it("returns false for an invalid branch name", async () => { + expect(isValidGitBranchName("invalid branch name!")).toBe(false); + }); + + it("disallows control characters (ASCII 0–31)", async () => { + for (let i = 0; i <= 31; i++) { + const branch = `feature${String.fromCharCode(i)}branch`; + // eslint-disable-next-line no-await-in-loop + expect(isValidGitBranchName(branch)).toBe(false); + } + }); + + it("disallows space", async () => { + expect(isValidGitBranchName("feature branch")).toBe(false); + }); + + it("disallows tilde (~)", async () => { + expect(isValidGitBranchName("feature~branch")).toBe(false); + }); + + it("disallows caret (^)", async () => { + expect(isValidGitBranchName("feature^branch")).toBe(false); + }); + + it("disallows colon (:)", async () => { + expect(isValidGitBranchName("feature:branch")).toBe(false); + }); + + it("disallows question mark (?)", async () => { + expect(isValidGitBranchName("feature?branch")).toBe(false); + }); + + it("disallows asterisk (*)", async () => { + expect(isValidGitBranchName("feature*branch")).toBe(false); + }); + + it("disallows open bracket ([)", async () => { + expect(isValidGitBranchName("feature[branch")).toBe(false); + }); + + it("disallows backslash (\\)", async () => { + expect(isValidGitBranchName("feature\\branch")).toBe(false); + }); + + it("disallows branch names that begin with a slash", async () => { + expect(isValidGitBranchName("/feature-branch")).toBe(false); + }); + + it("disallows branch names that end with a slash", async () => { + expect(isValidGitBranchName("feature-branch/")).toBe(false); + }); + + it("disallows consecutive slashes (//)", async () => { + expect(isValidGitBranchName("feature//branch")).toBe(false); + }); + + it("disallows the sequence ..", async () => { + expect(isValidGitBranchName("feature..branch")).toBe(false); + }); + + it("disallows @{ in the name", async () => { + expect(isValidGitBranchName("feature@{branch")).toBe(false); + }); + + it("disallows names ending with .lock", async () => { + expect(isValidGitBranchName("feature-branch.lock")).toBe(false); + }); +}); + +describe("branchNameFromRef", () => { + it("returns the branch name for refs/heads/branch", async () => { + const result = sanitizeBranchName("refs/heads/feature/branch"); + expect(result).toBe("feature/branch"); + }); + + it("returns the branch name for refs/remotes/origin/branch", async () => { + const result = sanitizeBranchName("refs/remotes/origin/feature/branch"); + expect(result).toBe("origin/feature/branch"); + }); + + it("returns the tag name for refs/tags/v1.0.0", async () => { + const result = sanitizeBranchName("refs/tags/v1.0.0"); + expect(result).toBe("v1.0.0"); + }); + + it("returns the input if just a branch name is given", async () => { + const result = sanitizeBranchName("feature/branch"); + expect(result).toBe("feature/branch"); + }); + + it("returns null for an invalid ref", async () => { + const result = sanitizeBranchName("refs/invalid/branch"); + expect(result).toBeNull(); + }); + + it("returns null for an empty string", async () => { + const result = sanitizeBranchName(""); + expect(result).toBeNull(); + }); +}); diff --git a/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql new file mode 100644 index 0000000000..eb32648d76 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250509180155_runtime_environment_branching/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" +ADD COLUMN IF NOT EXISTS "archivedAt" TIMESTAMP(3), +ADD COLUMN IF NOT EXISTS "branchName" TEXT, +ADD COLUMN IF NOT EXISTS "git" JSONB, +ADD COLUMN IF NOT EXISTS "parentEnvironmentId" TEXT; + +-- AddForeignKey +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_name = 'RuntimeEnvironment' + AND constraint_name = 'RuntimeEnvironment_parentEnvironmentId_fkey' + ) THEN + ALTER TABLE "RuntimeEnvironment" + ADD CONSTRAINT "RuntimeEnvironment_parentEnvironmentId_fkey" + FOREIGN KEY ("parentEnvironmentId") + REFERENCES "RuntimeEnvironment" ("id") + ON DELETE CASCADE + ON UPDATE CASCADE; + END IF; +END $$; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql new file mode 100644 index 0000000000..a3beef3c1e --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250509180346_runtime_environment_parent_environment_id_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_parentEnvironmentId_idx" ON "RuntimeEnvironment" ("parentEnvironmentId"); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql b/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql new file mode 100644 index 0000000000..d241e349b3 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250511145836_runtime_environment_add_is_branchable_environment/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "RuntimeEnvironment" +ADD COLUMN "isBranchableEnvironment" BOOLEAN NOT NULL DEFAULT false; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql b/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql new file mode 100644 index 0000000000..1787fc45db --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250513135201_runtime_environment_add_project_id_index/migration.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS "RuntimeEnvironment_projectId_idx" ON "RuntimeEnvironment" ("projectId"); \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql b/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql new file mode 100644 index 0000000000..3f6e1ef8ec --- /dev/null +++ b/internal-packages/database/prisma/migrations/20250515134154_worker_deployment_add_git_info/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "WorkerDeployment" +ADD COLUMN "git" JSONB; \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 5d143e18da..0435c7e779 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -373,13 +373,29 @@ model OrgMemberInvite { } model RuntimeEnvironment { - id String @id @default(cuid()) - slug String - apiKey String @unique + id String @id @default(cuid()) + slug String + apiKey String @unique + + /// @deprecated was for v2 pkApiKey String @unique type RuntimeEnvironmentType @default(DEVELOPMENT) + // Preview branches + /// If true, this environment has branches and is treated differently in the dashboard/API + isBranchableEnvironment Boolean @default(false) + branchName String? + parentEnvironment RuntimeEnvironment? @relation("parentEnvironment", fields: [parentEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + parentEnvironmentId String? + childEnvironments RuntimeEnvironment[] @relation("parentEnvironment") + + // This is GitMeta type + git Json? + + /// When set API calls will fail + archivedAt DateTime? + ///A memorable code for the environment shortcode String @@ -401,24 +417,26 @@ model RuntimeEnvironment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - tunnelId String? - - endpoints Endpoint[] - jobVersions JobVersion[] - events EventRecord[] - jobRuns JobRun[] - requestDeliveries HttpSourceRequestDelivery[] - jobAliases JobAlias[] - JobQueue JobQueue[] - sources TriggerSource[] - eventDispatchers EventDispatcher[] - scheduleSources ScheduleSource[] - ExternalAccount ExternalAccount[] - httpEndpointEnvironments TriggerHttpEndpointEnvironment[] - concurrencyLimitGroups ConcurrencyLimitGroup[] - keyValueItems KeyValueItem[] - webhookEnvironments WebhookEnvironment[] - webhookRequestDeliveries WebhookRequestDelivery[] + /// @deprecated (v2) + tunnelId String? + endpoints Endpoint[] + jobVersions JobVersion[] + events EventRecord[] + jobRuns JobRun[] + requestDeliveries HttpSourceRequestDelivery[] + jobAliases JobAlias[] + JobQueue JobQueue[] + sources TriggerSource[] + eventDispatchers EventDispatcher[] + scheduleSources ScheduleSource[] + ExternalAccount ExternalAccount[] + httpEndpointEnvironments TriggerHttpEndpointEnvironment[] + concurrencyLimitGroups ConcurrencyLimitGroup[] + keyValueItems KeyValueItem[] + webhookEnvironments WebhookEnvironment[] + webhookRequestDeliveries WebhookRequestDelivery[] + + /// v3 backgroundWorkers BackgroundWorker[] backgroundWorkerTasks BackgroundWorkerTask[] taskRuns TaskRun[] @@ -445,6 +463,8 @@ model RuntimeEnvironment { @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) + @@index([parentEnvironmentId]) + @@index([projectId]) } enum RuntimeEnvironmentType { @@ -2862,6 +2882,9 @@ model WorkerDeployment { failedAt DateTime? errorData Json? + // This is GitMeta type + git Json? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/internal-packages/run-engine/src/engine/db/worker.ts b/internal-packages/run-engine/src/engine/db/worker.ts index 3e4ce60b61..6b89eacbaf 100644 --- a/internal-packages/run-engine/src/engine/db/worker.ts +++ b/internal-packages/run-engine/src/engine/db/worker.ts @@ -32,7 +32,8 @@ type RunWithBackgroundWorkerTasksResult = | "TASK_NOT_IN_LATEST" | "TASK_NEVER_REGISTERED" | "BACKGROUND_WORKER_MISMATCH" - | "QUEUE_NOT_FOUND"; + | "QUEUE_NOT_FOUND" + | "RUN_ENVIRONMENT_ARCHIVED"; message: string; run: RunWithMininimalEnvironment; } @@ -69,6 +70,7 @@ export async function getRunWithBackgroundWorkerTasks( select: { id: true, type: true, + archivedAt: true, }, }, lockedToVersion: { @@ -88,6 +90,15 @@ export async function getRunWithBackgroundWorkerTasks( }; } + if (run.runtimeEnvironment.archivedAt) { + return { + success: false as const, + code: "RUN_ENVIRONMENT_ARCHIVED", + message: `Run is on an archived environment: ${run.id}`, + run, + }; + } + const workerId = run.lockedToVersionId ?? backgroundWorkerId; //get the relevant BackgroundWorker with tasks and deployment (if not DEV) diff --git a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts index 91e47c7ec4..d02ac75f97 100644 --- a/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/dequeueSystem.ts @@ -198,6 +198,19 @@ export class DequeueSystem { await this.$.runQueue.acknowledgeMessage(orgId, runId); return null; } + case "RUN_ENVIRONMENT_ARCHIVED": { + //this happens if the preview branch was archived + this.$.logger.warn( + "RunEngine.dequeueFromMasterQueue(): Run environment archived", + { + runId, + latestSnapshot: snapshot.id, + result, + } + ); + await this.$.runQueue.acknowledgeMessage(orgId, runId); + return null; + } case "NO_WORKER": case "TASK_NEVER_REGISTERED": case "QUEUE_NOT_FOUND": diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index a63110c970..687b5aa028 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -2,6 +2,7 @@ import { startSpan } from "@internal/tracing"; import { CompleteRunAttemptResult, ExecutionResult, + GitMeta, StartRunAttemptResult, TaskRunError, TaskRunExecution, @@ -25,15 +26,15 @@ import { retryOutcomeFromCompletion } from "../retrying.js"; import { isExecuting, isInitialState } from "../statuses.js"; import { RunEngineOptions } from "../types.js"; import { BatchSystem } from "./batchSystem.js"; +import { DelayedRunSystem } from "./delayedRunSystem.js"; import { executionResultFromSnapshot, ExecutionSnapshotSystem, getLatestExecutionSnapshot, } from "./executionSnapshotSystem.js"; +import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js"; import { SystemResources } from "./systems.js"; import { WaitpointSystem } from "./waitpointSystem.js"; -import { DelayedRunSystem } from "./delayedRunSystem.js"; -import { ReleaseConcurrencySystem } from "./releaseConcurrencySystem.js"; export type RunAttemptSystemOptions = { resources: SystemResources; @@ -284,6 +285,14 @@ export class RunAttemptSystem { dataType: taskRun.metadataType, }); + let git: GitMeta | undefined = undefined; + if (environment.git) { + const parsed = GitMeta.safeParse(environment.git); + if (parsed.success) { + git = parsed.data; + } + } + const execution: TaskRunExecution = { task: { id: run.lockedBy!.slug, @@ -334,6 +343,8 @@ export class RunAttemptSystem { id: environment.id, slug: environment.slug, type: environment.type, + branchName: environment.branchName ?? undefined, + git, }, organization: { id: environment.organization.id, diff --git a/packages/build/src/extensions/core/syncEnvVars.ts b/packages/build/src/extensions/core/syncEnvVars.ts index 231bdb86f0..107ea15359 100644 --- a/packages/build/src/extensions/core/syncEnvVars.ts +++ b/packages/build/src/extensions/core/syncEnvVars.ts @@ -11,6 +11,7 @@ export type SyncEnvVarsResult = export type SyncEnvVarsParams = { projectRef: string; environment: string; + branch?: string; env: Record; }; @@ -85,6 +86,7 @@ export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOption fn, manifest.deploy.env ?? {}, manifest.environment, + manifest.branch, context ); @@ -138,6 +140,7 @@ async function callSyncEnvVarsFn( syncEnvVarsFn: SyncEnvVarsFunction | undefined, env: Record, environment: string, + branch: string | undefined, context: BuildContext ): Promise | undefined> { if (syncEnvVarsFn && typeof syncEnvVarsFn === "function") { @@ -149,6 +152,7 @@ async function callSyncEnvVarsFn( projectRef: context.config.project, environment, env, + branch, }); } catch (error) { context.logger.warn("Error calling syncEnvVars function", error); diff --git a/packages/build/src/extensions/core/vercelSyncEnvVars.ts b/packages/build/src/extensions/core/vercelSyncEnvVars.ts index 8e125f95da..74a832d8c0 100644 --- a/packages/build/src/extensions/core/vercelSyncEnvVars.ts +++ b/packages/build/src/extensions/core/vercelSyncEnvVars.ts @@ -1,31 +1,43 @@ import { BuildExtension } from "@trigger.dev/core/v3/build"; import { syncEnvVars } from "../core.js"; -export function syncVercelEnvVars( - options?: { - projectId?: string; - vercelAccessToken?: string; - vercelTeamId?: string; - }, -): BuildExtension { +export function syncVercelEnvVars(options?: { + projectId?: string; + vercelAccessToken?: string; + vercelTeamId?: string; + branch?: string; +}): BuildExtension { const sync = syncEnvVars(async (ctx) => { - const projectId = options?.projectId ?? process.env.VERCEL_PROJECT_ID ?? - ctx.env.VERCEL_PROJECT_ID; - const vercelAccessToken = options?.vercelAccessToken ?? + const projectId = + options?.projectId ?? process.env.VERCEL_PROJECT_ID ?? ctx.env.VERCEL_PROJECT_ID; + const vercelAccessToken = + options?.vercelAccessToken ?? process.env.VERCEL_ACCESS_TOKEN ?? - ctx.env.VERCEL_ACCESS_TOKEN; - const vercelTeamId = options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? - ctx.env.VERCEL_TEAM_ID; + ctx.env.VERCEL_ACCESS_TOKEN ?? + process.env.VERCEL_TOKEN; + const vercelTeamId = + options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? ctx.env.VERCEL_TEAM_ID; + const branch = + options?.branch ?? + process.env.VERCEL_PREVIEW_BRANCH ?? + ctx.env.VERCEL_PREVIEW_BRANCH ?? + ctx.branch; + + console.debug("syncVercelEnvVars()", { + projectId, + vercelTeamId, + branch, + }); if (!projectId) { throw new Error( - "syncVercelEnvVars: you did not pass in a projectId or set the VERCEL_PROJECT_ID env var.", + "syncVercelEnvVars: you did not pass in a projectId or set the VERCEL_PROJECT_ID env var." ); } if (!vercelAccessToken) { throw new Error( - "syncVercelEnvVars: you did not pass in a vercelAccessToken or set the VERCEL_ACCESS_TOKEN env var.", + "syncVercelEnvVars: you did not pass in a vercelAccessToken or set the VERCEL_ACCESS_TOKEN env var." ); } @@ -33,20 +45,20 @@ export function syncVercelEnvVars( prod: "production", staging: "preview", dev: "development", + preview: "preview", } as const; - const vercelEnvironment = - environmentMap[ctx.environment as keyof typeof environmentMap]; + const vercelEnvironment = environmentMap[ctx.environment as keyof typeof environmentMap]; if (!vercelEnvironment) { throw new Error( - `Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', or 'dev'.`, + `Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', or 'dev'.` ); } const params = new URLSearchParams({ decrypt: "true" }); if (vercelTeamId) params.set("teamId", vercelTeamId); - const vercelApiUrl = - `https://api.vercel.com/v8/projects/${projectId}/env?${params}`; + if (branch) params.set("gitBranch", branch); + const vercelApiUrl = `https://api.vercel.com/v8/projects/${projectId}/env?${params}`; try { const response = await fetch(vercelApiUrl, { @@ -64,8 +76,7 @@ export function syncVercelEnvVars( const filteredEnvs = data.envs .filter( (env: { type: string; value: string; target: string[] }) => - env.value && - env.target.includes(vercelEnvironment), + env.value && env.target.includes(vercelEnvironment) ) .map((env: { key: string; value: string }) => ({ name: env.key, @@ -74,10 +85,7 @@ export function syncVercelEnvVars( return filteredEnvs; } catch (error) { - console.error( - "Error fetching or processing Vercel environment variables:", - error, - ); + console.error("Error fetching or processing Vercel environment variables:", error); throw error; // Re-throw the error to be handled by the caller } }); diff --git a/packages/cli-v3/package.json b/packages/cli-v3/package.json index c8652d448d..7ff111fac7 100644 --- a/packages/cli-v3/package.json +++ b/packages/cli-v3/package.json @@ -51,6 +51,7 @@ "@epic-web/test-server": "^0.1.0", "@types/eventsource": "^1.1.15", "@types/gradient-string": "^1.1.2", + "@types/ini": "^4.1.1", "@types/object-hash": "3.0.6", "@types/polka": "^0.5.7", "@types/react": "^18.2.48", @@ -107,10 +108,12 @@ "eventsource": "^3.0.2", "evt": "^2.4.13", "fast-npm-meta": "^0.2.2", + "git-last-commit": "^1.0.1", "gradient-string": "^2.0.2", "has-flag": "^5.0.1", "import-in-the-middle": "1.11.0", "import-meta-resolve": "^4.1.0", + "ini": "^5.0.0", "jsonc-parser": "3.2.1", "magicast": "^0.3.4", "minimatch": "^10.0.1", diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 5c01eea72a..1e4398b539 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -1,39 +1,37 @@ -import { z } from "zod"; -import { EventSource } from "eventsource"; import { CreateAuthorizationCodeResponseSchema, - GetPersonalAccessTokenResponseSchema, - WhoAmIResponseSchema, CreateBackgroundWorkerRequestBody, CreateBackgroundWorkerResponse, - StartDeploymentIndexingResponseBody, - GetProjectEnvResponse, - GetEnvironmentVariablesResponseBody, - InitializeDeploymentResponseBody, - InitializeDeploymentRequestBody, - StartDeploymentIndexingRequestBody, - GetDeploymentResponseBody, - GetProjectsResponseBody, - GetProjectResponseBody, - ImportEnvironmentVariablesRequestBody, + DevConfigResponseBody, + DevDequeueRequestBody, + DevDequeueResponseBody, EnvironmentVariableResponseBody, - TaskRunExecution, FailDeploymentRequestBody, FailDeploymentResponseBody, FinalizeDeploymentRequestBody, - WorkersListResponseBody, - WorkersCreateResponseBody, - WorkersCreateRequestBody, - TriggerTaskRequestBody, - TriggerTaskResponse, + GetDeploymentResponseBody, + GetEnvironmentVariablesResponseBody, GetLatestDeploymentResponseBody, - DevConfigResponseBody, - DevDequeueRequestBody, - DevDequeueResponseBody, + GetPersonalAccessTokenResponseSchema, + GetProjectEnvResponse, + GetProjectResponseBody, + GetProjectsResponseBody, + ImportEnvironmentVariablesRequestBody, + InitializeDeploymentRequestBody, + InitializeDeploymentResponseBody, PromoteDeploymentResponseBody, + StartDeploymentIndexingRequestBody, + StartDeploymentIndexingResponseBody, + TaskRunExecution, + TriggerTaskRequestBody, + TriggerTaskResponse, + UpsertBranchRequestBody, + UpsertBranchResponseBody, + WhoAmIResponseSchema, + WorkersCreateRequestBody, + WorkersCreateResponseBody, + WorkersListResponseBody, } from "@trigger.dev/core/v3"; -import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch"; -import { logger } from "./utilities/logger.js"; import { WorkloadDebugLogRequestBody, WorkloadHeartbeatRequestBody, @@ -43,6 +41,10 @@ import { WorkloadRunAttemptStartResponseBody, WorkloadRunLatestSnapshotResponseBody, } from "@trigger.dev/core/v3/workers"; +import { ApiResult, wrapZodFetch, zodfetchSSE } from "@trigger.dev/core/v3/zodfetch"; +import { EventSource } from "eventsource"; +import { z } from "zod"; +import { logger } from "./utilities/logger.js"; export class CliApiClient { private engineURL: string; @@ -50,10 +52,12 @@ export class CliApiClient { constructor( public readonly apiURL: string, // TODO: consider making this required - public readonly accessToken?: string + public readonly accessToken?: string, + public readonly branch?: string ) { this.apiURL = apiURL.replace(/\/$/, ""); this.engineURL = this.apiURL; + this.branch = branch; } async createAuthorizationCode() { @@ -136,44 +140,60 @@ export class CliApiClient { `${this.apiURL}/api/v1/projects/${projectRef}/background-workers`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); } - async createTaskRunAttempt( - runFriendlyId: string - ): Promise>> { + async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) { if (!this.accessToken) { - throw new Error("creatTaskRunAttempt: No access token"); + throw new Error("getProjectDevEnv: No access token"); } - return wrapZodFetch(TaskRunExecution, `${this.apiURL}/api/v1/runs/${runFriendlyId}/attempts`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, - }); + return wrapZodFetch( + GetProjectEnvResponse, + `${this.apiURL}/api/v1/projects/${projectRef}/${env}`, + { + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + } + ); } - async getProjectEnv({ projectRef, env }: { projectRef: string; env: string }) { + async upsertBranch(projectRef: string, body: UpsertBranchRequestBody) { if (!this.accessToken) { - throw new Error("getProjectDevEnv: No access token"); + throw new Error("upsertBranch: No access token"); } return wrapZodFetch( - GetProjectEnvResponse, - `${this.apiURL}/api/v1/projects/${projectRef}/${env}`, + UpsertBranchResponseBody, + `${this.apiURL}/api/v1/projects/${projectRef}/branches`, { + method: "POST", headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json", }, + body: JSON.stringify(body), + } + ); + } + + async archiveBranch(projectRef: string, branch: string) { + if (!this.accessToken) { + throw new Error("archiveBranch: No access token"); + } + + return wrapZodFetch( + z.object({ branch: z.object({ id: z.string() }) }), + `${this.apiURL}/api/v1/projects/${projectRef}/branches/archive`, + { + method: "POST", + headers: this.getHeaders(), + body: JSON.stringify({ branch }), } ); } @@ -187,10 +207,7 @@ export class CliApiClient { GetEnvironmentVariablesResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}/envvars`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), } ); } @@ -209,10 +226,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/projects/${projectRef}/envvars/${slug}/import`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(params), } ); @@ -225,10 +239,7 @@ export class CliApiClient { return wrapZodFetch(InitializeDeploymentResponseBody, `${this.apiURL}/api/v1/deployments`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), }); } @@ -246,10 +257,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${deploymentId}/background-workers`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -265,10 +273,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${id}/fail`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -295,10 +300,7 @@ export class CliApiClient { url: `${this.apiURL}/api/v3/deployments/${id}/finalize`, request: { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), }, messages: { @@ -352,10 +354,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${version}/promote`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), } ); } @@ -370,10 +369,7 @@ export class CliApiClient { `${this.apiURL}/api/v1/deployments/${deploymentId}/start-indexing`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - "Content-Type": "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body), } ); @@ -388,10 +384,7 @@ export class CliApiClient { GetDeploymentResponseBody, `${this.apiURL}/api/v1/deployments/${deploymentId}`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), } ); } @@ -403,10 +396,7 @@ export class CliApiClient { return wrapZodFetch(TriggerTaskResponse, `${this.apiURL}/api/v1/tasks/${taskId}/trigger`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(body ?? {}), }); } @@ -449,10 +439,7 @@ export class CliApiClient { GetLatestDeploymentResponseBody, `${this.apiURL}/api/v1/deployments/latest`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), } ); } @@ -463,10 +450,7 @@ export class CliApiClient { } return wrapZodFetch(WorkersListResponseBody, `${this.apiURL}/api/v1/workers`, { - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), }); } @@ -477,10 +461,7 @@ export class CliApiClient { return wrapZodFetch(WorkersCreateResponseBody, `${this.apiURL}/api/v1/workers`, { method: "POST", - headers: { - Authorization: `Bearer ${this.accessToken}`, - Accept: "application/json", - }, + headers: this.getHeaders(), body: JSON.stringify(options), }); } @@ -667,4 +648,17 @@ export class CliApiClient { private setEngineURL(engineURL: string) { this.engineURL = engineURL.replace(/\/$/, ""); } + + private getHeaders() { + const headers: Record = { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }; + + if (this.branch) { + headers["x-trigger-branch"] = this.branch; + } + + return headers; + } } diff --git a/packages/cli-v3/src/build/buildWorker.ts b/packages/cli-v3/src/build/buildWorker.ts index 8b396f21c7..168fb31e86 100644 --- a/packages/cli-v3/src/build/buildWorker.ts +++ b/packages/cli-v3/src/build/buildWorker.ts @@ -28,6 +28,7 @@ export type BuildWorkerOptions = { destination: string; target: BuildTarget; environment: string; + branch?: string; resolvedConfig: ResolvedConfig; listener?: BuildWorkerEventListener; envVars?: Record; @@ -75,6 +76,7 @@ export async function buildWorker(options: BuildWorkerOptions) { destination: options.destination, resolvedConfig, environment: options.environment, + branch: options.branch, target: options.target, envVars: options.envVars, }); diff --git a/packages/cli-v3/src/build/bundle.ts b/packages/cli-v3/src/build/bundle.ts index b4675cce6a..d8687922d1 100644 --- a/packages/cli-v3/src/build/bundle.ts +++ b/packages/cli-v3/src/build/bundle.ts @@ -344,6 +344,7 @@ export async function createBuildManifestFromBundle({ resolvedConfig, workerDir, environment, + branch, target, envVars, sdkVersion, @@ -353,6 +354,7 @@ export async function createBuildManifestFromBundle({ resolvedConfig: ResolvedConfig; workerDir?: string; environment: string; + branch?: string; target: BuildTarget; envVars?: Record; sdkVersion?: string; @@ -361,6 +363,7 @@ export async function createBuildManifestFromBundle({ contentHash: bundle.contentHash, runtime: resolvedConfig.runtime ?? DEFAULT_RUNTIME, environment: environment, + branch, packageVersion: sdkVersion ?? CORE_VERSION, cliPackageVersion: VERSION, target: target, diff --git a/packages/cli-v3/src/cli/index.ts b/packages/cli-v3/src/cli/index.ts index fd024d1f6f..694b2b4280 100644 --- a/packages/cli-v3/src/cli/index.ts +++ b/packages/cli-v3/src/cli/index.ts @@ -14,6 +14,7 @@ import { configureWorkersCommand } from "../commands/workers/index.js"; import { configureSwitchProfilesCommand } from "../commands/switch.js"; import { configureTriggerTaskCommand } from "../commands/trigger.js"; import { configurePromoteCommand } from "../commands/promote.js"; +import { configurePreviewCommand } from "../commands/preview.js"; export const program = new Command(); @@ -32,6 +33,7 @@ configureLogoutCommand(program); configureListProfilesCommand(program); configureSwitchProfilesCommand(program); configureUpdateCommand(program); +configurePreviewCommand(program); // configureWorkersCommand(program); // configureTriggerTaskCommand(program); diff --git a/packages/cli-v3/src/commands/deploy.ts b/packages/cli-v3/src/commands/deploy.ts index 581235e325..63f9d87a49 100644 --- a/packages/cli-v3/src/commands/deploy.ts +++ b/packages/cli-v3/src/commands/deploy.ts @@ -1,11 +1,11 @@ import { intro, log, outro } from "@clack/prompts"; -import { prepareDeploymentError, tryCatch } from "@trigger.dev/core/v3"; +import { getBranch, prepareDeploymentError, tryCatch } from "@trigger.dev/core/v3"; import { InitializeDeploymentResponseBody } from "@trigger.dev/core/v3/schemas"; import { Command, Option as CommandOption } from "commander"; import { resolve } from "node:path"; +import { isCI } from "std-env"; import { x } from "tinyexec"; import { z } from "zod"; -import { isCI } from "std-env"; import { CliApiClient } from "../apiClient.js"; import { buildWorker } from "../build/buildWorker.js"; import { resolveAlwaysExternal } from "../build/externals.js"; @@ -27,21 +27,24 @@ import { } from "../deploy/logs.js"; import { chalkError, cliLink, isLinksSupported, prettyError } from "../utilities/cliOutput.js"; import { loadDotEnvVars } from "../utilities/dotEnv.js"; +import { isDirectory } from "../utilities/fileSystem.js"; +import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; +import { createGitMeta } from "../utilities/gitMeta.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; +import { resolveLocalEnvVars } from "../utilities/localEnvVars.js"; import { logger } from "../utilities/logger.js"; -import { getProjectClient } from "../utilities/session.js"; +import { getProjectClient, upsertBranch } from "../utilities/session.js"; import { getTmpDir } from "../utilities/tempDirectories.js"; import { spinner } from "../utilities/windows.js"; import { login } from "./login.js"; +import { archivePreviewBranch } from "./preview.js"; import { updateTriggerPackages } from "./update.js"; -import { setGithubActionsOutputAndEnvVars } from "../utilities/githubActions.js"; -import { isDirectory } from "../utilities/fileSystem.js"; -import { resolveLocalEnvVars } from "../utilities/localEnvVars.js"; const DeployCommandOptions = CommonCommandOptions.extend({ dryRun: z.boolean().default(false), skipSyncEnvVars: z.boolean().default(false), - env: z.enum(["prod", "staging"]), + env: z.enum(["prod", "staging", "preview"]), + branch: z.string().optional(), loadImage: z.boolean().default(false), buildPlatform: z.enum(["linux/amd64", "linux/arm64"]).default("linux/amd64"), namespace: z.string().optional(), @@ -73,6 +76,10 @@ export function configureDeployCommand(program: Command) { "Deploy to a specific environment (currently only prod and staging are supported)", "prod" ) + .option( + "-b, --branch ", + "The preview branch to deploy to when passing --env preview. If not provided, we'll detect your git branch." + ) .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option("-c, --config ", "The name of the config file, found at [path]") .option( @@ -174,21 +181,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { const cwd = process.cwd(); const projectPath = resolve(cwd, dir); - if (dir !== "." && !isDirectory(projectPath)) { - if (dir === "staging" || dir === "prod") { - throw new Error(`To deploy to ${dir}, you need to pass "--env ${dir}", not just "${dir}".`); - } - - if (dir === "production") { - throw new Error(`To deploy to production, you need to pass "--env prod", not "production".`); - } - - if (dir === "stg") { - throw new Error(`To deploy to staging, you need to pass "--env staging", not "stg".`); - } - - throw new Error(`Directory "${dir}" not found at ${projectPath}`); - } + verifyDirectory(dir, projectPath); const authorization = await login({ embedded: true, @@ -222,11 +215,54 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { logger.debug("Resolved config", resolvedConfig); + const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); + logger.debug("gitMeta", gitMeta); + + const branch = + options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined; + if (options.env === "preview" && !branch) { + throw new Error( + "Didn't auto-detect preview branch, so you need to specify one. Pass --branch ." + ); + } + + if (options.env === "preview" && branch) { + //auto-archive a branch if the PR is merged or closed + if (gitMeta?.pullRequestState === "merged" || gitMeta?.pullRequestState === "closed") { + log.message(`Pull request ${gitMeta?.pullRequestNumber} is ${gitMeta?.pullRequestState}.`); + const $buildSpinner = spinner(); + $buildSpinner.start(`Archiving preview branch: "${branch}"`); + const result = await archivePreviewBranch(authorization, branch, resolvedConfig.project); + $buildSpinner.stop( + result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".` + ); + return; + } + + logger.debug("Upserting branch", { env: options.env, branch }); + const branchEnv = await upsertBranch({ + accessToken: authorization.auth.accessToken, + apiUrl: authorization.auth.apiUrl, + projectRef: resolvedConfig.project, + branch, + gitMeta, + }); + + logger.debug("Upserted branch env", branchEnv); + + log.success(`Using preview branch "${branch}"`); + + if (!branchEnv) { + throw new Error(`Failed to create branch "${branch}"`); + } + } + const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, + branch, profile: options.profile, }); @@ -249,6 +285,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { buildWorker({ target: "deploy", environment: options.env, + branch, destination: destination.path, resolvedConfig, rewritePaths: true, @@ -285,6 +322,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { selfHosted: options.selfHosted, registryHost: options.registry, namespace: options.namespace, + gitMeta, type: features.run_engine_v2 ? "MANAGED" : "V1", }); @@ -396,6 +434,7 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) { projectRef: resolvedConfig.project, apiUrl: projectClient.client.apiURL, apiKey: projectClient.client.accessToken!, + branchName: branch, authAccessToken: authorization.auth.accessToken, compilationPath: destination.path, buildEnvVars: buildManifest.build.env, @@ -683,3 +722,21 @@ async function failDeploy( } } } + +export function verifyDirectory(dir: string, projectPath: string) { + if (dir !== "." && !isDirectory(projectPath)) { + if (dir === "staging" || dir === "prod" || dir === "preview") { + throw new Error(`To deploy to ${dir}, you need to pass "--env ${dir}", not just "${dir}".`); + } + + if (dir === "production") { + throw new Error(`To deploy to production, you need to pass "--env prod", not "production".`); + } + + if (dir === "stg") { + throw new Error(`To deploy to staging, you need to pass "--env staging", not "stg".`); + } + + throw new Error(`Directory "${dir}" not found at ${projectPath}`); + } +} diff --git a/packages/cli-v3/src/commands/preview.ts b/packages/cli-v3/src/commands/preview.ts new file mode 100644 index 0000000000..2dab147a51 --- /dev/null +++ b/packages/cli-v3/src/commands/preview.ts @@ -0,0 +1,146 @@ +import { intro } from "@clack/prompts"; +import { getBranch } from "@trigger.dev/core/v3"; +import { Command } from "commander"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { + CommonCommandOptions, + commonOptions, + handleTelemetry, + wrapCommandAction, +} from "../cli/common.js"; +import { loadConfig } from "../config.js"; +import { createGitMeta } from "../utilities/gitMeta.js"; +import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; +import { logger } from "../utilities/logger.js"; +import { getProjectClient, LoginResultOk } from "../utilities/session.js"; +import { spinner } from "../utilities/windows.js"; +import { verifyDirectory } from "./deploy.js"; +import { login } from "./login.js"; +import { updateTriggerPackages } from "./update.js"; +import { CliApiClient } from "../apiClient.js"; + +const PreviewCommandOptions = CommonCommandOptions.extend({ + branch: z.string().optional(), + config: z.string().optional(), + projectRef: z.string().optional(), + skipUpdateCheck: z.boolean().default(false), +}); + +type PreviewCommandOptions = z.infer; + +export function configurePreviewCommand(program: Command) { + const preview = program.command("preview").description("Modify preview branches"); + + commonOptions( + preview + .command("archive") + .description("Archive a preview branch") + .argument("[path]", "The path to the project", ".") + .option( + "-b, --branch ", + "The preview branch to archive. If not provided, we'll detect your local git branch." + ) + .option("--skip-update-check", "Skip checking for @trigger.dev package updates") + .option("-c, --config ", "The name of the config file, found at [path]") + .option( + "-p, --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 ", + "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); + await previewArchiveCommand(path, options); + }); + }); +} + +export async function previewArchiveCommand(dir: string, options: unknown) { + return await wrapCommandAction( + "previewArchiveCommand", + PreviewCommandOptions, + options, + async (opts) => { + return await _previewArchiveCommand(dir, opts); + } + ); +} + +async function _previewArchiveCommand(dir: string, options: PreviewCommandOptions) { + intro(`Archiving preview 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 gitMeta = await createGitMeta(resolvedConfig.workspaceDir); + logger.debug("gitMeta", gitMeta); + + const branch = getBranch({ specified: options.branch, gitMeta }); + + if (!branch) { + throw new Error( + "Didn't auto-detect branch, so you need to specify a preview branch. Use --branch ." + ); + } + + const $buildSpinner = spinner(); + $buildSpinner.start(`Archiving "${branch}"`); + const result = await archivePreviewBranch(authorization, branch, resolvedConfig.project); + $buildSpinner.stop( + result ? `Successfully archived "${branch}"` : `Failed to archive "${branch}".` + ); + return result; +} + +export async function archivePreviewBranch( + authorization: LoginResultOk, + branch: string, + project: string +) { + const apiClient = new CliApiClient(authorization.auth.apiUrl, authorization.auth.accessToken); + + const result = await apiClient.archiveBranch(project, branch); + + if (result.success) { + return true; + } else { + logger.error(result.error); + return false; + } +} diff --git a/packages/cli-v3/src/commands/promote.ts b/packages/cli-v3/src/commands/promote.ts index bee97bac11..59452c9ec2 100644 --- a/packages/cli-v3/src/commands/promote.ts +++ b/packages/cli-v3/src/commands/promote.ts @@ -12,13 +12,16 @@ import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; import { getProjectClient } from "../utilities/session.js"; import { login } from "./login.js"; +import { createGitMeta } from "../utilities/gitMeta.js"; +import { getBranch } from "@trigger.dev/core/v3"; const PromoteCommandOptions = CommonCommandOptions.extend({ projectRef: z.string().optional(), apiUrl: z.string().optional(), skipUpdateCheck: z.boolean().default(false), config: z.string().optional(), - env: z.enum(["prod", "staging"]), + env: z.enum(["prod", "staging", "preview"]), + branch: z.string().optional(), }); type PromoteCommandOptions = z.infer; @@ -35,6 +38,10 @@ export function configurePromoteCommand(program: Command) { "Deploy to a specific environment (currently only prod and staging are supported)", "prod" ) + .option( + "-b, --branch ", + "The preview branch to promote when passing --env preview. If not provided, we'll detect your git branch." + ) .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option( "-p, --project-ref ", @@ -88,11 +95,23 @@ async function _promoteCommand(version: string, options: PromoteCommandOptions) logger.debug("Resolved config", resolvedConfig); + const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); + logger.debug("gitMeta", gitMeta); + + const branch = + options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined; + if (options.env === "preview" && !branch) { + throw new Error( + "Didn't auto-detect preview branch, so you need to specify one. Pass --branch ." + ); + } + const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, + branch, profile: options.profile, }); diff --git a/packages/cli-v3/src/commands/workers/build.ts b/packages/cli-v3/src/commands/workers/build.ts index 1a85a11269..9daca0ff3c 100644 --- a/packages/cli-v3/src/commands/workers/build.ts +++ b/packages/cli-v3/src/commands/workers/build.ts @@ -1,5 +1,5 @@ import { intro, outro, log } from "@clack/prompts"; -import { parseDockerImageReference, prepareDeploymentError } from "@trigger.dev/core/v3"; +import { getBranch, parseDockerImageReference, prepareDeploymentError } from "@trigger.dev/core/v3"; import { InitializeDeploymentResponseBody } from "@trigger.dev/core/v3/schemas"; import { Command, Option as CommandOption } from "commander"; import { resolve } from "node:path"; @@ -32,6 +32,7 @@ import { spinner } from "../../utilities/windows.js"; import { login } from "../login.js"; import { updateTriggerPackages } from "../update.js"; import { resolveAlwaysExternal } from "../../build/externals.js"; +import { createGitMeta } from "../../utilities/gitMeta.js"; const WorkersBuildCommandOptions = CommonCommandOptions.extend({ // docker build options @@ -45,7 +46,8 @@ const WorkersBuildCommandOptions = CommonCommandOptions.extend({ local: z.boolean().default(false), // TODO: default to true when webapp has no remote build support dryRun: z.boolean().default(false), skipSyncEnvVars: z.boolean().default(false), - env: z.enum(["prod", "staging"]), + env: z.enum(["prod", "staging", "preview"]), + branch: z.string().optional(), config: z.string().optional(), projectRef: z.string().optional(), apiUrl: z.string().optional(), @@ -69,6 +71,10 @@ export function configureWorkersBuildCommand(program: Command) { "Deploy to a specific environment (currently only prod and staging are supported)", "prod" ) + .option( + "-b, --branch ", + "The branch to deploy to. If not provided, the branch will be detected from the current git branch." + ) .option("--skip-update-check", "Skip checking for @trigger.dev package updates") .option("-c, --config ", "The name of the config file, found at [path]") .option( @@ -168,11 +174,23 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti logger.debug("Resolved config", resolvedConfig); + const gitMeta = await createGitMeta(resolvedConfig.workspaceDir); + logger.debug("gitMeta", gitMeta); + + const branch = + options.env === "preview" ? getBranch({ specified: options.branch, gitMeta }) : undefined; + if (options.env === "preview" && !branch) { + throw new Error( + "You need to specify a preview branch when deploying to preview, pass --branch ." + ); + } + const projectClient = await getProjectClient({ accessToken: authorization.auth.accessToken, apiUrl: authorization.auth.apiUrl, projectRef: resolvedConfig.project, env: options.env, + branch, profile: options.profile, }); @@ -192,6 +210,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti const buildManifest = await buildWorker({ target: "unmanaged", environment: options.env, + branch, destination: destination.path, resolvedConfig, rewritePaths: true, @@ -327,6 +346,7 @@ async function _workerBuildCommand(dir: string, options: WorkersBuildCommandOpti projectRef: resolvedConfig.project, apiUrl: projectClient.client.apiURL, apiKey: projectClient.client.accessToken!, + branchName: branch, authAccessToken: authorization.auth.accessToken, compilationPath: destination.path, buildEnvVars: buildManifest.build.env, diff --git a/packages/cli-v3/src/deploy/buildImage.ts b/packages/cli-v3/src/deploy/buildImage.ts index 6721317e40..1d4da183fe 100644 --- a/packages/cli-v3/src/deploy/buildImage.ts +++ b/packages/cli-v3/src/deploy/buildImage.ts @@ -35,6 +35,7 @@ export interface BuildImageOptions { extraCACerts?: string; apiUrl: string; apiKey: string; + branchName?: string; buildEnvVars?: Record; onLog?: (log: string) => void; @@ -65,6 +66,7 @@ export async function buildImage(options: BuildImageOptions) { extraCACerts, apiUrl, apiKey, + branchName, buildEnvVars, network, onLog, @@ -87,6 +89,7 @@ export async function buildImage(options: BuildImageOptions) { extraCACerts, apiUrl, apiKey, + branchName, buildEnvVars, network, onLog, @@ -124,6 +127,7 @@ export async function buildImage(options: BuildImageOptions) { extraCACerts, apiUrl, apiKey, + branchName, buildEnvVars, onLog, }); @@ -145,6 +149,7 @@ export interface DepotBuildImageOptions { buildPlatform: string; apiUrl: string; apiKey: string; + branchName?: string; loadImage?: boolean; noCache?: boolean; extraCACerts?: string; @@ -202,6 +207,8 @@ async function depotBuildImage(options: DepotBuildImageOptions): Promise; @@ -329,6 +337,8 @@ async function selfHostedBuildImage( "--build-arg", `TRIGGER_API_URL=${normalizeApiUrlForBuild(options.apiUrl)}`, "--build-arg", + `TRIGGER_PREVIEW_BRANCH=${options.branchName ?? ""}`, + "--build-arg", `TRIGGER_SECRET_KEY=${options.apiKey}`, ...(buildArgs || []), ...(options.extraCACerts ? ["--build-arg", `NODE_EXTRA_CA_CERTS=${options.extraCACerts}`] : []), @@ -572,6 +582,7 @@ ARG TRIGGER_PROJECT_REF ARG NODE_EXTRA_CA_CERTS ARG TRIGGER_SECRET_KEY ARG TRIGGER_API_URL +ARG TRIGGER_PREVIEW_BRANCH ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \ @@ -580,6 +591,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \ TRIGGER_SECRET_KEY=\${TRIGGER_SECRET_KEY} \ TRIGGER_API_URL=\${TRIGGER_API_URL} \ + TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production @@ -676,6 +688,7 @@ ARG TRIGGER_PROJECT_REF ARG NODE_EXTRA_CA_CERTS ARG TRIGGER_SECRET_KEY ARG TRIGGER_API_URL +ARG TRIGGER_PREVIEW_BRANCH ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_DEPLOYMENT_ID=\${TRIGGER_DEPLOYMENT_ID} \ @@ -684,6 +697,7 @@ ENV TRIGGER_PROJECT_ID=\${TRIGGER_PROJECT_ID} \ TRIGGER_CONTENT_HASH=\${TRIGGER_CONTENT_HASH} \ TRIGGER_SECRET_KEY=\${TRIGGER_SECRET_KEY} \ TRIGGER_API_URL=\${TRIGGER_API_URL} \ + TRIGGER_PREVIEW_BRANCH=\${TRIGGER_PREVIEW_BRANCH} \ TRIGGER_LOG_LEVEL=debug \ NODE_EXTRA_CA_CERTS=\${NODE_EXTRA_CA_CERTS} \ NODE_ENV=production \ diff --git a/packages/cli-v3/src/entryPoints/managed-index-controller.ts b/packages/cli-v3/src/entryPoints/managed-index-controller.ts index d7c2f048a9..26b3332bb1 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-controller.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-controller.ts @@ -26,7 +26,11 @@ async function bootstrap() { process.exit(1); } - const cliApiClient = new CliApiClient(env.TRIGGER_API_URL, env.TRIGGER_SECRET_KEY); + const cliApiClient = new CliApiClient( + env.TRIGGER_API_URL, + env.TRIGGER_SECRET_KEY, + env.TRIGGER_PREVIEW_BRANCH + ); if (!env.TRIGGER_PROJECT_REF) { console.error("TRIGGER_PROJECT_REF is not set"); diff --git a/packages/cli-v3/src/utilities/gitMeta.ts b/packages/cli-v3/src/utilities/gitMeta.ts new file mode 100644 index 0000000000..91e59a8781 --- /dev/null +++ b/packages/cli-v3/src/utilities/gitMeta.ts @@ -0,0 +1,263 @@ +import fs from "fs/promises"; +import { join } from "path"; +import ini from "ini"; +import git from "git-last-commit"; +import { x } from "tinyexec"; +import { GitMeta } from "@trigger.dev/core/v3"; + +export async function createGitMeta(directory: string): Promise { + // First try to get metadata from GitHub Actions environment + const githubMeta = await getGitHubActionsMeta(); + if (githubMeta) { + return githubMeta; + } + + // Fall back to git commands for local development + const remoteUrl = await getOriginUrl(join(directory, ".git/config")); + + const [commitResult, dirtyResult] = await Promise.allSettled([ + getLastCommit(directory), + isDirty(directory), + ]); + + if (commitResult.status === "rejected") { + return; + } + + if (dirtyResult.status === "rejected") { + return; + } + + const dirty = dirtyResult.value; + const commit = commitResult.value; + + // Get the pull request number from process.env (GitHub Actions) + const pullRequestNumber: number | undefined = process.env.GITHUB_PULL_REQUEST_NUMBER + ? parseInt(process.env.GITHUB_PULL_REQUEST_NUMBER) + : undefined; + + return { + remoteUrl: remoteUrl ?? undefined, + commitAuthorName: commit.author.name, + commitMessage: commit.subject, + commitRef: commit.branch, + commitSha: commit.hash, + dirty, + pullRequestNumber, + }; +} + +function getLastCommit(directory: string): Promise { + return new Promise((resolve, reject) => { + git.getLastCommit( + (err, commit) => { + if (err) { + return reject(err); + } + + resolve(commit); + }, + { dst: directory } + ); + }); +} + +async function isDirty(directory: string): Promise { + try { + const result = await x("git", ["--no-optional-locks", "status", "-s"], { + nodeOptions: { + cwd: directory, + }, + }); + + // Example output (when dirty): + // M ../fs-detectors/src/index.ts + return result.stdout.trim().length > 0; + } catch (error) { + throw error; + } +} + +async function parseGitConfig(configPath: string) { + try { + return ini.parse(await fs.readFile(configPath, "utf8")); + } catch (err: unknown) { + return; + } +} + +function pluckRemoteUrls(gitConfig: { [key: string]: any }): { [key: string]: string } | undefined { + const remoteUrls: { [key: string]: string } = {}; + + for (const key of Object.keys(gitConfig)) { + if (key.includes("remote")) { + // ex. remote "origin" — matches origin + const remoteName = key.match(/(?<=").*(?=")/g)?.[0]; + const remoteUrl = gitConfig[key]?.url; + if (remoteName && remoteUrl) { + remoteUrls[remoteName] = remoteUrl; + } + } + } + + if (Object.keys(remoteUrls).length === 0) { + return; + } + + return remoteUrls; +} + +function pluckOriginUrl(gitConfig: { [key: string]: any }): string | undefined { + // Assuming "origin" is the remote url that the user would want to use + return gitConfig['remote "origin"']?.url; +} + +async function getOriginUrl(configPath: string): Promise { + const gitConfig = await parseGitConfig(configPath); + if (!gitConfig) { + return null; + } + + const originUrl = pluckOriginUrl(gitConfig); + if (originUrl) { + return originUrl; + } + return null; +} + +function errorToString(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function isGitHubActions(): boolean { + return process.env.GITHUB_ACTIONS === "true"; +} + +async function getGitHubActionsMeta(): Promise { + if (!isGitHubActions()) { + return undefined; + } + + // Required fields that should always be present in GitHub Actions + if (!process.env.GITHUB_SHA || !process.env.GITHUB_REF) { + return undefined; + } + + const remoteUrl = + process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}.git` + : undefined; + + let commitRef = process.env.GITHUB_REF; + let commitMessage: string | undefined; + let pullRequestNumber: number | undefined; + let pullRequestTitle: string | undefined; + let pullRequestState: "open" | "closed" | "merged" | undefined; + let commitSha = process.env.GITHUB_SHA; + + if (process.env.GITHUB_EVENT_PATH) { + try { + const eventData = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8")); + + if (process.env.GITHUB_EVENT_NAME === "push") { + commitMessage = eventData.head_commit?.message; + // For push events, GITHUB_REF will be like "refs/heads/main" + commitRef = process.env.GITHUB_REF.replace(/^refs\/(heads|tags)\//, ""); + } else if (process.env.GITHUB_EVENT_NAME === "pull_request") { + // For PRs, use the head commit info + pullRequestTitle = eventData.pull_request?.title; + commitRef = eventData.pull_request?.head?.ref; + pullRequestNumber = eventData.pull_request?.number; + commitSha = eventData.pull_request?.head?.sha; + pullRequestState = eventData.pull_request?.state as "open" | "closed"; + + // Check if PR was merged + if (pullRequestState === "closed" && eventData.pull_request?.merged === true) { + pullRequestState = "merged"; + } + + await x("git", ["status"], { + nodeOptions: { + cwd: process.cwd(), + }, + }).then((result) => console.debug(result.stdout)); + + commitMessage = await getCommitMessage(process.cwd(), commitSha, pullRequestNumber); + } + } catch (error) { + console.debug("Failed to parse GitHub event payload:", errorToString(error)); + } + } else { + console.debug("No GITHUB_EVENT_PATH found"); + // If we can't read the event payload, at least try to clean up the ref + commitRef = process.env.GITHUB_REF.replace(/^refs\/(heads|tags)\//, ""); + } + + return { + remoteUrl, + commitSha, + commitRef, + // In CI, the workspace is always clean + dirty: false, + // These fields might not be available in all GitHub Actions contexts + commitAuthorName: process.env.GITHUB_ACTOR, + commitMessage, + pullRequestNumber: + pullRequestNumber ?? + (process.env.GITHUB_PULL_REQUEST_NUMBER + ? parseInt(process.env.GITHUB_PULL_REQUEST_NUMBER) + : undefined), + pullRequestTitle, + pullRequestState, + }; +} + +async function getCommitMessage( + directory: string, + sha: string, + prNumber?: number +): Promise { + try { + // First try to fetch the specific commit + await x("git", ["fetch", "origin", sha], { + nodeOptions: { + cwd: directory, + }, + }); + + // Try to get the commit message + const result = await x("git", ["log", "-1", "--format=%B", sha], { + nodeOptions: { + cwd: directory, + }, + }); + + const message = result.stdout.trim(); + + if (!message && prNumber) { + // If that didn't work, try fetching the PR branch + const branchResult = await x( + "git", + ["fetch", "origin", `pull/${prNumber}/head:pr-${prNumber}`], + { + nodeOptions: { + cwd: directory, + }, + } + ); + + // Try again with the fetched branch + const branchCommitResult = await x("git", ["log", "-1", "--format=%B", `pr-${prNumber}`], { + nodeOptions: { + cwd: directory, + }, + }); + return branchCommitResult.stdout.trim(); + } + + return message; + } catch (error) { + console.debug("Error getting commit message:", errorToString(error)); + return undefined; + } +} diff --git a/packages/cli-v3/src/utilities/session.ts b/packages/cli-v3/src/utilities/session.ts index 1853a3847d..be9d9ebfd4 100644 --- a/packages/cli-v3/src/utilities/session.ts +++ b/packages/cli-v3/src/utilities/session.ts @@ -3,6 +3,7 @@ import { CliApiClient } from "../apiClient.js"; import { readAuthConfigProfile } from "./configFiles.js"; import { getTracer } from "../telemetry/tracing.js"; import { logger } from "./logger.js"; +import { GitMeta } from "@trigger.dev/core/v3"; const tracer = getTracer(); @@ -94,6 +95,7 @@ export type GetEnvOptions = { apiUrl: string; projectRef: string; env: string; + branch?: string; profile: string; }; @@ -124,7 +126,7 @@ export async function getProjectClient(options: GetEnvOptions) { return; } - const client = new CliApiClient(projectEnv.data.apiUrl, projectEnv.data.apiKey); + const client = new CliApiClient(projectEnv.data.apiUrl, projectEnv.data.apiKey, options.branch); return { id: projectEnv.data.projectId, @@ -132,3 +134,28 @@ export async function getProjectClient(options: GetEnvOptions) { client, }; } + +export type UpsertBranchOptions = { + accessToken: string; + apiUrl: string; + projectRef: string; + branch: string; + gitMeta: GitMeta | undefined; +}; + +export async function upsertBranch(options: UpsertBranchOptions) { + const apiClient = new CliApiClient(options.apiUrl, options.accessToken); + + const branchEnv = await apiClient.upsertBranch(options.projectRef, { + env: "preview", + branch: options.branch, + git: options.gitMeta, + }); + + if (!branchEnv.success) { + logger.error(`Failed to upsert branch: ${branchEnv.error}`); + return; + } + + return branchEnv.data; +} diff --git a/packages/core/src/v3/apiClient/getBranch.ts b/packages/core/src/v3/apiClient/getBranch.ts new file mode 100644 index 0000000000..cf1f1f2d44 --- /dev/null +++ b/packages/core/src/v3/apiClient/getBranch.ts @@ -0,0 +1,33 @@ +import { GitMeta } from "../schemas/index.js"; +import { getEnvVar } from "../utils/getEnv.js"; + +export function getBranch({ + specified, + gitMeta, +}: { + specified?: string; + gitMeta?: GitMeta; +}): string | undefined { + if (specified) { + return specified; + } + + // not specified, so detect our variable from process.env + const envVar = getEnvVar("TRIGGER_PREVIEW_BRANCH"); + if (envVar) { + return envVar; + } + + // detect the Vercel preview branch + const vercelPreviewBranch = getEnvVar("VERCEL_GIT_COMMIT_REF"); + if (vercelPreviewBranch) { + return vercelPreviewBranch; + } + + // not specified, so detect from git metadata + if (gitMeta?.commitRef) { + return gitMeta.commitRef; + } + + return undefined; +} diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 0bac97c8e5..117792466c 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -128,17 +128,26 @@ export type { TaskRunShape, }; +export * from "./getBranch.js"; + /** * Trigger.dev v3 API client */ export class ApiClient { public readonly baseUrl: string; public readonly accessToken: string; + public readonly previewBranch?: string; private readonly defaultRequestOptions: ZodFetchOptions; - constructor(baseUrl: string, accessToken: string, requestOptions: ApiRequestOptions = {}) { + constructor( + baseUrl: string, + accessToken: string, + previewBranch?: string, + requestOptions: ApiRequestOptions = {} + ) { this.accessToken = accessToken; this.baseUrl = baseUrl.replace(/\/$/, ""); + this.previewBranch = previewBranch; this.defaultRequestOptions = mergeRequestOptions(DEFAULT_ZOD_FETCH_OPTIONS, requestOptions); } @@ -984,6 +993,10 @@ export class ApiClient { ), }; + if (this.previewBranch) { + headers["x-trigger-branch"] = this.previewBranch; + } + // Only inject the context if we are inside a task if (taskContext.isInsideTask) { headers["x-trigger-worker"] = "true"; diff --git a/packages/core/src/v3/apiClientManager/index.ts b/packages/core/src/v3/apiClientManager/index.ts index e2f47c9261..daaf354e0b 100644 --- a/packages/core/src/v3/apiClientManager/index.ts +++ b/packages/core/src/v3/apiClientManager/index.ts @@ -44,12 +44,18 @@ export class APIClientManagerAPI { ); } + get branchName(): string | undefined { + const config = this.#getConfig(); + const value = config?.previewBranch ?? getEnvVar("TRIGGER_PREVIEW_BRANCH") ?? undefined; + return value ? value : undefined; + } + get client(): ApiClient | undefined { if (!this.baseURL || !this.accessToken) { return undefined; } - return new ApiClient(this.baseURL, this.accessToken); + return new ApiClient(this.baseURL, this.accessToken, this.branchName); } clientOrThrow(): ApiClient { @@ -57,7 +63,7 @@ export class APIClientManagerAPI { throw new ApiClientMissingError(this.apiClientMissingError()); } - return new ApiClient(this.baseURL, this.accessToken); + return new ApiClient(this.baseURL, this.accessToken, this.branchName); } runWithConfig Promise>( diff --git a/packages/core/src/v3/apiClientManager/types.ts b/packages/core/src/v3/apiClientManager/types.ts index b0e3da624c..2905af6d8e 100644 --- a/packages/core/src/v3/apiClientManager/types.ts +++ b/packages/core/src/v3/apiClientManager/types.ts @@ -10,5 +10,9 @@ export type ApiClientConfiguration = { * The access token to authenticate with the Trigger API. */ accessToken?: string; + /** + * The preview branch name (for preview environments) + */ + previewBranch?: string; requestOptions?: ApiRequestOptions; }; diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index bc73ce534b..2fea3f9b08 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { DeserializedJsonSchema } from "../../schemas/json.js"; -import { FlushedRunMetadata, MachinePresetName, SerializedError, TaskRunError } from "./common.js"; +import { + FlushedRunMetadata, + GitMeta, + MachinePresetName, + SerializedError, + TaskRunError, +} from "./common.js"; import { BackgroundWorkerMetadata } from "./resources.js"; import { DequeuedMessage, MachineResources } from "./runEngine.js"; @@ -302,6 +308,20 @@ export const ExternalBuildData = z.object({ export type ExternalBuildData = z.infer; +export const UpsertBranchRequestBody = z.object({ + git: GitMeta.optional(), + env: z.enum(["preview"]), + branch: z.string(), +}); + +export type UpsertBranchRequestBody = z.infer; + +export const UpsertBranchResponseBody = z.object({ + id: z.string(), +}); + +export type UpsertBranchResponseBody = z.infer; + export const InitializeDeploymentResponseBody = z.object({ id: z.string(), contentHash: z.string(), @@ -320,6 +340,7 @@ export const InitializeDeploymentRequestBody = z.object({ registryHost: z.string().optional(), selfHosted: z.boolean().optional(), namespace: z.string().optional(), + gitMeta: GitMeta.optional(), type: z.enum(["MANAGED", "UNMANAGED", "V1"]).optional(), }); diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index c3df04eaa7..5c0c276f42 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -24,6 +24,7 @@ export const BuildManifest = z.object({ contentHash: z.string(), runtime: BuildRuntime, environment: z.string(), + branch: z.string().optional(), config: ConfigManifest, files: z.array(TaskFile), sources: z.record( diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 259b902687..99e5bc5f0c 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -263,12 +263,28 @@ export const TaskRunExecutionAttempt = z.object({ status: z.string(), }); +export const GitMeta = z.object({ + commitAuthorName: z.string().optional(), + commitMessage: z.string().optional(), + commitRef: z.string().optional(), + commitSha: z.string().optional(), + dirty: z.boolean().optional(), + remoteUrl: z.string().optional(), + pullRequestNumber: z.number().optional(), + pullRequestTitle: z.string().optional(), + pullRequestState: z.enum(["open", "closed", "merged"]).optional(), +}); + +export type GitMeta = z.infer; + export type TaskRunExecutionAttempt = z.infer; export const TaskRunExecutionEnvironment = z.object({ id: z.string(), slug: z.string(), type: z.enum(["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"]), + branchName: z.string().optional(), + git: GitMeta.optional(), }); export type TaskRunExecutionEnvironment = z.infer; diff --git a/packages/react-hooks/src/hooks/useApiClient.ts b/packages/react-hooks/src/hooks/useApiClient.ts index b2cb9c6082..21f0aa53de 100644 --- a/packages/react-hooks/src/hooks/useApiClient.ts +++ b/packages/react-hooks/src/hooks/useApiClient.ts @@ -11,6 +11,8 @@ export type UseApiClientOptions = { accessToken?: string; /** Optional base URL for the API endpoints */ baseURL?: string; + /** Optional preview branch name for preview environments */ + previewBranch?: string; /** Optional additional request configuration */ requestOptions?: ApiRequestOptions; @@ -47,7 +49,7 @@ export function useApiClient(options?: UseApiClientOptions): ApiClient | undefin const baseUrl = options?.baseURL ?? auth?.baseURL ?? "https://api.trigger.dev"; const accessToken = options?.accessToken ?? auth?.accessToken; - + const previewBranch = options?.previewBranch ?? auth?.previewBranch; if (!accessToken) { if (options?.enabled === false) { return undefined; @@ -61,5 +63,5 @@ export function useApiClient(options?: UseApiClientOptions): ApiClient | undefin ...options?.requestOptions, }; - return new ApiClient(baseUrl, accessToken, requestOptions); + return new ApiClient(baseUrl, accessToken, previewBranch, requestOptions); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57d142b076..6e55c2a198 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@prisma/instrumentation': specifier: ^5.11.0 version: 5.11.0 + '@radix-ui/react-accordion': + specifier: ^1.2.11 + version: 1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-alert-dialog': specifier: ^1.0.4 version: 1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) @@ -403,8 +406,8 @@ importers: specifier: workspace:* version: link:../../internal-packages/otlp-importer '@trigger.dev/platform': - specifier: 1.0.14 - version: 1.0.14 + specifier: 1.0.15 + version: 1.0.15 '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -1233,6 +1236,9 @@ importers: fast-npm-meta: specifier: ^0.2.2 version: 0.2.2 + git-last-commit: + specifier: ^1.0.1 + version: 1.0.1 gradient-string: specifier: ^2.0.2 version: 2.0.2 @@ -1245,6 +1251,9 @@ importers: import-meta-resolve: specifier: ^4.1.0 version: 4.1.0 + ini: + specifier: ^5.0.0 + version: 5.0.0 jsonc-parser: specifier: 3.2.1 version: 3.2.1 @@ -1333,6 +1342,9 @@ importers: '@types/gradient-string': specifier: ^1.1.2 version: 1.1.2 + '@types/ini': + specifier: ^4.1.1 + version: 4.1.1 '@types/object-hash': specifier: 3.0.6 version: 3.0.6 @@ -1960,6 +1972,9 @@ importers: references/hello-world: dependencies: + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build '@trigger.dev/sdk': specifier: workspace:* version: link:../../packages/trigger-sdk @@ -9904,6 +9919,38 @@ packages: resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} dev: false + /@radix-ui/primitive@1.1.2: + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + dev: false + + /@radix-ui/react-accordion@1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-direction': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-alert-dialog@1.0.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-jbfBCRlKYlhbitueOAv7z74PXYeIQmWpKwm3jllsdkw7fGWNkxqP3v0nY9WmOzcPqpQuoorNtvViBgL46n5gVg==} peerDependencies: @@ -10014,6 +10061,33 @@ packages: react-dom: 18.2.0(react@18.3.1) dev: false + /@radix-ui/react-collapsible@1.1.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collection@1.0.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-s8WdQQ6wNXpaxdZ308KSr8fEWGrg4un8i4r/w7fhiS4ElRNjk5rRcl0/C6TANG2LvLOGIxtzo/jAg6Qf73TEBw==} peerDependencies: @@ -10092,6 +10166,29 @@ packages: react-dom: 18.2.0(react@18.3.1) dev: false + /@radix-ui/react-collection@1.1.7(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-compose-refs@1.0.0(react@18.2.0): resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==} peerDependencies: @@ -10204,6 +10301,19 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-compose-refs@1.1.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-context@1.0.0(react@18.2.0): resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==} peerDependencies: @@ -10276,6 +10386,19 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-context@1.1.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-dialog@1.0.3(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-owNhq36kNPqC2/a+zJRioPg6HHnTn5B/sh/NjTY8r4W9g1L5VJlrzZIVcBr7R9Mg8iLjVmh6MGgMlfoVf/WO/A==} peerDependencies: @@ -10412,7 +10535,7 @@ packages: '@types/react': optional: true dependencies: - '@babel/runtime': 7.27.0 + '@babel/runtime': 7.26.7 '@types/react': 18.3.1 react: 18.3.1 dev: false @@ -10430,6 +10553,19 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-direction@1.1.1(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-dismissable-layer@1.0.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q==} peerDependencies: @@ -10699,6 +10835,20 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-id@1.1.1(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-label@2.0.1(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-qcfbS3B8hTYmEO44RNcXB6pegkxRsJIbdxTMu0PEX0Luv5O2DvTIwwVYxQfUwLpM88EL84QRPLOLgwUSApMsLQ==} peerDependencies: @@ -11023,6 +11173,27 @@ packages: react-dom: 18.2.0(react@18.3.1) dev: false + /@radix-ui/react-presence@1.1.4(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-primitive@1.0.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw==} peerDependencies: @@ -11149,6 +11320,26 @@ packages: react-dom: 19.0.0(react@19.0.0) dev: false + /@radix-ui/react-primitive@2.1.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-progress@1.1.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0)(react@18.3.1): resolution: {integrity: sha512-6diOawA84f/eMxFHcWut0aE1C2kyE9dOyCTQOMRR2C/qPiXz/X0SaiA/RLbapQaXUCmy0/hLMf9meSccD1N0pA==} peerDependencies: @@ -11498,6 +11689,20 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-slot@1.2.3(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} peerDependencies: @@ -11792,6 +11997,35 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-effect-event@0.0.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0): resolution: {integrity: sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA==} peerDependencies: @@ -11904,6 +12138,19 @@ packages: react: 19.0.0 dev: false + /@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.69 + react: 18.2.0 + dev: false + /@radix-ui/react-use-previous@1.0.0(react@18.2.0): resolution: {integrity: sha512-RG2K8z/K7InnOKpq6YLDmT49HGjNmrK+fr82UCVKT2sW0GYfVnYp4wZWBooT/EYfQ5faA9uIjvsuMMhH61rheg==} peerDependencies: @@ -17143,8 +17390,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@trigger.dev/platform@1.0.14: - resolution: {integrity: sha512-sYWzsH5oNnSTe4zhm1s0JFtvuRyAjBadScE9REN4f0AkntRG572mJHOolr0HyER4k1gS1mSK0nQScXSG5LCVIA==} + /@trigger.dev/platform@1.0.15: + resolution: {integrity: sha512-rorRJJl7ecyiO8iQZcHGlXR00bTzm7e1xZt0ddCYJFhaQjxq2bo2oen5DVxUbLZsE2cp60ipQWFrmAipFwK79Q==} dependencies: zod: 3.23.8 dev: false @@ -17402,6 +17649,10 @@ packages: resolution: {integrity: sha512-K3e+NZlpCKd6Bd/EIdqjFJRFHbrq5TzPPLwREk5Iv/YoIjQrs6ljdAUCo+Lb2xFlGNOjGSE0dqsVD19cZL137w==} dev: true + /@types/ini@4.1.1: + resolution: {integrity: sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==} + dev: true + /@types/interpret@1.1.3: resolution: {integrity: sha512-uBaBhj/BhilG58r64mtDb/BEdH51HIQLgP5bmWzc5qCtFMja8dCk/IOJmk36j0lbi9QHwI6sbtUNGuqXdKCAtQ==} dependencies: @@ -23767,6 +24018,10 @@ packages: tar: 6.2.1 dev: false + /git-last-commit@1.0.1: + resolution: {integrity: sha512-FDSgeMqa7GnJDxt/q0AbrxbfeTyxp4ImxEw1e4nw6NUHA5FMhFUq33dTXI4Xdgcj1VQ1q5QLWF6WxFrJ8KCBOg==} + dev: false + /github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} dev: false @@ -24471,6 +24726,11 @@ packages: engines: {node: '>=10'} dev: true + /ini@5.0.0: + resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==} + engines: {node: ^18.17.0 || >=20.5.0} + dev: false + /inline-style-parser@0.1.1: resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} dev: true diff --git a/references/hello-world/package.json b/references/hello-world/package.json index e0617645b8..b6a8d799f4 100644 --- a/references/hello-world/package.json +++ b/references/hello-world/package.json @@ -6,6 +6,7 @@ "trigger.dev": "workspace:*" }, "dependencies": { + "@trigger.dev/build": "workspace:*", "@trigger.dev/sdk": "workspace:*", "openai": "^4.97.0", "replicate": "^1.0.1", @@ -15,4 +16,4 @@ "dev": "trigger dev", "deploy": "trigger deploy" } -} \ No newline at end of file +} diff --git a/references/hello-world/src/trigger/envvars.ts b/references/hello-world/src/trigger/envvars.ts index 3a6bd700d8..efc1dc79cb 100644 --- a/references/hello-world/src/trigger/envvars.ts +++ b/references/hello-world/src/trigger/envvars.ts @@ -32,8 +32,8 @@ export const secretEnvVar = task({ //get secret env var const secretEnvVar = vars.find((v) => v.isSecret); - assert.equal(secretEnvVar?.isSecret, true); - assert.equal(secretEnvVar?.value, ""); + assert.equal(secretEnvVar?.isSecret, true, "no secretEnvVar found"); + assert.equal(secretEnvVar?.value, "", "secretEnvVar value should be redacted"); //retrieve the secret env var const retrievedSecret = await envvars.retrieve( diff --git a/references/hello-world/src/trigger/public-access-tokens.ts b/references/hello-world/src/trigger/public-access-tokens.ts new file mode 100644 index 0000000000..ddf1ac485b --- /dev/null +++ b/references/hello-world/src/trigger/public-access-tokens.ts @@ -0,0 +1,21 @@ +import { auth, batch, logger, runs, task, tasks, timeout, wait } from "@trigger.dev/sdk"; + +export const publicAccessTokensTask = task({ + id: "public-access-tokens", + run: async (payload: any, { ctx }) => { + const token = await auth.createPublicToken({ + scopes: { + read: { + runs: [ctx.run.id], + }, + }, + }); + + logger.info("Token", { token }); + + await auth.withAuth({ accessToken: token }, async () => { + const run = await runs.retrieve(ctx.run.id); + logger.info("Run", { run }); + }); + }, +}); diff --git a/references/hello-world/trigger.config.ts b/references/hello-world/trigger.config.ts index 6712952fdf..fa6cfa566b 100644 --- a/references/hello-world/trigger.config.ts +++ b/references/hello-world/trigger.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "@trigger.dev/sdk/v3"; +import { syncEnvVars } from "@trigger.dev/build/extensions/core"; export default defineConfig({ compatibilityFlags: ["run_engine_v2"], @@ -15,9 +16,19 @@ export default defineConfig({ randomize: true, }, }, - machine: "large-1x", + machine: "small-2x", build: { extensions: [ + syncEnvVars(async (ctx) => { + console.log(ctx.environment); + console.log(ctx.branch); + return [ + { name: "SYNC_ENV", value: ctx.environment }, + { name: "BRANCH", value: ctx.branch ?? "–" }, + { name: "SECRET_KEY", value: "secret-value" }, + { name: "ANOTHER_SECRET", value: "another-secret-value" }, + ]; + }), { name: "npm-token", onBuildComplete: async (context, manifest) => {