From c49d9f623b924b94a081b6e0f53778f96d1df1dc Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:14:49 +0100 Subject: [PATCH] fix(previews): pass production runtime variables without a preview copy Previews from v4.5.1 on did not get production runtime variables that have no preview variable. They get them again, except when public PR deployments are on. The fallback skips keys that have a preview variable, also when Runtime is off on it. Each preview deployment now logs the variables that it does not get: copies with the production value that public PR deployments skip, and production variables that have no preview variable. Co-Authored-By: Claude Opus 5.5 --- ...production-runtime-variables-in-preview.md | 6 ++ app/Jobs/ApplicationDeploymentJob.php | 71 +++++++++++++++++-- ...ationDeploymentControlVarFilteringTest.php | 12 ++-- 3 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 .changes/missing-production-runtime-variables-in-preview.md diff --git a/.changes/missing-production-runtime-variables-in-preview.md b/.changes/missing-production-runtime-variables-in-preview.md new file mode 100644 index 0000000000..acda6f98fb --- /dev/null +++ b/.changes/missing-production-runtime-variables-in-preview.md @@ -0,0 +1,6 @@ +--- +type: fixed +--- +Missing production runtime variables in preview deployments when the variable has no preview variable + +- A preview gets these production runtime variables again, except when `PR deployment access` is public. The deployment log lists each variable that a preview does not get diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 8d68902ffa..2c017aa9dd 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -461,6 +461,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue try { $this->validateDeploymentEnvironmentVariableKeys(); + $this->warnAboutPreviewVariablesNotPassed(); $this->ensureRegistryImageForMultipleServers(); $this->ensureRegistryImageIsNotShared(); $this->warnAboutVolumesOnMultipleServers(); @@ -2184,9 +2185,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $envs->push($this->runtimeEnvironmentAssignment($env)); } - // A preview from before v4.5.1 also gets the production variables that have no preview copy. - if ($this->preview?->keeps_production_values && $runtime_environment_variables_preview->isNotEmpty()) { - $previewKeys = $runtime_environment_variables_preview->pluck('key')->all(); + // The production runtime variables that have no preview variable. + if ($this->previewGetsProductionRuntimeFallback()) { + $previewKeys = $this->application->environment_variables_preview()->pluck('key')->all(); $sorted_environment_variables ->filter(fn (EnvironmentVariable $env) => $env->is_runtime && ! in_array($env->key, $previewKeys, true) && ! $this->shouldOmitBlankComposeEnvironmentVariable($env)) ->each(fn (EnvironmentVariable $env) => $envs->push($this->runtimeEnvironmentAssignment($env))); @@ -2194,12 +2195,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // Add PORT if not exists, use the first port as default if ($this->build_pack !== 'dockercompose') { - if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'PORT')->isEmpty() && ! $envs->contains(fn (string $env) => str_starts_with($env, 'PORT='))) { $envs->push("PORT={$ports[0]}"); } } // Add HOST if not exists - if ($this->deployedPreviewVariables()->where('key', 'HOST')->isEmpty()) { + if ($this->deployedPreviewVariables()->where('key', 'HOST')->isEmpty() && ! $envs->contains(fn (string $env) => str_starts_with($env, 'HOST='))) { $envs->push('HOST=0.0.0.0'); } } @@ -2805,6 +2806,64 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info'); } + /** + * A preview can run code from a fork when public PR deployments are on, so it must not get production + * values. A preview from before v4.5.1 keeps them, because it was deployed with them. + */ + private function previewUsesProductionValues(): bool + { + return ! $this->application->settings->is_pr_deployments_public_enabled || (bool) $this->preview?->keeps_production_values; + } + + /** + * A preview gets the production runtime variables that have no preview variable, as before v4.5.1. + * This needs at least one runtime preview variable, so previews without preview variables stay + * without production values. + */ + private function previewGetsProductionRuntimeFallback(): bool + { + return $this->pull_request_id !== 0 + && $this->previewUsesProductionValues() + && $this->application->runtime_environment_variables_preview()->where('is_runtime', true)->exists(); + } + + /** + * Log the variables that this preview deployment does not get, so users know what to add. + */ + private function warnAboutPreviewVariablesNotPassed(): void + { + if ($this->pull_request_id === 0) { + return; + } + + $previewVariables = $this->application->environment_variables_preview()->get(); + $skippedKeys = $previewVariables->whereIn('id', $this->previewVariableIdsWithProductionValues())->pluck('key'); + if ($skippedKeys->isNotEmpty()) { + $this->application_deployment_queue->addLogEntry('⚠️ Public PR deployments are on, so this preview does not get the preview variables that still have the production value: '.$skippedKeys->implode(', '), 'stderr'); + $this->application_deployment_queue->addLogEntry(' To use them, give them a preview value in Environment Variables > Preview Deployments.', type: 'info'); + } + + $getsRuntimeFallback = $this->previewGetsProductionRuntimeFallback(); + $passesBuildtimeVariables = $this->deploymentPassesBuildtimeVariables(); + $missingKeys = $this->application->environment_variables()->get() + ->reject(fn (EnvironmentVariable $env) => $previewVariables->contains('key', $env->key)) + ->reject(fn (EnvironmentVariable $env) => $this->build_pack === 'dockercompose' && $this->isGeneratedDockerComposeEnvironmentVariable($env)) + ->map(function (EnvironmentVariable $env) use ($getsRuntimeFallback, $passesBuildtimeVariables): ?string { + $missingAtRuntime = $env->is_runtime && ! $getsRuntimeFallback; + $missingAtBuildtime = $env->is_buildtime && $passesBuildtimeVariables; + if (! $missingAtRuntime && ! $missingAtBuildtime) { + return null; + } + + return $env->is_runtime && ! $missingAtRuntime ? "{$env->key} (at build time)" : $env->key; + }) + ->filter(); + if ($missingKeys->isNotEmpty()) { + $this->application_deployment_queue->addLogEntry('⚠️ These production variables have no preview variable, so this preview does not get them: '.$missingKeys->implode(', '), 'stderr'); + $this->application_deployment_queue->addLogEntry(' To use them, add them in Environment Variables > Preview Deployments.', type: 'info'); + } + } + /** * IDs of preview variables that have the same value as the production variable with the same key. * Coolify copies each new production variable to the previews. With public PR deployments, a @@ -2818,7 +2877,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $this->previewVariableIdsWithProductionValues; } - if ($this->pull_request_id === 0 || ! $this->application->settings->is_pr_deployments_public_enabled || $this->preview?->keeps_production_values) { + if ($this->pull_request_id === 0 || $this->previewUsesProductionValues()) { return $this->previewVariableIdsWithProductionValues = []; } diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 9ad41b1846..3db71b3135 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -948,8 +948,9 @@ it('filters docker compose generated service variables from build args', functio expect($envArgs->has('SERVICE_URL_APP'))->toBeFalse(); }); -it('does not fall back to production vars when preview copies are deleted', function () { +it('falls back to production runtime vars without a preview copy only when public PR deployments are off', function (bool $isPublic) { [$application, $server] = makeDeploymentControlVarFixture(); + $application->settings()->update(['is_pr_deployments_public_enabled' => $isPublic]); createApplicationEnvironmentVariable($application, [ 'key' => 'APP_NAME', @@ -967,16 +968,19 @@ it('does not fall back to production vars when preview copies are deleted', func $application->environment_variables_preview()->where('key', 'APP_NAME')->delete(); - [$job, $reflection] = makeControlVarFilteringJob($application, $server, [ + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ 'pull_request_id' => 99, ]); /** @var Collection $runtimeEnvs */ $runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); - expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBeFalse(); + expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=')))->toBe(! $isPublic); expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('PREVIEW_FLAG=')))->toBeTrue(); -}); +})->with([ + 'public PR deployments off' => [false], + 'public PR deployments on' => [true], +]); it('does not give production runtime values to a preview copy that is not a runtime variable', function () { [$application, $server] = makeDeploymentControlVarFixture([