feat: add update channels to the instance settings (#12359)

This commit is contained in:
🏔️ Peak
2026-11-02 00:04:46 +01:00
committed by GitHub
17 changed files with 445 additions and 42 deletions
-1
View File
@@ -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:<version>-dind` container); newer clients can reject data from older daemons.
+2
View File
@@ -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
+2 -1
View File
@@ -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 `<stable version>.<YYYYMMDD>.<N>`, `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:
+19 -8
View File
@@ -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",
+4 -3
View File
@@ -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;
+36
View File
@@ -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) {
+8 -1
View File
@@ -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);
+3
View File
@@ -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',
+4
View File
@@ -115,6 +115,10 @@ class CoolifyUpgradeStatus
return false;
}
if (! preg_match('/^\d+\.\d+/', $targetVersion)) {
return true;
}
return version_compare($runningVersion, $targetVersion, '>=');
}
}
+75 -12
View File
@@ -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<string, mixed> $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);
@@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('update_channel')->default('stable');
$table->string('update_custom_tag')->nullable();
$table->string('previous_update_channel')->nullable();
});
}
};
@@ -6,7 +6,7 @@
<x-settings.layout>
<form wire:submit="submit" class="application-settings-form flex min-w-0 flex-col gap-6">
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}}
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency" />
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency,update_channel,update_custom_tag" />
<x-application.settings-section title="Updates"
helper="Install the latest Coolify version manually when an update is available.">
@@ -18,6 +18,36 @@
</x-forms.button>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.listbox id="update_channel" label="Channel" live :options="[
['value' => 'stable', 'label' => 'Stable', 'description' => 'Stable releases'],
['value' => 'preview', 'label' => 'Preview', 'description' => 'Betas and release candidates, then the stable release'],
['value' => 'nightly', 'label' => 'Nightly', 'description' => 'A build of the next version twice a day, then every release'],
['value' => 'custom', 'label' => 'Custom image', 'description' => 'A pinned image, such as the build of a pull request'],
]"
helper="The versions this instance upgrades to." />
@if ($update_channel === 'custom')
<x-forms.input required id="update_custom_tag" label="Image tag" placeholder="pr-1234"
helper="A tag of ghcr.io/coollabsio/coolify, such as pr-1234 for the build of pull request 1234." />
@else
<div class="hidden lg:block"></div>
@endif
@if ($update_channel === 'nightly')
<x-callout type="danger" title="Nightly builds can break this instance" class="lg:col-span-2">
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.
</x-callout>
@elseif ($update_channel === 'custom')
<x-callout type="danger" title="A custom image can break this instance" class="lg:col-span-2">
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.
</x-callout>
@endif
<x-forms.input required id="update_check_frequency" label="Check frequency"
placeholder="0 * * * *"
helper="A cron expression or preset such as hourly, daily, weekly, monthly, or yearly." />
+1 -1
View File
@@ -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]);
+1 -1
View File
@@ -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([
@@ -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')
+11 -11
View File
@@ -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();
+229
View File
@@ -0,0 +1,229 @@
<?php
use App\Actions\Server\UpdateCoolify;
use App\Jobs\CheckForUpdatesJob;
use App\Livewire\Settings\Updates;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\CoolifyUpgradeStatus;
use App\Support\RemoteProcessCommand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Once;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
config([
'app.env' => '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();
});