From 2253c40e01bd7e7d5248183eab1b591f099d175a Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 11 May 2026 22:05:07 +0200 Subject: [PATCH] fix(deployment): include commit in preview image tags Generate pull request preview image tags with both the PR id and commit so different commits on the same PR do not reuse the same image tag. Sanitize and truncate generated tags to stay within Docker tag limits. --- app/Jobs/ApplicationDeploymentJob.php | 28 ++++- .../ApplicationPreviewImageNameTest.php | 107 ++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/ApplicationPreviewImageNameTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 4f9481794..5e5606c30 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1154,12 +1154,15 @@ private function generate_image_names() $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; } } elseif ($this->pull_request_id !== 0) { + $previewImageTag = $this->previewImageTag(); + $previewBuildImageTag = $this->previewImageTag(build: true); + if ($this->application->docker_registry_image_name) { - $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; - $this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}"; + $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}"; + $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}"; } else { - $this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build"; - $this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}"; + $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}"; + $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}"; } } else { $this->dockerImageTag = str($this->commit)->substr(0, 128); @@ -1176,6 +1179,23 @@ private function generate_image_names() } } + private function previewImageTag(bool $build = false): string + { + $prefix = "pr-{$this->pull_request_id}-"; + $suffix = $build ? '-build' : ''; + $maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix)); + $commit = Str::of($this->commit ?: 'HEAD') + ->replaceMatches('/[^A-Za-z0-9_.-]/', '-') + ->substr(0, $maxCommitLength) + ->toString(); + + if ($commit === '') { + $commit = 'HEAD'; + } + + return "{$prefix}{$commit}{$suffix}"; + } + private function just_restart() { $this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}."); diff --git a/tests/Feature/ApplicationPreviewImageNameTest.php b/tests/Feature/ApplicationPreviewImageNameTest.php new file mode 100644 index 000000000..6a3fb083b --- /dev/null +++ b/tests/Feature/ApplicationPreviewImageNameTest.php @@ -0,0 +1,107 @@ +newInstanceWithoutConstructor(); + + $application = new Application; + $application->uuid = 'preview-app'; + $application->build_pack = 'dockerfile'; + $application->dockerfile = null; + $application->docker_registry_image_name = $registryImageName; + + foreach ([ + 'application' => $application, + 'pull_request_id' => $pullRequestId, + 'commit' => $commit, + ] as $property => $value) { + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setAccessible(true); + $reflectionProperty->setValue($job, $value); + } + + return $job; +} + +function generatePreviewImageNames(object $job): array +{ + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $method = $reflection->getMethod('generate_image_names'); + $method->setAccessible(true); + $method->invoke($job); + + $buildImageName = $reflection->getProperty('build_image_name'); + $buildImageName->setAccessible(true); + + $productionImageName = $reflection->getProperty('production_image_name'); + $productionImageName->setAccessible(true); + + return [ + 'build' => $buildImageName->getValue($job), + 'production' => $productionImageName->getValue($job), + ]; +} + +it('includes the pull request id and commit in preview image names', function () { + $names = generatePreviewImageNames(makePreviewImageNameJob( + commit: '111222333444555666777888999000aaabbbccc1', + pullRequestId: 123, + )); + + expect($names['production'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1') + ->and($names['build'])->toBe('preview-app:pr-123-111222333444555666777888999000aaabbbccc1-build'); +}); + +it('generates different preview image names for different commits on the same pull request', function () { + $firstCommitNames = generatePreviewImageNames(makePreviewImageNameJob( + commit: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + pullRequestId: 123, + )); + $secondCommitNames = generatePreviewImageNames(makePreviewImageNameJob( + commit: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + pullRequestId: 123, + )); + + expect($firstCommitNames['production'])->not->toBe($secondCommitNames['production']) + ->and($firstCommitNames['build'])->not->toBe($secondCommitNames['build']); +}); + +it('uses the configured registry image name for commit-specific preview tags', function () { + $names = generatePreviewImageNames(makePreviewImageNameJob( + commit: '111222333444555666777888999000aaabbbccc1', + pullRequestId: 123, + registryImageName: 'registry.example.com/team/app', + )); + + expect($names['production'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1') + ->and($names['build'])->toBe('registry.example.com/team/app:pr-123-111222333444555666777888999000aaabbbccc1-build'); +}); + +it('sanitizes and truncates preview image tags to docker tag limits', function () { + $names = generatePreviewImageNames(makePreviewImageNameJob( + commit: str_repeat('feature/add dockerfile changes/', 10), + pullRequestId: 123, + )); + + $productionTag = str($names['production'])->after(':')->toString(); + $buildTag = str($names['build'])->after(':')->toString(); + + expect(strlen($productionTag))->toBeLessThanOrEqual(128) + ->and(strlen($buildTag))->toBeLessThanOrEqual(128) + ->and($productionTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+$/') + ->and($buildTag)->toMatch('/^pr-123-[A-Za-z0-9_.-]+-build$/'); +}); + +it('keeps non-preview dockerfile image names commit based', function () { + $names = generatePreviewImageNames(makePreviewImageNameJob( + commit: '111222333444555666777888999000aaabbbccc1', + pullRequestId: 0, + )); + + expect($names['production'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1') + ->and($names['build'])->toBe('preview-app:111222333444555666777888999000aaabbbccc1-build'); +});