Files
KantSites/scripts/build-release.php
T
kantarciandClaude Opus 5 c517173579
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Fassung 1.4.1: dem Releasearchiv fehlten migrations.php und cli-config.php
Auf dem Hosting brach die Installation im dritten Schritt ab - "The
configuration file does not exist", genau dort, wo die Tabellen
entstehen sollten. Beide Dateien standen weder in der Mitnahme- noch in
der Ausschlussliste des Releaseskripts.

Ein Test ordnet jetzt jeden Eintrag im Projektstamm einer der beiden
Listen zu: was neu dazukommt und unbedacht bleibt, laesst ihn
fehlschlagen. Der Installationsweg wurde danach vollstaendig
durchgespielt - 3504 Dateien entpackt, 14 Migrationen ausgefuehrt, 15
Tabellen angelegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:43:56 +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', 'public', 'src', 'templates', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION', 'README.md', 'LICENSE', '.env.example'];
$skip = ['.git', '.github', '.claude', '.gitattributes', '.gitignore', '.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.local.
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";