Files
typo3-13/deploy.php
T
frommeandClaude Opus 5.5 3f88229a70 feat: settings.php is shared on the server and no longer in git; production is leading
Every deploy replaced settings.php with the committed version, so changes
made in the production backend (extension configuration, install tool) were
lost with the next deploy or rollback. As LocalConfiguration.php on TYPO3 10,
it is now a shared file. typo3:settings:init seeds it once from the live
release; deploy:shared would otherwise seed it from the new release, i.e.
from git, and drop production's settings one last time.

The file is no longer tracked: it holds the encryptionKey and the install
tool password hash, and git has no use for it once production is leading.
Local copies stay in place (ignored).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:31:21 +02:00

221 lines
10 KiB
PHP

<?php
namespace Deployer;
// Run on the host (Deployer 8, PHP >= 8.3): dep deploy prod. The SSH keys stay on the host; the
// build runs inside DDEV, so Composer and Node are the project's versions.
// Prerequisites on the host: see .ddev/plans/TYPO3-13-GO-LIVE.md (PHP 8.5.12, database, secrets.php)
require 'recipe/typo3.php';
require 'contrib/rsync.php';
set('application', 'ep-reisen-typo3-13');
// Defined here rather than taken from recipe/typo3.php, whose task and setting names differ between
// Deployer versions (a globally installed "dep" may not be the project's vendor/bin/dep 8)
set('typo3/public_dir', 'public');
set('bin/typo3', 'vendor/bin/typo3');
set('keep_releases', 3);
// The release is built locally from the committed state, so nothing uncommitted is ever deployed
// and the host needs neither Composer nor Node
set('build_dir', __DIR__ . '/var/deploy-build');
set('use_rsync', true);
set('rsync_src', '{{build_dir}}');
// fileadmin is the production fileadmin of the TYPO3 10 release: it is the CDN origin's fileadmin
// (static/ep-reisen/fileadmin links there too), and TYPO3 13 writes its processed images into it
set('fileadmin_path', '/usr/home/eptypo3/public_html/ep-reisen-typo3/shared/public/fileadmin');
set('shared_dirs', [
'{{typo3/public_dir}}/typo3temp/assets',
'var/lock',
'var/log',
'var/session',
'var/spool',
// Baseline of the MyE&P import: without it every deploy would trigger a full re-import
'var/transient/ep_products_import',
]);
// settings.php is shared as LocalConfiguration.php was on TYPO3 10: production is leading, so what
// is changed in the backend (extension configuration, install tool) survives deploys and rollbacks.
// It is not in git (encryptionKey, install tool password): configuration changes for production are
// made there, in the backend or by hand on the shared file.
set('shared_files', [
'config/system/secrets.php',
'config/system/settings.php',
]);
set('writable_dirs', [
'{{typo3/public_dir}}/typo3temp/assets',
'var/cache',
'var/lock',
'var/log',
'var/transient',
]);
set('writable_mode', 'chmod');
$rsync = get('rsync');
$rsync['exclude'] = array_merge($rsync['exclude'], [
'/{{typo3/public_dir}}/fileadmin',
'node_modules',
'/packages/*/Resources/Private/Assets',
'/.ddev',
'/deploy.php',
'/rector.php',
'/fractor.php',
'/webpack.config.js',
'/tailwind.config.js',
'/package.json',
'/package-lock.json',
'/.nvmrc',
]);
// The build directory has no .gitignore filtering to do, and the filter would drop the built assets
$rsync['filter'] = [];
// -t preserves mtimes: without it every file lands with a fresh timestamp, so unchanged
// files get a new Last-Modified on every deploy and every conditional request revalidates
// into a full response instead of a 304.
$rsync['flags'] = 'rzt';
$rsync['options'] = ['delete', 'links', 'chmod=D755,F664'];
set('rsync', $rsync);
host('prod')
->setHostname('dedi10193.your-server.de')
->setRemoteUser('eptypo3')
->setForwardAgent(true)
->setSshMultiplexing(true)
->setDeployPath('/usr/home/eptypo3/public_html/{{application}}')
// Verify on the host once Hetzner has installed it
->set('bin/php', '/usr/bin/php85')
->set('http_user', 'eptypo3')
;
desc('Build the release locally: committed state, production dependencies, theme assets');
task('build', function () {
// Inside DDEV directly, on the host through "ddev exec" (paths relative to the project root)
$inDdev = getenv('IS_DDEV_PROJECT') === 'true';
$exec = fn(string $command) => runLocally($inDdev
? $command
: 'ddev exec bash -c ' . escapeshellarg($command));
runLocally('rm -rf {{build_dir}} && mkdir -p {{build_dir}}');
runLocally('git archive HEAD | tar -x -C {{build_dir}}');
// Assets first: Composer only publishes an extension's Resources/Public as public/_assets/<hash>
// if the directory exists when it runs, and the theme's is created by the build
$exec('cd var/deploy-build && npm ci --no-audit --no-fund && npm run build');
$exec('cd var/deploy-build && composer install --no-dev --prefer-dist --no-progress --no-interaction --optimize-autoloader');
runLocally('test -L {{build_dir}}/public/_assets/' . md5('/vendor/ep/ep-theme/') . ' && test -f {{build_dir}}/packages/ep_theme/Resources/Public/entrypoints.json');
})->once();
desc('Read-only checks of the host before the first deploy (see .ddev/plans/TYPO3-13-GO-LIVE.md)');
task('host:check', function () {
$checks = [
'PHP CLI 8.5.12+' => '{{bin/php}} -r \'exit(version_compare(PHP_VERSION, "8.5.12", ">=") ? 0 : 1);\' && {{bin/php}} -v | head -1',
'PHP extensions' => '{{bin/php}} -r \'$m = array_diff(["mysqli", "pdo_mysql", "intl", "mbstring", "xml", "zip", "gd", "fileinfo", "openssl", "curl", "zlib"], get_loaded_extensions()); echo $m ? "missing: " . implode(", ", $m) : "ok"; exit($m ? 1 : 0);\'',
'image processor (GraphicsMagick)' => 'test -x /usr/bin/gm && /usr/bin/gm version | head -1',
'production fileadmin' => 'test -d {{fileadmin_path}} && du -sh {{fileadmin_path}} 2>/dev/null | cut -f1',
'static/ep-reisen/fileadmin links there' => 'test "$(readlink -f /usr/home/eptypo3/public_html/static/ep-reisen/fileadmin)" = "$(readlink -f {{fileadmin_path}})" && echo yes',
'free disk space' => 'df -h /usr/home/eptypo3 | tail -1',
'secrets.php in place' => 'test -s {{deploy_path}}/shared/config/system/secrets.php && {{bin/php}} -r \'exit(is_array(require "{{deploy_path}}/shared/config/system/secrets.php") ? 0 : 1);\' && echo yes',
'MyE&P reachable' => 'curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://my.ep-reisen.de/api/last-update',
];
$failed = false;
foreach ($checks as $label => $command) {
try {
writeln(sprintf('<info>✔</info> %s: %s', $label, trim(run($command))));
} catch (\Throwable $e) {
writeln(sprintf('<error>✘</error> %s', $label));
$failed = true;
}
}
writeln('Not checkable from the CLI: open_basedir and PHP 8.5.12 (FPM) of the static vhosts, see the go-live plan.');
if ($failed) {
throw new \RuntimeException('Host checks failed');
}
});
desc('Put the secrets.php template into shared/ unless a secrets.php is there already (never overwrites)');
task('typo3:secrets:init', function () {
$target = '{{deploy_path}}/shared/config/system/secrets.php';
if (test("[ -s $target ]")) {
return;
}
run('mkdir -p {{deploy_path}}/shared/config/system');
upload(__DIR__ . '/config/system/secrets.example.php', $target);
run("chmod 600 $target");
writeln('<comment>Created ' . parse($target) . ' from the template: fill it in, then deploy again.</comment>');
});
desc('Seed the shared settings.php from the live release unless it is there already (never overwrites)');
task('typo3:settings:init', function () {
$target = '{{deploy_path}}/shared/config/system/settings.php';
if (test("[ -s $target ]")) {
return;
}
// deploy:shared would seed it from the new release, i.e. from git, and drop production's settings
if (!test('[ -f {{deploy_path}}/current/config/system/settings.php ]')) {
return;
}
run('mkdir -p {{deploy_path}}/shared/config/system');
run("cp -L {{deploy_path}}/current/config/system/settings.php $target");
writeln('<comment>Created ' . parse($target) . ' from the live release.</comment>');
});
desc('Stop before the database is touched while secrets.php still lacks the database connection');
task('typo3:secrets:check', function () {
$secrets = '{{deploy_path}}/shared/config/system/secrets.php';
$filled = run("{{bin/php}} -r '\$s = require \"$secrets\"; echo is_array(\$s) && (\$s[\"DB\"][\"Connections\"][\"Default\"][\"dbname\"] ?? \"\") !== \"\" ? \"yes\" : \"no\";'");
if ($filled !== 'yes') {
throw new \RuntimeException(parse("$secrets has no database connection yet: fill it in, then deploy again. Nothing went live."));
}
});
desc('TYPO3 - Set up all extensions (database schema: adds and changes only)');
task('typo3:extension:setup', function () {
run('{{bin/php}} {{release_path}}/{{bin/typo3}} extension:setup');
});
desc('TYPO3 - Clear all caches');
task('typo3:cache:flush', function () {
run('{{bin/php}} {{current_path}}/{{bin/typo3}} cache:flush');
});
desc('Link the production fileadmin into the release');
task('typo3:fileadmin', function () {
run('test -d {{fileadmin_path}}');
// Relative (-r), like Deployer's shared dirs: the vhosts answer 403 for an absolute symlink here
run('ln -sfnr {{fileadmin_path}} {{release_path}}/{{typo3/public_dir}}/fileadmin');
run('test -d {{release_path}}/{{typo3/public_dir}}/fileadmin/user_upload');
});
desc('Deploys TYPO3 13');
task('deploy', [
'deploy:info',
// Build first, so a failing build aborts before the server is touched at all.
'build',
'deploy:setup',
'deploy:lock',
'deploy:release',
'rsync',
'typo3:secrets:init',
'typo3:settings:init',
'deploy:shared',
'typo3:fileadmin',
'deploy:writable',
'typo3:secrets:check',
// The schema has to exist before the new code starts serving traffic (adds and changes only).
'typo3:extension:setup',
// deploy:publish == deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success
'deploy:publish',
]);
// Caches belong to the release that goes live, so they are cleared after the symlink flip. Hooking
// into deploy:symlink rather than appending to the list above keeps them inside deploy:publish, ahead
// of deploy:unlock and deploy:success - a failing cache task must not follow a declared success.
// No OPcache reset (cachetool): its web adapter would need PHP under a host's typo3temp. Vhosts that
// point at current/public keep running the previous release until PHP's path cache expires (~2 min);
// pages it renders in that window stay in the page cache until the next flush.
after('deploy:symlink', 'typo3:cache:flush');
// A rollback switches the code back; pages cached by the other release must not survive it
after('rollback', 'typo3:cache:flush');
after('deploy:failed', 'deploy:unlock');