Release v1.0.4: self-updater hotfixes (wrapper stripping + counter check)
- Self-updater extractZip: detect and strip a common top-level prefix folder so that release ZIPs built with a versioned wrapper (the build.sh convention) overlay the live install correctly instead of creating a schneespur-X.Y.Z/ subdirectory at the install root. The v1.0.3 update on the test environment surfaced this — files were copied, state.json was committed to 1.0.3, but the wrapper folder meant the live config/app.php and VERSION were never actually overwritten. Same defensive prefix-stripping pattern as the module installer. - Counter check ordering: the same-version short-circuit now runs BEFORE the strict counter check. Previously, after a successful install, the next "prüfen" fetched the same manifest (same counter) and the counter check would throw "Rollback-Versuch", masking the intended "you're up to date" response. The counter check is still enforced for genuinely older manifests — it just doesn't misfire when the server keeps serving the same release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
84c68ee35c
commit
87a84eb0ac
2 changed files with 91 additions and 8 deletions
|
|
@ -1 +1 @@
|
|||
1.0.3
|
||||
1.0.4
|
||||
|
|
|
|||
|
|
@ -170,6 +170,18 @@ class SchneespurUpdater
|
|||
throw new RuntimeException('Signatur ungültig — MITM oder beschädigt');
|
||||
}
|
||||
|
||||
// Same-version short-circuit first: if the server still serves the
|
||||
// currently installed release (same counter, same version), this is
|
||||
// not a rollback — just "you're up to date". The counter check below
|
||||
// would otherwise misfire after every successful install, because
|
||||
// the next manifest fetch carries the exact same counter that was
|
||||
// committed during install.
|
||||
if ($manifest['version'] === ($state['current_version'] ?? '')) {
|
||||
$this->writeLastCheck($state, false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$newCounter = (int) $manifest['counter'];
|
||||
if ($newCounter <= (int) ($state['last_counter'] ?? 0)) {
|
||||
throw new RuntimeException(
|
||||
|
|
@ -177,12 +189,6 @@ class SchneespurUpdater
|
|||
);
|
||||
}
|
||||
|
||||
if ($manifest['version'] === ($state['current_version'] ?? '')) {
|
||||
$this->writeLastCheck($state, false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->writeLastCheck($state, true, $manifest);
|
||||
|
||||
return $manifest;
|
||||
|
|
@ -279,7 +285,44 @@ class SchneespurUpdater
|
|||
|
||||
$this->validateZipEntries($zip);
|
||||
|
||||
$zip->extractTo($this->stagingDir);
|
||||
$prefix = $this->detectCommonPrefix($zip);
|
||||
|
||||
if ($prefix === null) {
|
||||
$zip->extractTo($this->stagingDir);
|
||||
} else {
|
||||
$this->logPhase('extract', 'stripping_prefix', ['prefix' => $prefix]);
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entry = $zip->getNameIndex($i);
|
||||
if ($entry === false || $entry === '' || $entry === $prefix) {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($entry, '__MACOSX/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = substr($entry, strlen($prefix));
|
||||
if ($relative === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dest = $this->stagingDir . '/' . $relative;
|
||||
|
||||
if (str_ends_with($entry, '/')) {
|
||||
$this->ensureDirectory($dest);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->ensureDirectory(dirname($dest));
|
||||
|
||||
$contents = $zip->getFromIndex($i);
|
||||
if ($contents === false || file_put_contents($dest, $contents) === false) {
|
||||
$zip->close();
|
||||
throw new RuntimeException("ZIP-Extraktion fehlgeschlagen: {$entry}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$this->logPhase('extract', 'complete', ['files' => $this->countFiles($this->stagingDir)]);
|
||||
|
|
@ -287,6 +330,46 @@ class SchneespurUpdater
|
|||
return $this->stagingDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether all (non-metadata) ZIP entries share one common
|
||||
* top-level folder. Returns the prefix (incl. trailing slash) when so,
|
||||
* null when the ZIP is flat or has mixed top-level entries.
|
||||
*
|
||||
* Mirrors the logic in SchneespurModuleInstaller so update ZIPs that
|
||||
* wrap their content in a versioned folder (the build.sh convention)
|
||||
* are unwrapped during extraction instead of leaving a stray
|
||||
* schneespur-X.Y.Z/ subdirectory in the live install.
|
||||
*/
|
||||
private function detectCommonPrefix(ZipArchive $zip): ?string
|
||||
{
|
||||
$prefix = null;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$entry = $zip->getNameIndex($i);
|
||||
if ($entry === false || $entry === '') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($entry, '__MACOSX/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slash = strpos($entry, '/');
|
||||
if ($slash === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$top = substr($entry, 0, $slash + 1);
|
||||
|
||||
if ($prefix === null) {
|
||||
$prefix = $top;
|
||||
} elseif ($prefix !== $top) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $prefix;
|
||||
}
|
||||
|
||||
private function validateZipEntries(ZipArchive $zip): void
|
||||
{
|
||||
$resolvedStaging = realpath($this->stagingDir) ?: $this->stagingDir;
|
||||
|
|
|
|||
Loading…
Reference in a new issue