GeezerVison is showing github.com — feat(webapp): redeploy --local-bundle deployments from the dashboard · triggerdotdev/trigger.dev@ecf3220
File treeExpand file treeCollapse file tree Open diff view settings routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +6Lines changed: 6 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +--- | | 2 | +area: webapp | | 3 | +type: feature | | 4 | +--- | | 5 | + | | 6 | +Recent deployments made with `--local-bundle` can now be redeployed from the deployments list, rebuilding the same bundle with its build-time environment variables. |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +7-1Lines changed: 7 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
@@ -158,6 +158,7 @@ export class DeploymentListPresenter { | 158 | 158 | triggeredVia: string | null; | 159 | 159 | git: Prisma.JsonValue | null; | 160 | 160 | buildServerMetadata: Prisma.JsonValue | null; | | 161 | + hasBuildEnvVars: boolean; | 161 | 162 | createdAt: Date; | 162 | 163 | integrationDeploymentId: string | null; | 163 | 164 | }[] | @@ -182,6 +183,7 @@ export class DeploymentListPresenter { | 182 | 183 | wd."triggeredVia", | 183 | 184 | wd."git", | 184 | 185 | wd."buildServerMetadata", | | 186 | + wd."buildEnvVars" IS NOT NULL AS "hasBuildEnvVars", | 185 | 187 | wd."createdAt" | 186 | 188 | ${vercelSelect} | 187 | 189 | FROM | @@ -229,6 +231,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; | 229 | 231 | const label = labeledDeployments.find( | 230 | 232 | (labeledDeployment) => labeledDeployment.deploymentId === deployment.id | 231 | 233 | ); | | 234 | + const redeploySource = resolveRedeploySource(deployment); | 232 | 235 |
| 233 | 236 | let vercelDeploymentUrl: string | null = null; | 234 | 237 | if ( | @@ -277,7 +280,10 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; | 277 | 280 | triggeredVia: deployment.triggeredVia, | 278 | 281 | git: processGitMetadata(deployment.git), | 279 | 282 | createdAt: deployment.createdAt, | 280 | | - redeploySource: resolveRedeploySource(deployment)?.kind ?? null, | | 283 | + redeploySource: | | 284 | + redeploySource?.kind === "artifact" && redeploySource.fromBundle | | 285 | + ? ("bundle" as const) | | 286 | + : (redeploySource?.kind ?? null), | 281 | 287 | vercelDeploymentUrl, | 282 | 288 | }; | 283 | 289 | }), | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +6Lines changed: 6 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -760,6 +760,12 @@ function RedeployDeploymentDialog({ | 760 | 760 | <InlineCode variant="extra-small">{deployment.git.shortSha}</InlineCode> from branch{" "} | 761 | 761 | <InlineCode variant="extra-small">{deployment.git.branchName}</InlineCode>. | 762 | 762 | </> | | 763 | + ) : deployment.redeploySource === "bundle" ? ( | | 764 | + <> | | 765 | + A new deployment will be created from the bundle uploaded for deployment{" "} | | 766 | + <InlineCode variant="extra-small">{deployment.shortCode}</InlineCode>, with the | | 767 | + build-time environment variables it was deployed with. | | 768 | + </> | 763 | 769 | ) : ( | 764 | 770 | <> | 765 | 771 | A new deployment will be created from the source files of deployment{" "} | |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/redeploy.ts +16-8Lines changed: 16 additions & 8 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -8,13 +8,20 @@ export function redeployOfShortCode(buildServerMetadata: unknown): string | null | 8 | 8 |
| 9 | 9 | export type RedeploySource = | 10 | 10 | | { kind: "github"; commitSha: string; commitRef: string; git: GitMeta } | 11 | | - | { kind: "artifact"; artifactKey: string; configFilePath?: string; git?: GitMeta }; | | 11 | + | { | | 12 | + kind: "artifact"; | | 13 | + artifactKey: string; | | 14 | + configFilePath?: string; | | 15 | + git?: GitMeta; | | 16 | + fromBundle: boolean; | | 17 | + }; | 12 | 18 |
| 13 | | -/** Which build source a deployment can be rebuilt from. Bundle uploads are not redeployable yet. */ | | 19 | +/** Which build source a deployment can be rebuilt from. A bundle also needs its stored build env vars. */ | 14 | 20 | export function resolveRedeploySource(deployment: { | 15 | 21 | triggeredVia: string | null; | 16 | 22 | git: unknown; | 17 | 23 | buildServerMetadata: unknown; | | 24 | + hasBuildEnvVars: boolean; | 18 | 25 | }): RedeploySource | null { | 19 | 26 | const git = GitMeta.safeParse(deployment.git); | 20 | 27 |
| @@ -31,17 +38,18 @@ export function resolveRedeploySource(deployment: { | 31 | 38 | } | 32 | 39 |
| 33 | 40 | const metadata = BuildServerMetadata.safeParse(deployment.buildServerMetadata); | 34 | | - if ( | 35 | | - metadata.success && | 36 | | - metadata.data.isNativeBuild && | 37 | | - metadata.data.artifactKey && | 38 | | - !metadata.data.fromBundle | 39 | | - ) { | | 41 | + if (metadata.success && metadata.data.isNativeBuild && metadata.data.artifactKey) { | | 42 | + // The key prefix still marks a bundle when the metadata flag was lost to schema skew | | 43 | + const fromBundle = | | 44 | + metadata.data.fromBundle === true || metadata.data.artifactKey.startsWith("bundles/"); | | 45 | + if (fromBundle && !deployment.hasBuildEnvVars) return null; | | 46 | + | 40 | 47 | return { | 41 | 48 | kind: "artifact", | 42 | 49 | artifactKey: metadata.data.artifactKey, | 43 | 50 | configFilePath: metadata.data.configFilePath, | 44 | 51 | git: git.success ? git.data : undefined, | | 52 | + fromBundle, | 45 | 53 | }; | 46 | 54 | } | 47 | 55 |
| |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/services/initializeDeployment.server.ts +11-34Lines changed: 11 additions & 34 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -8,7 +8,7 @@ import { resolveBuildRuntime } from "@trigger.dev/core/v3/build"; | 8 | 8 | import { customAlphabet } from "nanoid"; | 9 | 9 | import { env } from "~/env.server"; | 10 | 10 | import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; | 11 | | -import { encryptSecret } from "~/services/secrets/secretStore.server"; | | 11 | +import { type EncryptedSecretValue } from "~/services/secrets/secretStore.server"; | 12 | 12 | import { logger } from "~/services/logger.server"; | 13 | 13 | import { generateFriendlyId } from "../friendlyIdentifiers"; | 14 | 14 | import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; | @@ -20,6 +20,7 @@ import { tryCatch } from "@trigger.dev/core"; | 20 | 20 | import { getRegistryConfig } from "../registryConfig.server"; | 21 | 21 | import { DeploymentService } from "./deployment.server"; | 22 | 22 | import { recordDeploymentInitialized } from "./recordDeploymentFinished.server"; | | 23 | +import { buildEnvVarsEnvelope } from "./initializeDeployment/buildEnvVarsEnvelope.server"; | 23 | 24 | import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server"; | 24 | 25 | import { | 25 | 26 | cancelSupersededDeployments, | @@ -61,7 +62,7 @@ export class InitializeDeploymentService extends BaseService { | 61 | 62 | public async call( | 62 | 63 | environment: AuthenticatedEnvironment, | 63 | 64 | payload: InitializeDeploymentRequestBody, | 64 | | - options?: { cliVersion?: string; redeployOf?: string } | | 65 | + options?: { cliVersion?: string; redeployOf?: string; buildEnvVars?: EncryptedSecretValue } | 65 | 66 | ): Promise<InitializeDeploymentResult> { | 66 | 67 | return this.traceWithEnv("call", environment, async (span) => { | 67 | 68 | let runtime: BuildRuntime; | @@ -223,6 +224,14 @@ export class InitializeDeploymentService extends BaseService { | 223 | 224 |
| 224 | 225 | span.setAttribute("outcome", "created"); | 225 | 226 |
| | 227 | + // Before cancelling superseded deployments, so an invalid payload leaves them running | | 228 | + const encryptedBuildEnvVars = await buildEnvVarsEnvelope(payload, { | | 229 | + copyFrom: options?.buildEnvVars, | | 230 | + encryptionKey: env.ENCRYPTION_KEY, | | 231 | + maxKeys: env.DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS, | | 232 | + maxBytes: env.DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES, | | 233 | + }); | | 234 | + | 226 | 235 | const canceledDeployments = | 227 | 236 | reuse.action === "cancel-then-build" | 228 | 237 | ? await cancelSupersededDeployments({ | @@ -303,38 +312,6 @@ export class InitializeDeploymentService extends BaseService { | 303 | 312 | } | 304 | 313 | : undefined; | 305 | 314 |
| 306 | | - let encryptedBuildEnvVars: Awaited<ReturnType<typeof encryptSecret>> | undefined; | 307 | | - | 308 | | - if ( | 309 | | - payload.isNativeBuild && | 310 | | - payload.fromBundle && | 311 | | - payload.buildEnvVars && | 312 | | - Object.keys(payload.buildEnvVars).length > 0 | 313 | | - ) { | 314 | | - const buildEnvVars = payload.buildEnvVars; | 315 | | - | 316 | | - const keyCount = Object.keys(buildEnvVars).length; | 317 | | - if (keyCount > env.DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS) { | 318 | | - throw new ServiceValidationError( | 319 | | - `Build environment variable count (${keyCount}) exceeds the allowed limit of ${env.DEPLOYMENT_BUILD_ENV_VARS_MAX_KEYS}. Reach out to us if you are seeing this error consistently.` | 320 | | - ); | 321 | | - } | 322 | | - | 323 | | - const serialized = JSON.stringify(buildEnvVars); | 324 | | - const serializedBytes = Buffer.byteLength(serialized, "utf8"); | 325 | | - if (serializedBytes > env.DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES) { | 326 | | - const sizeKB = parseFloat((serializedBytes / 1024).toFixed(1)); | 327 | | - const limitKB = parseFloat( | 328 | | - (env.DEPLOYMENT_BUILD_ENV_VARS_SIZE_LIMIT_BYTES / 1024).toFixed(1) | 329 | | - ); | 330 | | - throw new ServiceValidationError( | 331 | | - `Build environment variables size (${sizeKB} KB) exceeds the allowed limit of ${limitKB} KB. Reach out to us if you are seeing this error consistently.` | 332 | | - ); | 333 | | - } | 334 | | - | 335 | | - encryptedBuildEnvVars = await encryptSecret(env.ENCRYPTION_KEY, serialized); | 336 | | - } | 337 | | - | 338 | 315 | const buildServerMetadata: BuildServerMetadata | undefined = | 339 | 316 | payload.isNativeBuild || payload.buildId || options?.redeployOf | 340 | 317 | ? { | |
Collapse file Copy file name to clipboard +55Lines changed: 55 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import { InitializeDeploymentRequestBody } from "@trigger.dev/core/v3"; | | 2 | +import { describe, expect, it } from "vitest"; | | 3 | +import { decryptSecret } from "~/services/secrets/secretStore.server"; | | 4 | +import { buildEnvVarsEnvelope } from "./buildEnvVarsEnvelope.server"; | | 5 | + | | 6 | +const encryptionKey = "0123456789abcdef0123456789abcdef"; | | 7 | +const limits = { encryptionKey, maxKeys: 3, maxBytes: 128 }; | | 8 | + | | 9 | +function payload(overrides: Record<string, unknown> = {}) { | | 10 | + return InitializeDeploymentRequestBody.parse({ | | 11 | + contentHash: "hash", | | 12 | + type: "MANAGED", | | 13 | + isNativeBuild: true, | | 14 | + artifactKey: "bundles/proj_ref/prod/abc.tar.gz", | | 15 | + fromBundle: true, | | 16 | + ...overrides, | | 17 | + }); | | 18 | +} | | 19 | + | | 20 | +async function decrypted(envelope: Awaited<ReturnType<typeof buildEnvVarsEnvelope>>) { | | 21 | + expect(envelope).toBeDefined(); | | 22 | + return JSON.parse(await decryptSecret(encryptionKey, envelope!)); | | 23 | +} | | 24 | + | | 25 | +describe("buildEnvVarsEnvelope", () => { | | 26 | + it("encrypts a bundle deploy's build env vars", async () => { | | 27 | + const vars = { DATABASE_URL: "postgres://build" }; | | 28 | + expect( | | 29 | + await decrypted(await buildEnvVarsEnvelope(payload({ buildEnvVars: vars }), limits)) | | 30 | + ).toEqual(vars); | | 31 | + }); | | 32 | + | | 33 | + it("stores an empty envelope for a bundle deploy without build env vars", async () => { | | 34 | + expect(await decrypted(await buildEnvVarsEnvelope(payload(), limits))).toEqual({}); | | 35 | + }); | | 36 | + | | 37 | + it("stores a redeploy source's envelope verbatim", async () => { | | 38 | + const copyFrom = { nonce: "n", ciphertext: "c", tag: "t" }; | | 39 | + expect( | | 40 | + await buildEnvVarsEnvelope(payload({ buildEnvVars: { IGNORED: "x" } }), { | | 41 | + ...limits, | | 42 | + copyFrom, | | 43 | + }) | | 44 | + ).toBe(copyFrom); | | 45 | + }); | | 46 | + | | 47 | + it("stores nothing for a source upload", async () => { | | 48 | + expect( | | 49 | + await buildEnvVarsEnvelope( | | 50 | + payload({ fromBundle: undefined, buildEnvVars: { A: "1" } }), | | 51 | + limits | | 52 | + ) | | 53 | + ).toBeUndefined(); | | 54 | + }); | | 55 | +}); |
Collapse file Copy file name to clipboard +39Lines changed: 39 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import { type InitializeDeploymentRequestBody } from "@trigger.dev/core/v3"; | | 2 | +import { type EncryptedSecretValue, encryptSecret } from "~/services/secrets/secretStore.server"; | | 3 | +import { ServiceValidationError } from "../baseService.server"; | | 4 | + | | 5 | +/** The envelope stored on a bundle deployment: the redeploy source's copy, or the payload's vars encrypted. */ | | 6 | +export async function buildEnvVarsEnvelope( | | 7 | + payload: InitializeDeploymentRequestBody, | | 8 | + options: { | | 9 | + copyFrom?: EncryptedSecretValue; | | 10 | + encryptionKey: string; | | 11 | + maxKeys: number; | | 12 | + maxBytes: number; | | 13 | + } | | 14 | +): Promise<EncryptedSecretValue | undefined> { | | 15 | + if (!payload.isNativeBuild || !payload.fromBundle) return undefined; | | 16 | + if (options.copyFrom) return options.copyFrom; | | 17 | + | | 18 | + // Stored even when empty: redeploying a bundle requires its envelope | | 19 | + const buildEnvVars = payload.buildEnvVars ?? {}; | | 20 | + | | 21 | + const keyCount = Object.keys(buildEnvVars).length; | | 22 | + if (keyCount > options.maxKeys) { | | 23 | + throw new ServiceValidationError( | | 24 | + `Build environment variable count (${keyCount}) exceeds the allowed limit of ${options.maxKeys}. Reach out to us if you are seeing this error consistently.` | | 25 | + ); | | 26 | + } | | 27 | + | | 28 | + const serialized = JSON.stringify(buildEnvVars); | | 29 | + const serializedBytes = Buffer.byteLength(serialized, "utf8"); | | 30 | + if (serializedBytes > options.maxBytes) { | | 31 | + const sizeKB = parseFloat((serializedBytes / 1024).toFixed(1)); | | 32 | + const limitKB = parseFloat((options.maxBytes / 1024).toFixed(1)); | | 33 | + throw new ServiceValidationError( | | 34 | + `Build environment variables size (${sizeKB} KB) exceeds the allowed limit of ${limitKB} KB. Reach out to us if you are seeing this error consistently.` | | 35 | + ); | | 36 | + } | | 37 | + | | 38 | + return encryptSecret(options.encryptionKey, serialized); | | 39 | +} |
Collapse file Copy file name to clipboardExpand all lines: apps/webapp/app/v3/services/redeployDeployment.server.test.ts +62-5Lines changed: 62 additions & 5 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -87,6 +87,22 @@ function artifactDeployment(overrides: Record<string, unknown> = {}) { | 87 | 87 | }); | 88 | 88 | } | 89 | 89 |
| | 90 | +const envelope = { nonce: "n", ciphertext: "c", tag: "t" }; | | 91 | + | | 92 | +function bundleDeployment(overrides: Record<string, unknown> = {}) { | | 93 | + return artifactDeployment({ | | 94 | + buildServerMetadata: { | | 95 | + isNativeBuild: true, | | 96 | + artifactKey: "bundles/proj_ref/prod/abc.tar.gz", | | 97 | + configFilePath: "trigger.config.ts", | | 98 | + fromBundle: true, | | 99 | + }, | | 100 | + runtime: "bun", | | 101 | + buildEnvVars: envelope, | | 102 | + ...overrides, | | 103 | + }); | | 104 | +} | | 105 | + | 90 | 106 | // findFirst resolves the source deployment, then the in-flight probe. | 91 | 107 | function mockLookups(source: unknown, inFlight: unknown = null) { | 92 | 108 | deps.findFirst.mockReset().mockResolvedValueOnce(source).mockResolvedValueOnce(inFlight); | @@ -136,10 +152,12 @@ describe("RedeployDeploymentService", () => { | 136 | 152 | ["a GitHub deployment missing the commit ref", githubDeployment({ git: { commitSha: sha } })], | 137 | 153 | ["a CLI deployment without an artifact", githubDeployment({ triggeredVia: "cli:manual" })], | 138 | 154 | [ | 139 | | - "a bundle deployment", | 140 | | - artifactDeployment({ | 141 | | - buildServerMetadata: { isNativeBuild: true, artifactKey: "bundles/x", fromBundle: true }, | 142 | | - }), | | 155 | + "a bundle deployment whose build env vars were cleared", | | 156 | + bundleDeployment({ buildEnvVars: null }), | | 157 | + ], | | 158 | + [ | | 159 | + "a bundle deployment with an unreadable envelope", | | 160 | + bundleDeployment({ buildEnvVars: { ciphertext: "c" } }), | 143 | 161 | ], | 144 | 162 | [ | 145 | 163 | "a legacy build deployment", | @@ -268,15 +286,54 @@ describe("RedeployDeploymentService", () => { | 268 | 286 | expect(payload.externalId).toBeUndefined(); | 269 | 287 | expect(payload.force).toBeUndefined(); | 270 | 288 |
| | 289 | + expect(deps.initialize.mock.calls[0]![2]).toEqual({ redeployOf: "abcd" }); | 271 | 290 | expect(deps.enqueueArtifact).toHaveBeenCalledWith( | 272 | 291 | "proj_1", | 273 | 292 | "deployment_new", | 274 | 293 | "deployments/proj_ref/prod/abc.tar.gz", | 275 | | - { configFilePath: "trigger.config.ts", skipPromotion: false } | | 294 | + { configFilePath: "trigger.config.ts", skipPromotion: false, fromBundle: false } | 276 | 295 | ); | 277 | 296 | expect(deps.enqueueGithub).not.toHaveBeenCalled(); | 278 | 297 | }); | 279 | 298 |
| | 299 | + it("rebuilds a bundle deployment and carries its build env vars over", async () => { | | 300 | + mockLookups(bundleDeployment()); | | 301 | + expect(await callOk()).toEqual({ version: "20260930.2", shortCode: "efgh" }); | | 302 | + | | 303 | + const [, payload, options] = deps.initialize.mock.calls[0]!; | | 304 | + expect(payload).toMatchObject({ | | 305 | + isNativeBuild: true, | | 306 | + artifactKey: "bundles/proj_ref/prod/abc.tar.gz", | | 307 | + configFilePath: "trigger.config.ts", | | 308 | + fromBundle: true, | | 309 | + runtime: "bun", | | 310 | + skipEnqueue: true, | | 311 | + triggeredVia: "dashboard", | | 312 | + }); | | 313 | + expect(payload.buildEnvVars).toBeUndefined(); | | 314 | + expect(options).toEqual({ redeployOf: "abcd", buildEnvVars: envelope }); | | 315 | + | | 316 | + expect(deps.enqueueArtifact).toHaveBeenCalledWith( | | 317 | + "proj_1", | | 318 | + "deployment_new", | | 319 | + "bundles/proj_ref/prod/abc.tar.gz", | | 320 | + { configFilePath: "trigger.config.ts", skipPromotion: false, fromBundle: true } | | 321 | + ); | | 322 | + }); | | 323 | + | | 324 | + it("treats a bundles/ artifact key as a bundle when the metadata flag is missing", async () => { | | 325 | + mockLookups( | | 326 | + bundleDeployment({ | | 327 | + buildServerMetadata: { | | 328 | + isNativeBuild: true, | | 329 | + artifactKey: "bundles/proj_ref/prod/abc.tar.gz", | | 330 | + }, | | 331 | + }) | | 332 | + ); | | 333 | + expect(await callOk()).toMatchObject({ shortCode: "efgh" }); | | 334 | + expect(deps.enqueueArtifact.mock.calls[0]![3]).toMatchObject({ fromBundle: true }); | | 335 | + }); | | 336 | + | 280 | 337 | it("passes skipPromotion through to the deployment and the build", async () => { | 281 | 338 | expect(await callOk(undefined, true)).toMatchObject({ shortCode: "efgh" }); | 282 | 339 | expect(deps.initialize.mock.calls[0]![1]).toMatchObject({ skipPromotion: true }); | |
|