From 27249e7822cb619ad51bbdb1b28f3adf6affc927 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 18:13:43 +0000 Subject: [PATCH] Build correctly after a deploy into a new release directory With zero-downtime deploys the app moves to a new path while storage is shared, so the compiled content (which holds absolute paths) comes with it. Stache::clear() forgets each store's saved keys but not the ones it has in memory, so in flatpack:build every file looked like a duplicate of itself at its old path, nothing was built, and the cleared store was then persisted. flatpack:build now clears the keys in memory too, and a build that throws discards what it buffered, leaving the last good build in place. A refresh of content built for another path builds everything instead, and flatpack:status says when that's needed. The parity suite moves the app and builds, then moves it again and refreshes. bin/parity lets Composer update every package when installing a Statamic version: the skeleton pins laravel/pail to a release that needs Laravel 12, which Statamic 5.11 can't use. --- CHANGELOG.md | 5 ++++ bin/parity | 8 ++++-- src/Builder.php | 56 +++++++++++++++++++++++++++++++++++++--- src/Commands/Refresh.php | 6 +++++ src/Commands/Status.php | 6 +++++ src/FlatpackStore.php | 7 +++++ src/Jobs/RunFlatpack.php | 3 ++- tests/parity/run.sh | 13 ++++++++++ 8 files changed, 97 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dacd6ef..a82527d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ - **Statamic's own fast YAML parser is used where it has one.** Flatpack's copy is only used on Statamic versions without it. +### Fixed + +- **Building after a deploy into a new release directory.** With zero-downtime deploys the app moves to a new path while `storage` is shared. `flatpack:build` then failed, and left the site with no content until it was fixed. It now builds from the new path, and a build that fails for any reason leaves the last good one in place. +- `flatpack:refresh` (and Refresh in the Control Panel) builds everything when the content was compiled for another path, where before it failed. `flatpack:status` says so too. + ## 1.1.0 (2026-11-10) ### Changed diff --git a/bin/parity b/bin/parity index 2d96968..3269ea8 100755 --- a/bin/parity +++ b/bin/parity @@ -22,7 +22,10 @@ if [[ -z "${KEEP:-}" || ! -d "$APP/vendor" ]]; then # Older Statamic releases have published security advisories, which Composer refuses to # install by default. This throwaway app only exists to test against them. "${COMPOSER[@]}" config policy.advisories.block false 2>/dev/null || "${COMPOSER[@]}" config audit.block-insecure false - "${COMPOSER[@]}" require "statamic/cms:$VERSION" "1994/flatpack-statamic:@dev" "laravel/framework:^10.0|^11.0|^12.0|^13.0" -W --no-interaction -q + # Everything is updated, not only these three: the skeleton pins tools (laravel/pail, for one) + # to versions that need a newer Laravel than an older Statamic allows. + "${COMPOSER[@]}" require "statamic/cms:$VERSION" "1994/flatpack-statamic:@dev" "laravel/framework:^10.0|^11.0|^12.0|^13.0" --no-update --no-interaction -q + "${COMPOSER[@]}" update -W --no-interaction -q "$PHP" artisan key:generate -q for kv in STATAMIC_PRO_ENABLED=true STATAMIC_STACHE_WATCHER=false CACHE_STORE=file CACHE_DRIVER=file QUEUE_CONNECTION=sync APP_ENV=local; do k="${kv%%=*}"; grep -q "^$k=" .env && sed -i.bak "s|^$k=.*|$kv|" .env || echo "$kv" >> .env @@ -36,7 +39,8 @@ sed -i.bak "s/'multisite' => false/'multisite' => true/" "$APP/config/statamic/s # KEEP=1 with a different version: switch the existing app to it instead of starting again. cd "$APP" if [[ "$("$PHP" -r 'require "vendor/autoload.php"; echo ltrim(Composer\InstalledVersions::getPrettyVersion("statamic/cms"), "v");')" != "${VERSION#v}" ]] && [[ "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - "${COMPOSER[@]}" require "statamic/cms:$VERSION" "laravel/framework:^10.0|^11.0|^12.0|^13.0" -W --no-interaction -q + "${COMPOSER[@]}" require "statamic/cms:$VERSION" "laravel/framework:^10.0|^11.0|^12.0|^13.0" --no-update --no-interaction -q + "${COMPOSER[@]}" update -W --no-interaction -q fi # The fast YAML parser against this version's Symfony YAML. diff --git a/src/Builder.php b/src/Builder.php index 46e396f..e2247d3 100644 --- a/src/Builder.php +++ b/src/Builder.php @@ -17,23 +17,53 @@ public static function store(): FlatpackStore public static function compile(int $bodyWorkers = 0): array { $t = hrtime(true); - Stache::clear(); Listeners\RecordManifest::$paused = true; try { + Stache::clear(); + // Clearing forgets each store's saved keys but leaves the ones it has in memory. They + // hold absolute paths, so after a deploy into a new release directory every file + // would look like a duplicate of itself at its old path, and nothing would be built. + self::forgetKeysInMemory(); Stache::warm(); + self::recordTimestamps(); + } catch (\Throwable $e) { + // A build that fails must leave the last good one in place, not a cleared store. + self::store()->discard(); + + throw $e; } finally { Listeners\RecordManifest::$paused = false; } - self::recordTimestamps(); self::store()->persist(); $gc = self::store()->gc(); $out = ['compile_s' => round((hrtime(true) - $t) / 1e9, 2), 'gc_files' => $gc]; if ($bodyWorkers > 0) $out += self::precompileBodies($bodyWorkers); - self::stamp(['built_at' => time(), 'build_s' => round((hrtime(true) - $t) / 1e9, 2), 'refreshed_at' => null, 'via' => 'flatpack:build']); + self::stamp(['built_at' => time(), 'build_s' => round((hrtime(true) - $t) / 1e9, 2), 'refreshed_at' => null, 'via' => 'flatpack:build', 'base_path' => base_path()]); return $out; } + /** + * Plain stores first: finding an aggregate store's children (a collection's entries, a + * taxonomy's terms) reads the collections and taxonomies stores, which must be clean by then. + */ + private static function forgetKeysInMemory(): void + { + $aggregates = []; + foreach (Stache::stores() as $store) { + if ($store instanceof \Statamic\Stache\Stores\AggregateStore) { + $aggregates[] = $store; + } else { + $store->keys()->setKeys([]); + } + } + foreach ($aggregates as $aggregate) { + foreach ($aggregate->discoverStores() as $store) { + $store->keys()->setKeys([]); + } + } + } + /** @return array every concrete store, keyed by its key */ public static function allStores(): array { @@ -92,7 +122,25 @@ public static function stamps(): array /** A warm outside flatpack:build (stache:warm, stache:refresh, the Cache Manager) counts as a build. */ public static function stampWarm(): void { - self::stamp(['built_at' => time(), 'build_s' => null, 'refreshed_at' => null, 'via' => 'stache:warm']); + self::stamp(['built_at' => time(), 'build_s' => null, 'refreshed_at' => null, 'via' => 'stache:warm', 'base_path' => base_path()]); + } + + /** + * Whether the content was compiled for the app at another path: a deploy into a new release + * directory with shared storage. Everything compiled holds absolute paths, so it all has to + * be built again from here. + */ + public static function builtElsewhere(): bool + { + if (($path = self::stamps()['base_path'] ?? null) !== null) { + return $path !== base_path(); + } + + // A build from before the path was recorded: see where its collection files were. + $saved = Stache::cacheStore()->get('stache::indexes::collections::path') ?: []; + $directory = Stache::store('collections')?->directory(); + + return is_string($first = reset($saved)) && $directory && ! str_starts_with($first, rtrim($directory, '/').'/'); } private static function stamp(array $values): void diff --git a/src/Commands/Refresh.php b/src/Commands/Refresh.php index 1f9b949..f827438 100644 --- a/src/Commands/Refresh.php +++ b/src/Commands/Refresh.php @@ -38,6 +38,12 @@ public function handle(): int return $this->call('flatpack:build'); } + if (Builder::builtElsewhere()) { + $this->components->info('The content was built for the app at another path (a new release directory), so building everything.'); + + return $this->call('flatpack:build'); + } + $out = Builder::refresh(); if (! $out['changed_stores']) { diff --git a/src/Commands/Status.php b/src/Commands/Status.php index 649ee93..8a5268f 100644 --- a/src/Commands/Status.php +++ b/src/Commands/Status.php @@ -59,6 +59,12 @@ public function handle(): int return self::SUCCESS; } + if (\NineteenNinetyFour\Flatpack\Builder::builtElsewhere()) { + $row('Changed on disk since', 'built for another path (a new release directory: run `php please flatpack:build`)'); + + return self::SUCCESS; + } + $pending = State::pending(); if (! $pending['manifest']) { $row('Changed on disk since', 'unknown: run `php please flatpack:build` to start tracking'); diff --git a/src/FlatpackStore.php b/src/FlatpackStore.php index 2b56d36..4264009 100644 --- a/src/FlatpackStore.php +++ b/src/FlatpackStore.php @@ -211,6 +211,13 @@ public function getPrefix() // ---------------------------------------------------------------- persist + /** Drop everything buffered in this process, so what's on disk stays as it was. */ + public function discard(): void + { + $this->kvDirty = $this->shardOps = $this->fbOps = $this->fbRead = $this->loaded = []; + $this->pointer = null; + } + /** Write buffered changes: new content-addressed files, then the pointer, under a lock. */ public function persist(): void { diff --git a/src/Jobs/RunFlatpack.php b/src/Jobs/RunFlatpack.php index 42490d2..810bea7 100644 --- a/src/Jobs/RunFlatpack.php +++ b/src/Jobs/RunFlatpack.php @@ -32,7 +32,8 @@ public function handle(): void Cache::put(Status::RUNNING, ['action' => $this->action, 'at' => time()], 900); try { - $result = $this->action === 'build' + // A refresh can't follow the app to a new path: everything compiled holds absolute paths. + $result = $this->action === 'build' || Builder::builtElsewhere() ? Builder::compile((int) config('flatpack.build_workers', 4)) : Builder::refresh(); diff --git a/tests/parity/run.sh b/tests/parity/run.sh index 69d5ca1..f1819e0 100755 --- a/tests/parity/run.sh +++ b/tests/parity/run.sh @@ -39,6 +39,19 @@ oracle "$HERE/clear.php" "$O" >/dev/null (cd "$C" && flatpack please flatpack:refresh -q) || { echo " refresh failed:"; (cd "$C" && flatpack please flatpack:refresh 2>&1 | head -15); fails=$((fails+1)); } dump edited; same "files changed on disk, then flatpack:refresh" edited +# A deploy into a new release directory with shared storage: the app is at a new path and the +# compiled content, which holds absolute paths, comes with it. A build there, or a refresh, has +# to give the same answers from the new path. +M="$C-moved"; rm -rf "$M" "$M-again"; cp -R "$C" "$M" +(cd "$M" && flatpack please flatpack:build -q) || { echo " build after moving failed:"; (cd "$M" && flatpack please flatpack:build 2>&1 | head -15); fails=$((fails+1)); } +flatpack "$HERE/dump.php" "$M" "$OUT/c-moved.json" >/dev/null +check "moved to a new directory, then flatpack:build" python3 "$HERE/compare.py" "$OUT/o-edited.json" "$OUT/c-moved.json" --show 5 +cp -R "$M" "$M-again" +(cd "$M-again" && flatpack please flatpack:refresh -q) || { echo " refresh after moving failed:"; (cd "$M-again" && flatpack please flatpack:refresh 2>&1 | head -15); fails=$((fails+1)); } +flatpack "$HERE/dump.php" "$M-again" "$OUT/c-moved-again.json" >/dev/null +check "moved again, then flatpack:refresh" python3 "$HERE/compare.py" "$OUT/o-edited.json" "$OUT/c-moved-again.json" --show 5 +rm -rf "$M" "$M-again" + # Switched off, Flatpack must stay out of the way: Statamic's own commands work, and # flatpack:build / flatpack:refresh fall back to Statamic's own clear + warm. check "switched off: stache:warm, flatpack:build and flatpack:refresh still work" bash -c "cd '$O' && FLATPACK_ENABLED=false '${PHP[0]}' please stache:warm -q && FLATPACK_ENABLED=false '${PHP[0]}' please flatpack:build -q && FLATPACK_ENABLED=false '${PHP[0]}' please flatpack:refresh -q"