diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index fbb3c2c2f..e8d943724 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -4,13 +4,15 @@ on: jobs: publish: runs-on: ubuntu-latest + env: + PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: 1 outputs: version: ${{ steps.get_version.outputs.version }} short_sha: ${{ steps.get_commit.outputs.sha_short }} steps: - name: Setup Depot CLI uses: depot/setup-action@v1 - + - name: ⬇️ Checkout repo uses: actions/checkout@v3 with: diff --git a/apps/webapp/app/components/primitives/Hint.tsx b/apps/webapp/app/components/primitives/Hint.tsx index e593f20e1..dc049ab0b 100644 --- a/apps/webapp/app/components/primitives/Hint.tsx +++ b/apps/webapp/app/components/primitives/Hint.tsx @@ -1,5 +1,9 @@ import { Paragraph } from "./Paragraph"; -export function Hint({ children }: { children: React.ReactNode }) { - return {children}; +export function Hint({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + + {children} + + ); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 2502c9f65..8b799c97e 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -195,7 +195,12 @@ function BlankState({ isLoading, filters }: Pick {" "} - in + in{" "} + ) : null} diff --git a/apps/webapp/app/hooks/useList.tsx b/apps/webapp/app/hooks/useList.tsx new file mode 100644 index 000000000..6350a6272 --- /dev/null +++ b/apps/webapp/app/hooks/useList.tsx @@ -0,0 +1,72 @@ +import { Reducer, useReducer } from "react"; + +export type ListState = { + items: T[]; +}; + +type AppendAction = { + type: "append"; + items: T[]; +}; + +type UpdateAction = { + type: "update"; + index: number; + item: T; +}; + +type DeleteAction = { + type: "delete"; + index: number; +}; + +type InsertAfter = { + type: "insertAfter"; + index: number; + items: T[]; +}; + +type Action = AppendAction | UpdateAction | DeleteAction | InsertAfter; + +function reducer(state: ListState, action: Action): ListState { + switch (action.type) { + case "append": + return { items: [...state.items, ...action.items] }; + case "update": + return { + items: state.items.map((v, i) => (i === action.index ? action.item : v)), + }; + case "delete": + return { items: state.items.filter((_, i) => i !== action.index) }; + case "insertAfter": + return { + items: [ + ...state.items.slice(0, action.index + 1), + ...action.items, + ...state.items.slice(action.index + 1), + ], + }; + } +} + +type HookReturn = { + items: T[]; + append: (items: T[]) => void; + update: (index: number, item: T) => void; + delete: (index: number) => void; + insertAfter: (index: number, items: T[]) => void; +}; + +export function useList(initialItems: T[]): HookReturn { + const [state, dispatch] = useReducer, Action>>(reducer, { + items: initialItems, + }); + + return { + items: state.items, + append: (items: T[]) => dispatch({ type: "append", items }), + update: (index: number, item: T) => dispatch({ type: "update", index, item }), + delete: (index: number) => dispatch({ type: "delete", index }), + insertAfter: (index: number, items: T[]) => dispatch({ type: "insertAfter", index, items }), + }; +} diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index e30da84cd..130935b2b 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -63,17 +63,18 @@ export class TestPresenter { const searchParams = createSearchParams(url, TestSearchParams); //no environmentId - if (!searchParams.success || !searchParams.params.get("environment")) { + if (!searchParams.success) { return { hasSelectedEnvironment: false as const, environments, }; } + //default to dev environment + const environment = searchParams.params.get("environment") ?? "dev"; + //is the environmentId valid? - const matchingEnvironment = project.environments.find( - (env) => env.slug === searchParams.params.get("environment") - ); + const matchingEnvironment = project.environments.find((env) => env.slug === environment); if (!matchingEnvironment) { return { hasSelectedEnvironment: false as const, diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index c0473db9a..4092089f7 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -171,17 +171,23 @@ export class TestTaskPresenter { return { triggerSource: "SCHEDULED", task: taskWithEnvironment, - runs: await Promise.all( - latestRuns.map(async (r) => { - const number = Number(r.number); + runs: ( + await Promise.all( + latestRuns.map(async (r) => { + const number = Number(r.number); - return { - ...r, - number, - payload: await getScheduleTaskRunPayload(r), - }; - }) - ), + const payload = await getScheduleTaskRunPayload(r); + + if (payload.success) { + return { + ...r, + number, + payload: payload.data, + }; + } + }) + ) + ).filter(Boolean), }; } } @@ -189,6 +195,6 @@ export class TestTaskPresenter { async function getScheduleTaskRunPayload(run: RawRun) { const payload = await parsePacket({ data: run.payload, dataType: run.payloadType }); - const parsed = ScheduledTaskPayload.parse(payload); + const parsed = ScheduledTaskPayload.safeParse(payload); return parsed; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx index 2502c4586..ed07d6843 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx @@ -1,35 +1,43 @@ -import { Submission, conform, useForm } from "@conform-to/react"; +import { + FieldConfig, + list, + requestIntent, + useFieldList, + useFieldset, + useForm, +} from "@conform-to/react"; import { parse } from "@conform-to/zod"; -import { Form, useActionData, useLocation, useNavigate, useNavigation } from "@remix-run/react"; +import { PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react"; import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; -import { Fragment, useEffect, useRef, useState } from "react"; +import { RefObject, useCallback, useEffect, useRef, useState } from "react"; import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; -import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { + environmentTextClassName, + environmentTitle, +} from "~/components/environments/EnvironmentLabel"; import { Button, LinkButton } from "~/components/primitives/Buttons"; -import { Callout } from "~/components/primitives/Callout"; +import { Checkbox } from "~/components/primitives/Checkbox"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; import { Fieldset } from "~/components/primitives/Fieldset"; import { FormButtons } from "~/components/primitives/FormButtons"; import { FormError } from "~/components/primitives/FormError"; +import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Switch } from "~/components/primitives/Switch"; import { prisma } from "~/db.server"; +import { useList } from "~/hooks/useList"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { redirectWithSuccessMessage } from "~/models/message.server"; import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { - ProjectParamSchema, - v3EnvironmentVariablesPath, - v3NewEnvironmentVariablesPath, -} from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; +import { ProjectParamSchema, v3EnvironmentVariablesPath } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; -import { CreateEnvironmentVariable } from "~/v3/environmentVariables/repository"; +import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -47,7 +55,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environments, }); } catch (error) { - console.error(error); throw new Response(undefined, { status: 400, statusText: "Something went wrong, if this problem persists please contact support.", @@ -55,9 +62,39 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } }; +const Variable = z.object({ + key: EnvironmentVariableKey, + value: z.string().nonempty("Value is required"), +}); + +type Variable = z.infer; + const schema = z.object({ - action: z.enum(["create", "create-more"]), - ...CreateEnvironmentVariable.shape, + overwrite: z.preprocess((i) => { + if (i === "true") return true; + if (i === "false") return false; + return; + }, z.boolean()), + environmentIds: z.preprocess((i) => { + if (typeof i === "string") return [i]; + + if (Array.isArray(i)) { + const ids = i.filter((v) => typeof v === "string" && v !== ""); + if (ids.length === 0) { + return; + } + return ids; + } + + return; + }, z.array(z.string(), { required_error: "At least one environment is required" })), + variables: z.preprocess((i) => { + if (!Array.isArray(i)) { + return []; + } + + return i; + }, Variable.array().nonempty("At least one variable is required")), }); export const action = async ({ request, params }: ActionFunctionArgs) => { @@ -92,22 +129,22 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const result = await repository.create(project.id, userId, submission.value); if (!result.success) { - submission.error.key = result.error; + if (result.variableErrors) { + for (const { key, error } of result.variableErrors) { + const index = submission.value.variables.findIndex((v) => v.key === key); + + if (index !== -1) { + submission.error[`variables[${index}].key`] = error; + } + } + } else { + submission.error.variables = result.error; + } + return json(submission); } - switch (submission.value.action) { - case "create": - return redirect( - v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }) - ); - case "create-more": - return redirectWithSuccessMessage( - v3NewEnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }), - request, - `Created ${submission.value.key} environment variable` - ); - } + return redirect(v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam })); }; export default function Page() { @@ -118,15 +155,11 @@ export default function Page() { const navigate = useNavigate(); const organization = useOrganization(); const project = useProject(); - const keyFieldRef = useRef(null); - const isLoading = - navigation.state !== "idle" && - navigation.formMethod === "post" && - navigation.formData?.get("action") === "create"; + const isLoading = navigation.state !== "idle" && navigation.formMethod === "post"; - const [form, { key }] = useForm({ - id: "create-environment-variable", + const [form, { environmentIds, variables }] = useForm({ + id: "create-environment-variables", // TODO: type this lastSubmission: lastSubmission as any, onValidate({ formData }) { @@ -141,14 +174,6 @@ export default function Page() { setIsOpen(true); }, []); - useEffect(() => { - if (navigation.state !== "idle") return; - if (lastSubmission !== undefined) return; - - form.ref.current?.reset(); - keyFieldRef.current?.focus(); - }, [navigation.state, lastSubmission]); - return ( - New environment variable -
+ New environment variables +
- - - - -
- - setRevealAll(e.valueOf())} - /> -
-
- {environments.map((environment, index) => { - return ( - - - +
+ {environments.map((environment) => ( + - - - - - ); - })} + {environmentTitle(environment)} + + } + variant="button" + /> + ))}
+ {environmentIds.error} + + Dev environment variables specified here will be overridden by ones in your .env + file when running locally. + + + Tip: Paste your .env into this form to populate it: + + + +
+ + setRevealAll(e.valueOf())} + /> +
+
+ + {variables.error}
- - Dev environment variables specified here will be overridden by ones in your{" "} - .env file when running locally. - - - {key.error} {form.error} - {isLoading ? "Saving" : "Save and add another"} + {isLoading ? "Saving" : "Save"}
} @@ -251,3 +279,164 @@ export default function Page() {
); } + +function FieldLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function VariableFields({ + revealValues, + formId, + variablesFields, + formRef, +}: { + revealValues: boolean; + formId?: string; + variablesFields: FieldConfig; + formRef: RefObject; +}) { + const { + items, + append, + update, + delete: remove, + insertAfter, + } = useList([{ key: "", value: "" }]); + + const handlePaste = useCallback((index: number, e: React.ClipboardEvent) => { + const clipboardData = e.clipboardData; + if (!clipboardData) return; + + let text = clipboardData.getData("text"); + //replace carriage returns + text = text.replace(/\r/g, ""); + const lines = text.split("\n"); + + const keyValuePairs = lines.flatMap((line) => { + if (line.trim().startsWith("#")) return []; + + const split = line.split("="); + if (split.length === 2) { + return [{ key: split[0], value: split[1] }]; + } + return []; + }); + + if (keyValuePairs.length === 0) return; + + //prevent default pasting + e.preventDefault(); + + const [firstPair, ...rest] = keyValuePairs; + update(index, firstPair); + + for (const pair of rest) { + requestIntent(formRef.current ?? undefined, list.append(variablesFields.name)); + } + insertAfter(index, rest); + }, []); + + const fields = useFieldList(formRef, variablesFields); + + return ( + <> + {fields.map((field, index) => { + const item = items[index]; + + return ( + update(index, value)} + onPaste={(e) => handlePaste(index, e)} + onDelete={() => { + requestIntent( + formRef.current ?? undefined, + list.remove(variablesFields.name, { index }) + ); + remove(index); + }} + showDeleteButton={items.length > 1} + showValue={revealValues} + config={field} + /> + ); + })} + + + ); +} + +function VariableField({ + formId, + index, + value, + onChange, + onPaste, + onDelete, + showDeleteButton, + showValue, + config, +}: { + formId?: string; + index: number; + value: Variable; + onChange: (value: Variable) => void; + onPaste: (e: React.ClipboardEvent) => void; + onDelete: () => void; + showDeleteButton: boolean; + showValue: boolean; + config: FieldConfig; +}) { + const ref = useRef(null); + const fields = useFieldset(ref, config); + const baseFieldName = `variables[${index}]`; + + return ( +
+ + onChange({ ...value, key: e.currentTarget.value })} + autoFocus={index === 0} + onPaste={onPaste} + /> + onChange({ ...value, value: e.currentTarget.value })} + /> + {showDeleteButton && ( +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx index ef9712496..e0d5c40a2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx @@ -187,8 +187,9 @@ export default function Page() { to={v3NewEnvironmentVariablesPath(organization, project)} variant="primary/small" LeadingIcon={PlusIcon} + shortcut={{ key: "n" }} > - New environment variable + Add new diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx index 14ff782f7..3db6aaf27 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route.tsx @@ -9,6 +9,7 @@ import { } from "~/components/environments/EnvironmentLabel"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { Header2 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioButtonCircle } from "~/components/primitives/RadioButton"; @@ -20,6 +21,7 @@ import { import { Spinner } from "~/components/primitives/Spinner"; import { Table, + TableBlankRow, TableBody, TableCell, TableHeader, @@ -32,6 +34,7 @@ import { useLinkStatus } from "~/hooks/useLinkStatus"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useTextFilter } from "~/hooks/useTextFilter"; import { SelectedEnvironment, TaskListItem, @@ -67,7 +70,7 @@ export default function Page() { //get optimistic location for the segment control const optimisticLocation = useOptimisticLocation(); - const environment = new URLSearchParams(optimisticLocation.search).get("environment"); + const environment = new URLSearchParams(optimisticLocation.search).get("environment") ?? "dev"; const navigation = useNavigation(); @@ -150,8 +153,50 @@ function TaskSelector({ tasks: TaskListItem[]; environmentSlug: string; }) { + const { filterText, setFilterText, filteredItems } = useTextFilter({ + items: tasks, + filter: (task, text) => { + if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.exportName.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.filePath.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.id.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) { + return true; + } + + if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) { + return true; + } + + return false; + }, + }); + return (
+
+ setFilterText(e.target.value)} + /> +
@@ -163,9 +208,17 @@ function TaskSelector({ - {tasks.map((t) => ( - - ))} + {filteredItems.length > 0 ? ( + filteredItems.map((t) => ( + + )) + ) : ( + + + No tasks match "{filterText}" + + + )}
diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index b6528b503..3a4e6ba6c 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -1,10 +1,17 @@ -import { Prisma, PrismaClient } from "@trigger.dev/database"; +import { Prisma, PrismaClient, RuntimeEnvironmentType } from "@trigger.dev/database"; import { z } from "zod"; +import { environmentTitle } 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 { EnvironmentVariable, ProjectEnvironmentVariable, Repository, Result } from "./repository"; -import { env } from "~/env.server"; +import { + CreateResult, + EnvironmentVariable, + ProjectEnvironmentVariable, + Repository, + Result, +} from "./repository"; function secretKeyProjectPrefix(projectId: string) { return `environmentvariable:${projectId}:`; @@ -35,8 +42,15 @@ export class EnvironmentVariablesRepository implements Repository { async create( projectId: string, userId: string, - options: { key: string; values: { value: string; environmentId: string }[] } - ): Promise { + options: { + overwrite: boolean; + environmentIds: string[]; + variables: { + key: string; + value: string; + }[]; + } + ): Promise { const project = await this.prismaClient.project.findUnique({ where: { id: projectId, @@ -55,6 +69,18 @@ export class EnvironmentVariablesRepository implements Repository { id: true, }, }, + environmentVariables: { + select: { + key: true, + values: { + select: { + environment: { + select: { id: true, type: true }, + }, + }, + }, + }, + }, }, }); @@ -62,58 +88,109 @@ export class EnvironmentVariablesRepository implements Repository { return { success: false as const, error: "Project not found" }; } - if (options.values.every((v) => !project.environments.some((e) => e.id === v.environmentId))) { + if (options.environmentIds.every((v) => !project.environments.some((e) => e.id === v))) { return { success: false as const, error: `Environment not found` }; } - //get rid of empty strings - const values = options.values.filter((v) => v.value.trim() !== ""); - + //get rid of empty variables + const values = options.variables.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` }; } + //check if any of them exist in an environment we're setting + if (!options.overwrite) { + const existingVariableKeys: { key: string; environments: RuntimeEnvironmentType[] }[] = []; + for (const variable of values) { + const existingVariable = project.environmentVariables.find((v) => v.key === variable.key); + if ( + existingVariable && + existingVariable.values.some((v) => options.environmentIds.includes(v.environment.id)) + ) { + existingVariableKeys.push({ + key: variable.key, + environments: existingVariable.values + .filter((v) => options.environmentIds.includes(v.environment.id)) + .map((v) => v.environment.type), + }); + } + } + + if (existingVariableKeys.length > 0) { + return { + success: false as const, + error: `Some of the variables are already set for these environments`, + variableErrors: existingVariableKeys.map((val) => ({ + key: val.key, + error: `Variable already set in ${val.environments + .map((e) => environmentTitle({ type: e })) + .join(", ")}.`, + })), + }; + } + } + try { const result = await $transaction(this.prismaClient, async (tx) => { - const environmentVariable = await tx.environmentVariable.create({ - data: { - key: options.key, - friendlyId: generateFriendlyId("envvar"), - project: { - connect: { - id: projectId, + for (const variable of values) { + const environmentVariable = await tx.environmentVariable.upsert({ + where: { + projectId_key: { + key: variable.key, + projectId, }, }, - }, - }); - - const secretStore = getSecretStore("DATABASE", { - prismaClient: tx, - }); - - //create the secret values and references - for (const value of values) { - const key = secretKey(projectId, value.environmentId, options.key); - - //create the secret reference - const secretReference = await tx.secretReference.create({ - data: { - key, - provider: "DATABASE", + create: { + key: variable.key, + friendlyId: generateFriendlyId("envvar"), + project: { + connect: { + id: projectId, + }, + }, }, + update: {}, }); - const variableValue = await tx.environmentVariableValue.create({ - data: { - variableId: environmentVariable.id, - environmentId: value.environmentId, - valueReferenceId: secretReference.id, - }, + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, }); - await secretStore.setSecret<{ secret: string }>(key, { - secret: value.value, - }); + //set the secret values and references + for (const environmentId of options.environmentIds) { + const key = secretKey(projectId, environmentId, variable.key); + + //create the secret reference + const secretReference = await tx.secretReference.upsert({ + where: { + key, + }, + create: { + key, + provider: "DATABASE", + }, + update: {}, + }); + + const variableValue = await tx.environmentVariableValue.upsert({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId, + }, + }, + create: { + variableId: environmentVariable.id, + environmentId: environmentId, + valueReferenceId: secretReference.id, + }, + update: {}, + }); + + await secretStore.setSecret<{ secret: string }>(key, { + secret: variable.value, + }); + } } }); @@ -126,7 +203,7 @@ export class EnvironmentVariablesRepository implements Repository { if (error.code === "P2002") { return { success: false as const, - error: `There's already an environment variable called ${options.key}.`, + error: `There was already an existing field`, }; } } diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts index 41277a861..920720290 100644 --- a/apps/webapp/app/v3/environmentVariables/repository.ts +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -1,22 +1,27 @@ import { RuntimeEnvironmentType } from "@trigger.dev/database"; import { z } from "zod"; -const EnvironmentVariable = z +export const EnvironmentVariableKey = z .string() - .nonempty("Environment variable key is required") - .regex(/^\w+$/, "Environment variables can only contain alphanumeric characters and underscores"); + .nonempty("Key is required") + .regex(/^\w+$/, "Keys can only use alphanumeric characters and underscores"); -export const CreateEnvironmentVariable = z.object({ - key: EnvironmentVariable, - values: z.array( - z.object({ - environmentId: z.string(), - value: z.string(), - }) - ), +export const CreateEnvironmentVariables = z.object({ + environmentIds: z.array(z.string()), + variables: z.array(z.object({ key: EnvironmentVariableKey, value: z.string() })), }); -export type CreateEnvironmentVariable = z.infer; +export type CreateEnvironmentVariables = z.infer; + +export type CreateResult = + | { + success: true; + } + | { + success: false; + error: string; + variableErrors?: { key: string; error: string }[]; + }; export const EditEnvironmentVariable = z.object({ id: z.string(), @@ -60,7 +65,11 @@ export type EnvironmentVariable = { }; export interface Repository { - create(projectId: string, userId: string, options: CreateEnvironmentVariable): Promise; + create( + projectId: string, + userId: string, + options: CreateEnvironmentVariables + ): Promise; edit(projectId: string, userId: string, options: EditEnvironmentVariable): Promise; getProject(projectId: string, userId: string): Promise; getEnvironment( diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 43f66000e..ddacf1b1b 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -76,6 +76,15 @@ export class TriggerTaskService extends BaseService { immediate: true, }, async (event, traceContext) => { + const runFriendlyId = generateFriendlyId("run"); + + const payloadPacket = await this.#handlePayloadPacket( + body.payload, + body.options?.payloadType ?? "application/json", + runFriendlyId, + environment + ); + const lockId = taskIdentifierToLockId(taskId); const run = await $transaction(this._prisma, async (tx) => { @@ -105,15 +114,6 @@ export class TriggerTaskService extends BaseService { event.setAttribute("queueName", queueName); span.setAttribute("queueName", queueName); - const runFriendlyId = generateFriendlyId("run"); - - const payloadPacket = await this.#handlePayloadPacket( - body.payload, - body.options?.payloadType ?? "application/json", - runFriendlyId, - environment - ); - const taskRun = await tx.taskRun.create({ data: { status: "PENDING", diff --git a/docs/v3/github-actions.mdx b/docs/v3/github-actions.mdx index 233bf5599..15ea3f81c 100644 --- a/docs/v3/github-actions.mdx +++ b/docs/v3/github-actions.mdx @@ -7,8 +7,10 @@ This simple GitHub action file will deploy you Trigger.dev tasks when new code i The deploy step will fail if any version mismatches are detected. Please see the [version pinning](/v3/github-actions#version-pinning) section for more details. -```yaml .github/workflows/release-trigger.yml -name: Deploy to Trigger.dev + + +```yaml .github/workflows/release-trigger-prod.yml +name: Deploy to Trigger.dev (prod) on: push: @@ -39,6 +41,37 @@ jobs: npx trigger.dev@beta deploy ``` + +```yaml .github/workflows/release-trigger-staging.yml +name: Deploy to Trigger.dev (staging) + +# Requires manually calling the workflow from a branch / commit to deploy to staging +on: + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: "20.x" + + - name: Install dependencies + run: npm install + + - name: 🚀 Deploy Trigger.dev + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} + run: | + npx trigger.dev@beta deploy --env staging +``` + + If you already have a GitHub action file, you can just add the final step "🚀 Deploy Trigger.dev" to your existing file. You need to add the `TRIGGER_ACCESS_TOKEN` secret to your repository. You can create a new access token by going to your profile page and then clicking on the "Personal Access Tokens" tab.