$line) { if (! str_contains($line, 'docker compose exec')) { continue; } // Only the app container. The gateway and the queues are other // images with other users, and none of them own this checkout. if (! preg_match('/docker compose exec\b[^|]*?\bapp\b/', $line)) { continue; } if (preg_match('/\s-u\s+\S+/', $line)) { continue; } // Help text counts. Telling an operator to run it as root is how // the file ends up owned by root in the first place. $offenders[] = basename($path).':'.($i + 1).' — '.trim($line); } } expect($offenders)->toBe([]); }); it('repairs ownership before it needs it, not after', function () { // A server already carrying the damage has to heal on its next deployment. // Nothing else ever will: a root-owned log file stays root-owned, and the // page that trips over it is nowhere near the deployment that caused it. $update = File::get(base_path('deploy/update.sh')); expect($update)->toContain('normalise_ownership') // Every directory the deployment writes into, and node_modules by name: // the first version sampled the top-level owner and skipped the // recursion when it matched, which is how a root-owned // node_modules/.vite-temp under a www-data-owned node_modules failed // the build in maintenance mode. ->and($update)->toContain('! -user www-data -exec chown www-data:www-data') ->and($update)->toContain('node_modules'); // Before the first unprivileged step, or it cannot help: composer and npm // would already have failed on a root-owned vendor directory. $repair = strpos($update, "\nnormalise_ownership\n"); $maintenance = strpos($update, 'php artisan down --retry=60'); expect($repair)->not->toBeFalse() ->and($maintenance)->not->toBeFalse() ->and($repair)->toBeLessThan($maintenance); });