feat(buildExtensions): syncSupabaseEnvVars build extension (#3152)
with docs
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/build": patch
|
||||
---
|
||||
|
||||
Add syncSupabaseEnvVars to pull database connection strings and save them as trigger.dev environment variables
|
||||
@@ -2,6 +2,7 @@ import { Switch } from "~/components/primitives/Switch";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
@@ -18,6 +19,8 @@ type BuildSettingsFieldsProps = {
|
||||
atomicBuilds: EnvSlug[];
|
||||
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
|
||||
envVarsConfigLink?: string;
|
||||
/** Slugs that should be forced off and disabled, with tooltip reason. */
|
||||
disabledEnvSlugs?: Partial<Record<EnvSlug, string>>;
|
||||
};
|
||||
|
||||
export function BuildSettingsFields({
|
||||
@@ -29,7 +32,11 @@ export function BuildSettingsFields({
|
||||
atomicBuilds,
|
||||
onAtomicBuildsChange,
|
||||
envVarsConfigLink,
|
||||
disabledEnvSlugs,
|
||||
}: BuildSettingsFieldsProps) {
|
||||
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
|
||||
const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
@@ -41,11 +48,11 @@ export function BuildSettingsFields({
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
enabledSlugs.length > 0 &&
|
||||
enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -63,8 +70,13 @@ export function BuildSettingsFields({
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
return (
|
||||
<div key={slug} className="flex items-center justify-between">
|
||||
const disabled = isSlugDisabled(slug);
|
||||
const disabledReason = disabledEnvSlugs?.[slug];
|
||||
const row = (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
@@ -73,7 +85,8 @@ export function BuildSettingsFields({
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={pullEnvVarsBeforeBuild.includes(slug)}
|
||||
checked={disabled ? false : pullEnvVarsBeforeBuild.includes(slug)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(
|
||||
checked
|
||||
@@ -84,6 +97,12 @@ export function BuildSettingsFields({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (disabled && disabledReason) {
|
||||
return (
|
||||
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
|
||||
);
|
||||
}
|
||||
return row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,17 +116,17 @@ export function BuildSettingsFields({
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every(
|
||||
enabledSlugs.length > 0 &&
|
||||
enabledSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
enabledSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
@@ -122,11 +141,13 @@ export function BuildSettingsFields({
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
const disabled = isSlugDisabled(slug);
|
||||
const disabledReason = disabledEnvSlugs?.[slug];
|
||||
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
|
||||
return (
|
||||
const row = (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
|
||||
className={`flex items-center justify-between ${disabled || isPullDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
@@ -136,8 +157,8 @@ export function BuildSettingsFields({
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={discoverEnvVars.includes(slug)}
|
||||
disabled={isPullDisabled}
|
||||
checked={disabled ? false : discoverEnvVars.includes(slug)}
|
||||
disabled={disabled || isPullDisabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
@@ -148,6 +169,12 @@ export function BuildSettingsFields({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (disabled && disabledReason) {
|
||||
return (
|
||||
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
|
||||
);
|
||||
}
|
||||
return row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInsta
|
||||
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { usePostHogTracking } from "~/hooks/usePostHog";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
|
||||
function safeRedirectUrl(url: string): string | null {
|
||||
try {
|
||||
@@ -224,9 +225,10 @@ export function VercelOnboardingModal({
|
||||
() => availableEnvSlugsForOnboardingBuildSettings
|
||||
);
|
||||
|
||||
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once)
|
||||
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true
|
||||
// AND a custom Vercel environment is mapped (once)
|
||||
useEffect(() => {
|
||||
if (hasStagingEnvironment && !hasSyncedStagingRef.current) {
|
||||
if (hasStagingEnvironment && vercelStagingEnvironment && !hasSyncedStagingRef.current) {
|
||||
hasSyncedStagingRef.current = true;
|
||||
setPullEnvVarsBeforeBuild((prev) => {
|
||||
if (!prev.includes("stg")) {
|
||||
@@ -241,7 +243,15 @@ export function VercelOnboardingModal({
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasStagingEnvironment]);
|
||||
}, [hasStagingEnvironment, vercelStagingEnvironment]);
|
||||
|
||||
// Strip "stg" from build settings when the staging environment mapping is cleared
|
||||
useEffect(() => {
|
||||
if (!vercelStagingEnvironment) {
|
||||
setPullEnvVarsBeforeBuild((prev) => prev.filter((s) => s !== "stg"));
|
||||
setDiscoverEnvVars((prev) => prev.filter((s) => s !== "stg"));
|
||||
}
|
||||
}, [vercelStagingEnvironment]);
|
||||
|
||||
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once)
|
||||
useEffect(() => {
|
||||
@@ -670,6 +680,11 @@ export function VercelOnboardingModal({
|
||||
const showBuildSettings = state === "build-settings";
|
||||
const showGitHubConnection = state === "github-connection";
|
||||
|
||||
const disabledEnvSlugsForBuildSettings =
|
||||
hasStagingEnvironment && !vercelStagingEnvironment
|
||||
? ({ stg: "Map a custom Vercel environment to Staging to enable this" } as Partial<Record<EnvSlug, string>>)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open && !fromMarketplaceContext) {
|
||||
@@ -731,7 +746,7 @@ export function VercelOnboardingModal({
|
||||
)}
|
||||
|
||||
<Hint>
|
||||
Once connected, your <code className="text-xs">TRIGGER_SECRET_KEY</code> will be
|
||||
Once connected, your <code className="text-xs rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code> will be
|
||||
automatically synced to Vercel for each environment.
|
||||
</Hint>
|
||||
|
||||
@@ -775,6 +790,10 @@ export function VercelOnboardingModal({
|
||||
<Paragraph className="text-sm">
|
||||
Select which custom Vercel environment should map to Trigger.dev's Staging
|
||||
environment. Production and Preview environments are mapped automatically.
|
||||
If you skip this step, the{" "}
|
||||
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code>{" "}
|
||||
will not be installed for the staging environment in Vercel. You can configure this later in
|
||||
project settings.
|
||||
</Paragraph>
|
||||
|
||||
<Select
|
||||
@@ -800,15 +819,6 @@ export function VercelOnboardingModal({
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Callout variant="info">
|
||||
<p className="text-xs">
|
||||
If you skip this step, the{" "}
|
||||
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code>{" "}
|
||||
will not be installed for the staging environment in Vercel. You can configure this later in
|
||||
project settings.
|
||||
</p>
|
||||
</Callout>
|
||||
|
||||
<Paragraph className="text-xs text-text-dimmed">
|
||||
Make sure the staging branch in your Vercel project's Git settings matches the staging branch
|
||||
configured in your GitHub integration.
|
||||
@@ -845,25 +855,13 @@ export function VercelOnboardingModal({
|
||||
|
||||
{showEnvVarSync && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Header3>Pull Environment Variables</Header3>
|
||||
<Paragraph className="text-sm">
|
||||
Select which environment variables to pull from Vercel now. This is a one-time pull.
|
||||
Later on environment variables can be pulled before each build.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="rounded border bg-charcoal-750 px-3 py-2">
|
||||
<span className="font-medium text-text-bright">{syncableEnvVars.length}</span>
|
||||
<span className="text-text-dimmed"> can be pulled</span>
|
||||
</div>
|
||||
{secretEnvVars.length > 0 && (
|
||||
<div className="rounded border bg-charcoal-750 px-3 py-2">
|
||||
<span className="font-medium text-amber-400">{secretEnvVars.length}</span>
|
||||
<span className="text-text-dimmed"> secret (cannot pull)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3>Pull Environment Variables</Header3>
|
||||
<Paragraph className="text-sm">
|
||||
Choose which environment variables to import from Vercel. This runs as a one time pull to prefill your project with the variables it needs. You’ll be able to pull again later, or enable automatic syncing before each build if you prefer.
|
||||
If you are using Supabase or Neon branching, <TextLink href="https://trigger.dev/docs/vercel-integration#supabase-and-neon-database-branching" target="_blank" rel="noopener noreferrer">read the docs</TextLink> for the recommended setup.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded border bg-charcoal-800 p-3">
|
||||
<div>
|
||||
<Label>Pull all environment variables now</Label>
|
||||
@@ -1052,6 +1050,7 @@ export function VercelOnboardingModal({
|
||||
onDiscoverEnvVarsChange={setDiscoverEnvVars}
|
||||
atomicBuilds={atomicBuilds}
|
||||
onAtomicBuildsChange={setAtomicBuilds}
|
||||
disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
|
||||
/>
|
||||
|
||||
<FormButtons
|
||||
|
||||
@@ -1759,7 +1759,7 @@ export class VercelIntegrationRepository {
|
||||
vercelProjectId: string,
|
||||
teamId?: string | null
|
||||
): ResultAsync<void, VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.updateProject({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
@@ -1767,8 +1767,10 @@ export class VercelIntegrationRepository {
|
||||
autoAssignCustomDomains: false,
|
||||
},
|
||||
}),
|
||||
VercelSchemas.updateProject,
|
||||
"Failed to disable autoAssignCustomDomains",
|
||||
{ vercelProjectId, teamId }
|
||||
{ vercelProjectId, teamId },
|
||||
toVercelApiError
|
||||
).map(() => undefined);
|
||||
}
|
||||
|
||||
|
||||
+24
-6
@@ -632,6 +632,11 @@ function ConnectedVercelProjectForm({
|
||||
const availableEnvSlugs = getAvailableEnvSlugs(hasStagingEnvironment, hasPreviewEnvironment);
|
||||
const availableEnvSlugsForBuildSettings = getAvailableEnvSlugsForBuildSettings(hasStagingEnvironment, hasPreviewEnvironment);
|
||||
|
||||
const disabledEnvSlugsForBuildSettings: Partial<Record<EnvSlug, string>> | undefined =
|
||||
hasStagingEnvironment && !configValues.vercelStagingEnvironment
|
||||
? { stg: "Map a custom Vercel environment to Staging to enable this" }
|
||||
: undefined;
|
||||
|
||||
const formatSelectedEnvs = (selected: EnvSlug[], availableSlugs: EnvSlug[] = availableEnvSlugs): string => {
|
||||
if (selected.length === 0) return "None selected";
|
||||
if (selected.length === availableSlugs.length) return "All environments";
|
||||
@@ -727,12 +732,24 @@ function ConnectedVercelProjectForm({
|
||||
setValue={(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
const env = customEnvironments?.find((e) => e.id === value);
|
||||
setConfigValues((prev) => ({
|
||||
...prev,
|
||||
vercelStagingEnvironment: env
|
||||
? { environmentId: env.id, displayName: env.slug }
|
||||
: null,
|
||||
}));
|
||||
setConfigValues((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
vercelStagingEnvironment: env
|
||||
? { environmentId: env.id, displayName: env.slug }
|
||||
: null,
|
||||
};
|
||||
// When clearing the staging mapping, strip "stg" from build settings
|
||||
if (!env) {
|
||||
next.pullEnvVarsBeforeBuild = prev.pullEnvVarsBeforeBuild.filter(
|
||||
(s) => s !== "stg"
|
||||
);
|
||||
next.discoverEnvVars = prev.discoverEnvVars.filter(
|
||||
(s) => s !== "stg"
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
items={[{ id: "", slug: "None" }, ...customEnvironments]}
|
||||
@@ -770,6 +787,7 @@ function ConnectedVercelProjectForm({
|
||||
setConfigValues((prev) => ({ ...prev, atomicBuilds: slugs }))
|
||||
}
|
||||
envVarsConfigLink={`/orgs/${organizationSlug}/projects/${projectSlug}/env/${environmentSlug}/environment-variables`}
|
||||
disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
|
||||
/>
|
||||
|
||||
{/* Warning: autoAssignCustomDomains must be disabled for atomic deployments */}
|
||||
|
||||
@@ -85,8 +85,8 @@ export function createDefaultVercelIntegrationData(
|
||||
return {
|
||||
config: {
|
||||
atomicBuilds: ["prod"],
|
||||
pullEnvVarsBeforeBuild: ["prod", "stg", "preview"],
|
||||
discoverEnvVars: ["prod", "stg", "preview"],
|
||||
pullEnvVarsBeforeBuild: ["prod", "preview"],
|
||||
discoverEnvVars: ["prod", "preview"],
|
||||
vercelStagingEnvironment: null,
|
||||
},
|
||||
syncEnvVarsMapping: {},
|
||||
|
||||
@@ -57,6 +57,7 @@ Trigger.dev provides a set of built-in extensions that you can use to customize
|
||||
| [additionalPackages](/config/extensions/additionalPackages) | Install additional npm packages in your build image |
|
||||
| [syncEnvVars](/config/extensions/syncEnvVars) | Automatically sync environment variables from external services to Trigger.dev |
|
||||
| [syncVercelEnvVars](/config/extensions/syncEnvVars#syncVercelEnvVars) | Automatically sync environment variables from Vercel to Trigger.dev |
|
||||
| [syncSupabaseEnvVars](/config/extensions/syncEnvVars#syncSupabaseEnvVars) | Automatically sync environment variables from Supabase to Trigger.dev |
|
||||
| [esbuildPlugin](/config/extensions/esbuildPlugin) | Add existing or custom esbuild extensions to customize your build process |
|
||||
| [emitDecoratorMetadata](/config/extensions/emitDecoratorMetadata) | Enable `emitDecoratorMetadata` in your TypeScript build |
|
||||
| [audioWaveform](/config/extensions/audioWaveform) | Add Audio Waveform to your build image |
|
||||
|
||||
@@ -220,3 +220,72 @@ The extension syncs the following environment variables (with optional prefix):
|
||||
- `POSTGRES_PRISMA_URL` - Connection string optimized for Prisma
|
||||
- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE`
|
||||
- `PGHOST`, `PGHOST_UNPOOLED`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`
|
||||
|
||||
### syncSupabaseEnvVars
|
||||
|
||||
The `syncSupabaseEnvVars` build extension syncs environment variables from your Supabase project to Trigger.dev. It uses [Supabase Branching](https://supabase.com/docs/guides/deployment/branching) to automatically detect branches and build the appropriate database connection strings and API keys for your environment.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting up authentication">
|
||||
You need to set the `SUPABASE_ACCESS_TOKEN` and `SUPABASE_PROJECT_ID` environment variables, or pass them
|
||||
as arguments to the `syncSupabaseEnvVars` build extension.
|
||||
|
||||
You can generate a `SUPABASE_ACCESS_TOKEN` in your Supabase [dashboard](https://supabase.com/dashboard/account/tokens).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Running in Vercel environment">
|
||||
When running the build from a Vercel environment (determined by checking if the `VERCEL`
|
||||
environment variable is present), this extension is skipped entirely.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
<Note>
|
||||
For `prod` environments, this extension uses credentials from your default Supabase
|
||||
branch. For `preview` and `staging` environments, it matches the git branch name to a Supabase
|
||||
branch and syncs the corresponding database connection strings and API keys. `dev` environments are skipped.
|
||||
</Note>
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
import { syncSupabaseEnvVars } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<project ref>",
|
||||
// Your other config settings...
|
||||
build: {
|
||||
// This will automatically use the SUPABASE_ACCESS_TOKEN and SUPABASE_PROJECT_ID environment variables
|
||||
extensions: [syncSupabaseEnvVars()],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Or you can pass in the token, project ID, and other options as arguments:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
import { syncSupabaseEnvVars } from "@trigger.dev/build/extensions/core";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<project ref>",
|
||||
// Your other config settings...
|
||||
build: {
|
||||
extensions: [
|
||||
syncSupabaseEnvVars({
|
||||
projectId: "your-supabase-project-id",
|
||||
supabaseAccessToken: "your-supabase-access-token", // optional, we recommend to keep it as env variable
|
||||
branch: "your-branch-name", // optional, defaults to ctx.branch
|
||||
envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The extension syncs the following environment variables (with optional prefix):
|
||||
|
||||
- `DATABASE_URL`, `POSTGRES_URL`, `SUPABASE_DB_URL` — PostgreSQL connection strings
|
||||
- `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` — Individual connection parameters
|
||||
- `SUPABASE_URL` — Supabase API URL
|
||||
- `SUPABASE_ANON_KEY` — Anonymous API key
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` — Service role API key
|
||||
- `SUPABASE_JWT_SECRET` — JWT secret
|
||||
|
||||
@@ -104,10 +104,9 @@ The following variables are excluded from the Vercel → Trigger.dev sync:
|
||||
|
||||
You can control sync behavior per-variable from your project's Vercel settings. Deselecting a variable prevents its value from being updated during future syncs.
|
||||
|
||||
<Tip>
|
||||
For dynamic environment variables (e.g., from NeonDB branching), use the `syncEnvVars` build
|
||||
extension instead. Learn more about [environment variables](/deploy-environment-variables).
|
||||
</Tip>
|
||||
### Supabase and Neon database branching
|
||||
|
||||
If you use [Supabase Branching](https://supabase.com/docs/guides/deployment/branching) or [Neon Database Branching](https://neon.tech/docs/guides/branching-intro) for preview environments, disable syncing for database env vars on the Environment Variables page and use the [syncSupabaseEnvVars](/config/extensions/syncEnvVars#syncsupabaseenvvars) or [syncNeonEnvVars](/config/extensions/syncEnvVars#syncneonenvvars) build extensions instead. These extensions automatically resolve the correct branch-specific credentials at build time.
|
||||
|
||||
## Atomic deployments
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./core/aptGet.js";
|
||||
export * from "./core/ffmpeg.js";
|
||||
export * from "./core/neonSyncEnvVars.js";
|
||||
export * from "./core/vercelSyncEnvVars.js";
|
||||
export * from "./core/syncSupabaseEnvVars.js";
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { syncEnvVars } from "../core.js";
|
||||
|
||||
type EnvVar = { name: string; value: string; isParentEnv?: boolean };
|
||||
|
||||
type SupabaseBranch = {
|
||||
id: string;
|
||||
name: string;
|
||||
project_ref: string;
|
||||
parent_project_ref: string;
|
||||
is_default: boolean;
|
||||
git_branch: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type SupabaseBranchDetail = {
|
||||
ref: string;
|
||||
db_host: string;
|
||||
db_port: number;
|
||||
db_user: string;
|
||||
db_pass: string;
|
||||
jwt_secret: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type SupabaseApiKey = {
|
||||
name: string;
|
||||
api_key: string;
|
||||
};
|
||||
|
||||
// List of Supabase related environment variables to sync
|
||||
export const SUPABASE_ENV_VARS = [
|
||||
"DATABASE_URL",
|
||||
"POSTGRES_URL",
|
||||
"SUPABASE_DB_URL",
|
||||
"PGHOST",
|
||||
"PGPORT",
|
||||
"PGUSER",
|
||||
"PGPASSWORD",
|
||||
"PGDATABASE",
|
||||
"SUPABASE_URL",
|
||||
"SUPABASE_ANON_KEY",
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
"SUPABASE_JWT_SECRET",
|
||||
];
|
||||
|
||||
function buildSupabaseEnvVarMappings(options: {
|
||||
user: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
ref: string;
|
||||
jwtSecret: string;
|
||||
anonKey?: string;
|
||||
serviceRoleKey?: string;
|
||||
}): Record<string, string> {
|
||||
const { user, password, host, port, database, ref, jwtSecret, anonKey, serviceRoleKey } = options;
|
||||
|
||||
const connectionString = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}?sslmode=require`;
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
DATABASE_URL: connectionString,
|
||||
POSTGRES_URL: connectionString,
|
||||
SUPABASE_DB_URL: connectionString,
|
||||
PGHOST: host,
|
||||
PGPORT: String(port),
|
||||
PGUSER: user,
|
||||
PGPASSWORD: password,
|
||||
PGDATABASE: database,
|
||||
SUPABASE_URL: `https://${ref}.supabase.co`,
|
||||
SUPABASE_JWT_SECRET: jwtSecret,
|
||||
};
|
||||
|
||||
if (anonKey) {
|
||||
mappings.SUPABASE_ANON_KEY = anonKey;
|
||||
}
|
||||
|
||||
if (serviceRoleKey) {
|
||||
mappings.SUPABASE_SERVICE_ROLE_KEY = serviceRoleKey;
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
export function syncSupabaseEnvVars(options?: {
|
||||
projectId?: string;
|
||||
/**
|
||||
* Supabase Management API access token for authentication.
|
||||
* It's recommended to use the SUPABASE_ACCESS_TOKEN environment variable instead of hardcoding this value.
|
||||
*/
|
||||
supabaseAccessToken?: string;
|
||||
branch?: string;
|
||||
envVarPrefix?: string;
|
||||
}): BuildExtension {
|
||||
const sync = syncEnvVars(async (ctx) => {
|
||||
// Skip for development environments
|
||||
if (ctx.environment === "dev") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectId =
|
||||
options?.projectId ?? process.env.SUPABASE_PROJECT_ID ?? ctx.env.SUPABASE_PROJECT_ID;
|
||||
const supabaseAccessToken =
|
||||
options?.supabaseAccessToken ??
|
||||
process.env.SUPABASE_ACCESS_TOKEN ??
|
||||
ctx.env.SUPABASE_ACCESS_TOKEN;
|
||||
const branch = options?.branch ?? ctx.branch;
|
||||
const envVarPrefix = options?.envVarPrefix ?? "";
|
||||
const outputEnvVars = SUPABASE_ENV_VARS;
|
||||
|
||||
// Skip the whole process for Vercel environments
|
||||
if (ctx.env.VERCEL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: you did not pass in a projectId or set the SUPABASE_PROJECT_ID env var."
|
||||
);
|
||||
}
|
||||
|
||||
if (!supabaseAccessToken) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: you did not pass in a supabaseAccessToken or set the SUPABASE_ACCESS_TOKEN env var."
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${supabaseAccessToken}`,
|
||||
};
|
||||
|
||||
// Step 1: List branches
|
||||
const branchesUrl = `https://api.supabase.com/v1/projects/${projectId}/branches`;
|
||||
const [branchesFetchError, branchesResponse] = await tryCatch(
|
||||
fetch(branchesUrl, { headers })
|
||||
);
|
||||
|
||||
if (branchesFetchError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: network error fetching branches from ${branchesUrl}: ${branchesFetchError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!branchesResponse.ok) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to list branches from ${branchesUrl} (status ${branchesResponse.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const [branchesParseError, branches] = await tryCatch(
|
||||
branchesResponse.json() as Promise<SupabaseBranch[]>
|
||||
);
|
||||
|
||||
if (branchesParseError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to parse branches response from ${branchesUrl}: ${branchesParseError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (branches.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Find the target branch based on environment
|
||||
let targetBranch: SupabaseBranch | undefined;
|
||||
|
||||
if (ctx.environment === "prod") {
|
||||
targetBranch = branches.find((b) => b.is_default);
|
||||
|
||||
if (!targetBranch) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: no default Supabase branch found for the project."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!branch) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: you did not pass in a branch and no branch was detected from context."
|
||||
);
|
||||
}
|
||||
|
||||
targetBranch = branches.find((b) => b.git_branch === branch || b.name === branch);
|
||||
|
||||
if (!targetBranch) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Get branch configuration (connection details)
|
||||
const branchDetailUrl = `https://api.supabase.com/v1/branches/${targetBranch.id}`;
|
||||
const [detailFetchError, branchDetailResponse] = await tryCatch(
|
||||
fetch(branchDetailUrl, { headers })
|
||||
);
|
||||
|
||||
if (detailFetchError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: network error fetching branch details from ${branchDetailUrl}: ${detailFetchError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!branchDetailResponse.ok) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to fetch branch details from ${branchDetailUrl} (status ${branchDetailResponse.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const [detailParseError, branchDetail] = await tryCatch(
|
||||
branchDetailResponse.json() as Promise<SupabaseBranchDetail>
|
||||
);
|
||||
|
||||
if (detailParseError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to parse branch details response from ${branchDetailUrl}: ${detailParseError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Get API keys for the branch project
|
||||
const apiKeysUrl = `https://api.supabase.com/v1/projects/${branchDetail.ref}/api-keys`;
|
||||
const [apiKeysFetchError, apiKeysResponse] = await tryCatch(
|
||||
fetch(apiKeysUrl, { headers })
|
||||
);
|
||||
|
||||
let anonKey: string | undefined;
|
||||
let serviceRoleKey: string | undefined;
|
||||
|
||||
if (apiKeysFetchError) {
|
||||
console.warn(
|
||||
`syncSupabaseEnvVars: failed to fetch API keys from ${apiKeysUrl}: ${apiKeysFetchError.message}`
|
||||
);
|
||||
} else if (!apiKeysResponse.ok) {
|
||||
console.warn(
|
||||
`syncSupabaseEnvVars: failed to fetch API keys from ${apiKeysUrl} (status ${apiKeysResponse.status})`
|
||||
);
|
||||
} else {
|
||||
const [apiKeysParseError, apiKeys] = await tryCatch(
|
||||
apiKeysResponse.json() as Promise<SupabaseApiKey[]>
|
||||
);
|
||||
|
||||
if (apiKeysParseError) {
|
||||
console.warn(
|
||||
`syncSupabaseEnvVars: failed to parse API keys response from ${apiKeysUrl}: ${apiKeysParseError.message}`
|
||||
);
|
||||
} else {
|
||||
anonKey = apiKeys.find((k) => k.name === "anon")?.api_key;
|
||||
serviceRoleKey = apiKeys.find((k) => k.name === "service_role")?.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Build environment variable mappings
|
||||
const envVarMappings = buildSupabaseEnvVarMappings({
|
||||
user: branchDetail.db_user,
|
||||
password: branchDetail.db_pass,
|
||||
host: branchDetail.db_host,
|
||||
port: branchDetail.db_port,
|
||||
database: "postgres",
|
||||
ref: branchDetail.ref,
|
||||
jwtSecret: branchDetail.jwt_secret,
|
||||
anonKey,
|
||||
serviceRoleKey,
|
||||
});
|
||||
|
||||
const newEnvVars: EnvVar[] = [];
|
||||
|
||||
for (const supabaseEnvVar of outputEnvVars) {
|
||||
const prefixedKey = `${envVarPrefix}${supabaseEnvVar}`;
|
||||
if (envVarMappings[supabaseEnvVar]) {
|
||||
newEnvVars.push({
|
||||
name: prefixedKey,
|
||||
value: envVarMappings[supabaseEnvVar],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newEnvVars;
|
||||
});
|
||||
|
||||
return {
|
||||
name: "SyncSupabaseEnvVarsExtension",
|
||||
async onBuildComplete(context, manifest) {
|
||||
await sync.onBuildComplete?.(context, manifest);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user