Compare commits

...

1 Commits

Author SHA1 Message Date
f36ee534af fix: make staging initialization failures fatal 2026-08-06 11:41:58 -03:00
5 changed files with 144 additions and 7 deletions

View File

@@ -14,7 +14,7 @@ services:
restart: "no"
env_file:
- .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:
- dokploy-network

View File

@@ -17,7 +17,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
| Piece | Detail |
|---|---|
| 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`) |
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
| 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`.
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`.
### 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**.
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
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.
@@ -192,9 +196,9 @@ XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::que
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
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
@@ -219,7 +223,7 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
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

View File

@@ -102,6 +102,17 @@ while (( SECONDS < deadline )); do
error|failed|failure)
echo "Dokploy deployment failed." >&2
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
;;
esac

View 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

View 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',
],
],
]);