feat: add update channels to the settings

- Adds the channels stable, preview, nightly and custom
- Preview and nightly read /coolify/release.json on the CDN and take the newest version of their channel and the more stable ones, nightly gets a new build of next or the release branch twice a day
- Custom installs any image tag of ghcr.io by hand, such as pr-1234 for the build of pull request 1234
This commit is contained in:
peaklabs-dev committed 2026-10-27 23:32:26 +01:00
1 parent 3f2d76ab7a
commit c3eba0376e
12 files changed
+315 -15

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
### 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`
- Publish a nightly 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
- Publish prereleases from `next` and the release branches, tagged like `4.6.0-beta.1`, and the moving `preview` tag
- Publish the image of every push to a pull request from a branch of this repository as `pr-<number>` on ghcr.io
+1
View File
@@ -175,6 +175,7 @@ Every push to a pull request from a branch of this repository publishes `ghcr.io
}
```
- An instance on the preview or nightly channel reads it and takes the newest version of its channel and the more stable ones, then the files of that channel
- `versions.json` in each channel names its version and the helper, Sentinel and Traefik versions of `upstream.json`, the instance reads them from there, `install.sh` and older instances read it alone
The CDN lets Cloudflare keep a file for 10 minutes, `Cache-Control: max-age=600`. To clear it 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",
+2 -1
View File
@@ -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;
+17
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,23 @@ 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']);
$changedFields = auditChangedFields($this->settings);
$this->settings->save();
if ($channelChanged) {
(new CheckForUpdatesJob(cdnTries: 1))->handle();
$this->dispatch('updateAvailable');
}
if ($changedFields !== []) {
auditLog('ui.instance.settings.updated', [
'team_id' => null,
+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);
+2
View File
@@ -46,6 +46,8 @@ class InstanceSettings extends Model
'allowed_ips',
'auto_update_frequency',
'update_check_frequency',
'update_channel',
'update_custom_tag',
'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, '>=');
}
}
+42 -4
View File
@@ -88,6 +88,45 @@ function cdn_retry_delay_ms(int $attempt, Throwable $exception): int
return ($seconds + random_int(0, $seconds)) * 1000;
}
/**
* @return array{versions: array, upgrade_script_url: string}|null
*/
function fetch_latest_versions(int $tries = 3): ?array
{
$cdnUrl = dirname(config('constants.coolify.versions_url'));
$channels = match (instanceSettings()->update_channel) {
'preview' => ['stable', 'preview'],
'nightly' => ['stable', 'preview', 'nightly'],
default => ['stable'],
};
$channel = 'stable';
if (count($channels) > 1) {
$response = cdn_http($tries)->get("{$cdnUrl}/release.json");
$release = $response->json();
if ($response->failed() || ! is_array($release)) {
return null;
}
foreach ($channels as $candidate) {
$version = data_get($release, "{$candidate}.coolify");
if (is_string($version) && version_compare($version, (string) data_get($release, "{$channel}.coolify"), '>')) {
$channel = $candidate;
}
}
}
$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'),
];
}
/**
* 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.
@@ -97,10 +136,9 @@ function cdn_retry_delay_ms(int $attempt, Throwable $exception): int
*/
function refresh_versions_file(int $tries = 3): ?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()]);
$versions = fetch_latest_versions($tries)['versions'] ?? null;
if ($versions === null) {
Log::warning('Could not fetch versions.json from the CDN, using the local file');
return null;
}
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('update_channel')->default('stable');
$table->string('update_custom_tag')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn(['update_channel', 'update_custom_tag']);
});
}
};
@@ -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. Switching back to stable
waits until a stable release is newer than the installed nightly.
</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." />
+159
View File
@@ -0,0 +1,159 @@
<?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\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();
});
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'],
]);