mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-11 22:38:54 +00:00
8f12c83d21
ProjectSend prints the update instructions for the way this server was installed, and it knew two answers where it needed three: anything inside a container was handed `docker compose pull && docker compose up -d`. On the Compose stack that builds from a checkout there is no image behind those containers, so `pull` skips every ProjectSend service and `up -d` then finds them all current — the update reports success, changes nothing, and the dashboard goes on offering the same release. Reported by @mueller7382, who stayed on 2.0.0 that way while 2.1.0 was out (#1661). Those installations are now their own kind, told to `git pull` and rebuild, with the two steps a checkout needs that an image does not: its dependencies and its compiled frontend live outside git, so a release that moved either leaves them stale. Two signals decide it, in that order. The published image now declares itself with PROJECTSEND_IMAGE, which is the only evidence an operator bind-mounting over /var/www/html can neither hide nor forge; failing that — images published before this — a working tree in the install directory, which the image never has and the repository's own stack always does. getenv() rather than env(), because a cached configuration makes env() outside a config file return null, and the answer would flip silently on exactly the installs most likely to have cached it. The stale-code banner keeps treating both container kinds alike: what clears it is recreating the container, whichever way its image was built. The changelog also credits the reporter of #1663, which was missed when that entry was written.
135 lines
5.2 KiB
PHP
135 lines
5.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Platform\Installation\Installation;
|
|
use App\Modules\Platform\Installation\InstallationKind;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
/**
|
|
* The application tells administrators how to upgrade, and the answer is
|
|
* different for the published image, for a container somebody builds from a
|
|
* checkout, and for files unpacked onto a server. Getting it wrong is worse
|
|
* than saying nothing: before this existed, every surface printed
|
|
* `docker compose pull` to everybody, including the people INSTALL.md was
|
|
* written for, who have no docker to run it with — and including the
|
|
* clone-and-build stacks where those commands succeed without updating
|
|
* anything (#1661).
|
|
*/
|
|
class FakeInstallation extends Installation
|
|
{
|
|
public function __construct(
|
|
private readonly bool $container,
|
|
private readonly bool $source = false,
|
|
) {}
|
|
|
|
protected function inContainer(): bool
|
|
{
|
|
return $this->container;
|
|
}
|
|
|
|
protected function builtFromSource(): bool
|
|
{
|
|
return $this->source;
|
|
}
|
|
}
|
|
|
|
beforeEach(function () {
|
|
$this->admin = User::factory()->create();
|
|
});
|
|
|
|
/** Puts a newer release in the cache so the update surfaces have something to show. */
|
|
function anUpdateIsAvailable(): void
|
|
{
|
|
$settings = app(Settings::class);
|
|
$current = (string) config('projectsend.version');
|
|
|
|
$settings->set(Setting::CheckForUpdates, true);
|
|
$settings->set(Setting::LatestKnownVersion, $current.'1');
|
|
$settings->set(Setting::LatestReleaseTitle, 'A newer ProjectSend');
|
|
$settings->set(Setting::LatestReleaseNotes, 'Notes.');
|
|
$settings->set(Setting::LatestReleaseUrl, 'https://example.test/release');
|
|
$settings->set(Setting::LatestReleasePublishedAt, '2026-08-09T00:00:00Z');
|
|
}
|
|
|
|
test('a container running the published image reports itself as one', function () {
|
|
expect((new FakeInstallation(container: true))->kind())->toBe(InstallationKind::Container);
|
|
});
|
|
|
|
test('a container built from a checkout is told apart from the image', function () {
|
|
// `docker compose pull` on this stack skips every ProjectSend service
|
|
// and then reports success, so being handed that command is how an
|
|
// installation stays on the version it is on.
|
|
expect((new FakeInstallation(container: true, source: true))->kind())->toBe(InstallationKind::ContainerSource);
|
|
});
|
|
|
|
test('a working tree is only asked about inside a container', function () {
|
|
expect((new FakeInstallation(container: false, source: true))->kind())->toBe(InstallationKind::Manual);
|
|
});
|
|
|
|
test('the image says so itself, whatever is on disk', function () {
|
|
// Precedence, asserted where it matters: the test suite runs from a
|
|
// working tree, so without the marker this same object answers
|
|
// ContainerSource. An operator bind-mounting a checkout into the
|
|
// published image is still updating it by pulling.
|
|
$installation = new class extends Installation
|
|
{
|
|
protected function inContainer(): bool
|
|
{
|
|
return true;
|
|
}
|
|
};
|
|
|
|
expect($installation->kind())->toBe(InstallationKind::ContainerSource);
|
|
|
|
putenv('PROJECTSEND_IMAGE=1');
|
|
|
|
try {
|
|
expect($installation->kind())->toBe(InstallationKind::Container);
|
|
} finally {
|
|
putenv('PROJECTSEND_IMAGE');
|
|
}
|
|
})->skip(fn () => ! file_exists(base_path('.git')), 'Asserts precedence over a working tree, and there is none here.');
|
|
|
|
test('anything else is treated as a manual install', function () {
|
|
// The safer wrong answer: manual instructions are steps a person reads
|
|
// and checks, the container command is one they would paste.
|
|
expect((new FakeInstallation(container: false))->kind())->toBe(InstallationKind::Manual);
|
|
});
|
|
|
|
test('the dashboard tells the frontend which kind of install this is', function (bool $container, bool $source, string $expected) {
|
|
app()->instance(Installation::class, new FakeInstallation($container, $source));
|
|
|
|
$this->actingAs($this->admin)
|
|
->get('/dashboard')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('system.install_kind', $expected));
|
|
})->with([
|
|
'the published image' => [true, false, 'container'],
|
|
'built from a checkout' => [true, true, 'container-source'],
|
|
'manual' => [false, false, 'manual'],
|
|
]);
|
|
|
|
test('the update notice carries the install kind too', function (bool $container, bool $source, string $expected) {
|
|
app()->instance(Installation::class, new FakeInstallation($container, $source));
|
|
anUpdateIsAvailable();
|
|
|
|
$this->actingAs($this->admin)
|
|
->get('/dashboard')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('update_notice.install_kind', $expected));
|
|
})->with([
|
|
'the published image' => [true, false, 'container'],
|
|
'built from a checkout' => [true, true, 'container-source'],
|
|
'manual' => [false, false, 'manual'],
|
|
]);
|
|
|
|
test('no update means no notice, whatever the install kind', function () {
|
|
app()->instance(Installation::class, new FakeInstallation(container: false));
|
|
|
|
$this->actingAs($this->admin)
|
|
->get('/dashboard')
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('update_notice', null));
|
|
});
|