`) or literal (`|`) block scalar preserves the newline before * any line indented deeper than the first one. `sh -c` then receives a * multi-line string and aborts with `sh: 2: Syntax error: "&&" unexpected` * before running a single command — the `migrate` one-shot fails, and * because every other service waits on * `condition: service_completed_successfully`, the whole stack stays down. * * This has now broken staging twice: fixed once in 5949fad by switching to * the exec-array form, then reintroduced by 58f24a6. Hence a guard. */ public function test_deploy_compose_never_uses_a_block_scalar_for_command(): void { $contents = (string) file_get_contents(base_path('docker-compose.deploy.yml')); preg_match_all('/^\s*command:\s*([>|][-+]?\d*)\s*$/m', $contents, $matches); $this->assertSame( [], $matches[1], 'docker-compose.deploy.yml declares `command:` as a YAML block scalar ('.implode(', ', $matches[1]).'). ' .'Folding keeps the newline before every line indented deeper than the first, so `sh -c` gets a multi-line ' .'string and dies with `sh: 2: Syntax error: "&&" unexpected` before running anything. ' .'Use the single-line exec-array form: command: ["sh", "-c", "a && b && c"].' ); } public function test_every_artisan_call_in_the_deploy_compose_is_valid(): void { $invocations = $this->artisanInvocationsInDeployCompose(); $this->assertNotEmpty( $invocations, 'Expected docker-compose.deploy.yml to invoke artisan; the parser found nothing, so this guard is not actually checking anything.' ); $registered = Artisan::all(); foreach ($invocations as [$name, $options]) { $this->assertArrayHasKey( $name, $registered, "docker-compose.deploy.yml calls `php artisan {$name}`, which is not a registered command." ); $command = $registered[$name]; // Options like --no-interaction and --env belong to the console // application rather than the command, and only appear in the // command's definition once the two are merged. $command->mergeApplicationDefinition(); $definition = $command->getDefinition(); foreach ($options as $option) { $this->assertTrue( $definition->hasOption($option), "docker-compose.deploy.yml calls `php artisan {$name} --{$option}`, but that command defines no `--{$option}` option. " .'Artisan exits non-zero on an unknown option, which fails the migrate one-shot and prevents the whole stack from starting.' ); } } } /** * @return list}> */ private function artisanInvocationsInDeployCompose(): array { // Read the file as text rather than parsing YAML: the commands are // folded block scalars, so a call can wrap across lines, and the repo // ships no YAML parser. Collapsing whitespace makes the wrapping // irrelevant and keeps the guard dependency-free. $contents = (string) file_get_contents(base_path('docker-compose.deploy.yml')); $flattened = (string) preg_replace('/\s+/', ' ', $contents); preg_match_all('/php artisan ([\w:.-]+)((?: --[\w-]+(?:=\S+)?)*)/', $flattened, $matches, PREG_SET_ORDER); $invocations = []; foreach ($matches as $match) { preg_match_all('/--([\w-]+)/', $match[2], $optionMatches); $invocations[] = [$match[1], $optionMatches[1]]; } return $invocations; } }