Files
KantSites/scripts/build-release.php
T
kantarciandClaude Opus 5 c8a8fa7460 Rebuild DEEG as a fifth installation, add stepped forms and a plugin system, and clear the review backlog
Forms gained steps, conditions, image choices, number fields with units and a
summary step; the DEEG price finder uses all of it. Plugins are manifest plus
Twig: blocks, settings, containers, routes, presets and theme packs, switched
per installation. The review backlog is done: archive pages render through the
normal page pipeline, forms and post lists work inside areas and sections,
repeated sections get their own ids, breakpoints and colours come from the
palette, backup and update work on MySQL and lock each other out.

The task worker no longer shortens a process that has no time limit:
set_time_limit() sets the limit instead of extending it, which turned an
unlimited CLI run into eighty seconds and killed the full test suite far away
from the scheduler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:32:35 +02:00

167 lines
7.4 KiB
PHP

<?php
/**
* Builds a release archive that runs on hosting without Composer.
*
* Umut is right: shared hosting usually has no Composer. A release therefore has to carry
* `vendor/` with it, and that folder has to be built by somebody - here, on a machine that does
* have Composer. The archive is what `install.php` downloads and what a host without Composer
* unpacks; the Git repository stays free of `vendor/`.
*
* Run: php scripts/build-release.php [--out=var/releases]
*
* The script refuses to build rather than produce something misleading: without Composer there is
* no way to get a dependency tree without development packages, and shipping the development one
* would mean an archive with PHPUnit in it.
*/
$root = dirname(__DIR__);
$options = getopt('', ['out::', 'keep-dev', 'url::']);
// Wohin das Archiv beim Veröffentlichen gelegt wird. Steht fest in install.php, deshalb muss
// sie hier bekannt sein - eine Adresse, die erst im Browser abgefragt wird, wäre eine Lücke.
$releaseBase = rtrim($options['url'] ?? 'https://git.kant-it.de/kantarci/KantSites/releases/download', '/');
$outDirectory = $root.'/'.trim($options['out'] ?? 'var/releases', '/');
$version = trim((string) file_get_contents($root.'/VERSION'));
if (1 !== preg_match('/^\d+\.\d+\.\d+$/D', $version)) {
fwrite(STDERR, "VERSION enthält keine Fassung der Form 1.2.3.\n");
exit(1);
}
/** Runs a command and returns [ok, output]; the shell is used because this is a build tool. */
$run = static function (string $command) use ($root): array {
$output = [];
$status = 0;
exec('cd '.escapeshellarg($root).' && '.$command.' 2>&1', $output, $status);
return [0 === $status, implode("\n", $output)];
};
// Composer, in the order that works on a developer machine.
$composer = null;
foreach (['php '.escapeshellarg($root.'/composer.phar'), 'composer'] as $candidate) {
[$ok] = $run($candidate.' --version');
if ($ok) {
$composer = $candidate;
break;
}
}
if (null === $composer) {
fwrite(STDERR, "Composer ist auf diesem Rechner nicht aufrufbar.\n");
fwrite(STDERR, "Ein Release wird auf einem Build-Rechner gebaut, nicht auf dem Hosting - genau deshalb gibt es dieses Skript.\n");
exit(1);
}
$staging = $outDirectory.'/kantsites-'.$version;
$archive = $outDirectory.'/kantsites-'.$version.'.zip';
if (is_dir($staging) || is_file($archive)) {
fwrite(STDERR, sprintf("Für Fassung %s gibt es schon ein Release. Erst entfernen oder VERSION erhöhen.\n", $version));
exit(1);
}
if (!is_dir($outDirectory) && !mkdir($outDirectory, 0o775, true)) {
fwrite(STDERR, sprintf("%s ließ sich nicht anlegen.\n", $outDirectory));
exit(1);
}
echo "Fassung {$version}\n";
// What belongs into a release: the application, and nothing that only a developer needs.
// migrations.php und cli-config.php gehoeren dazu: ohne die erste findet Doctrine beim
// Einrichten keine Migrationsliste und bricht mit "configuration file does not exist" ab - genau
// dort, wo eine frische Installation ihre Tabellen anlegen will.
$include = ['bin', 'config', 'migrations', 'plugins', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', '.env.example'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.env.lock', '.php-cs-fixer.cache', 'node_modules', 'var', 'tests', 'scripts', 'reports', 'phpunit.xml.dist', '.php-cs-fixer.dist.php', 'package.json', 'package-lock.json', 'AGENTS.md', 'ENTWICKLUNGSPLAN.md', 'docs', 'composer.phar', '.venv', 'vendor', 'phpunit.cache', 'migrations.php.bak'];
$copy = static function (string $from, string $to) use (&$copy, $skip): int {
$count = 0;
if (is_file($from)) {
@mkdir(dirname($to), 0o775, true);
return @copy($from, $to) ? 1 : 0;
}
foreach (scandir($from) ?: [] as $entry) {
if ('.' === $entry || '..' === $entry || in_array($entry, $skip, true)) {
continue;
}
$count += $copy($from.'/'.$entry, $to.'/'.$entry);
}
return $count;
};
$files = 0;
foreach ($include as $entry) {
if (file_exists($root.'/'.$entry)) {
$files += $copy($root.'/'.$entry, $staging.'/'.$entry);
}
}
// Die .env des Entwicklungsrechners gehört nicht in ein Release; gebraucht wird trotzdem eine,
// denn ohne sie bootet die Anwendung nicht einmal so weit, dass der Installer erscheint. Also
// wird die Vorlage zur .env des Releases - mit Produktionswerten und ohne Geheimnis. Das
// Geheimnis erzeugt der erste Aufruf selbst, die Zugangsdaten schreibt der Installer nach
// .env.
copy($root.'/.env.example', $staging.'/.env');
++$files;
echo "Quelldateien: {$files}\n";
// The dependencies, without the development ones - that is the whole point of building here.
$flags = isset($options['keep-dev']) ? '' : ' --no-dev';
[$ok, $output] = $run(sprintf(
'%s install%s --optimize-autoloader --no-interaction --no-progress --working-dir=%s',
$composer,
$flags,
escapeshellarg($staging),
));
if (!$ok) {
fwrite(STDERR, "composer install ist gescheitert:\n".$output."\n");
exit(1);
}
echo 'vendor/ gebaut'.($flags ? ' ohne Entwicklungspakete' : ' MIT Entwicklungspaketen')."\n";
// The environment file is not part of a release: it holds the secret of one installation.
foreach (['.env.local', '.env.prod.local'] as $secret) {
if (is_file($staging.'/'.$secret)) {
unlink($staging.'/'.$secret);
}
}
if (!class_exists(ZipArchive::class)) {
fwrite(STDERR, "Die PHP-Erweiterung zip fehlt; ohne sie lässt sich kein Archiv schreiben.\n");
exit(1);
}
$zip = new ZipArchive();
if (true !== $zip->open($archive, ZipArchive::CREATE | ZipArchive::OVERWRITE)) {
fwrite(STDERR, "Archiv ließ sich nicht anlegen.\n");
exit(1);
}
$entries = 0;
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
// Paths inside the archive start with the version folder, so unpacking never scatters
// files across an existing installation.
$zip->addFile($file->getPathname(), 'kantsites-'.$version.'/'.str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)));
++$entries;
}
}
$zip->close();
$hash = hash_file('sha256', $archive);
file_put_contents($archive.'.sha256', $hash.' '.basename($archive)."\n");
printf("\nArchiv: %s\n", $archive);
printf("Dateien: %d, Größe: %.1f MB\n", $entries, filesize($archive) / 1048576);
printf("SHA-256: %s\n", $hash);
// Und der einzelne Dateidownload: dieselbe install.php, nur mit eingetragener Adresse und
// Prüfsumme. Ohne beides verweigert sie die Arbeit, deshalb entsteht sie erst hier.
$installer = strtr((string) file_get_contents($root.'/scripts/install.php'), [
"const KANT_ARCHIVE_URL = '';" => sprintf("const KANT_ARCHIVE_URL = '%s';", $releaseBase.'/v'.$version.'/'.basename($archive)),
"const KANT_ARCHIVE_SHA256 = '';" => sprintf("const KANT_ARCHIVE_SHA256 = '%s';", $hash),
"const KANT_VERSION = '';" => sprintf("const KANT_VERSION = '%s';", $version),
]);
file_put_contents($outDirectory.'/install.php', $installer);
printf("Installer: %s (lädt %s)\n", $outDirectory.'/install.php', $releaseBase.'/v'.$version.'/'.basename($archive));
echo "\nBeides gehört auf die Releaseseite: das Archiv unter genau dieser Adresse, und install.php zum Herunterladen.\n";
echo "Die Prüfsumme steckt jetzt in install.php, damit ein manipuliertes Archiv nicht entpackt wird.\n";