diff --git a/.ai/lessons.md b/.ai/lessons.md index ca1fb85c9c..70e8451793 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -21,7 +21,6 @@ - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. - Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. -- A running dev instance mounts the checkout, and its scheduled `CheckForUpdatesJob` writes the CDN data to `base_path('versions.json')`. Check `git diff versions.json` before you commit a version change. - A dev deployment that stays `in_progress` with no log lines and no `horizon_job_id` is waiting in a queue that Horizon does not serve. With `SELF_HOSTED=false` in `.env`, deployments go to the cloud `deployments` queue; run `./scripts/dev exec php artisan queue:work redis --queue=deployments --once` or fix the env. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. diff --git a/CHANGELOG.md b/CHANGELOG.md index 60756bda74..cfe0e46303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Choose the update channel of an instance on the update page: stable, preview with betas and release candidates, nightly with a warning that breaking changes are possible, or a custom image such as `pr-1234` +- Keep the updates of the old channel after a switch to a more stable channel, until the stable release of the installed version is out - Publish prereleases from `next` and the release branches, tagged like `4.6.0-beta.1`, and the moving `preview` tag - Publish a nightly version twice a day, tagged like `4.6.0-alpha.0.20261026.1`, with the moving `nightly` tag and the nightly channel of the CDN, from `next` or from the release branch while one is in progress - Build `main` twice a day when it changed and move the `main` tag on ghcr.io to it, versioned like `4.5.0.20261028.1`, to test fixes before their patch release diff --git a/RELEASE.md b/RELEASE.md index dbf6cfb8b2..81a11a7588 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -142,7 +142,7 @@ Print what the next run publishes with `scripts/plan-version.sh --nightly`. The main workflow builds `main` twice a day when it changed since the last build and moves `ghcr.io/coollabsio/coolify:main` to it, to test fixes before their patch release. A main build is versioned `..`, `4.5.0.20261028.1`, which sorts after `4.5.0` and before `4.5.1`. -- ghcr.io only, no version tag, no git tag, no GitHub release and no channel of the CDN +- ghcr.io only, no version tag, no git tag, no GitHub release and no channel of the CDN, an instance installs it through the custom channel with the tag `main` Print what the next run builds with `scripts/plan-version.sh --main`. @@ -172,6 +172,7 @@ The pull request workflow builds every pull request and publishes the image of a - `CDN_TOKEN` in the environment `release` is a fine-grained token for `coollabsio/coollabs-cdn` alone, with read and write access to contents and pull requests - `versions.json` of a channel names its Coolify version and the helper, Sentinel and Traefik versions of `upstream.json` - `/coolify/release.json` lists the Coolify version of every channel, each sync sets the version of its channel +- An instance on the preview or nightly channel takes the newest version of its channel and the more stable ones from `/coolify/release.json` Cloudflare keeps a file of the CDN for up to 10 minutes. To clear the cache sooner, for example once the pull request of the stable channel is merged and deployed, set `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ZONE_ID` in `.env` or the environment, with a token that can purge the cache of the zone: diff --git a/app/Actions/Server/UpdateCoolify.php b/app/Actions/Server/UpdateCoolify.php index f0701977b1..97429cd904 100644 --- a/app/Actions/Server/UpdateCoolify.php +++ b/app/Actions/Server/UpdateCoolify.php @@ -20,6 +20,8 @@ class UpdateCoolify public ?string $currentVersion = null; + public ?string $upgradeScriptUrl = null; + public function handle($manual_update = false) { if (isDev()) { @@ -34,15 +36,24 @@ class UpdateCoolify return; } + if ($settings->update_channel === 'custom') { + if ($manual_update && filled($settings->update_custom_tag)) { + $this->latestVersion = $settings->update_custom_tag; + $this->backupDatabase(); + $this->update(registryUrl: 'ghcr.io'); + } + + return; + } + // Fetch fresh version from CDN instead of using cache try { // A manual update runs in the web request, so it does not wait for a retry; it falls back to the cache. - $response = cdn_http(tries: $manual_update ? 1 : 3) - ->get(config('constants.coolify.versions_url')); + $latest = fetch_latest_versions(tries: $manual_update ? 1 : 3); - if ($response->successful()) { - $versions = $response->json(); - $this->latestVersion = data_get($versions, 'coolify.v4.version'); + if ($latest !== null) { + $this->latestVersion = data_get($latest, 'versions.coolify.v4.version'); + $this->upgradeScriptUrl = $latest['upgrade_script_url']; } else { // Fallback to cache if CDN unavailable $cacheVersion = get_latest_version_of_coolify(); @@ -135,13 +146,13 @@ class UpdateCoolify } } - private function update() + private function update(?string $registryUrl = null) { $this->ensureNoUpgradeIsRunning(); $latestHelperImageVersion = getHelperVersion(); - $upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); - $registryUrl = coolifyRegistryUrl(); + $upgradeScriptUrl = $this->upgradeScriptUrl ?? config('constants.coolify.upgrade_script_url'); + $registryUrl ??= coolifyRegistryUrl(); remote_process([ "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index 2414238b86..39dcc4519c 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -17,7 +17,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue /** A manual check runs in the web request, so it does not wait for CDN retries. */ public int $cdnTries = 3; - public function __construct(int $cdnTries = 3) + public function __construct(int $cdnTries = 3, public bool $channelChanged = false) { $this->cdnTries = $cdnTries; } @@ -31,7 +31,7 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue if (isDev() || isCloud()) { return false; } - $versions = refresh_versions_file($this->cdnTries); + $versions = refresh_versions_file($this->cdnTries, $this->channelChanged); if ($versions === null) { return false; } @@ -39,7 +39,8 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue CheckTraefikVersionJob::dispatch(); $latestVersion = data_get($versions, 'coolify.v4.version'); - $newVersionAvailable = version_compare($latestVersion, config('constants.coolify.version'), '>'); + $newVersionAvailable = instanceSettings()->update_channel !== 'custom' + && version_compare($latestVersion, config('constants.coolify.version'), '>'); instanceSettings()->update(['new_version_available' => $newVersionAvailable]); return true; diff --git a/app/Livewire/Settings/Updates.php b/app/Livewire/Settings/Updates.php index 8fd02e046e..87fbbd3b7c 100644 --- a/app/Livewire/Settings/Updates.php +++ b/app/Livewire/Settings/Updates.php @@ -31,6 +31,12 @@ class Updates extends Component #[Validate('required|string|in:docker.io,ghcr.io')] public string $docker_registry_url; + #[Validate('required|string|in:stable,preview,nightly,custom')] + public string $update_channel = 'stable'; + + #[Validate('nullable|required_if:update_channel,custom|string|max:128|regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/')] + public ?string $update_custom_tag = null; + public function mount() { if (! isInstanceAdmin()) { @@ -45,6 +51,8 @@ class Updates extends Component $this->update_check_frequency = $this->settings->update_check_frequency; $this->is_auto_update_enabled = $this->settings->is_auto_update_enabled; $this->docker_registry_url = $this->settings->docker_registry_url ?: 'docker.io'; + $this->update_channel = $this->settings->update_channel; + $this->update_custom_tag = $this->settings->update_custom_tag; } public function instantSave() @@ -58,14 +66,26 @@ class Updates extends Component } $validated = $this->validate([ 'docker_registry_url' => ['required', 'string', 'in:docker.io,ghcr.io'], + 'update_channel' => ['required', 'string', 'in:stable,preview,nightly,custom'], + 'update_custom_tag' => ['nullable', 'required_if:update_channel,custom', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/'], ]); $this->settings->auto_update_frequency = $this->auto_update_frequency; $this->settings->update_check_frequency = $this->update_check_frequency; $this->settings->is_auto_update_enabled = $this->is_auto_update_enabled; $this->settings->docker_registry_url = $validated['docker_registry_url']; + $this->settings->update_channel = $validated['update_channel']; + $this->settings->update_custom_tag = $validated['update_custom_tag']; $this->syncRegistryUrlToEnv($validated['docker_registry_url']); + $channelChanged = $this->settings->isDirty(['update_channel', 'update_custom_tag']); + if ($this->settings->isDirty('update_channel')) { + $this->settings->previous_update_channel = $this->channelFollowedUntilRelease($this->settings->getOriginal('update_channel'), $validated['update_channel']); + } $changedFields = auditChangedFields($this->settings); $this->settings->save(); + if ($channelChanged) { + (new CheckForUpdatesJob(cdnTries: 1, channelChanged: true))->handle(); + $this->dispatch('updateAvailable'); + } if ($changedFields !== []) { auditLog('ui.instance.settings.updated', [ 'team_id' => null, @@ -82,6 +102,22 @@ class Updates extends Component } } + /** + * After a switch to a more stable channel, the instance keeps the updates of the channel it left until the stable release of its version is out. + */ + private function channelFollowedUntilRelease(?string $from, string $to): ?string + { + $channels = ['stable', 'preview', 'nightly']; + $fromRank = array_search($from, $channels, true); + $toRank = array_search($to, $channels, true); + if ($fromRank === false || $toRank === false || $toRank >= $fromRank) { + return null; + } + $followed = $this->settings->getOriginal('previous_update_channel'); + + return array_search($followed, $channels, true) > $fromRank ? $followed : $from; + } + protected function syncRegistryUrlToEnv(string $registryUrl): void { if (! $this->server) { diff --git a/app/Livewire/Upgrade.php b/app/Livewire/Upgrade.php index 75d9b32b3e..45a4a28cca 100644 --- a/app/Livewire/Upgrade.php +++ b/app/Livewire/Upgrade.php @@ -51,6 +51,13 @@ class Upgrade extends Component } $settings = InstanceSettings::find(0); + if (data_get($settings, 'update_channel') === 'custom') { + $this->latestVersion = (string) $settings->update_custom_tag; + $this->isUpgradeAvailable = $this->fullButton && $this->latestVersion !== ''; + + return; + } + $hasNewerVersion = version_compare($this->latestVersion, $this->currentVersion, '>'); $newVersionAvailable = (bool) data_get($settings, 'new_version_available', false); @@ -84,7 +91,7 @@ class Upgrade extends Component 'team_id' => null, 'resource' => 'instance', 'from_version' => config('constants.coolify.version'), - 'to_version' => get_latest_version_of_coolify(), + 'to_version' => $this->latestVersion, ]); } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php index fbc9e7c099..b95328b889 100644 --- a/app/Models/InstanceSettings.php +++ b/app/Models/InstanceSettings.php @@ -46,6 +46,9 @@ class InstanceSettings extends Model 'allowed_ips', 'auto_update_frequency', 'update_check_frequency', + 'update_channel', + 'update_custom_tag', + 'previous_update_channel', 'new_version_available', 'instance_timezone', 'helper_version', diff --git a/app/Services/CoolifyUpgradeStatus.php b/app/Services/CoolifyUpgradeStatus.php index 9027d5bcd9..fb27017083 100644 --- a/app/Services/CoolifyUpgradeStatus.php +++ b/app/Services/CoolifyUpgradeStatus.php @@ -115,6 +115,10 @@ class CoolifyUpgradeStatus return false; } + if (! preg_match('/^\d+\.\d+/', $targetVersion)) { + return true; + } + return version_compare($runningVersion, $targetVersion, '>='); } } diff --git a/bootstrap/helpers/versions.php b/bootstrap/helpers/versions.php index ef6e34f295..dba3959ce9 100644 --- a/bootstrap/helpers/versions.php +++ b/bootstrap/helpers/versions.php @@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Log; function get_versions_data(): ?array { return Cache::remember('coolify:versions:all', 3600, function () { - $versionsPath = base_path('versions.json'); + $versionsPath = File::exists(storage_path('app/versions.json')) ? storage_path('app/versions.json') : base_path('versions.json'); if (! File::exists($versionsPath)) { return null; @@ -89,18 +89,81 @@ function cdn_retry_delay_ms(int $attempt, Throwable $exception): int } /** - * Fetch versions.json from the CDN and write it to the local file of this node. - * The Coolify version never goes below the cached or the running version. - * When the CDN fails, the local file stays as it is. + * @return array{versions: array, upgrade_script_url: string}|null + */ +function fetch_latest_versions(int $tries = 3): ?array +{ + $settings = instanceSettings(); + $cdnUrl = dirname(config('constants.coolify.versions_url')); + $channel = 'stable'; + if ($settings->update_channel !== 'stable' || $settings->previous_update_channel !== null) { + $response = cdn_http($tries)->get("{$cdnUrl}/release.json"); + $release = $response->json(); + if ($response->failed() || ! is_array($release)) { + return null; + } + $channel = newest_update_channel(array_map(fn ($channel) => data_get($channel, 'coolify'), $release)); + } + + $channelUrl = $channel === 'stable' ? null : "{$cdnUrl}/{$channel}"; + $response = cdn_http($tries)->get($channelUrl ? "{$channelUrl}/versions.json" : config('constants.coolify.versions_url')); + $versions = $response->json(); + if ($response->failed() || ! is_string(data_get($versions, 'coolify.v4.version'))) { + return null; + } + + return [ + 'versions' => $versions, + 'upgrade_script_url' => $channelUrl ? "{$channelUrl}/upgrade.sh" : config('constants.coolify.upgrade_script_url'), + ]; +} + +/** + * The channel of the newest Coolify version this instance takes: its update channel and the more stable ones. + * After a switch to a more stable channel, also the channel it left, below the stable release of the running version, + * so an instance on a nightly of 4.6.0 gets the nightlies of 4.6.0 until 4.6.0 is released. + * + * @param array $versions The Coolify version of each channel + */ +function newest_update_channel(array $versions): string +{ + $settings = instanceSettings(); + $channels = ['stable', 'preview', 'nightly']; + $follows = (int) array_search($settings->update_channel, $channels, true); + $followsUntilRelease = $settings->previous_update_channel ? (int) array_search($settings->previous_update_channel, $channels, true) : -1; + $release = preg_replace('/^(\d+\.\d+\.\d+).*/', '$1', config('constants.coolify.version')); + $newest = 'stable'; + foreach ($channels as $rank => $channel) { + $version = $versions[$channel] ?? null; + if (! is_string($version) || version_compare($version, (string) ($versions[$newest] ?? ''), '<=')) { + continue; + } + if ($rank <= $follows || ($rank <= $followsUntilRelease && version_compare($version, $release, '<'))) { + $newest = $channel; + } + } + + return $newest; +} + +/** + * Fetch versions.json from the CDN and replace the local file of this node with it in one step. + * The Coolify version never goes below the running version, or below the cached one while the channel stays. + * When the CDN fails, the local file stays as it is. After a channel change it is removed instead, + * so the versions of the image apply until the next check and not the version of the old channel. * * @return array|null The written versions data, or null if the CDN fetch failed */ -function refresh_versions_file(int $tries = 3): ?array +function refresh_versions_file(int $tries = 3, bool $channelChanged = false): ?array { - $response = cdn_http($tries)->get(config('constants.coolify.versions_url')); - $versions = $response->json(); - if ($response->failed() || ! is_array($versions)) { - Log::warning('Could not fetch versions.json from the CDN, using the local file', ['status' => $response->status()]); + $versionsPath = storage_path('app/versions.json'); + $versions = fetch_latest_versions($tries)['versions'] ?? null; + if ($versions === null) { + Log::warning('Could not fetch versions.json from the CDN, using the local file'); + if ($channelChanged) { + File::delete($versionsPath); + invalidate_versions_cache(); + } return null; } @@ -109,8 +172,8 @@ function refresh_versions_file(int $tries = 3): ?array $currentVersion = config('constants.coolify.version'); $existingCoolifyVersion = null; - if (File::exists(base_path('versions.json'))) { - $existingVersions = json_decode(File::get(base_path('versions.json')), true); + if (! $channelChanged && File::exists($versionsPath)) { + $existingVersions = json_decode(File::get($versionsPath), true); $existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version'); } @@ -138,7 +201,7 @@ function refresh_versions_file(int $tries = 3): ?array data_set($versions, 'coolify.v4.version', $bestVersion); - File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT)); + File::replace($versionsPath, json_encode($versions, JSON_PRETTY_PRINT)); invalidate_versions_cache(); update_helper_version($versions); diff --git a/database/migrations/2026_11_01_142702_add_update_channel_to_instance_settings_table.php b/database/migrations/2026_11_01_142702_add_update_channel_to_instance_settings_table.php new file mode 100644 index 0000000000..72e96a41ac --- /dev/null +++ b/database/migrations/2026_11_01_142702_add_update_channel_to_instance_settings_table.php @@ -0,0 +1,17 @@ +string('update_channel')->default('stable'); + $table->string('update_custom_tag')->nullable(); + $table->string('previous_update_channel')->nullable(); + }); + } +}; diff --git a/resources/views/livewire/settings/updates.blade.php b/resources/views/livewire/settings/updates.blade.php index 73ebedac72..6a096081be 100644 --- a/resources/views/livewire/settings/updates.blade.php +++ b/resources/views/livewire/settings/updates.blade.php @@ -6,7 +6,7 @@
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}} - + @@ -18,6 +18,36 @@
+ + + @if ($update_channel === 'custom') + + @else + + @endif + + @if ($update_channel === 'nightly') + + Nightly builds are untested builds of the next version. Things can break at any time and + breaking changes are possible, including database changes a stable release does not expect. + Back up first and use nightly only on an instance you can rebuild. After switching back to stable, + the instance keeps getting the nightlies of its version until that version is released as stable. + + @elseif ($update_channel === 'custom') + + The image installs only when you upgrade to it here, and automatic updates pause until you + switch back to a channel. Pull request builds are untested and can include database changes + a later release does not expect. + + @endif + diff --git a/tests/Feature/CheckForUpdatesTraefikTest.php b/tests/Feature/CheckForUpdatesTraefikTest.php index ac53a5a8cb..3cac6349f8 100644 --- a/tests/Feature/CheckForUpdatesTraefikTest.php +++ b/tests/Feature/CheckForUpdatesTraefikTest.php @@ -19,7 +19,7 @@ it('checks installed Traefik versions after refreshing available versions', func ]), ]); File::shouldReceive('exists')->andReturn(false); - File::shouldReceive('put')->once(); + File::shouldReceive('replace')->once(); InstanceSettings::forceCreate(['id' => 0]); diff --git a/tests/Feature/HelperVersionTest.php b/tests/Feature/HelperVersionTest.php index 908b3ba484..4f48acfa75 100644 --- a/tests/Feature/HelperVersionTest.php +++ b/tests/Feature/HelperVersionTest.php @@ -33,7 +33,7 @@ it('uses a newer helper version from the local versions file without asking the }); it('stores the helper version when the update check refreshes versions.json from the CDN', function () { - File::partialMock()->shouldReceive('put')->once()->with(base_path('versions.json'), Mockery::type('string')); + File::partialMock()->shouldReceive('replace')->once()->with(storage_path('app/versions.json'), Mockery::type('string')); config(['constants.coolify.version' => '4.0.0']); Http::preventStrayRequests(); Http::fake([ diff --git a/tests/Feature/SettingsUpdatesCheckNowTest.php b/tests/Feature/SettingsUpdatesCheckNowTest.php index dbdd049fd9..af12f4cc85 100644 --- a/tests/Feature/SettingsUpdatesCheckNowTest.php +++ b/tests/Feature/SettingsUpdatesCheckNowTest.php @@ -32,7 +32,7 @@ beforeEach(function () { it('reports a failed CDN request instead of "No new version available"', function () { Http::fake(['*' => Http::response('error code: 1200', 503)]); - File::partialMock()->shouldReceive('put')->with(base_path('versions.json'), Mockery::any())->never(); + File::partialMock()->shouldReceive('replace')->with(storage_path('app/versions.json'), Mockery::any())->never(); Livewire::test(Updates::class) ->call('checkManually') @@ -42,7 +42,7 @@ it('reports a failed CDN request instead of "No new version available"', functio it('reports a new version when the CDN has a newer version', function () { Http::fake(['*' => Http::response(['coolify' => ['v4' => ['version' => '4.4.7']]])]); - File::partialMock()->shouldReceive('put')->with(base_path('versions.json'), Mockery::any())->once(); + File::partialMock()->shouldReceive('replace')->with(storage_path('app/versions.json'), Mockery::any())->once(); Livewire::test(Updates::class) ->call('checkManually') diff --git a/tests/Unit/CheckForUpdatesJobTest.php b/tests/Unit/CheckForUpdatesJobTest.php index 45d1f61942..b1f69062d9 100644 --- a/tests/Unit/CheckForUpdatesJobTest.php +++ b/tests/Unit/CheckForUpdatesJobTest.php @@ -37,16 +37,16 @@ it('uses max of CDN and cache versions', function () { // Cache has newer version File::shouldReceive('exists') - ->with(base_path('versions.json')) + ->with(storage_path('app/versions.json')) ->andReturn(true); File::shouldReceive('get') - ->with(base_path('versions.json')) + ->with(storage_path('app/versions.json')) ->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.10']]])); - File::shouldReceive('put') + File::shouldReceive('replace') ->once() - ->with(base_path('versions.json'), Mockery::on(function ($json) { + ->with(storage_path('app/versions.json'), Mockery::on(function ($json) { $data = json_decode($json, true); // Should use cached version (4.0.10), not CDN version (4.0.0) @@ -77,16 +77,16 @@ it('never downgrades from current running version', function () { // Cache also has older version File::shouldReceive('exists') - ->with(base_path('versions.json')) + ->with(storage_path('app/versions.json')) ->andReturn(true); File::shouldReceive('get') - ->with(base_path('versions.json')) + ->with(storage_path('app/versions.json')) ->andReturn(json_encode(['coolify' => ['v4' => ['version' => '4.0.5']]])); - File::shouldReceive('put') + File::shouldReceive('replace') ->once() - ->with(base_path('versions.json'), Mockery::on(function ($json) { + ->with(storage_path('app/versions.json'), Mockery::on(function ($json) { $data = json_decode($json, true); // Should use running version (4.0.10), not CDN (4.0.0) or cache (4.0.5) @@ -131,9 +131,9 @@ it('preserves other component versions when preventing Coolify downgrade', funct 'traefik' => ['v3.5' => '3.5.6'], ])); - File::shouldReceive('put') + File::shouldReceive('replace') ->once() - ->with(base_path('versions.json'), Mockery::on(function ($json) { + ->with(storage_path('app/versions.json'), Mockery::on(function ($json) { $data = json_decode($json, true); // Coolify should use running version expect($data['coolify']['v4']['version'])->toBe('4.0.10'); @@ -167,7 +167,7 @@ it('preserves other component versions when preventing Coolify downgrade', funct it('keeps the local versions.json when the CDN fetch fails', function () { Http::fake(['*' => Http::response('', 500)]); - File::shouldReceive('put')->never(); + File::shouldReceive('replace')->never(); Cache::shouldReceive('forget')->never(); expect(refresh_versions_file())->toBeNull(); diff --git a/tests/Unit/UpdateChannelTest.php b/tests/Unit/UpdateChannelTest.php new file mode 100644 index 0000000000..a387128cbb --- /dev/null +++ b/tests/Unit/UpdateChannelTest.php @@ -0,0 +1,229 @@ + 'testing', + 'constants.coolify.version' => '4.5.0', + 'constants.coolify.helper_version' => '1.0.19', + 'constants.coolify.versions_url' => 'https://cdn.example.com/coolify/versions.json', + 'constants.coolify.upgrade_script_url' => 'https://cdn.example.com/coolify/upgrade.sh', + 'constants.ssh.mux_enabled' => false, + ]); + Team::factory()->create(['id' => 0]); + Server::forceCreate(['id' => 0, 'name' => 'localhost', 'ip' => '127.0.0.1', 'user' => 'root', 'team_id' => 0, 'private_key_id' => 1]); + InstanceSettings::forceCreate(['id' => 0, 'is_auto_update_enabled' => true, 'auto_update_frequency' => '0 0 * * *', 'update_check_frequency' => '0 * * * *']); + Once::flush(); +}); + +afterEach(function () { + File::delete(storage_path('app/versions.json')); +}); + +function updateChannelTestUse(string $channel, ?string $customTag = null, ?string $preview = '4.6.0-beta.2', string $nightly = '4.6.0-beta.1.20261027.3'): void +{ + InstanceSettings::findOrFail(0)->update(['update_channel' => $channel, 'update_custom_tag' => $customTag]); + Once::flush(); + Http::fake([ + 'cdn.example.com/coolify/release.json' => Http::response(array_filter([ + 'stable' => ['coolify' => '4.5.1'], + 'preview' => $preview ? ['coolify' => $preview] : null, + 'nightly' => ['coolify' => $nightly], + ])), + 'cdn.example.com/coolify/versions.json' => Http::response(['coolify' => ['v4' => ['version' => '4.5.1']]]), + 'cdn.example.com/coolify/preview/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $preview]]]), + 'cdn.example.com/coolify/nightly/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $nightly]]]), + ]); +} + +it('follows the newest version of the channel and the more stable ones', function (string $channel, string $version, string $upgradeScriptUrl) { + updateChannelTestUse($channel); + + expect(fetch_latest_versions())->toMatchArray([ + 'versions' => ['coolify' => ['v4' => ['version' => $version]]], + 'upgrade_script_url' => $upgradeScriptUrl, + ]); +})->with([ + 'stable' => ['stable', '4.5.1', 'https://cdn.example.com/coolify/upgrade.sh'], + 'preview' => ['preview', '4.6.0-beta.2', 'https://cdn.example.com/coolify/preview/upgrade.sh'], + 'nightly while a newer beta waits for the next nightly' => ['nightly', '4.6.0-beta.2', 'https://cdn.example.com/coolify/preview/upgrade.sh'], +]); + +it('asks only the stable channel of the CDN on the stable channel', function () { + updateChannelTestUse('stable'); + + fetch_latest_versions(); + + Http::assertSentCount(1); +}); + +it('skips a channel that release.json does not list yet', function () { + updateChannelTestUse('nightly', preview: null, nightly: '4.6.0-alpha.0.20261027.1'); + + expect(fetch_latest_versions(tries: 1)['upgrade_script_url'])->toBe('https://cdn.example.com/coolify/nightly/upgrade.sh'); +}); + +it('checks nothing when release.json does not answer', function () { + InstanceSettings::findOrFail(0)->update(['update_channel' => 'nightly']); + Once::flush(); + Http::fake(['cdn.example.com/coolify/release.json' => Http::response('', 404)]); + + expect(fetch_latest_versions(tries: 1))->toBeNull(); +}); + +it('upgrades with the upgrade script of the channel of the newest version', function () { + updateChannelTestUse('nightly'); + + (new UpdateCoolify)->handle(manual_update: true); + + expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first()))->toBe( + "curl -fsSL https://cdn.example.com/coolify/preview/upgrade.sh -o /data/coolify/source/upgrade.sh\n". + "bash /data/coolify/source/upgrade.sh '4.6.0-beta.2' '1.0.19' 'docker.io'" + ); +}); + +it('upgrades to the custom image from ghcr.io only when asked', function () { + updateChannelTestUse('custom', 'pr-1234'); + + (new UpdateCoolify)->handle(); + expect(Activity::query()->count())->toBe(0); + + (new UpdateCoolify)->handle(manual_update: true); + expect(RemoteProcessCommand::read(Activity::query()->latest('id')->first()))->toBe( + "curl -fsSL https://cdn.example.com/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh\n". + "bash /data/coolify/source/upgrade.sh 'pr-1234' '1.0.19' 'ghcr.io'" + ); +}); + +it('reports no new version while a custom image is pinned', function () { + updateChannelTestUse('custom', 'pr-1234'); + + (new CheckForUpdatesJob)->handle(); + + expect(InstanceSettings::findOrFail(0)->new_version_available)->toBeFalsy(); +}); + +it('treats an image tag that names no version as reached once the upgrade completes', function () { + expect(CoolifyUpgradeStatus::hasReachedTargetVersion('4.6.0-beta.1-pr.1234', 'pr-1234'))->toBeTrue() + ->and(CoolifyUpgradeStatus::hasReachedTargetVersion('4.5.0', '4.5.1'))->toBeFalse(); +}); + +it('accepts only an image tag for the custom image', function (?string $tag, bool $valid) { + $user = User::factory()->create(); + Team::findOrFail(0)->members()->attach($user->id, ['role' => 'admin']); + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + $component = Livewire::test(Updates::class) + ->set('update_channel', 'custom') + ->set('update_custom_tag', $tag) + ->call('submit'); + + $valid ? $component->assertHasNoErrors('update_custom_tag') : $component->assertHasErrors('update_custom_tag'); +})->with([ + 'pull request' => ['pr-1234', true], + 'version' => ['4.6.0-beta.1', true], + 'missing' => [null, false], + 'shell' => ['pr-1; touch /tmp/pwned', false], + 'leading dash' => ['-pr', false], +]); + +it('warns before an instance switches to nightly or a custom image', function (string $channel, string $warning) { + $user = User::factory()->create(); + Team::findOrFail(0)->members()->attach($user->id, ['role' => 'admin']); + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + + Livewire::test(Updates::class) + ->assertDontSee($warning) + ->set('update_channel', $channel) + ->assertSee($warning); +})->with([ + 'nightly' => ['nightly', 'Nightly builds can break this instance'], + 'custom image' => ['custom', 'A custom image can break this instance'], +]); + +it('keeps the nightlies of the running version after a switch to stable until its stable release', function (string $stable, string $nightly, string $version) { + config(['constants.coolify.version' => '4.6.0-beta.1.20261027.3']); + InstanceSettings::findOrFail(0)->update(['update_channel' => 'stable', 'previous_update_channel' => 'nightly']); + Once::flush(); + Http::fake([ + 'cdn.example.com/coolify/release.json' => Http::response(['stable' => ['coolify' => $stable], 'nightly' => ['coolify' => $nightly]]), + 'cdn.example.com/coolify/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $stable]]]), + 'cdn.example.com/coolify/nightly/versions.json' => Http::response(['coolify' => ['v4' => ['version' => $nightly]]]), + ]); + + expect(data_get(fetch_latest_versions(), 'versions.coolify.v4.version'))->toBe($version); +})->with([ + 'a newer nightly of 4.6.0' => ['4.5.1', '4.6.0-beta.2.20261028.1', '4.6.0-beta.2.20261028.1'], + 'the stable release of 4.6.0' => ['4.6.0', '4.7.0-alpha.0.20261101.1', '4.6.0'], + 'no nightly of 4.7.0 before 4.6.0 is stable' => ['4.5.1', '4.7.0-alpha.0.20261101.1', '4.5.1'], +]); + +it('follows the channel it left after a switch to a more stable channel', function (string $from, string $to, ?string $followed) { + $user = User::factory()->create(); + Team::findOrFail(0)->members()->attach($user->id, ['role' => 'admin']); + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); + updateChannelTestUse($from); + + $component = new class extends Updates + { + protected function syncRegistryUrlToEnv(string $registryUrl): void {} + }; + $component->settings = InstanceSettings::findOrFail(0); + $component->auto_update_frequency = '0 0 * * *'; + $component->update_check_frequency = '0 * * * *'; + $component->is_auto_update_enabled = true; + $component->docker_registry_url = 'docker.io'; + $component->update_channel = $to; + $component->update_custom_tag = $to === 'custom' ? 'pr-1234' : null; + $component->instantSave(); + + expect(InstanceSettings::findOrFail(0)->previous_update_channel)->toBe($followed); +})->with([ + 'nightly to stable' => ['nightly', 'stable', 'nightly'], + 'nightly to preview' => ['nightly', 'preview', 'nightly'], + 'preview to stable' => ['preview', 'stable', 'preview'], + 'stable to nightly' => ['stable', 'nightly', null], + 'nightly to a custom image' => ['nightly', 'custom', null], +]); + +it('replaces the versions of the old channel after a channel change', function () { + config(['constants.coolify.version' => '4.5.1']); + File::put(storage_path('app/versions.json'), json_encode(['coolify' => ['v4' => ['version' => '4.6.0-beta.2.20261027.1']]])); + updateChannelTestUse('stable'); + + refresh_versions_file(tries: 1, channelChanged: true); + + expect(data_get(json_decode(File::get(storage_path('app/versions.json')), true), 'coolify.v4.version'))->toBe('4.5.1'); +}); + +it('falls back to the versions of the image when the CDN fails after a channel change', function () { + File::put(storage_path('app/versions.json'), json_encode(['coolify' => ['v4' => ['version' => '4.6.0-beta.2.20261027.1']]])); + Http::fake(['*' => Http::response('', 500)]); + + refresh_versions_file(tries: 1, channelChanged: true); + + expect(File::exists(storage_path('app/versions.json')))->toBeFalse(); +});