fix: make staging initialization failures fatal
This commit is contained in:
@@ -14,7 +14,7 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
|
command: ["/app/scripts/deploy/initialize-application.sh"]
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
|
|||||||
| Piece | Detail |
|
| Piece | Detail |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
|
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
|
||||||
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
|
| Processes | `migrate` initialization (one-shot) → `web` / `queue` / `scheduler` |
|
||||||
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
|
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
|
||||||
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
|
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
|
||||||
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
|
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
|
||||||
@@ -145,7 +145,7 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
|
|||||||
|
|
||||||
1. Waits for workflow `CI` success on push to `main`.
|
1. Waits for workflow `CI` success on push to `main`.
|
||||||
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
2. Builds once; pushes `:<full-sha>` and `:staging`.
|
||||||
3. Calls Dokploy `compose.deploy` and polls until done.
|
3. Calls Dokploy `compose.deploy`; initialization migrates, runs `ContentSeeder`, and generates media variants before the app services start.
|
||||||
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
|
||||||
|
|
||||||
### Production (manual)
|
### Production (manual)
|
||||||
@@ -155,7 +155,11 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
|
|||||||
1. Operator runs **Actions → Promote production**.
|
1. Operator runs **Actions → Promote production**.
|
||||||
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
|
||||||
3. Retags the **same digest** as `:production` (no rebuild).
|
3. Retags the **same digest** as `:production` (no rebuild).
|
||||||
4. Deploys production compose + smoke.
|
4. Deploys production compose; initialization runs migrations only, then smoke.
|
||||||
|
|
||||||
|
The one-shot initializer is a dependency of `web`, `queue`, and `scheduler`. Migration failure is fatal in every environment. In staging, `ContentSeeder` and `media:generate-variants` also run automatically and are fatal: if any initialization command fails, application services do not start. Production and every non-staging environment remain migrate-only.
|
||||||
|
|
||||||
|
When Dokploy reports a failed deployment, [`scripts/deploy/dokploy-deploy.sh`](../../scripts/deploy/dokploy-deploy.sh) prints the latest deployment metadata and fetches the last 1,000 log lines through `deployment.readLogs`. Log retrieval is best-effort and cannot hide the original deployment failure.
|
||||||
|
|
||||||
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
||||||
|
|
||||||
@@ -192,9 +196,9 @@ XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::que
|
|||||||
|
|
||||||
Never reuse `admin@amare.local` / `password`.
|
Never reuse `admin@amare.local` / `password`.
|
||||||
|
|
||||||
### Load authorized testimonials after migrations
|
### Load authorized testimonials in production
|
||||||
|
|
||||||
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
|
Staging receives the five authorized testimonials through the automatic `ContentSeeder`. Production is migrate-only, so manually load them there after migrations. From Dokploy, open a terminal on the production `web` service (or run an equivalent one-off process):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
|
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
|
||||||
@@ -219,7 +223,7 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
|||||||
|
|
||||||
Expected output: `ok`.
|
Expected output: `ok`.
|
||||||
|
|
||||||
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
Never run `DatabaseSeeder` in staging or production because it creates local credentials. Do not run `ContentSeeder` in production: its broad content fixtures are intended for staging initialization only. Production testimonial loading remains the deliberate, narrowly scoped manual operation above.
|
||||||
|
|
||||||
## Backup and restore
|
## Backup and restore
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ while (( SECONDS < deadline )); do
|
|||||||
error|failed|failure)
|
error|failed|failure)
|
||||||
echo "Dokploy deployment failed." >&2
|
echo "Dokploy deployment failed." >&2
|
||||||
echo "$latest" | jq . >&2 || true
|
echo "$latest" | jq . >&2 || true
|
||||||
|
deployment_id="$(echo "$latest" | jq -r '.deploymentId // .id // empty')"
|
||||||
|
|
||||||
|
if [[ -z "$deployment_id" ]]; then
|
||||||
|
echo "Dokploy deployment logs could not be retrieved: deployment metadata has no id." >&2
|
||||||
|
elif deployment_logs="$(api GET "/deployment.readLogs?deploymentId=${deployment_id}&tail=1000")"; then
|
||||||
|
echo "Dokploy deployment logs:" >&2
|
||||||
|
printf '%s\n' "$deployment_logs" >&2
|
||||||
|
else
|
||||||
|
echo "Dokploy deployment logs could not be retrieved; the original deployment failure remains authoritative." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
10
scripts/deploy/initialize-application.sh
Executable file
10
scripts/deploy/initialize-application.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
php artisan migrate --force --no-interaction
|
||||||
|
|
||||||
|
if [ "${APP_ENV:-}" = "staging" ]; then
|
||||||
|
php artisan db:seed --class=ContentSeeder --force --no-interaction
|
||||||
|
php artisan media:generate-variants
|
||||||
|
fi
|
||||||
112
tests/Unit/DeployInitializationScriptTest.php
Normal file
112
tests/Unit/DeployInitializationScriptTest.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
function runDeployInitializationScript(string $environment, ?string $failingCommand = null, int $failureCode = 1): array
|
||||||
|
{
|
||||||
|
$temporaryDirectory = sys_get_temp_dir().'/amare-deploy-initialization-'.bin2hex(random_bytes(8));
|
||||||
|
$fakeBinDirectory = $temporaryDirectory.'/bin';
|
||||||
|
$commandLog = $temporaryDirectory.'/commands.log';
|
||||||
|
|
||||||
|
mkdir($fakeBinDirectory, 0777, true);
|
||||||
|
|
||||||
|
$fakePhp = <<<'SH'
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >> "$FAKE_PHP_LOG"
|
||||||
|
|
||||||
|
if [ -n "${FAKE_PHP_FAIL_ON:-}" ] && [ "$*" = "$FAKE_PHP_FAIL_ON" ]; then
|
||||||
|
exit "${FAKE_PHP_FAIL_CODE:-1}"
|
||||||
|
fi
|
||||||
|
SH;
|
||||||
|
|
||||||
|
file_put_contents($fakeBinDirectory.'/php', $fakePhp);
|
||||||
|
chmod($fakeBinDirectory.'/php', 0755);
|
||||||
|
|
||||||
|
$process = new Process(
|
||||||
|
['/bin/sh', dirname(__DIR__, 2).'/scripts/deploy/initialize-application.sh'],
|
||||||
|
dirname(__DIR__, 2),
|
||||||
|
[
|
||||||
|
'APP_ENV' => $environment,
|
||||||
|
'FAKE_PHP_FAIL_CODE' => (string) $failureCode,
|
||||||
|
'FAKE_PHP_FAIL_ON' => $failingCommand ?? '',
|
||||||
|
'FAKE_PHP_LOG' => $commandLog,
|
||||||
|
'PATH' => $fakeBinDirectory.PATH_SEPARATOR.getenv('PATH'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
$commands = file_exists($commandLog)
|
||||||
|
? file($commandLog, FILE_IGNORE_NEW_LINES)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
unlink($fakeBinDirectory.'/php');
|
||||||
|
if (file_exists($commandLog)) {
|
||||||
|
unlink($commandLog);
|
||||||
|
}
|
||||||
|
rmdir($fakeBinDirectory);
|
||||||
|
rmdir($temporaryDirectory);
|
||||||
|
|
||||||
|
return [$process->getExitCode(), $commands];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('runs migrations, content seeding, and media generation in staging', function (): void {
|
||||||
|
[$exitCode, $commands] = runDeployInitializationScript('staging');
|
||||||
|
|
||||||
|
expect($exitCode)->toBe(0)
|
||||||
|
->and($commands)->toBe([
|
||||||
|
'artisan migrate --force --no-interaction',
|
||||||
|
'artisan db:seed --class=ContentSeeder --force --no-interaction',
|
||||||
|
'artisan media:generate-variants',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs only migrations outside staging', function (string $environment): void {
|
||||||
|
[$exitCode, $commands] = runDeployInitializationScript($environment);
|
||||||
|
|
||||||
|
expect($exitCode)->toBe(0)
|
||||||
|
->and($commands)->toBe([
|
||||||
|
'artisan migrate --force --no-interaction',
|
||||||
|
]);
|
||||||
|
})->with([
|
||||||
|
'production' => 'production',
|
||||||
|
'another environment' => 'preview',
|
||||||
|
]);
|
||||||
|
|
||||||
|
it('propagates initialization failures and stops later commands', function (
|
||||||
|
string $failingCommand,
|
||||||
|
int $failureCode,
|
||||||
|
array $expectedCommands,
|
||||||
|
): void {
|
||||||
|
[$exitCode, $commands] = runDeployInitializationScript('staging', $failingCommand, $failureCode);
|
||||||
|
|
||||||
|
expect($exitCode)->toBe($failureCode)
|
||||||
|
->and($commands)->toBe($expectedCommands);
|
||||||
|
})->with([
|
||||||
|
'migration failure' => [
|
||||||
|
'artisan migrate --force --no-interaction',
|
||||||
|
41,
|
||||||
|
['artisan migrate --force --no-interaction'],
|
||||||
|
],
|
||||||
|
'content seeding failure' => [
|
||||||
|
'artisan db:seed --class=ContentSeeder --force --no-interaction',
|
||||||
|
42,
|
||||||
|
[
|
||||||
|
'artisan migrate --force --no-interaction',
|
||||||
|
'artisan db:seed --class=ContentSeeder --force --no-interaction',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'media generation failure' => [
|
||||||
|
'artisan media:generate-variants',
|
||||||
|
43,
|
||||||
|
[
|
||||||
|
'artisan migrate --force --no-interaction',
|
||||||
|
'artisan db:seed --class=ContentSeeder --force --no-interaction',
|
||||||
|
'artisan media:generate-variants',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
Reference in New Issue
Block a user