diff --git a/.changeset/empty-dev-env-vars.md b/.changeset/empty-dev-env-vars.md new file mode 100644 index 00000000000..e28169a97e3 --- /dev/null +++ b/.changeset/empty-dev-env-vars.md @@ -0,0 +1,6 @@ +--- +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Fix stale and empty project environment values in `trigger dev`, and support empty values in `syncEnvVars()`. diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 7a2f8fe42f0..a3e55807b4e 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -2,7 +2,7 @@ name: 🤖 PR Checks on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, ready_for_review] merge_group: concurrency: @@ -91,6 +91,10 @@ jobs: - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'turbo.json' + - 'vitest.internal.config.ts' + - 'vitest.internal-reports.config.mjs' + - 'internal-test-projects.mts' + - 'test-timings.json' # The whole webapp app tree, not just its routes, and that is the whole reason this # filter exists. Two tests in @internal/observability-map read it: integration.test.ts # scans the live route tree, and webappSymbols.test.ts walks all of apps/webapp/app and @@ -110,8 +114,8 @@ jobs: # Deliberately NOT here: this package's own paths, and packages/plugins/src and # internal-packages/rbac/src, the other two trees webappSymbols.test.ts reads. # `internal` above already matches `internal-packages/**` and the shared packages, and - # `unit-tests-internal.yml` runs `turbo run test --filter "@internal/*"`, which picks up - # @internal/observability-map and runs the same vitest suite. Listing them here as well + # `unit-tests-internal.yml` loads every @internal/* test project through + # vitest.internal.config.ts, including the same observability-map suite. Listing them here as well # ran the suite twice on every PR touching them, which was this filter's own doing. # # Also deliberately NOT here: pr_checks.yml, package.json, pnpm-lock.yaml, diff --git a/.github/workflows/unit-tests-internal.yml b/.github/workflows/unit-tests-internal.yml index 8a0d5bebba2..c375d4fed8e 100644 --- a/.github/workflows/unit-tests-internal.yml +++ b/.github/workflows/unit-tests-internal.yml @@ -14,14 +14,18 @@ on: jobs: unitTests: name: "🧪 Unit Tests: Internal" - # Single big machine instead of a 12-job matrix: the internal suites are serial - # (fileParallelism: false) and container-wait-bound, so 12 in-machine shard processes - # fit comfortably in 32 vCPUs while paying the setup cost (install, prisma generate, - # image pulls) once instead of 12 times. + # Two Docker daemons and disks, with twelve serial test processes per runner. + # Global shard indices ensure every file runs exactly once across both machines. + strategy: + fail-fast: false + matrix: + machine: [1, 2] runs-on: warp-ubuntu-latest-x64-32x env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - SHARD_TOTAL: 12 + MACHINE_INDEX: ${{ matrix.machine }} + SHARDS_PER_MACHINE: 12 + SHARD_TOTAL: 24 steps: - name: 🔧 Disable IPv6 run: | @@ -104,24 +108,32 @@ jobs: run: pnpm run generate - name: 🏗️ Build test dependencies - # Build once up-front so the parallel shard runs below (turbo --only) never race - # to build or cache-restore the same outputs concurrently. + # Build once up-front so shard processes never race to build or + # cache-restore the same outputs concurrently. run: pnpm exec turbo run build --filter "@internal/*..." - - name: 🧪 Run Internal Unit Tests (${{ env.SHARD_TOTAL }} in-machine shards) + - name: 🧪 Run Internal Unit Tests (machine ${{ matrix.machine }}/2) run: | - # Same shard partitioning as the old 12-job matrix (DurationShardingSequencer - # keys off --shard=i/N), but as parallel local processes. --only skips the - # ^build dependency handled by the step above. + # Each process discovers all internal projects, then takes its globally + # balanced share of files. Keep one test file active per process. + first=$(( (MACHINE_INDEX - 1) * SHARDS_PER_MACHINE + 1 )) + last=$(( MACHINE_INDEX * SHARDS_PER_MACHINE )) status=0 declare -a pids - for i in $(seq 1 "$SHARD_TOTAL"); do - pnpm exec turbo run test --only --concurrency=1 --filter "@internal/*" -- \ - --run --reporter=default --reporter=blob --shard="$i/$SHARD_TOTAL" --passWithNoTests \ - > "/tmp/internal-shard-$i.log" 2>&1 & + for i in $(seq "$first" "$last"); do + ( + started=$SECONDS + result=0 + pnpm exec vitest run --config vitest.internal.config.ts \ + --sequence.concurrent=false --reporter=default --reporter=blob \ + --shard="$i/$SHARD_TOTAL" --passWithNoTests \ + > "/tmp/internal-shard-$i.log" 2>&1 || result=$? + echo "::notice::internal shard $i/$SHARD_TOTAL finished in $((SECONDS - started))s (exit $result)" + exit "$result" + ) & pids[i]=$! done - for i in $(seq 1 "$SHARD_TOTAL"); do + for i in $(seq "$first" "$last"); do if ! wait "${pids[i]}"; then status=1 echo "::error::internal unit test shard $i/$SHARD_TOTAL failed" @@ -132,13 +144,67 @@ jobs: done exit "$status" - - name: Gather all reports + - name: Upload blob reports if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: internal-blob-report-${{ matrix.machine }} + path: .vitest-reports/* + include-hidden-files: true + if-no-files-found: error + retention-days: 1 + + merge-reports: + name: "📊 Merge Internal Reports" + if: ${{ !cancelled() }} + needs: [unitTests] + runs-on: warp-ubuntu-latest-x64-2x + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 10.33.2 + + - name: ⎔ Setup node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.18.0 + + - name: Download blob reports + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: .vitest-reports + pattern: internal-blob-report-* + merge-multiple: true + + - name: Check all shard reports are present run: | - mkdir -p .vitest-reports - find . -type f -path '*/.vitest-reports/blob-*.json' \ - -exec bash -c 'src="$1"; basename=$(basename "$src"); pkg=$(dirname "$src" | sed "s|^\./||;s|/\.vitest-reports$||;s|/|_|g"); cp "$src" ".vitest-reports/${pkg}-${basename}"' _ {} \; + for i in $(seq 1 24); do + test -s ".vitest-reports/blob-$i-24.json" + done - name: 📊 Merge reports + run: >- + pnpm dlx vitest@4.1.7 run --config vitest.internal-reports.config.mjs --merge-reports + --reporter=default --reporter=json --outputFile.json=internal-test-results.json + + - name: Validate merged test timings + run: | + cp test-timings.json "$RUNNER_TEMP/checked-test-timings.json" + node internal-packages/testcontainers/scripts/update-test-timings.mjs \ + internal-test-results.json "$RUNNER_TEMP/checked-test-timings.json" + + - name: Upload internal test timings if: ${{ !cancelled() }} - run: pnpm dlx vitest@4.1.7 run --merge-reports --pass-with-no-tests + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: internal-test-results + path: internal-test-results.json + if-no-files-found: error + retention-days: 14 diff --git a/.server-changes/envvar-empty-value.md b/.server-changes/envvar-empty-value.md new file mode 100644 index 00000000000..892de94c810 --- /dev/null +++ b/.server-changes/envvar-empty-value.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Support empty-string environment values across the dashboard, API, and Vercel sync behind a feature flag, disabled by default. diff --git a/.server-changes/preview-branch-auto-archive.md b/.server-changes/preview-branch-auto-archive.md new file mode 100644 index 00000000000..b2b6bf5055c --- /dev/null +++ b/.server-changes/preview-branch-auto-archive.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Automatically archive preview branches after a configurable period without deployments, with protected branch names and a preview of affected branches. diff --git a/apps/supervisor/src/workloadManager/runCrd.test.ts b/apps/supervisor/src/workloadManager/runCrd.test.ts index e2db95ee1d7..e8cbcf7ef7c 100644 --- a/apps/supervisor/src/workloadManager/runCrd.test.ts +++ b/apps/supervisor/src/workloadManager/runCrd.test.ts @@ -227,3 +227,173 @@ describe("runnerTokenSecretName", () => { ); }); }); + +/** + * Ties every shared workload-manager create-option to the Runner that the + * run-crd producer builds, so a new per-run option cannot be added and quietly + * dropped. It is the class of drift that lost TRIGGER_SNAPSHOT_ROUTE: the field + * was optional, three pod backends set it, and runCrd never did, which the type + * checker was happy with. + * + * The property, stated once: every create-option field either changes the + * Runner `runnerBodyFor` builds, or is listed in RUN_CRD_EXCLUDED with a reason. + * Optionality is not an excuse; a silent drop is. It is checked by behaviour, + * not by scanning source: for each field the test sets a probe value and asks + * whether the built Runner changes. A comment, a log line, or a reference from + * another method cannot fool that, where a textual `opts.` scan could. + */ +describe("run-crd carries every shared create-option or excludes it on purpose", () => { + /** + * Every key of WorkloadManagerCreateOptions. Adding a field to the interface + * without adding it here is a compile error (see the exhaustiveness assertion + * below): a new option is acknowledged here or the build breaks. + */ + const CREATE_OPTION_KEYS = [ + "image", + "machine", + "version", + "nextAttemptNumber", + "dequeuedAt", + "placementTags", + "dequeueResponseMs", + "pollingIntervalMs", + "warmStartCheckMs", + "envId", + "envType", + "orgId", + "projectId", + "deploymentFriendlyId", + "deploymentVersion", + "runtime", + "deploymentToken", + "runId", + "runFriendlyId", + "snapshotId", + "snapshotFriendlyId", + "snapshotRoute", + "traceContext", + "annotations", + "hasPrivateLink", + ] as const satisfies readonly (keyof WorkloadManagerCreateOptions)[]; + + type ListedKey = (typeof CREATE_OPTION_KEYS)[number]; + type MissingKey = Exclude; + const KEYS_ARE_EXHAUSTIVE: [MissingKey] extends [never] ? true : ["unlisted keys", MissingKey] = + true; + + /** + * Create-option fields that do not change the Runner `runnerBodyFor` builds, + * each with the reason it is not a spec field. Every such field must appear + * here, so not carrying one is always a decision on the record rather than an + * omission the type checker allowed. Some of these still shape creation by + * another route, the runner's name or its token Secret; they just are not + * values in the spec. + * + * snapshotRoute is superseded transport rather than a gap to fill: snapshot + * routing is becoming server-owned, so the runner-facing route is being + * removed rather than built into the Runner. This entry, the field on the + * create options, and the pod backends that set it come out together when that + * lands. + */ + const RUN_CRD_EXCLUDED: Partial> = { + snapshotRoute: + "Superseded transport, not a run-crd gap: snapshot routing is becoming server-owned (a run's residency is decided from its stored state, not a runner-provided field), so the runner-facing route is being removed rather than built into the Runner. This entry, the field, and the pod backends that set it come out together when that lands.", + nextAttemptNumber: + "Not a spec field: it selects the runner's name via getRunnerId, so the attempt lives in the object's name rather than in the spec runnerBodyFor builds.", + deploymentToken: + "Not a spec field: it is written to a per-runner Secret and referenced by name (deployment.token), so the raw token is never a value in the spec.", + snapshotId: "Internal id; the bootstrap snapshot is identified by its friendly id.", + runId: "Internal id; the runner is named for, and reported against, the run's friendly id.", + version: "The deployment version is carried instead; nothing reads this alias.", + traceContext: + "Span context for the pod path's own emission; runnerBodyFor does not build it in.", + dequeueResponseMs: "Producer-side timing for the wide event; not built into the Runner.", + pollingIntervalMs: "Producer-side timing for the wide event; not built into the Runner.", + warmStartCheckMs: "Producer-side timing for the wide event; not built into the Runner.", + }; + + /** + * A value for each field that differs from createOptions()'s baseline, so a + * field the producer builds in makes the Runner change and one it ignores + * leaves it identical. Total over the keys, so a new option forces a probe + * here too. + */ + const PROBES: Record> = { + image: { image: `registry.example.com/other/worker:2@sha256:${"1".repeat(64)}` }, + machine: { machine: { name: "micro", cpu: 0.25, memory: 0.25, centsPerMs: 0 } }, + version: { version: "99.9.9" }, + nextAttemptNumber: { nextAttemptNumber: 5 }, + dequeuedAt: { dequeuedAt: new Date("2026-01-01T00:00:00.000Z") }, + placementTags: { placementTags: [{ key: "pool", values: ["spot"] }] }, + dequeueResponseMs: { dequeueResponseMs: 999 }, + pollingIntervalMs: { pollingIntervalMs: 999 }, + warmStartCheckMs: { warmStartCheckMs: 999 }, + envId: { envId: "env_other" }, + envType: { envType: "STAGING" }, + orgId: { orgId: "org_other" }, + projectId: { projectId: "proj_other" }, + deploymentFriendlyId: { deploymentFriendlyId: "deployment_other" }, + deploymentVersion: { deploymentVersion: "99.9.9" }, + runtime: { runtime: "bun" }, + deploymentToken: { deploymentToken: "tok_probe" }, + runId: { runId: "run_other_internal" }, + runFriendlyId: { runFriendlyId: "run_other" }, + snapshotId: { snapshotId: "snapshot_other_internal" }, + snapshotFriendlyId: { snapshotFriendlyId: "snapshot_other" }, + snapshotRoute: { + snapshotRoute: { version: 1, residency: "redis-primary", organizationId: "org_abc" }, + }, + traceContext: { traceContext: { traceparent: "00-probe-probe-01" } }, + annotations: { + annotations: { + triggerSource: "schedule", + triggerAction: "trigger", + rootTriggerSource: "schedule", + }, + }, + hasPrivateLink: { hasPrivateLink: true }, + }; + + const excluded = new Set(Object.keys(RUN_CRD_EXCLUDED) as ListedKey[]); + const baseline = JSON.stringify(runnerBodyFor(createOptions(), meta)); + + /** True when setting the field's probe changes the Runner runnerBodyFor builds. */ + function changesRunner(field: ListedKey): boolean { + return JSON.stringify(runnerBodyFor(createOptions(PROBES[field]), meta)) !== baseline; + } + + it("lists every create option, so a new field cannot slip past this test", () => { + // Fails to compile, not just at runtime, when a key is missing above. + expect(KEYS_ARE_EXHAUSTIVE).toBe(true); + }); + + it("gives each field a probe that sets only that field", () => { + // changesRunner reports any output difference, so a probe that also moved a + // second option could pass without its own field being built in. Pinning + // each probe to a single key keeps a difference attributable to that field. + for (const field of CREATE_OPTION_KEYS) { + expect(Object.keys(PROBES[field]), `PROBES.${field} must set only ${field}`).toEqual([field]); + } + }); + + it("builds every non-excluded create option into the Runner", () => { + const dropped = CREATE_OPTION_KEYS.filter((f) => !excluded.has(f) && !changesRunner(f)); + expect( + dropped, + `runnerBodyFor ignores create-options it neither builds in nor excludes: ${ + dropped.join(", ") || "(none)" + }. Build each into the Runner, or declare it in RUN_CRD_EXCLUDED with a reason. ` + + `Optionality is not an excuse for a silent drop.` + ).toEqual([]); + }); + + it("excludes only options the Runner genuinely does not carry", () => { + // An excluded field that changes the Runner means the exclusion is a lie: + // the field is built in after all and its reason is stale. + const carried = [...excluded].filter((f) => changesRunner(f)).sort(); + expect( + carried, + `RUN_CRD_EXCLUDED lists options runnerBodyFor does build in: ${carried.join(", ")}` + ).toEqual([]); + }); +}); diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 277a985678b..2e95576bd2b 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -1,3 +1,11 @@ +import { emptyEnvironmentVariableValuesEnabledForProject } from "~/v3/environmentVariables/emptyValuesFlag.server"; +import { + normalizeTarget, + isVercelSecretType, + toVercelEnvironmentVariableValue, + resolveVercelSharedValue, + mergeVercelEnvironmentVariableValues, +} from "~/v3/vercel/environmentVariableValues"; import pLimit from "p-limit"; import { Vercel } from "@vercel/sdk"; import type { @@ -38,12 +46,6 @@ import { // Pure helpers // --------------------------------------------------------------------------- -function normalizeTarget(target: string[] | string | undefined): string[] { - if (Array.isArray(target)) return target.filter(Boolean); - if (typeof target === "string") return [target]; - return []; -} - function readProjectEnvs( response: unknown, logContext: Record @@ -86,10 +88,6 @@ function hasVercelEnvVarForTarget(envs: ResponseBodyEnvs[], key: string, target: }); } -function isVercelSecretType(type: string): boolean { - return type === "secret" || type === "sensitive"; -} - export type CreateEnvVarsIfAbsentResult = { written: string[]; skipped: string[]; @@ -226,19 +224,6 @@ function toVercelCustomEnvironment({ return { id, slug, description, branchMatcher }; } -function toVercelEnvironmentVariableValue( - env: ResponseBodyEnvs -): VercelEnvironmentVariableValue | null { - if (!env.value) return null; - return { - key: env.key, - value: env.value, - target: normalizeTarget(env.target), - type: env.type, - isSecret: isVercelSecretType(env.type), - }; -} - // --------------------------------------------------------------------------- // Repository // --------------------------------------------------------------------------- @@ -474,7 +459,8 @@ export class VercelIntegrationRepository { teamId?: string | null, target?: string, /** If provided, only include keys that pass this filter */ - shouldIncludeKey?: (key: string) => boolean + shouldIncludeKey?: (key: string) => boolean, + allowEmptyValues = true ): ResultAsync { return wrapVercelCallWithRecovery( client.projects.filterProjectEnvs({ @@ -499,7 +485,9 @@ export class VercelIntegrationRepository { return ResultAsync.fromPromise( Promise.all( filteredEnvs.map((env) => - concurrencyLimit(() => this.#resolveEnvVarValue(client, projectId, teamId, env)) + concurrencyLimit(() => + this.#resolveEnvVarValue(client, projectId, teamId, env, allowEmptyValues) + ) ) ), (error) => toVercelApiError(error) @@ -511,12 +499,13 @@ export class VercelIntegrationRepository { client: Vercel, projectId: string, teamId: string | null | undefined, - env: ResponseBodyEnvs + env: ResponseBodyEnvs, + allowEmptyValues: boolean ): Promise { // Non-encrypted vars: use value from list response if present if (env.type !== "encrypted" || !env.id) { if (env.value === undefined || env.value === null) return null; - return toVercelEnvironmentVariableValue(env); + return toVercelEnvironmentVariableValue(env, allowEmptyValues); } // Encrypted vars: fetch decrypted value via individual endpoint @@ -545,7 +534,8 @@ export class VercelIntegrationRepository { // API returns union: ResponseBody1 has no value, ResponseBody2/3 have value const decryptedValue = (result.value as { value?: string }).value; - if (typeof decryptedValue !== "string") return null; + if (typeof decryptedValue !== "string" || (!allowEmptyValues && decryptedValue.trim() === "")) + return null; return { key: env.key, @@ -653,7 +643,8 @@ export class VercelIntegrationRepository { client: Vercel, accessToken: string, teamId: string, - projectId?: string // Optional: filter by project + projectId?: string, + allowEmptyValues = true ): ResultAsync< Array<{ key: string; @@ -684,53 +675,43 @@ export class VercelIntegrationRepository { if (isSecret) return null; - const listValue = env.value; - const applyToAllCustomEnvs = env.applyToAllCustomEnvironments; - - if (listValue) { - return { - key: envKey, - value: listValue, - target: normalizeTarget(env.target), - type, - isSecret, - applyToAllCustomEnvironments: applyToAllCustomEnvs, - }; - } - - // Try to get the decrypted value for this shared env var - const getResult = await callVercelWithRecovery( - client.environment.getSharedEnvVar({ - id: envId, - teamId, - }), - VercelSchemas.getSharedEnvVar, - { context: "getSharedEnvVar" } - ); + const value = await resolveVercelSharedValue( + env.value, + async () => { + const getResult = await callVercelWithRecovery( + client.environment.getSharedEnvVar({ id: envId, teamId }), + VercelSchemas.getSharedEnvVar, + { context: "getSharedEnvVar" } + ); - if (getResult.isOk()) { - if (!getResult.value.value) return null; - return { - key: envKey, - value: getResult.value.value, - target: normalizeTarget(env.target), - type, - isSecret, - applyToAllCustomEnvironments: applyToAllCustomEnvs, - }; - } - - logger.warn("Failed to get decrypted value for shared env var", { - teamId, - projectId, - envId, - envKey, - error: getResult.error.message, - errorType: getResult.error.errorType, - status: getResult.error.status, - authInvalid: getResult.error.authInvalid, - }); - return null; + if (getResult.isOk()) { + return getResult.value.value ?? null; + } + + logger.warn("Failed to get decrypted value for shared env var", { + teamId, + projectId, + envId, + envKey, + error: getResult.error.message, + errorType: getResult.error.errorType, + status: getResult.error.status, + authInvalid: getResult.error.authInvalid, + }); + return null; + }, + allowEmptyValues + ); + if (value === null) return null; + + return { + key: envKey, + value, + target: normalizeTarget(env.target), + type, + isSecret, + applyToAllCustomEnvironments: env.applyToAllCustomEnvironments, + }; }) ) ), @@ -1334,6 +1315,9 @@ export class VercelIntegrationRepository { } const envVarRepository = new EnvironmentVariablesRepository(); + const allowEmptyValues = await emptyEnvironmentVariableValuesEnabledForProject( + params.projectId + ); // Fetch shared env vars once (they apply across all targets) let sharedEnvVars: Array<{ @@ -1350,7 +1334,8 @@ export class VercelIntegrationRepository { client, accessToken, params.teamId, - params.vercelProjectId + params.vercelProjectId, + allowEmptyValues ); sharedEnvVars = sharedResult.unwrapOr([]); } @@ -1373,7 +1358,8 @@ export class VercelIntegrationRepository { params.vercelProjectId, params.teamId, mapping.vercelTarget, - shouldIncludeKey + shouldIncludeKey, + allowEmptyValues ); if (envVarsResult.isErr()) { @@ -1399,11 +1385,10 @@ export class VercelIntegrationRepository { return matchesTarget || matchesCustomEnv; }); - const projectEnvVarKeys = new Set(projectEnvVars.map((v) => v.key)); - const sharedEnvVarsToAdd = filteredSharedEnvVars.filter( - (v) => !projectEnvVarKeys.has(v.key) + const mergedEnvVars = mergeVercelEnvironmentVariableValues( + projectEnvVars, + filteredSharedEnvVars ); - const mergedEnvVars = [...projectEnvVars, ...sharedEnvVarsToAdd]; if (mergedEnvVars.length === 0) { return; diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 06dad762b0c..86159e061ec 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -1,3 +1,8 @@ +import { classifyPreviewBranch } from "~/utils/previewAutoArchive"; +import { + previewBranchActivity, + isPreviewAutoArchiveEnabled, +} from "~/services/previewAutoArchive.server"; import { GitMeta } from "@trigger.dev/core/v3"; import { DEFAULT_DEV_BRANCH } from "@trigger.dev/core/v3/utils/gitBranch"; import { type RuntimeEnvironmentType } from "@trigger.dev/database"; @@ -103,10 +108,12 @@ export class BranchesPresenter { projectSlug: Project["slug"]; env: BranchableEnvironmentToken; } & Options) { + const envType = toBranchableEnvironmentType(env); const project = await this.#prismaClient.project.findFirst({ select: { id: true, organizationId: true, + ...(envType === "PREVIEW" ? { organization: { select: { featureFlags: true } } } : {}), }, where: { slug: projectSlug, @@ -124,11 +131,18 @@ export class BranchesPresenter { throw new Error("Project not found"); } - const envType = toBranchableEnvironmentType(env); + const autoArchiveAvailable = + envType === "PREVIEW" && + (await isPreviewAutoArchiveEnabled( + this.#prismaClient, + project.organization?.featureFlags ?? null + )); const branchableEnvironment = await this.#prismaClient.runtimeEnvironment.findFirst({ select: { id: true, + previewAutoArchiveAfterDays: true, + previewAutoArchiveExcludedBranches: true, }, where: { projectId: project.id, @@ -149,6 +163,7 @@ export class BranchesPresenter { } return { branchableEnvironment: null, + autoArchiveAvailable, currentPage: page, totalPages: 0, hasBranches: false, @@ -235,10 +250,30 @@ export class BranchesPresenter { }, }); + const archiveActivity = autoArchiveAvailable + ? await previewBranchActivity( + this.#prismaClient, + branches.map(({ id }) => id) + ) + : new Map(); + + const excluded = new Set(branchableEnvironment.previewAutoArchiveExcludedBranches); + const archiveNow = new Date(); const branchesFiltered = branches .filter((branch) => envType === "DEVELOPMENT" || branch.branchName !== null) .map((branch) => ({ ...branch, + lastDeploymentAt: archiveActivity.get(branch.id)?.lastDeploymentAt ?? null, + autoArchive: + autoArchiveAvailable && branchableEnvironment.previewAutoArchiveAfterDays !== null + ? classifyPreviewBranch( + branch, + archiveActivity.get(branch.id)!, + branchableEnvironment.previewAutoArchiveAfterDays, + excluded, + archiveNow + ) + : null, git: processGitMetadata(branch.git), branchName: branch.branchName ?? DEFAULT_DEV_BRANCH, })); @@ -252,6 +287,7 @@ export class BranchesPresenter { return { branchableEnvironment, + autoArchiveAvailable, currentPage: page, totalPages: Math.ceil(visibleCount / BRANCHES_PER_PAGE), hasBranches: totalBranches > 0, diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index 4a217b568bc..87d59b04687 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -1,3 +1,4 @@ +import { emptyEnvironmentVariableValuesEnabled } from "~/v3/environmentVariables/emptyValuesFlag.server"; import type { PrismaClient, PrismaReplicaClient } from "~/db.server"; import { $replica, prisma } from "~/db.server"; import type { Project } from "~/models/project.server"; @@ -39,6 +40,7 @@ export class EnvironmentVariablesPresenter { const project = await this.#replicaClient.project.findFirst({ select: { id: true, + organization: { select: { featureFlags: true } }, }, where: { slug: projectSlug, @@ -176,6 +178,10 @@ export class EnvironmentVariablesPresenter { } return { + allowEmptyEnvironmentVariableValues: await emptyEnvironmentVariableValuesEnabled( + project.organization.featureFlags, + this.#prismaClient + ), environmentVariables: environmentVariables.flatMap((environmentVariable) => { return sortedEnvironments.flatMap((env) => { const valueRecord = environmentVariable.values.find((v) => v.environmentId === env.id); 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 index 044b24f8e0f..48a31788eb7 100644 --- 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 @@ -1,3 +1,4 @@ +import { AutoArchiveSettings } from "~/routes/resources.branches.auto-archive"; import { getFormProps, getInputProps, useForm } from "@conform-to/react"; import { parseWithZod } from "@conform-to/zod/v4"; import { ArrowUpCircleIcon, CheckIcon, EnvelopeIcon, PlusIcon } from "@heroicons/react/20/solid"; @@ -6,7 +7,7 @@ import { DialogClose } from "@radix-ui/react-dialog"; import { useFetcher, useSearchParams } from "@remix-run/react"; import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/v3"; -import { useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { SearchInput } from "~/components/primitives/SearchInput"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -223,6 +224,7 @@ export default function Page() { hasBranches, canPurchaseBranches, canManageBranches, + autoArchiveAvailable, extraBranches, branchPricing, maxBranchQuota, @@ -321,6 +323,21 @@ export default function Page() {
+
+ + {autoArchiveAvailable && ( + + )} + + +
{!hasBranches ? ( ) : ( <> -
- - -
-
Branch Created + {autoArchiveAvailable && ( + <> + Last deployment + Auto-archive + + )} Git Archived @@ -357,7 +371,7 @@ export default function Page() { {branches.length === 0 ? ( - + There are no matches for your filters ) : ( @@ -383,6 +397,33 @@ export default function Page() { + {autoArchiveAvailable && ( + <> + + {branch.lastDeploymentAt ? ( + + ) : ( + "Never" + )} + + + {branch.archivedAt ? ( + "–" + ) : !branch.autoArchive ? ( + "Disabled" + ) : branch.autoArchive.status === "protected" ? ( + "Never" + ) : branch.autoArchive.status === "inProgress" ? ( + "Deployment in progress" + ) : ( + + )} + + + )}
@@ -526,7 +567,7 @@ export default function Page() { ); } -export function BranchFilters() { +export function BranchFilters({ children }: { children?: ReactNode }) { const [searchParams, setSearchParams] = useSearchParams(); const { showArchived } = BranchesOptions.parse(Object.fromEntries(searchParams.entries())); @@ -546,14 +587,17 @@ export function BranchFilters() { ); return ( -
+
- +
+ + {children} +
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx index 7ba44ff4ee8..bf9af682105 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx @@ -1,5 +1,9 @@ import { getFormProps, useForm, type FieldMetadata, type FormMetadata } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod/v4"; +import { + emptyEnvironmentVariableValuesEnabled, + EMPTY_ENV_VALUES_DISABLED, +} from "~/v3/environmentVariables/emptyValuesFlag.server"; +import { parseEnvironmentVariableForm } from "~/v3/environmentVariables/forms"; import { LockClosedIcon, LockOpenIcon, @@ -54,7 +58,10 @@ import { v3EnvironmentVariablesPath, } from "~/utils/pathBuilder"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; -import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository"; +import { + EnvironmentVariableKey, + EnvironmentVariableValue, +} from "~/v3/environmentVariables/repository"; import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments"; import { pageMeta } from "~/utils/pageTitle"; @@ -62,7 +69,7 @@ export const meta = pageMeta("New environment variable"); const Variable = z.object({ key: EnvironmentVariableKey, - value: z.string().nonempty("Value is required"), + value: EnvironmentVariableValue, }); type Variable = z.infer; @@ -121,7 +128,7 @@ export const action = dashboardAction( } const formData = await request.formData(); - const submission = parseWithZod(formData, { schema }); + const submission = parseEnvironmentVariableForm(formData, schema); if (submission.status !== "success") { return json(submission.reply()); @@ -162,6 +169,7 @@ export const action = dashboardAction( }, select: { id: true, + organization: { select: { featureFlags: true } }, }, }); if (!project) { @@ -193,6 +201,13 @@ export const action = dashboardAction( ); } + if ( + submission.value.variables.some((v) => v.value.trim() === "") && + !(await emptyEnvironmentVariableValuesEnabled(project.organization.featureFlags)) + ) { + return json(submission.reply({ formErrors: [EMPTY_ENV_VALUES_DISABLED] })); + } + const repository = new EnvironmentVariablesRepository(prisma); const result = await repository.create(project.id, { ...submission.value, @@ -238,7 +253,8 @@ export default function Page() { parentData, "Environment variables page loader data must be defined when rendering the create dialog" ); - const { environments, hasStaging, writableEnvironmentIds } = parentData; + const { environments, hasStaging, writableEnvironmentIds, allowEmptyEnvironmentVariableValues } = + parentData; // Creating a variable is a write, so gate the targets on write access. const writableEnvironmentIdSet = new Set(writableEnvironmentIds); const lastSubmission = useActionData(); @@ -266,7 +282,7 @@ export default function Page() { // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseEnvironmentVariableForm(formData, schema, allowEmptyEnvironmentVariableValues); }, shouldRevalidate: "onSubmit", defaultValue: { 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 2cb1758661b..ec76f56374e 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 @@ -1,5 +1,6 @@ +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { getFormProps, getInputProps, useForm } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod/v4"; +import { parseEnvironmentVariableForm } from "~/v3/environmentVariables/forms"; import { BookOpenIcon, InformationCircleIcon, @@ -108,6 +109,7 @@ export type MaskedEnvironmentVariable = EnvironmentVariableWithSetValues & { }; export type EnvironmentVariablesPageLoaderData = { + allowEmptyEnvironmentVariableValues: boolean; environmentVariables: MaskedEnvironmentVariable[]; environments: EnvironmentVariablesEnvironment[]; hasStaging: boolean; @@ -142,13 +144,19 @@ export const loader = dashboardLoader( try { const presenter = new EnvironmentVariablesPresenter(); - const { environmentVariables, environments, hasStaging, vercelIntegration, pagination } = - await presenter.call({ - userId: user.id, - projectSlug: projectParam, - page: searchParams.page, - search: searchParams.search, - }); + const { + environmentVariables, + environments, + hasStaging, + vercelIntegration, + pagination, + allowEmptyEnvironmentVariableValues, + } = await presenter.call({ + userId: user.id, + projectSlug: projectParam, + page: searchParams.page, + search: searchParams.search, + }); const accessibleEnvironmentIds = environments .filter((env) => ability.can("read", { type: "envvars", envType: env.type })) @@ -178,6 +186,7 @@ export const loader = dashboardLoader( return typedjson({ environmentVariables: masked, + allowEmptyEnvironmentVariableValues, environments, hasStaging, vercelIntegration, @@ -232,7 +241,7 @@ export const action = dashboardAction( } const formData = await request.formData(); - const submission = parseWithZod(formData, { schema }); + const submission = parseEnvironmentVariableForm(formData, schema); if (submission.status !== "success") { return json(submission.reply()); @@ -808,6 +817,10 @@ function EditEnvironmentVariablePanel({ revealAll: boolean; }) { const [isOpen, setIsOpen] = useState(false); + const { allowEmptyEnvironmentVariableValues } = + useTypedLoaderData(); + const [emptyValueSelected, setEmptyValueSelected] = useState(false); + const [draftValue, setDraftValue] = useState(variable.value); const fetcher = useFetcher(); const lastSubmission = fetcher.data as any; @@ -826,13 +839,22 @@ function EditEnvironmentVariablePanel({ // TODO: type this lastResult: lastSubmission as any, onValidate({ formData }) { - return parseWithZod(formData, { schema }); + return parseEnvironmentVariableForm(formData, schema, allowEmptyEnvironmentVariableValues); }, shouldRevalidate: "onSubmit", }); return ( - + { + setIsOpen(open); + if (open) { + setEmptyValueSelected(false); + setDraftValue(variable.value); + } + }} + > + + )} +
+ {fetcher.data && !fetcher.data.ok && {fetcher.data.error}} +
+ { + event.preventDefault(); + toggleRef.current?.focus(); + }} + > + Auto-archive preview branches + {open && setOpen(false)} />} + + + ); +} + +function AutoArchiveForm({ + environment, + onSaved, +}: { + environment: { + id: string; + previewAutoArchiveAfterDays: number | null; + previewAutoArchiveExcludedBranches: string[]; + }; + onSaved: () => void; +}) { + const fetcher = useFetcher(); + const [preview, setPreview] = useState<{ + data?: SerializeFrom | { ok: false; error: string }; + pending: boolean; + }>({ pending: false }); + const [requestedKey, setRequestedKey] = useState(null); + const [days, setDays] = useState(String(environment.previewAutoArchiveAfterDays ?? 14)); + const [excluded, setExcluded] = useState( + environment.previewAutoArchiveExcludedBranches.join("\n") + ); + const policyKey = JSON.stringify({ + days: Number(days), + excludedBranches: [ + ...new Set( + excluded + .split("\n") + .map((name) => name.trim()) + .filter(Boolean) + ), + ], + }); + const valid = PreviewAutoArchivePolicy.safeParse(JSON.parse(policyKey)).success; + const reviewed = + valid && !preview.pending && preview.data?.ok && preview.data.policyKey === policyKey; + const busy = fetcher.state !== "idle"; + useEffect(() => { + if (!valid || busy) return; + const controller = new AbortController(); + const timer = setTimeout(async () => { + const policy = JSON.parse(policyKey) as { days: number; excludedBranches: string[] }; + setRequestedKey(policyKey); + setPreview({ pending: true }); + try { + // A read-only POST avoids URL-size limits for exclusions and Remix's + // page-wide action revalidation while the user edits the form. + const response = await fetch("/resources/branches/auto-archive", { + method: "POST", + signal: controller.signal, + body: new URLSearchParams({ + environmentId: environment.id, + enabled: "on", + intent: "preview", + days: String(policy.days), + excludedBranches: policy.excludedBranches.join("\n"), + }), + }); + const data = (await response.json()) as SerializeFrom; + if (!controller.signal.aborted) setPreview({ data, pending: false }); + } catch { + if (!controller.signal.aborted) { + setPreview({ + pending: false, + data: { ok: false, error: "Unable to preview branches. Edit a field to try again." }, + }); + } + } + }, 200); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [policyKey, valid, busy, environment.id]); + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && fetcher.data.saved) onSaved(); + }, [fetcher.state, fetcher.data, onSaved]); + + return ( + + + +
+ + setDays(event.target.value)} + /> +
+
+ +