From 0bf588d89c06b9af0825b5ca34aae340828dec4c Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Sun, 29 Jun 2025 18:46:41 -0700 Subject: [PATCH 1/9] Initial release of release tools --- buildLanguages.php | 473 ++++++++++++++++++++++++++++++++++++ buildPatch.php | 535 ++++++++++++++++++++++++++++++++++++++++ buildRelease.php | 397 ++++++++++++++++++++++++++++++ composer.json | 3 +- composer.lock | 593 ++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 1999 insertions(+), 2 deletions(-) create mode 100644 buildLanguages.php create mode 100644 buildPatch.php create mode 100644 buildRelease.php diff --git a/buildLanguages.php b/buildLanguages.php new file mode 100644 index 0000000..a7d31c8 --- /dev/null +++ b/buildLanguages.php @@ -0,0 +1,473 @@ +getMessage()); + exit(1); +} + +class buildLanguages +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * The directory where archives will be saved and additionally the temp files are handled here. + * @var string + */ + protected static string $output_dir = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + protected static bool $skip_download = false; + + protected static string $crowdin_api_key = ''; + + protected static array $crowdin_branch_map = [ + 'SMF_2-1' => ['2.1.0-alpha1','2.1.99'], + 'SMF_3-0' => ['3.0.0-alpha1','3.0.99'], + ]; + + /** + * Language map. SMF 3.0 will not use these and will just use the locale. + * This does not match (yet) the list in 3.0, as it matches what Crowodin export gives us. + * @var array + */ + protected static array $language_map = [ + 'af-ZA' => 'afrikaans', + 'sq-AL' => 'albanian', + 'ar-SA' => 'arabic', + 'bg-BG' => 'bulgarian', + 'ca-ES' => 'catalan', + 'zh-CN' => 'chinese_simplified', + 'zh-TW' => 'chinese_traditional', + 'hr-HR' => 'croatian', + 'cs' => 'czech_informal', + 'cs-CZ' => 'czech', + 'da-DK' => 'danish', + 'nl-NL' => 'dutch', + 'en-GB' => 'english_british', + 'eo-UY' => 'esperanto', + 'et-EE' => 'estonian', + 'fi-FI' => 'finnish', + 'fr-FR' => 'french', + 'gl-ES' => 'galician', + 'de' => 'german_informal', + 'de-DE' => 'german', + 'el-GR' => 'greek', + 'he-IL' => 'hebrew', + 'hu-HU' => 'hungarian', + 'id-ID' => 'indonesian', + 'it-IT' => 'italian', + 'ja-JP' => 'japanese', + 'kmr-TR' => 'kurdish_kurmanji', + 'lt-LT' => 'lithuanian', + 'mk-MK' => 'macedonian', + 'ms-MY' => 'malay', + 'no-NO' => 'norwegian', + 'fa-IR' => 'persian', + 'pl-PL' => 'polish', + 'pt-BR' => 'portuguese_brazilian', + 'pt-PT' => 'portuguese_pt', + 'ro-RO' => 'romanian', + 'ru-RU' => 'russian', + 'sr-SP' => 'serbian_cyrillic', + 'sr-CS' => 'serbian_latin', + 'sk-SK' => 'slovak', + 'sl-SI' => 'slovenian', + 'es-ES' => 'spanish_es', + 'es-MX' => 'spanish_latin', + 'sv-SE' => 'swedish', + 'th-TH' => 'thai', + 'tr-TR' => 'turkish', + 'uk-UA' => 'ukrainian', + 'ur-PK' => 'urdu', + 'vi-VN' => 'vietnamese', + 'eu' => 'basque', + 'bs-BA' => 'bosnian', + 'hi' => 'hindi', + 'ckb-IR' => 'kurdish_sorani', + 'lv-LV' => 'latvian', + 'ml-IN' => 'malayalam', + 'te' => 'telugu', + 'tk' => 'turkmen', + 'uz-UZ' => 'uzbek_latin', + 'az-AZ' => 'azerbaijani', + 'be-BY' => 'belarusian', + 'en-PT' => 'english_pirate', + 'ach' => 'acholi', + 'ug-CN' => 'uyghur' + ]; + /** + * A list of archives we will build. + * Currently this is: + * ZIP + * Tar.gz + * Tar.bz2 + * @var array + */ + protected static array $archives = [ + [Phar::ZIP, Phar::NONE], + [Phar::TAR, Phar::GZ], + [Phar::TAR, Phar::BZ2], + ]; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'o' => 'output_dir', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug', + 'key' => 'crowdin_api_key', + 'skip-download' => 'skip_download' + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + if (empty(self::$crowdin_api_key)) { + throw new Exception('Missing API Key'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + $smf_version = $version[1]; + $file_prefix = self::getFileNamePrefix($smf_version); + $tmp_file = self::$output_dir . '/' . $file_prefix . 'language_'; + + // Find out which Crowdin branch we are building. + // Pick our latest as the default. + $current_crowdin_project = array_key_last(self::$crowdin_branch_map); + foreach (self::$crowdin_branch_map as $k => $v) { + if (version_compare($v[0], $smf_version, '<=') && version_compare($v[1], $smf_version, '>=')) { + $current_crowdin_project = $k; + break; + } + } + + // Startup a new API with the Crowdin PHP API client. + if (!self::$skip_download) { + self::writeDebug('Connecting to Crowdin API'); + require_once(__DIR__ . '/vendor/autoload.php'); + $api = new \CrowdinApiClient\Crowdin([ + 'access_token' => self::$crowdin_api_key, + ]); + } + + // We sometimes may wan to skip a download, such as if we are rerunning this locally. + if (!self::$skip_download) + { + // Obtain project ID here.. + $project_id = $api->project->list()[0]->getId() ?? 0; + $project_identifier = $api->project->list()[0]->getIdentifier() ?? ''; + if (empty($project_id)) { + throw new Exception('Unable to obtain the project id'); + } + + /** @@todo Can we switch over to this? Simple Machines Download page should match these. + * Sample: ["es-ES"]=> array(1) { ["name"]=> string(10) "spanish_es" } + */ + //$lang_map = $api->project->list()[0]->getLanguageMapping(); + + $branch_id = $api->branch->list($project_id)[0]->getId() ?? 0; + if (empty($project_id)) { + throw new Exception('Unable to obtain the branch id'); + } + + // Ensure the project is built. + try + { + self::writeDebug('Starting Build'); + $results = $api->translation->buildProject( + $project_id, //$projectId : int + [ + 'branchId' => $branch_id, //integer $params[branchId] + //[],//array $params[targetLanguageIds] + 'skipUntranslatedStrings' => false, //bool $params[skipUntranslatedStrings] true value can't be used with skipUntranslatedFiles=true in same request + 'skipUntranslatedFiles' => false, //bool $params[skipUntranslatedFiles] true value can't be used with skipUntranslatedStrings=true in same request + 'exportApprovedOnly' => false, //bool $params[exportApprovedOnly] + //false, //integer $params[exportWithMinApprovalsCount] + ] + ); + } + catch (CrowdinApiClient\Exceptions\ApiException $e) + { + self::writeDebug($e->getMessage()); + var_dump($e); + throw $e; + } + catch (CrowdinApiClient\Exceptions\ApiValidationException $e) + { + $errs = $e->getErrors(); + foreach ($errs as $err) + self::writeDebug($err['error']['errors']); + throw $e; + } + + // Obtain the build id. + $buildID = $results->getId(); + + // We need to wait for it to build. + $done = false; + while (!$done) + { + $status = $api->translation->getProjectBuildStatus( + $project_id, //$projectId : int, + $buildID + ); + + if ($status->getProgress() > 99) + { + $done = true; + break; + } + + self::writeDebug('Building [' . $status->getProgress() . '%]'); + + sleep(10); + } + + self::writeDebug('Downloading archive [' . $tmp_file . "all.zip" . ']'); + $results = $api->translation->downloadProjectBuild( + $project_id, //$projectId : int, + $buildID + ); + $downloadURL = $results->getUrl(); + + file_put_contents($tmp_file . "all.zip", fopen($downloadURL, 'r')); + } + + if (!file_exists($tmp_file . "all.zip")) { + throw new Exception('Unable to locate language bundle[' . $tmp_file . "all.zip]"); + } + + // Extract it. + self::writeDebug('Extracting bundle'); + $zip = new ZipArchive; + if ($zip->open($tmp_file . "all.zip") === TRUE) { + $zip->extractTo($tmp_file . "tmp"); + $zip->close(); + } else { + throw new Exception('Unable to extract ZIP'); + } + + foreach (self::$language_map as $locale => $naming) { + self::writeDebug("[$locale] Building files"); + + $language_directory = $tmp_file . "tmp" . DIRECTORY_SEPARATOR . $locale; + + // Ensure we run a clean setup for the build. + array_map('unlink', glob($tmp_file . $locale . '*')); + + if (!file_exists($language_directory)) { + self::writeDebug("[$locale] Not found, skipping [" . $language_directory . ']'); + continue; + } + + $fileList = new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $language_directory, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + fn ($file, $key, $iterator) => strpos($file->getPathname(), $language_directory) === 0, + ), + ); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[$locale] [$extension] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $locale . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[$locale] [$extension] Adding initial files"); + $pd->buildFromIterator($fileList, $language_directory); + + // Convert the archive into the proper archive and compression. + self::writeDebug("[$locale] [$extension] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[$locale] [$extension] Compressing"); + $zip = new ZipArchive; + $zip->open($tmp_file . $locale . '.' . $extension); + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $locale . '.tmp'); + } + } + } + + self::writeDebug('Cleaning up'); + @unlink($tmp_file . "all.zip"); + $tmp_files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($tmp_file . "tmp"), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($tmp_files as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + @rmdir($tmp_file . "tmp"); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } else { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Build Release Tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp \n" + . '--s=/path/to/smf Where SMF has its files' . "\n" + . '--o=/path/to/out Where to store the generated files' . "\n" + . '--key=.... Crowdin API key, this defaults from the environment variable CROWDIN_API_TOKEN' . "\n" + . '-h, --help This help file.' . "\n" + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + self::$output_dir = self::$output_dir === '' ? realpath($_SERVER['PWD'] . '/..') : realpath(self::$output_dir); + self::$crowdin_api_key = self::$crowdin_api_key === '' ? getenv('CROWDIN_API_TOKEN') : self::$crowdin_api_key; + } + + /** + * Taking the SMF version found in index.php, we figure out how we would name the files. + * + * @param string $version + * @return string + */ + protected static function getFileNamePrefix(string $version): string + { + preg_match('~v?([\d]+)[-._]?([\d]+)[-._\s]?(alpha|beta|rc)?\.?\s?([\d]?)~i', $version, $matches); + + // Sometimes we used beta.1 instead of beta-1 + if (isset($matches[3]) && $matches[3] == 'beta.') { + $matches[3] = 'beta'; + } + + $prefix = 'smf_' . $matches[1] . '-' . $matches[2]; + + // 4 part name "SMF 2.0 Alpha 3" will produce [2, 0, 'Alpha', 3] + if (!empty($matches[4])) { + $prefix .= '-' . strtolower($matches[3]) . $matches[4]; + } elseif (!empty($matches[3])) { + $prefix .= '-' . $matches[3]; + } + + return $prefix . '_'; + } + + /** + * Write a debug output. + * + * @param string $msg + * @return void + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } +} diff --git a/buildPatch.php b/buildPatch.php new file mode 100644 index 0000000..04a4c6f --- /dev/null +++ b/buildPatch.php @@ -0,0 +1,535 @@ +getMessage()); + exit(1); +} + +class buildPatch +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * ID of the tag we are starting from. + * @var string + */ + protected static string $from_tag = ''; + + /** + * ID of the tag we are going to. + * @var string + */ + protected static string $to_tag = ''; + + /** + * The directory where archives will be saved and additionally the temp files are handled here. + * @var string + */ + protected static string $output_dir = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + /** + * What type of patching we are doing. Either xml or diff + * @var + */ + protected static ?string $patch_type = null; + + /** + * A list of archives we will build. + * Currently this is: + * ZIP + * Tar.gz + * Tar.bz2 + * @var array + */ + protected static array $archives = [ + [Phar::TAR, Phar::GZ], + ]; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'f' => 'from_tag', + 't' => 'to_tag', + 'o' => 'output_dir', + 'p' => 'patch_type', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug' + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + // Setting up our from version. + $from_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$from_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$from_tag); + } + // Get the version information. + $from_index = trim(shell_exec('git show ' . escapeshellarg(self::$from_tag . ':index.php')) ?? ''); + + // Validation of from version. + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $from_index, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$from_tag); + } + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$from_tag); + } + $from_smf_version = $version[1]; + + // Setting up our to version + $to_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$to_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$to_tag); + } + // Get the version information. + $to_index = trim(shell_exec('git show ' . escapeshellarg(self::$to_tag . ':index.php')) ?? ''); + + // Validation of from version. + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $to_index, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$to_tag); + } + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$to_tag); + } + $to_smf_version = $version[1]; + + // Additional variables we need. + $to_file_prefix = self::getFileNamePrefix($to_smf_version); + $to_php_version = self::getPhpMinimumVersion(self::$to_tag); + + // Ensure we have a sane patch type. + if (self::$patch_type === null || !in_array(self::$patch_type, ['xml', 'diff'])) { + self::$patch_type = version_compare($to_smf_version, '3.0.0-alpha1', '<') ? 'xml' : 'diff'; + } + + self::writeDebug("[patch] Creating working folder"); + $tmp_dir = self::$output_dir . '/' . $to_file_prefix . 'patch' . DIRECTORY_SEPARATOR; + if (empty($tmp_dir)) { + throw new Exception("Temp directory name missing"); + } + + // Cleanup any previous runs. + @array_map('unlink', glob($tmp_dir . '/*')); + @rmdir($tmp_dir); + @mkdir($tmp_dir); + + //template for our package info file. + self::writeDebug("[patch] Building info file"); + $infoFileContents = self::packageInfoTemplate($to_smf_version, $to_file_prefix, $from_smf_version, $to_php_version, self::$patch_type); + + self::writeDebug("[patch] Writing info file"); + file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); + + self::writeDebug("[patch] Generating diff"); + shell_exec('git diff -p -M -C -C -B --default-prefix --no-relative ' . escapeshellarg(self::$from_tag) . '...' . escapeshellarg(self::$to_tag) . ' > ' . escapeshellcmd($tmp_dir . $to_file_prefix . 'patch.diff')); + + // Running something below 3.0 + if (self::$patch_type === 'xml') { + self::writeDebug("[patch] Converting to xml"); + $mod_file = self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', $to_smf_version); + + self::writeDebug("[patch] Writing XML"); + file_put_contents($tmp_dir . $to_file_prefix . 'patch.xml', $infoFileContents); + unlink($tmp_dir . $to_file_prefix . 'patch.diff'); + } + + $tmp_file = self::$output_dir . '/' . $to_file_prefix; + $build = 'patch'; + + // Ensure we run a clean setup for the build. + @array_map('unlink', glob($tmp_file . $build . '.*')); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[patch] [$extension] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $build . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[$build] [$extension] Adding initial files"); + $pd->buildFromIterator(new GlobIterator(pattern: $tmp_dir . DIRECTORY_SEPARATOR . '*'), $tmp_dir . DIRECTORY_SEPARATOR); + + // Convert the archive into the proper archive and compression. + self::writeDebug("[$build] [$extension] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[$build] [$extension] Compressing"); + $zip = new ZipArchive; + $zip->open($tmp_file . $build . '.' . $extension); + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $build . '.tmp'); + } + } + + // Cleanup. + @array_map('unlink', glob($tmp_dir . '/*')); + @array_map('unlink', glob($tmp_dir . '/.*')); + @rmdir($tmp_dir); + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } else { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Build Release Tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp -f=3.0.1 -t=3.0.2 \n" + . '--s=/path/to/smf Where SMF has its files' . "\n" + . '--o=/path/to/out Where to store the generated files' . "\n" + . '--f=tag_id Tag in git for our source version.' . "\n" + . '--t=tag_id Tag in git for our target version.' . "\n" + . '-p=xml The of patch file (xml or diff)' . "\n" + . '-h, --help This help file.' . "\n" + . '-d, --debug Prints out more debug info.' . "\n" + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + self::$output_dir = self::$output_dir === '' ? realpath($_SERVER['PWD'] . '/..') : realpath(self::$output_dir); + } + + /** + * Taking the SMF version found in index.php, we figure out how we would name the files. + * + * @param string $version + * @return string + */ + protected static function getFileNamePrefix(string $version): string + { + preg_match('~v?([\d]+)[-._]?([\d]+)[-._\s]?(alpha|beta|rc)?\.?\s?([\d]?)~i', $version, $matches); + + // Sometimes we used beta.1 instead of beta-1 + if (isset($matches[3]) && $matches[3] == 'beta.') { + $matches[3] = 'beta'; + } + + $prefix = 'smf_' . $matches[1] . '-' . $matches[2]; + + // 4 part name "SMF 2.0 Alpha 3" will produce [2, 0, 'Alpha', 3] + if (!empty($matches[4])) { + $prefix .= '-' . strtolower($matches[3]) . $matches[4]; + } elseif (!empty($matches[3])) { + $prefix .= '-' . $matches[3]; + } + + return $prefix . '_'; + } + + /** + * Write a debug output. + * + * @param string $msg + * @return void + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } + + /** + * Generates the package-info.xml File + * + * @param string $version SMF version we are going to. + * @param string $file_version SMF version file prefix. + * @param string $previous_version The previous SMF version (friendly) + * @param string $min_php_version Minimum version of PHP supported for the version we are going to. + * @return string XML data for package-info.xml + */ + protected static function packageInfoTemplate(string $version, string $file_version, string $previous_version, string $min_php_version, string $extension) { + $template = << + + + smf:smf-{$version} + SMF {$version} Update + {$version} + modification + + + This will update your forum to SMF {$version}. + '{$version}')); + ?>]]> + {$file_version}patch.{$extension} + + + This will remove the changes introduced by SMF {$version}. [b]This is generally not a good idea.[/b] + {$file_version}patch.diff + '{$previous_version}'));]]> + + +END; + + return $template; + } + + /** + * Given a git tag, find the minimum version of PHP it supports. + * This will search in locations for SMF 3.0 (Sources/Maintenance/Maintenance.php) and 2.x (other/install.php) + * + * @param string $tag (git tag -l) + * @throws \Exception + * @return string Minimum PHP version supported + */ + protected static function getPhpMinimumVersion(string $tag): string + { + // SMF 3.0 way. + $maintenance_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':Sources/Maintenance/Maintenance.php') . ' 2> /dev/null || echo ""') ?? ''); + + if (!empty($maintenance_file)) { + if (!preg_match('/public\s*const\s*PHP_MIN_VERSION\s*=\s*\'([^\']+)\';/i', $maintenance_file, $version)) { + throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); + } + + return $version[1]; + } + + // SMF 2.1 and below. + $install_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':other/install.php') . ' 2> /dev/null || echo ""') ?? ''); + + if (empty($install_file)) { + throw new Exception('Error: Unable to read contents of installer in ' . $tag); + } + + if (!preg_match('/\$GLOBALS\[\'required_php_version\'\]\s*=\s*\'([^\']+)\';/i', $install_file, $version)) { + throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); + } + + return $version[1]; + } + + /** + * Given the contents of a diff file, attempt to parse our a valid XML data file. + * + * @param string $diff_file File path to the diff file. + * @param string $version SMF Version we are going to. + * @return string A XML data set for modification.xml + */ + protected static function convertDiffToPatch(string $diff_file, string $version): string + { + $content = file($diff_file); + $file_operations = []; + $operations = []; + $counter = 0; + $opCounter = 0; + + // First walk each line to figure out what we are doing. + for ($i = 0; $i < count($content); $i++) + { + if (str_starts_with($content[$i], '--- a/')) + { + $directory = substr($content[$i], 6, strpos($content[$i], '/', 7) - 6); + if ($directory == 'Sources') + $dir = '$source'. 'dir'; + elseif (strpos($content[$i], 'languages') !== false) + $dir = '$language'. 'dir'; + elseif (strpos($content[$i], 'images') !== false) + $dir = '$images'. 'dir'; + elseif (strpos($content[$i], 'default/scripts') !== false) + $dir = '$theme'. 'dir/scripts'; + elseif ($directory == 'Themes') + $dir = '$theme'. 'dir'; + else + $dir = '$board'. 'dir'; + + $operations[$counter]['path'] = $dir . '/' . basename($content[$i]); + while (!str_starts_with($content[$i], '@@')) + $i++; + continue; + } + + // Appearing to start a new section, tie things off. + /** + * When we end a block of code, tie it off and add it as a operation + * We do this when we detect: + * A new block (@@) + * A new file (diff --git) + * No more operations / EOF + * + * @author emanuele + * @copyright 2012 emanuele, Simple Machines + * @license http://www.simplemachines.org/about/smf/license.php BSD + */ + if ( + (str_starts_with($content[$i], '@@') + || str_starts_with($content[$i], 'diff --git') + || !isset($content[$i + 1]) + ) && !empty($file_operations)) + { + $operations[$counter]['operations'][$opCounter]['search'] = str_replace(array(''), array(''), implode("\n", $file_operations['search'])); + $operations[$counter]['operations'][$opCounter]['replace'] = str_replace(array(''), array(''), implode("\n", $file_operations['replace'])); + + $file_operations = []; + $opCounter++; + if (str_starts_with($content[$i], 'diff --git')) + { + $dir = ''; + $counter++; + } + continue; + } + if (!empty($dir)) + { + if (str_starts_with($content[$i], ' ')) + { + $file_operations['replace'][] = $file_operations['search'][] = substr($content[$i], 1); + } + if (str_starts_with($content[$i], '-')) + $file_operations['search'][] = substr($content[$i], 1); + elseif (str_starts_with($content[$i], '+')) + $file_operations['replace'][] = substr($content[$i], 1); + } + } + + // Build the data. + $ret = ' + + + + smf:' . $version . ' + ' . $version . ''; + + foreach ($operations as $file) + { + $ret .= ' + '; + + foreach ($file['operations'] as $file_operations) + $ret .= ' + + + + '; + + $ret .= ' + '; + } + + $ret .= ' +'; + + return $ret; + } +} \ No newline at end of file diff --git a/buildRelease.php b/buildRelease.php new file mode 100644 index 0000000..56a2a91 --- /dev/null +++ b/buildRelease.php @@ -0,0 +1,397 @@ +getMessage()); + exit(1); +} + +class buildRelease +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * Which version of SMF we are working with. + * This allows this tool to handle multiple SMF versions. + * + * @var string + */ + protected static string $target_version = '30'; + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * The directory where archives will be saved and additionally the temp files are handled here. + * @var string + */ + protected static string $output_dir = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + /** + * List of files we will exclude. Grouping is + * all: All archives (install and upgrade) + * install: Files to ignore just for install + * upgrade: Files to ignore for upgrades + * @var array + */ + protected static array $ignoreFiles = [ + 'all' => [ + // Git files. + '.git', + '.git*', + + // System folders. + '.*', + '*/.DS_Store', + '*/._*', + + // SMF files. + 'favicon.ico', + 'other/*', + 'cache/data*', + 'cache/db_last_error.php', + 'custom_avatar/avatar*', + 'db_last_error.php', + + // Development files. + '.editorconfig', + 'DCO.txt', + '*.md', + 'error_log', + 'changelog.txt', + 'composer.*', + // If we ever include this, make sure we don't include developer files. + 'vendor/*', + ], + 'install' => [], + 'upgrade' => [ + 'agreement.txt', + ], + ]; + + /** + * List of files we will pull in from Other into our root archive. + * @var array + */ + protected static array $otherFiles = [ + 'install' => [ + 'readme.html', + 'install.php', + 'Settings.php', + 'Settings_bak.php', + // SMF 2.1 or below, SMF 3.0 does not make use of this. + 'install*.sql', + ], + 'upgrade' => [ + 'upgrade.php', + // SMF 2.1 or below, SMF 3.0 just uses upgrade.php + 'upgrade*.php', + 'upgrade*.sql', + ], + ]; + + /** + * A list of archives we will build. + * Currently this is: + * ZIP + * Tar.gz + * Tar.bz2 + * @var array + */ + protected static array $archives = [ + [Phar::ZIP, Phar::NONE], + [Phar::TAR, Phar::GZ], + [Phar::TAR, Phar::BZ2], + ]; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'v' => 'target_version', + 'o' => 'output_dir', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug' + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + $smf_version = $version[1]; + $file_prefix = self::getFileNamePrefix($smf_version); + $tmp_file = self::$output_dir . '/' . $file_prefix; + + foreach (['install', 'upgrade'] as $build) { + self::writeDebug("[$build] Building file list"); + + // Ensure we run a clean setup for the build. + array_map('unlink', glob($tmp_file . $build . '*')); + + // Builds our lists. + $fileList = self::generateFileList(self::$smf_root, array_merge(self::$ignoreFiles['all'], self::$ignoreFiles[$build])); + $otherList = self::generateOtherFilesList(self::$smf_root, self::$otherFiles[$build]); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[$build] [$extension] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $build . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[$build] [$extension] Adding initial files"); + $pd->buildFromIterator($fileList, self::$smf_root); + + // Add other into the root. + self::writeDebug("[$build] [$extension] Adding other files"); + foreach ($otherList as $item) { + $pd->addFile($item->getPathname(), $item->getFilename()); + } + + // Convert the archive into the proper archive and compression. + self::writeDebug("[$build] [$extension] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[$build] [$extension] Compressing"); + $zip = new ZipArchive; + $zip->open($tmp_file . $build . '.' . $extension); + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $build . '.tmp'); + } + } + } + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } else { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Build Release Tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp \n" + . '--s=/path/to/smf Where SMF has its files' . "\n" + . '--o=/path/to/out Where to store the generated files' . "\n" + . '--v=[30] What Version of SMF. This defaults to SMF 30.' . "\n" + . '-h, --help This help file.' . "\n" + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + self::$output_dir = self::$output_dir === '' ? realpath($_SERVER['PWD'] . '/..') : realpath(self::$output_dir); + } + + /** + * Taking the SMF version found in index.php, we figure out how we would name the files. + * + * @param string $version + * @return string + */ + protected static function getFileNamePrefix(string $version): string + { + preg_match('~v?([\d]+)[-._]?([\d]+)[-._\s]?(alpha|beta|rc)?\.?\s?([\d]?)~i', $version, $matches); + + // Sometimes we used beta.1 instead of beta-1 + if (isset($matches[3]) && $matches[3] == 'beta.') { + $matches[3] = 'beta'; + } + + $prefix = 'smf_' . $matches[1] . '-' . $matches[2]; + + // 4 part name "SMF 2.0 Alpha 3" will produce [2, 0, 'Alpha', 3] + if (!empty($matches[4])) { + $prefix .= '-' . strtolower($matches[3]) . $matches[4]; + } elseif (!empty($matches[3])) { + $prefix .= '-' . $matches[3]; + } + + return $prefix . '_'; + } + + /** + * Generate a list of files that are to be included in the main archive using a exclusion list. + * + * @param string $path + * @param array $ignores List of files we will exclude, these are based on the SMF root forward. + * @return RecursiveIteratorIterator + */ + protected static function generateFileList(string $path, array $ignores): RecursiveIteratorIterator + { + return new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $path, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + function ($file, $key, $iterator) use ($ignores, $path) { + // Simple is directory or exact matches. + if ($iterator->hasChildren() && !in_array($file->getFilename(), $ignores)) { + return true; + } + + // Work out the SMF root. + $filename = substr($file->getPathname(), 0, strlen($path)) === $path ? substr($file->getPathname(), strlen($path)) : $file->getPathname(); + + foreach ($ignores as $e) { + if (fnmatch($e, $filename)) { + return false; + } + } + + // Otherwise, only include this if its a file. + return $file->isFile(); + }, + ), + ); + } + + /** + * Generates a list of files that matches our filters to provide into the root of the archive from our other folder. + * + * @param string $path + * @param array $includes + * @return RecursiveIteratorIterator + */ + protected static function generateOtherFilesList(string $path, array $includes): RecursiveIteratorIterator + { + return new RecursiveIteratorIterator( + new RecursiveCallbackFilterIterator( + new RecursiveDirectoryIterator( + $path . 'other' . DIRECTORY_SEPARATOR, + RecursiveDirectoryIterator::SKIP_DOTS, + ), + function ($file, $key, $iterator) use ($includes) { + if (in_array($file->getFilename(), $includes)) { + return true; + } + + foreach ($includes as $e) { + if (fnmatch($e, $file->getFilename())) { + return true; + } + } + + return false; + }, + ), + ); + } + + /** + * Write a debug output. + * + * @param string $msg + * @return void + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } +} diff --git a/composer.json b/composer.json index 768c429..aead52f 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,8 @@ "name": "simplemachines/build-tools", "require": { "overtrue/phplint": "9.0.4", - "php": ">=8.0" + "php": ">=8.0", + "crowdin/crowdin-api-client": "^1.18" }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index eee086c..4659da8 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,395 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a4fe00912e8466ebbe96641b8f1c1352", + "content-hash": "34f7a8dc76833e3a958f35f7e5e41172", "packages": [ + { + "name": "crowdin/crowdin-api-client", + "version": "1.18.0", + "source": { + "type": "git", + "url": "https://github.com/crowdin/crowdin-api-client-php.git", + "reference": "25b9dddfe1b99059e462bdde2d2cef5ed50d2bb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/crowdin/crowdin-api-client-php/zipball/25b9dddfe1b99059e462bdde2d2cef5ed50d2bb6", + "reference": "25b9dddfe1b99059e462bdde2d2cef5ed50d2bb6", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^6.2 || ^7", + "php": ">=7.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "CrowdinApiClient\\FrameworkSupport\\Laravel\\CrowdinServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "CrowdinApiClient\\": "src/CrowdinApiClient" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Crowdin", + "homepage": "https://crowdin.com" + } + ], + "description": "PHP client library for Crowdin API v2", + "homepage": "https://github.com/crowdin/crowdin-api-client-php", + "keywords": [ + "API-Client", + "api", + "client", + "crowdin", + "sdk" + ], + "support": { + "issues": "https://github.com/crowdin/crowdin-api-client-php/issues", + "source": "https://github.com/crowdin/crowdin-api-client-php/tree/1.18.0" + }, + "time": "2025-03-28T16:06:40+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:37:11+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", + "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2025-03-27T13:27:01+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2025-03-27T12:30:47+00:00" + }, { "name": "overtrue/phplint", "version": "9.0.4", @@ -236,6 +623,166 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/log", "version": "2.0.0", @@ -286,6 +833,50 @@ }, "time": "2021-07-14T16:41:46+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "symfony/cache", "version": "v5.4.31", From 62a96bea1ba5c2495bbf4ff7a241b0a684cecdb4 Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Thu, 18 Sep 2025 20:17:35 -0700 Subject: [PATCH 2/9] Fix some logic issues with generating a patch --- buildPatch.php | 302 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 263 insertions(+), 39 deletions(-) diff --git a/buildPatch.php b/buildPatch.php index 04a4c6f..052eec8 100644 --- a/buildPatch.php +++ b/buildPatch.php @@ -72,7 +72,7 @@ class buildPatch /** * What type of patching we are doing. Either xml or diff - * @var + * @var */ protected static ?string $patch_type = null; @@ -185,12 +185,8 @@ public static function run() @rmdir($tmp_dir); @mkdir($tmp_dir); - //template for our package info file. - self::writeDebug("[patch] Building info file"); - $infoFileContents = self::packageInfoTemplate($to_smf_version, $to_file_prefix, $from_smf_version, $to_php_version, self::$patch_type); - - self::writeDebug("[patch] Writing info file"); - file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); + // When we generate the patch, we may end up needing to do some special operations. + $info_operations = []; self::writeDebug("[patch] Generating diff"); shell_exec('git diff -p -M -C -C -B --default-prefix --no-relative ' . escapeshellarg(self::$from_tag) . '...' . escapeshellarg(self::$to_tag) . ' > ' . escapeshellcmd($tmp_dir . $to_file_prefix . 'patch.diff')); @@ -198,13 +194,25 @@ public static function run() // Running something below 3.0 if (self::$patch_type === 'xml') { self::writeDebug("[patch] Converting to xml"); - $mod_file = self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', $to_smf_version); + self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', $to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); - self::writeDebug("[patch] Writing XML"); - file_put_contents($tmp_dir . $to_file_prefix . 'patch.xml', $infoFileContents); + self::writeDebug(msg: "[patch] Cleaning up diff"); unlink($tmp_dir . $to_file_prefix . 'patch.diff'); } + //template for our package info file. + self::writeDebug("[patch] Building info file"); + $infoFileContents = self::packageInfoTemplate($to_smf_version, $to_file_prefix, $from_smf_version, $to_php_version, self::$patch_type, $info_operations); + + self::writeDebug(msg: "[patch] Writing info file"); + file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); + + // Sort the output so its more organized. + self::sortPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); + + // Apply some qualify of life fixes. + self::cleanupPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); + $tmp_file = self::$output_dir . '/' . $to_file_prefix; $build = 'patch'; @@ -328,7 +336,7 @@ protected static function getFileNamePrefix(string $version): string /** * Write a debug output. - * + * * @param string $msg * @return void */ @@ -342,14 +350,15 @@ protected static function writeDebug(string $msg): void /** * Generates the package-info.xml File - * + * * @param string $version SMF version we are going to. * @param string $file_version SMF version file prefix. * @param string $previous_version The previous SMF version (friendly) * @param string $min_php_version Minimum version of PHP supported for the version we are going to. + * @param array $info_operations Additional operations to perform. * @return string XML data for package-info.xml */ - protected static function packageInfoTemplate(string $version, string $file_version, string $previous_version, string $min_php_version, string $extension) { + protected static function packageInfoTemplate(string $version, string $file_version, string $previous_version, string $min_php_version, string $extension, array $info_operations) { $template = << @@ -371,27 +380,46 @@ protected static function packageInfoTemplate(string $version, string $file_vers updateSettings(array('smfVersion' => '{$version}')); ?>]]> {$file_version}patch.{$extension} +END; + + foreach ($info_operations['remove-file'] ?? [] as $rm) { + $template .= ' + '; + } + + $template .= << This will remove the changes introduced by SMF {$version}. [b]This is generally not a good idea.[/b] {$file_version}patch.diff '{$previous_version}'));]]> - - END; + foreach ($info_operations['remove-file'] ?? [] as $rm) { + $file_base = basename($rm); + $file_path = dirname($rm); + + $template .= ' + '; + } + + $template .= ' + +'; + return $template; } /** * Given a git tag, find the minimum version of PHP it supports. * This will search in locations for SMF 3.0 (Sources/Maintenance/Maintenance.php) and 2.x (other/install.php) - * + * * @param string $tag (git tag -l) * @throws \Exception * @return string Minimum PHP version supported */ - protected static function getPhpMinimumVersion(string $tag): string + protected static function getPhpMinimumVersion(string $tag): string { // SMF 3.0 way. $maintenance_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':Sources/Maintenance/Maintenance.php') . ' 2> /dev/null || echo ""') ?? ''); @@ -420,18 +448,22 @@ protected static function getPhpMinimumVersion(string $tag): string /** * Given the contents of a diff file, attempt to parse our a valid XML data file. - * + * * @param string $diff_file File path to the diff file. * @param string $version SMF Version we are going to. - * @return string A XML data set for modification.xml + * @param string $working_dir Working directory. + * @param string $to_file_prefix File prefix for this patch. + * @param array &$info_operations Operations passed onto our package-info.xml + * @return void */ - protected static function convertDiffToPatch(string $diff_file, string $version): string + protected static function convertDiffToPatch(string $diff_file, string $version, string $working_dir, string $to_file_prefix, array &$info_operations): void { $content = file($diff_file); $file_operations = []; $operations = []; $counter = 0; $opCounter = 0; + $infoOperation = null; // First walk each line to figure out what we are doing. for ($i = 0; $i < count($content); $i++) @@ -453,8 +485,17 @@ protected static function convertDiffToPatch(string $diff_file, string $version) $dir = '$board'. 'dir'; $operations[$counter]['path'] = $dir . '/' . basename($content[$i]); - while (!str_starts_with($content[$i], '@@')) + + // Is this a file deletion? + if (str_starts_with($content[$i + 1], '+++ /dev/null')) { + $file_operations['replace'] = ['']; + $infoOperation = basename(trim($content[$i])); + $info_operations['remove-file'][] = $dir . '/' . $infoOperation; + } + + while (!str_starts_with($content[$i], '@@')) { $i++; + } continue; } @@ -465,7 +506,7 @@ protected static function convertDiffToPatch(string $diff_file, string $version) * A new block (@@) * A new file (diff --git) * No more operations / EOF - * + * * @author emanuele * @copyright 2012 emanuele, Simple Machines * @license http://www.simplemachines.org/about/smf/license.php BSD @@ -476,16 +517,27 @@ protected static function convertDiffToPatch(string $diff_file, string $version) || !isset($content[$i + 1]) ) && !empty($file_operations)) { - $operations[$counter]['operations'][$opCounter]['search'] = str_replace(array(''), array(''), implode("\n", $file_operations['search'])); - $operations[$counter]['operations'][$opCounter]['replace'] = str_replace(array(''), array(''), implode("\n", $file_operations['replace'])); + // If this was a special info operation, don't do this. + if ($infoOperation !== null) { + self::writeDebug("[patch] Writing file {$infoOperation}"); + + file_put_contents($working_dir . DIRECTORY_SEPARATOR . $infoOperation, $file_operations['search']); + unset($operations[$counter]); + $infoOperation = null; + } + else { + $operations[$counter]['operations'][$opCounter]['search'] = str_replace([''], [''], implode("", $file_operations['search'])); + $operations[$counter]['operations'][$opCounter]['replace'] = str_replace([''], [''], implode("", $file_operations['replace'])); + + $opCounter++; + if (str_starts_with($content[$i], 'diff --git')) + { + $dir = ''; + $counter++; + } + } $file_operations = []; - $opCounter++; - if (str_starts_with($content[$i], 'diff --git')) - { - $dir = ''; - $counter++; - } continue; } if (!empty($dir)) @@ -509,13 +561,13 @@ protected static function convertDiffToPatch(string $diff_file, string $version) smf:' . $version . ' ' . $version . ''; - foreach ($operations as $file) - { - $ret .= ' + foreach ($operations as $file) + { + $ret .= ' '; - foreach ($file['operations'] as $file_operations) - $ret .= ' + foreach ($file['operations'] as $file_operations) + $ret .= ' @@ -523,13 +575,185 @@ protected static function convertDiffToPatch(string $diff_file, string $version) $file_operations['replace'] . ']]> '; - $ret .= ' + $ret .= ' '; - } + } - $ret .= ' + $ret .= ' '; - return $ret; + self::writeDebug("[patch] Writing patch.xml"); + file_put_contents($working_dir . DIRECTORY_SEPARATOR . $to_file_prefix . 'patch.xml', $ret); } + + /** + * Given a patch file, apply our XLS template to automatically sort the contents. + * @param string $output_file + * @return void + */ + protected static function sortPatchFile(string $output_file): void + { + $xml1 = new DOMDocument(); + $xml1->load($output_file); + + $xslt = new DOMDocument(); + $xslt->loadXML(self::xlsTemplate()); + + $proc = new XSLTProcessor(); + $proc->importStylesheet($xslt); + $proc->transformToURI($xml1, 'file://' . $output_file); + } + + /** + * A template for sorting our XML output. + * Not required just makes things easier to read. + * + * @return string XML Template + */ + protected static function xlsTemplate(): string + { + return << + + + + + + + + + + + + + + + + + + + + + + + + + <!-- 2.1.6 updates for + + --> + + + + + +EOF; + } + + /** + * Perform some cleanup operations that just make things easier. + * + * @param string $output_file + * @return void + */ + protected static function cleanupPatchFile(string $output_file): void + { + $contents = file_get_contents($output_file); + + // Be more precise with changes to license blocks. + $contents = preg_replace( + '~ + + + ~', + ' + + + + + + + ', + $contents, + ); + + // Additional version fixing. + $contents = preg_replace( + '~ + + + ~', + ' + + + + + + + ', + $contents, + ); + + // Would you guess we are doing more cleaning of the version updates? + $contents = preg_replace( + '~ + + + ~', + ' + + + + + + + + + + + + + + + ', + $contents, + ); + + // Get rid of useless ending newlines in replace statements. + $contents = preg_replace('~())*)\n(\]\]>\s+))*)\n(\]\]>)~', '$1$2$3$4$5', $contents); + + // Move ending newlines to start in before statements. + $contents = preg_replace('~())*)\n(\]\]>\s+))*)\n(\]\]>)~', '$1' . "\n" . '$2$3' . "\n" . '$4$5', $contents); + + file_put_contents($output_file, $contents); + } } \ No newline at end of file From 001481fb98eb40dbe03bb8213c9da2f525eb4f08 Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Thu, 18 Sep 2025 20:19:16 -0700 Subject: [PATCH 3/9] Code styling --- buildPatch.php | 615 +++++++++++++++++++++++++------------------------ 1 file changed, 309 insertions(+), 306 deletions(-) diff --git a/buildPatch.php b/buildPatch.php index 052eec8..982970a 100644 --- a/buildPatch.php +++ b/buildPatch.php @@ -22,9 +22,9 @@ // Ensure that we exit with a failure if an error occurs. try { buildPatch::run(); -} -catch (Exception $e) { +} catch (Exception $e) { fwrite(STDERR, $e->getMessage()); + exit(1); } @@ -40,16 +40,16 @@ class buildPatch */ protected static string $smf_root = ''; - /** - * ID of the tag we are starting from. - * @var string - */ + /** + * ID of the tag we are starting from. + * @var string + */ protected static string $from_tag = ''; - /** - * ID of the tag we are going to. - * @var string - */ + /** + * ID of the tag we are going to. + * @var string + */ protected static string $to_tag = ''; /** @@ -70,11 +70,11 @@ class buildPatch */ protected static bool $debug = false; - /** - * What type of patching we are doing. Either xml or diff - * @var - */ - protected static ?string $patch_type = null; + /** + * What type of patching we are doing. Either xml or diff + * @var + */ + protected static ?string $patch_type = null; /** * A list of archives we will build. @@ -98,11 +98,11 @@ class buildPatch 'f' => 'from_tag', 't' => 'to_tag', 'o' => 'output_dir', - 'p' => 'patch_type', + 'p' => 'patch_type', 'h' => 'help', 'd' => 'debug', 'help' => 'help', - 'debug' => 'debug' + 'debug' => 'debug', ]; /*********************** @@ -131,81 +131,86 @@ public static function run() throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); } - // Setting up our from version. - $from_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$from_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); - if (empty($from_tag_exists)) { - throw new Exception('Unable to tag for ' . self::$from_tag); - } - // Get the version information. - $from_index = trim(shell_exec('git show ' . escapeshellarg(self::$from_tag . ':index.php')) ?? ''); + // Setting up our from version. + $from_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$from_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$from_tag); + } + // Get the version information. + $from_index = trim(shell_exec('git show ' . escapeshellarg(self::$from_tag . ':index.php')) ?? ''); - // Validation of from version. + // Validation of from version. if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $from_index, $version)) { throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$from_tag); } - if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { - throw new Exception('Error: Version is not stable in ' . self::$from_tag); - } + + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$from_tag); + } $from_smf_version = $version[1]; - // Setting up our to version - $to_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$to_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); - if (empty($from_tag_exists)) { - throw new Exception('Unable to tag for ' . self::$to_tag); - } - // Get the version information. - $to_index = trim(shell_exec('git show ' . escapeshellarg(self::$to_tag . ':index.php')) ?? ''); + // Setting up our to version + $to_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$to_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); - // Validation of from version. + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$to_tag); + } + // Get the version information. + $to_index = trim(shell_exec('git show ' . escapeshellarg(self::$to_tag . ':index.php')) ?? ''); + + // Validation of from version. if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $to_index, $version)) { throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$to_tag); } - if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { - throw new Exception('Error: Version is not stable in ' . self::$to_tag); - } + + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$to_tag); + } $to_smf_version = $version[1]; - // Additional variables we need. + // Additional variables we need. $to_file_prefix = self::getFileNamePrefix($to_smf_version); - $to_php_version = self::getPhpMinimumVersion(self::$to_tag); + $to_php_version = self::getPhpMinimumVersion(self::$to_tag); - // Ensure we have a sane patch type. - if (self::$patch_type === null || !in_array(self::$patch_type, ['xml', 'diff'])) { - self::$patch_type = version_compare($to_smf_version, '3.0.0-alpha1', '<') ? 'xml' : 'diff'; - } + // Ensure we have a sane patch type. + if (self::$patch_type === null || !in_array(self::$patch_type, ['xml', 'diff'])) { + self::$patch_type = version_compare($to_smf_version, '3.0.0-alpha1', '<') ? 'xml' : 'diff'; + } + + self::writeDebug('[patch] Creating working folder'); + $tmp_dir = self::$output_dir . '/' . $to_file_prefix . 'patch' . DIRECTORY_SEPARATOR; - self::writeDebug("[patch] Creating working folder"); - $tmp_dir = self::$output_dir . '/' . $to_file_prefix . 'patch' . DIRECTORY_SEPARATOR; - if (empty($tmp_dir)) { - throw new Exception("Temp directory name missing"); - } + if (empty($tmp_dir)) { + throw new Exception('Temp directory name missing'); + } - // Cleanup any previous runs. - @array_map('unlink', glob($tmp_dir . '/*')); - @rmdir($tmp_dir); - @mkdir($tmp_dir); + // Cleanup any previous runs. + @array_map('unlink', glob($tmp_dir . '/*')); + @rmdir($tmp_dir); + @mkdir($tmp_dir); // When we generate the patch, we may end up needing to do some special operations. $info_operations = []; - self::writeDebug("[patch] Generating diff"); - shell_exec('git diff -p -M -C -C -B --default-prefix --no-relative ' . escapeshellarg(self::$from_tag) . '...' . escapeshellarg(self::$to_tag) . ' > ' . escapeshellcmd($tmp_dir . $to_file_prefix . 'patch.diff')); + self::writeDebug('[patch] Generating diff'); + shell_exec('git diff -p -M -C -C -B --default-prefix --no-relative ' . escapeshellarg(self::$from_tag) . '...' . escapeshellarg(self::$to_tag) . ' > ' . escapeshellcmd($tmp_dir . $to_file_prefix . 'patch.diff')); - // Running something below 3.0 - if (self::$patch_type === 'xml') { - self::writeDebug("[patch] Converting to xml"); - self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', $to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); + // Running something below 3.0 + if (self::$patch_type === 'xml') { + self::writeDebug('[patch] Converting to xml'); + self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', $to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); - self::writeDebug(msg: "[patch] Cleaning up diff"); - unlink($tmp_dir . $to_file_prefix . 'patch.diff'); - } + self::writeDebug(msg: '[patch] Cleaning up diff'); + unlink($tmp_dir . $to_file_prefix . 'patch.diff'); + } - //template for our package info file. - self::writeDebug("[patch] Building info file"); - $infoFileContents = self::packageInfoTemplate($to_smf_version, $to_file_prefix, $from_smf_version, $to_php_version, self::$patch_type, $info_operations); + //template for our package info file. + self::writeDebug('[patch] Building info file'); + $infoFileContents = self::packageInfoTemplate($to_smf_version, $to_file_prefix, $from_smf_version, $to_php_version, self::$patch_type, $info_operations); - self::writeDebug(msg: "[patch] Writing info file"); - file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); + self::writeDebug(msg: '[patch] Writing info file'); + file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); // Sort the output so its more organized. self::sortPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); @@ -214,53 +219,54 @@ public static function run() self::cleanupPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); $tmp_file = self::$output_dir . '/' . $to_file_prefix; - $build = 'patch'; - - // Ensure we run a clean setup for the build. - @array_map('unlink', glob($tmp_file . $build . '.*')); - - foreach (self::$archives as $a) { - $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); - - self::writeDebug("[patch] [$extension] Creating empty archive"); - - $pd = new PharData( - $tmp_file . $build . '.tmp', - FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, - null, - $a[0], - ); - - // Quickly now, use a iterator to build the main archive. - self::writeDebug("[$build] [$extension] Adding initial files"); - $pd->buildFromIterator(new GlobIterator(pattern: $tmp_dir . DIRECTORY_SEPARATOR . '*'), $tmp_dir . DIRECTORY_SEPARATOR); - - // Convert the archive into the proper archive and compression. - self::writeDebug("[$build] [$extension] Writing file"); - $pd->convertToData($a[0], $a[1], $extension); - - // Zip needs to be compressed with DEFLATE, which phar doesn't do. - if ($a[0] === Phar::ZIP) { - self::writeDebug("[$build] [$extension] Compressing"); - $zip = new ZipArchive; - $zip->open($tmp_file . $build . '.' . $extension); - for ($i = 0; $i < $zip->numFiles; $i++) { - $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); - } - $zip->close(); - } - - // Tar files leave behind the .tmp file. - if ($a[0] === Phar::TAR) { - @unlink($tmp_file . $build . '.tmp'); - } - } - - // Cleanup. - @array_map('unlink', glob($tmp_dir . '/*')); - @array_map('unlink', glob($tmp_dir . '/.*')); - @rmdir($tmp_dir); - } + $build = 'patch'; + + // Ensure we run a clean setup for the build. + @array_map('unlink', glob($tmp_file . $build . '.*')); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[patch] [{$extension}] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $build . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[{$build}] [{$extension}] Adding initial files"); + $pd->buildFromIterator(new GlobIterator(pattern: $tmp_dir . DIRECTORY_SEPARATOR . '*'), $tmp_dir . DIRECTORY_SEPARATOR); + + // Convert the archive into the proper archive and compression. + self::writeDebug("[{$build}] [{$extension}] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[{$build}] [{$extension}] Compressing"); + $zip = new ZipArchive(); + $zip->open($tmp_file . $build . '.' . $extension); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $build . '.tmp'); + } + } + + // Cleanup. + @array_map('unlink', glob($tmp_dir . '/*')); + @array_map('unlink', glob($tmp_dir . '/.*')); + @rmdir($tmp_dir); + } /************************* * Internal static methods @@ -338,7 +344,6 @@ protected static function getFileNamePrefix(string $version): string * Write a debug output. * * @param string $msg - * @return void */ protected static function writeDebug(string $msg): void { @@ -358,29 +363,30 @@ protected static function writeDebug(string $msg): void * @param array $info_operations Additional operations to perform. * @return string XML data for package-info.xml */ - protected static function packageInfoTemplate(string $version, string $file_version, string $previous_version, string $min_php_version, string $extension, array $info_operations) { - $template = << - - - smf:smf-{$version} - SMF {$version} Update - {$version} - modification - - - This will update your forum to SMF {$version}. - '{$version}')); - ?>]]> - {$file_version}patch.{$extension} -END; + protected static function packageInfoTemplate(string $version, string $file_version, string $previous_version, string $min_php_version, string $extension, array $info_operations) + { + $template = << + + + smf:smf-{$version} + SMF {$version} Update + {$version} + modification + + + This will update your forum to SMF {$version}. + '{$version}')); + ?>]]> + {$file_version}patch.{$extension} + END; foreach ($info_operations['remove-file'] ?? [] as $rm) { $template .= ' @@ -389,12 +395,12 @@ protected static function packageInfoTemplate(string $version, string $file_vers $template .= << - - This will remove the changes introduced by SMF {$version}. [b]This is generally not a good idea.[/b] - {$file_version}patch.diff - '{$previous_version}'));]]> -END; + + + This will remove the changes introduced by SMF {$version}. [b]This is generally not a good idea.[/b] + {$file_version}patch.diff + '{$previous_version}'));]]> + END; foreach ($info_operations['remove-file'] ?? [] as $rm) { $file_base = basename($rm); @@ -408,8 +414,8 @@ protected static function packageInfoTemplate(string $version, string $file_vers '; - return $template; - } + return $template; + } /** * Given a git tag, find the minimum version of PHP it supports. @@ -419,104 +425,103 @@ protected static function packageInfoTemplate(string $version, string $file_vers * @throws \Exception * @return string Minimum PHP version supported */ - protected static function getPhpMinimumVersion(string $tag): string - { - // SMF 3.0 way. - $maintenance_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':Sources/Maintenance/Maintenance.php') . ' 2> /dev/null || echo ""') ?? ''); + protected static function getPhpMinimumVersion(string $tag): string + { + // SMF 3.0 way. + $maintenance_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':Sources/Maintenance/Maintenance.php') . ' 2> /dev/null || echo ""') ?? ''); - if (!empty($maintenance_file)) { - if (!preg_match('/public\s*const\s*PHP_MIN_VERSION\s*=\s*\'([^\']+)\';/i', $maintenance_file, $version)) { - throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); - } + if (!empty($maintenance_file)) { + if (!preg_match('/public\s*const\s*PHP_MIN_VERSION\s*=\s*\'([^\']+)\';/i', $maintenance_file, $version)) { + throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); + } - return $version[1]; - } + return $version[1]; + } - // SMF 2.1 and below. - $install_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':other/install.php') . ' 2> /dev/null || echo ""') ?? ''); + // SMF 2.1 and below. + $install_file = trim(shell_exec('git show ' . escapeshellarg($tag . ':other/install.php') . ' 2> /dev/null || echo ""') ?? ''); - if (empty($install_file)) { + if (empty($install_file)) { throw new Exception('Error: Unable to read contents of installer in ' . $tag); - } + } if (!preg_match('/\$GLOBALS\[\'required_php_version\'\]\s*=\s*\'([^\']+)\';/i', $install_file, $version)) { throw new Exception('Error: Unable to parse PHP version from installer in ' . $tag); } - return $version[1]; - } + return $version[1]; + } - /** - * Given the contents of a diff file, attempt to parse our a valid XML data file. - * - * @param string $diff_file File path to the diff file. - * @param string $version SMF Version we are going to. + /** + * Given the contents of a diff file, attempt to parse our a valid XML data file. + * + * @param string $diff_file File path to the diff file. + * @param string $version SMF Version we are going to. * @param string $working_dir Working directory. * @param string $to_file_prefix File prefix for this patch. * @param array &$info_operations Operations passed onto our package-info.xml - * @return void - */ - protected static function convertDiffToPatch(string $diff_file, string $version, string $working_dir, string $to_file_prefix, array &$info_operations): void - { - $content = file($diff_file); - $file_operations = []; - $operations = []; - $counter = 0; - $opCounter = 0; + */ + protected static function convertDiffToPatch(string $diff_file, string $version, string $working_dir, string $to_file_prefix, array &$info_operations): void + { + $content = file($diff_file); + $file_operations = []; + $operations = []; + $counter = 0; + $opCounter = 0; $infoOperation = null; - // First walk each line to figure out what we are doing. - for ($i = 0; $i < count($content); $i++) - { - if (str_starts_with($content[$i], '--- a/')) - { - $directory = substr($content[$i], 6, strpos($content[$i], '/', 7) - 6); - if ($directory == 'Sources') - $dir = '$source'. 'dir'; - elseif (strpos($content[$i], 'languages') !== false) - $dir = '$language'. 'dir'; - elseif (strpos($content[$i], 'images') !== false) - $dir = '$images'. 'dir'; - elseif (strpos($content[$i], 'default/scripts') !== false) - $dir = '$theme'. 'dir/scripts'; - elseif ($directory == 'Themes') - $dir = '$theme'. 'dir'; - else - $dir = '$board'. 'dir'; - - $operations[$counter]['path'] = $dir . '/' . basename($content[$i]); + // First walk each line to figure out what we are doing. + for ($i = 0; $i < count($content); $i++) { + if (str_starts_with($content[$i], '--- a/')) { + $directory = substr($content[$i], 6, strpos($content[$i], '/', 7) - 6); + + if ($directory == 'Sources') { + $dir = '$source' . 'dir'; + } elseif (strpos($content[$i], 'languages') !== false) { + $dir = '$language' . 'dir'; + } elseif (strpos($content[$i], 'images') !== false) { + $dir = '$images' . 'dir'; + } elseif (strpos($content[$i], 'default/scripts') !== false) { + $dir = '$theme' . 'dir/scripts'; + } elseif ($directory == 'Themes') { + $dir = '$theme' . 'dir'; + } else { + $dir = '$board' . 'dir'; + } + + $operations[$counter]['path'] = $dir . '/' . basename($content[$i]); // Is this a file deletion? - if (str_starts_with($content[$i + 1], '+++ /dev/null')) { + if (str_starts_with($content[$i + 1], '+++ /dev/null')) { $file_operations['replace'] = ['']; $infoOperation = basename(trim($content[$i])); $info_operations['remove-file'][] = $dir . '/' . $infoOperation; } while (!str_starts_with($content[$i], '@@')) { - $i++; + $i++; } - continue; - } - - // Appearing to start a new section, tie things off. - /** - * When we end a block of code, tie it off and add it as a operation - * We do this when we detect: - * A new block (@@) - * A new file (diff --git) - * No more operations / EOF - * - * @author emanuele - * @copyright 2012 emanuele, Simple Machines - * @license http://www.simplemachines.org/about/smf/license.php BSD - */ - if ( - (str_starts_with($content[$i], '@@') - || str_starts_with($content[$i], 'diff --git') - || !isset($content[$i + 1]) - ) && !empty($file_operations)) - { + continue; + } + + // Appearing to start a new section, tie things off. + /* + * When we end a block of code, tie it off and add it as a operation + * We do this when we detect: + * A new block (@@) + * A new file (diff --git) + * No more operations / EOF + * + * @author emanuele + * @copyright 2012 emanuele, Simple Machines + * @license http://www.simplemachines.org/about/smf/license.php BSD + */ + if ( + ( + str_starts_with($content[$i], '@@') + || str_starts_with($content[$i], 'diff --git') + || !isset($content[$i + 1]) + ) && !empty($file_operations)) { // If this was a special info operation, don't do this. if ($infoOperation !== null) { self::writeDebug("[patch] Writing file {$infoOperation}"); @@ -524,49 +529,48 @@ protected static function convertDiffToPatch(string $diff_file, string $version, file_put_contents($working_dir . DIRECTORY_SEPARATOR . $infoOperation, $file_operations['search']); unset($operations[$counter]); $infoOperation = null; - } - else { - $operations[$counter]['operations'][$opCounter]['search'] = str_replace([''], [''], implode("", $file_operations['search'])); - $operations[$counter]['operations'][$opCounter]['replace'] = str_replace([''], [''], implode("", $file_operations['replace'])); + } else { + $operations[$counter]['operations'][$opCounter]['search'] = str_replace([''], [''], implode('', $file_operations['search'])); + $operations[$counter]['operations'][$opCounter]['replace'] = str_replace([''], [''], implode('', $file_operations['replace'])); $opCounter++; - if (str_starts_with($content[$i], 'diff --git')) - { + + if (str_starts_with($content[$i], 'diff --git')) { $dir = ''; $counter++; } } - $file_operations = []; - continue; - } - if (!empty($dir)) - { - if (str_starts_with($content[$i], ' ')) - { - $file_operations['replace'][] = $file_operations['search'][] = substr($content[$i], 1); - } - if (str_starts_with($content[$i], '-')) - $file_operations['search'][] = substr($content[$i], 1); - elseif (str_starts_with($content[$i], '+')) - $file_operations['replace'][] = substr($content[$i], 1); - } - } - - // Build the data. - $ret = ' + $file_operations = []; + continue; + } + + if (!empty($dir)) { + if (str_starts_with($content[$i], ' ')) { + $file_operations['replace'][] = $file_operations['search'][] = substr($content[$i], 1); + } + + if (str_starts_with($content[$i], '-')) { + $file_operations['search'][] = substr($content[$i], 1); + } elseif (str_starts_with($content[$i], '+')) { + $file_operations['replace'][] = substr($content[$i], 1); + } + } + } + + // Build the data. + $ret = ' smf:' . $version . ' ' . $version . ''; - foreach ($operations as $file) - { + foreach ($operations as $file) { $ret .= ' '; - foreach ($file['operations'] as $file_operations) + foreach ($file['operations'] as $file_operations) { $ret .= ' '; + } $ret .= ' '; @@ -582,14 +587,13 @@ protected static function convertDiffToPatch(string $diff_file, string $version, $ret .= ' '; - self::writeDebug("[patch] Writing patch.xml"); + self::writeDebug('[patch] Writing patch.xml'); file_put_contents($working_dir . DIRECTORY_SEPARATOR . $to_file_prefix . 'patch.xml', $ret); - } + } /** * Given a patch file, apply our XLS template to automatically sort the contents. * @param string $output_file - * @return void */ protected static function sortPatchFile(string $output_file): void { @@ -604,60 +608,59 @@ protected static function sortPatchFile(string $output_file): void $proc->transformToURI($xml1, 'file://' . $output_file); } - /** - * A template for sorting our XML output. - * Not required just makes things easier to read. - * - * @return string XML Template - */ + /** + * A template for sorting our XML output. + * Not required just makes things easier to read. + * + * @return string XML Template + */ protected static function xlsTemplate(): string { return << - - - - - - - - - - - - - - - - - - - - - - - - - <!-- 2.1.6 updates for - - --> - - - - - -EOF; + + + + + + + + + + + + + + + + + + + + + + + + + + <!-- 2.1.6 updates for + + --> + + + + + + EOF; } /** * Perform some cleanup operations that just make things easier. - * + * * @param string $output_file - * @return void */ protected static function cleanupPatchFile(string $output_file): void { @@ -665,7 +668,7 @@ protected static function cleanupPatchFile(string $output_file): void // Be more precise with changes to license blocks. $contents = preg_replace( - '~ + '~ ~', - ' + ' @@ -685,12 +688,12 @@ protected static function cleanupPatchFile(string $output_file): void ', - $contents, + $contents, ); // Additional version fixing. $contents = preg_replace( - '~ + '~ ~', - ' + ' @@ -708,12 +711,12 @@ protected static function cleanupPatchFile(string $output_file): void ', - $contents, + $contents, ); // Would you guess we are doing more cleaning of the version updates? $contents = preg_replace( - '~ + '~ ~', - ' + ' @@ -745,7 +748,7 @@ protected static function cleanupPatchFile(string $output_file): void ', - $contents, + $contents, ); // Get rid of useless ending newlines in replace statements. @@ -756,4 +759,4 @@ protected static function cleanupPatchFile(string $output_file): void file_put_contents($output_file, $contents); } -} \ No newline at end of file +} From 820b39fc5471e912bedf06ee48ec42bf09a9346e Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Sat, 20 Sep 2025 08:38:06 -0700 Subject: [PATCH 4/9] Allow skipping destination tag verification Allow building using system tools or none at all (for testing) Improve file naming logic Skip the other directory --- buildPatch.php | 296 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 215 insertions(+), 81 deletions(-) diff --git a/buildPatch.php b/buildPatch.php index 982970a..f0ef758 100644 --- a/buildPatch.php +++ b/buildPatch.php @@ -70,12 +70,43 @@ class buildPatch */ protected static bool $debug = false; + /** + * Should we verify the destination tag exists? + * + * @var bool + */ + protected static bool $no_verification = false; + /** * What type of patching we are doing. Either xml or diff * @var */ protected static ?string $patch_type = null; + /** + * Archive Options + * Yes = Build normal + * System = Use System binaries to build + * No = Do not build. + * + * @var string|null + */ + protected static string $archive_mode = 'yes'; + + /** + * SMF Version we are coming from. + * + * @var string + */ + protected static string $from_smf_version = ''; + + /** + * SMF Version we are going to. + * + * @var string + */ + protected static string $to_smf_version = ''; + /** * A list of archives we will build. * Currently this is: @@ -103,6 +134,25 @@ class buildPatch 'd' => 'debug', 'help' => 'help', 'debug' => 'debug', + 'skip-verify' => 'no_verification', + 'name' => 'to_smf_version', + 'archive' => 'archive_mode', + ]; + + /** + * List of operations we perform to normalize the file name. + * Other directory is built and filtered later as the logic is easier to exclude after we have processed it. + * + * @var array + */ + protected static array $replacements = [ + '~^/Themes/default/scripts~i' => '$theme' . 'dir/scripts', + '~^/Themes/default/images~i' => '$images' . 'dir', + '~^/Themes/default/languages~i' => '$language' . 'dir', + '~^/Themes/default~i' => '$theme' . 'dir', + '~^/Sources~i' => '$source' . 'dir', + '~^/other~i' => '$board' . 'dir/other', + '~^/~i' => '$board' . 'dir/', ]; /*********************** @@ -148,34 +198,37 @@ public static function run() if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { throw new Exception('Error: Version is not stable in ' . self::$from_tag); } - $from_smf_version = $version[1]; + self::$from_smf_version = $version[1]; // Setting up our to version - $to_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$to_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + $to_tag_exists = self::$no_verification ? true : trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$to_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); - if (empty($from_tag_exists)) { + if (empty($to_tag_exists)) { throw new Exception('Unable to tag for ' . self::$to_tag); } + // Get the version information. - $to_index = trim(shell_exec('git show ' . escapeshellarg(self::$to_tag . ':index.php')) ?? ''); + if (empty(self::$to_smf_version)) { + $to_index = trim(shell_exec('git show ' . escapeshellarg(self::$to_tag . ':index.php')) ?? ''); - // Validation of from version. - if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $to_index, $version)) { - throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$to_tag); - } + // Validation of from version. + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $to_index, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$to_tag); + } - if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { - throw new Exception('Error: Version is not stable in ' . self::$to_tag); + if (!preg_match('/(\d+)\.(\d+)\.(\d+)/i', $version[1])) { + throw new Exception('Error: Version is not stable in ' . self::$to_tag); + } + self::$to_smf_version = $version[1]; } - $to_smf_version = $version[1]; // Additional variables we need. - $to_file_prefix = self::getFileNamePrefix($to_smf_version); + $to_file_prefix = self::getFileNamePrefix(self::$to_smf_version); $to_php_version = self::getPhpMinimumVersion(self::$to_tag); // Ensure we have a sane patch type. if (self::$patch_type === null || !in_array(self::$patch_type, ['xml', 'diff'])) { - self::$patch_type = version_compare($to_smf_version, '3.0.0-alpha1', '<') ? 'xml' : 'diff'; + self::$patch_type = version_compare(self::$to_smf_version, '3.0.0-alpha1', '<') ? 'xml' : 'diff'; } self::writeDebug('[patch] Creating working folder'); @@ -199,15 +252,15 @@ public static function run() // Running something below 3.0 if (self::$patch_type === 'xml') { self::writeDebug('[patch] Converting to xml'); - self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', $to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); + self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', self::$to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); self::writeDebug(msg: '[patch] Cleaning up diff'); unlink($tmp_dir . $to_file_prefix . 'patch.diff'); } - //template for our package info file. + // Template for our package info file. self::writeDebug('[patch] Building info file'); - $infoFileContents = self::packageInfoTemplate($to_smf_version, $to_file_prefix, $from_smf_version, $to_php_version, self::$patch_type, $info_operations); + $infoFileContents = self::packageInfoTemplate(self::$to_smf_version, $to_file_prefix, self::$from_smf_version, $to_php_version, self::$patch_type, $info_operations); self::writeDebug(msg: '[patch] Writing info file'); file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); @@ -221,45 +274,19 @@ public static function run() $tmp_file = self::$output_dir . '/' . $to_file_prefix; $build = 'patch'; - // Ensure we run a clean setup for the build. - @array_map('unlink', glob($tmp_file . $build . '.*')); - - foreach (self::$archives as $a) { - $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + if (strtolower(self::$archive_mode) === 'no') { + self::writeDebug('[patch] Not building archive'); - self::writeDebug("[patch] [{$extension}] Creating empty archive"); - - $pd = new PharData( - $tmp_file . $build . '.tmp', - FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, - null, - $a[0], - ); - - // Quickly now, use a iterator to build the main archive. - self::writeDebug("[{$build}] [{$extension}] Adding initial files"); - $pd->buildFromIterator(new GlobIterator(pattern: $tmp_dir . DIRECTORY_SEPARATOR . '*'), $tmp_dir . DIRECTORY_SEPARATOR); - - // Convert the archive into the proper archive and compression. - self::writeDebug("[{$build}] [{$extension}] Writing file"); - $pd->convertToData($a[0], $a[1], $extension); - - // Zip needs to be compressed with DEFLATE, which phar doesn't do. - if ($a[0] === Phar::ZIP) { - self::writeDebug("[{$build}] [{$extension}] Compressing"); - $zip = new ZipArchive(); - $zip->open($tmp_file . $build . '.' . $extension); + exit; + } - for ($i = 0; $i < $zip->numFiles; $i++) { - $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); - } - $zip->close(); - } + // Ensure we run a clean setup for the build. + @array_map('unlink', glob($tmp_file . $build . '.*')); - // Tar files leave behind the .tmp file. - if ($a[0] === Phar::TAR) { - @unlink($tmp_file . $build . '.tmp'); - } + if (strtolower(self::$archive_mode) === 'system') { + self::archiveUsingSystemTools($tmp_file, $tmp_dir, $build); + } else { + self::archiveWithPhar($tmp_file, $tmp_dir, $build); } // Cleanup. @@ -285,9 +312,14 @@ protected static function prepareCLIhandler(): void foreach ($params as $param) { if (strpos($param, '=') !== false) { list($var, $val) = explode('=', $param); + + if (!isset(self::$cli_param_map[ltrim($var, '-')])) { + continue; + } + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; - } else { - self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } elseif (isset(self::$cli_param_map[ltrim($param, '-')])) { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; } } @@ -295,13 +327,17 @@ protected static function prepareCLIhandler(): void if (empty($params) || self::$help) { echo 'SMF Build Release Tool' . "\n" . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp -f=3.0.1 -t=3.0.2 \n" - . '--s=/path/to/smf Where SMF has its files' . "\n" - . '--o=/path/to/out Where to store the generated files' . "\n" - . '--f=tag_id Tag in git for our source version.' . "\n" - . '--t=tag_id Tag in git for our target version.' . "\n" + . '-s=/path/to/smf Where SMF has its files' . "\n" + . '-o=/path/to/out Where to store the generated files' . "\n" + . '-f=tag_id Tag in git for our source version.' . "\n" + . '-t=tag_id Tag in git for our target version.' . "\n" . '-p=xml The of patch file (xml or diff)' . "\n" + . '--skip-verify Skips verification of destination tag.' . "\n" + . '--name=VERSION Provides an alternative name for the destination version.' . "\n" + . '--archive=yes Archive building. Yes (default): build using PHP Phar; No: Do not build; System: Build using the operation system tools' . "\n" . '-h, --help This help file.' . "\n" . '-d, --debug Prints out more debug info.' . "\n" + . "\n"; die; @@ -371,7 +407,7 @@ protected static function packageInfoTemplate(string $version, string $file_vers smf:smf-{$version} SMF {$version} Update - {$version} + 1.0 modification @@ -473,29 +509,19 @@ protected static function convertDiffToPatch(string $diff_file, string $version, // First walk each line to figure out what we are doing. for ($i = 0; $i < count($content); $i++) { if (str_starts_with($content[$i], '--- a/')) { - $directory = substr($content[$i], 6, strpos($content[$i], '/', 7) - 6); - - if ($directory == 'Sources') { - $dir = '$source' . 'dir'; - } elseif (strpos($content[$i], 'languages') !== false) { - $dir = '$language' . 'dir'; - } elseif (strpos($content[$i], 'images') !== false) { - $dir = '$images' . 'dir'; - } elseif (strpos($content[$i], 'default/scripts') !== false) { - $dir = '$theme' . 'dir/scripts'; - } elseif ($directory == 'Themes') { - $dir = '$theme' . 'dir'; - } else { - $dir = '$board' . 'dir'; - } + $file = preg_replace( + array_keys(self::$replacements), + array_values(self::$replacements), + trim(substr($content[$i], 5)), + ); - $operations[$counter]['path'] = $dir . '/' . basename($content[$i]); + $operations[$counter]['path'] = $file; //$dir . '/' . trim($content[$i]); // Is this a file deletion? if (str_starts_with($content[$i + 1], '+++ /dev/null')) { $file_operations['replace'] = ['']; - $infoOperation = basename(trim($content[$i])); - $info_operations['remove-file'][] = $dir . '/' . $infoOperation; + $infoOperation = basename($file); + $info_operations['remove-file'][] = $file; //$dir . '/' . $infoOperation; } while (!str_starts_with($content[$i], '@@')) { @@ -536,7 +562,7 @@ protected static function convertDiffToPatch(string $diff_file, string $version, $opCounter++; if (str_starts_with($content[$i], 'diff --git')) { - $dir = ''; + $file = ''; $counter++; } } @@ -545,7 +571,7 @@ protected static function convertDiffToPatch(string $diff_file, string $version, continue; } - if (!empty($dir)) { + if (!empty($file)) { if (str_starts_with($content[$i], ' ')) { $file_operations['replace'][] = $file_operations['search'][] = substr($content[$i], 1); } @@ -564,11 +590,16 @@ protected static function convertDiffToPatch(string $diff_file, string $version, smf:' . $version . ' - ' . $version . ''; + 1.0'; foreach ($operations as $file) { + if (str_starts_with($file['path'], '$board' . 'dir/other')) { + continue; + } + $ret .= ' - '; + + '; foreach ($file['operations'] as $file_operations) { $ret .= ' @@ -759,4 +790,107 @@ protected static function cleanupPatchFile(string $output_file): void file_put_contents($output_file, $contents); } + + /** + * Build the archive using PHP's PHAR. + * + * @param string $tmp_file Prefix of filename we are building + * @param string $tmp_dir Directory containing the files we are archiving + * @param string $build Name of the build + */ + protected static function archiveWithPhar(string $tmp_file, string $tmp_dir, string $build): void + { + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + + self::writeDebug("[patch] [{$extension}] Creating empty archive"); + + $pd = new PharData( + $tmp_file . $build . '.tmp', + FilesystemIterator::SKIP_DOTS | FilesystemIterator::UNIX_PATHS, + null, + $a[0], + ); + + // Quickly now, use a iterator to build the main archive. + self::writeDebug("[{$build}] [{$extension}] Adding initial files"); + $pd->buildFromIterator(new GlobIterator(pattern: $tmp_dir . DIRECTORY_SEPARATOR . '*'), $tmp_dir . DIRECTORY_SEPARATOR); + + // Convert the archive into the proper archive and compression. + self::writeDebug("[{$build}] [{$extension}] Writing file"); + $pd->convertToData($a[0], $a[1], $extension); + + // Zip needs to be compressed with DEFLATE, which phar doesn't do. + if ($a[0] === Phar::ZIP) { + self::writeDebug("[{$build}] [{$extension}] Compressing"); + $zip = new ZipArchive(); + $zip->open($tmp_file . $build . '.' . $extension); + + for ($i = 0; $i < $zip->numFiles; $i++) { + $zip->setCompressionIndex($i, ZipArchive::CM_DEFLATE); + } + $zip->close(); + } + + // Tar files leave behind the .tmp file. + if ($a[0] === Phar::TAR) { + @unlink($tmp_file . $build . '.tmp'); + } + } + } + + protected static function archiveUsingSystemTools(string $tmp_file, string $tmp_dir, string $build): void + { + $current_directory = getcwd(); + + // Try to locate the tar binaries. + $tar_paths = ['/usr/bin/tar', '/bin/tar']; + $tar_path = array_filter($tar_paths, fn($bin) => file_exists($bin))[0] ?? null; + + if ($tar_path === null) { + throw new Exception('Unable to locate the tar binary'); + } + + // Try to locate the zip binaries. + $zip_paths = ['/usr/bin/zip', '/bin/zip']; + $zip_path = array_filter($zip_paths, fn($bin) => file_exists($bin))[0] ?? null; + + if ($zip_path === null) { + throw new Exception('Unable to locate the zip binary'); + } + + // Tar needs some extra args. + $tar_args = [ + '--no-xattrs', + '--no-acls', + '--exclude=\'.*\'', + ]; + + // Mac resource files and other garbage. + if (PHP_OS_FAMILY === 'Darwin') { + $tar_args[] = '--no-mac-metadata'; + $tar_args[] = '--no-fflags'; + } + + // Enter the working directory. + chdir($tmp_dir); + + foreach (self::$archives as $a) { + $extension = $a[0] === Phar::ZIP ? 'zip' : ($a[1] === Phar::GZ ? 'tar.gz' : 'tar.bz2'); + self::writeDebug("[patch] [{$extension}] Building"); + + if ($a[0] === Phar::ZIP) { + shell_exec($zip_path . ' -x ".*/" -1 ' . $tmp_file . $build . '.zip -r *'); + } elseif ($a[0] === Phar::TAR && $a[1] === Phar::GZ) { + shell_exec($tar_path . ' ' . implode(' ', $tar_args) . ' -czf ' . $tmp_file . $build . '.tar.gz *'); + } elseif ($a[0] === Phar::TAR && $a[1] === Phar::BZ2) { + shell_exec($tar_path . ' ' . implode(' ', $tar_args) . ' -cjf ' . $tmp_file . $build . '.tar.bz *'); + } else { + throw new Exception('Unknown compression method'); + } + } + + // Return to where we started. + chdir($current_directory); + } } From 2e083e1034d31492f52fc3626f92a9879932d589 Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Sat, 20 Sep 2025 17:39:33 -0700 Subject: [PATCH 5/9] Add tool for ensuring we update file headers --- updateHeaders.php | 271 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 updateHeaders.php diff --git a/updateHeaders.php b/updateHeaders.php new file mode 100644 index 0000000..fbeccee --- /dev/null +++ b/updateHeaders.php @@ -0,0 +1,271 @@ +getMessage()); + + exit(1); +} + +class updateHeaders +{ + /**************************** + * Internal static properties + ****************************/ + + /** + * SMF root folder. + * @var string + */ + protected static string $smf_root = ''; + + /** + * ID of the tag we are starting from. + * @var string + */ + protected static string $from_tag = ''; + + /** + * ID of the branch we are going to. + * @var string + */ + protected static string $to_branch = ''; + + /** + * When true, shows the CLI help output. + * @var bool + */ + protected static bool $help = false; + + /** + * When true, shows the CLI debug output. + * @var bool + */ + protected static bool $debug = false; + + /** + * SMF Version we are going to. + * + * @var string + */ + protected static string $to_smf_version = ''; + + /** + * Map of CLI parameters to variables in this class. + * + * @var array + */ + protected static array $cli_param_map = [ + 's' => 'smf_root', + 'f' => 'from_tag', + 'h' => 'help', + 'd' => 'debug', + 'help' => 'help', + 'debug' => 'debug', + 'name' => 'to_smf_version', + ]; + + /*********************** + * Public static methods + ***********************/ + + public static function run() + { + if (php_sapi_name() !== 'cli') { + throw new Exception('This tool is to be ran via CLI'); + } + self::prepareCLIhandler(); + + // The file has to exist. + if (!file_exists(self::$smf_root)) { + throw new Exception('Error: SMF Root does not exist'); + } + + // Cleanup the slashes. + self::$smf_root = realpath(rtrim(self::$smf_root, '/')) . '/'; + + // Find our version. + $contents = file_get_contents(self::$smf_root . '/index.php', false, null, 0, 1500); + + if (!preg_match('/define\(\'SMF_VERSION\', \'([^\']+)\'\);/i', $contents, $version)) { + throw new Exception('Error: Could not locate SMF_VERSION in ' . self::$smf_root); + } + + // Setting up our from version. + $from_tag_exists = trim(shell_exec('if [ $(git tag -l ' . escapeshellarg(self::$from_tag) . ') ]; then echo "true"; else echo ""; fi') ?? ''); + + if (empty($from_tag_exists)) { + throw new Exception('Unable to tag for ' . self::$from_tag); + } + + // Setting up our to version + $to_branch_exists = trim(shell_exec('git rev-parse --abbrev-ref HEAD') ?? ''); + + if (empty($to_branch_exists)) { + throw new Exception('Unable to tag for ' . self::$to_smf_version); + } + + // Get the version information. + if (empty(self::$to_smf_version)) { + throw new Exception('Error: Version is not stable in current branch'); + } + + $files = explode(PHP_EOL, shell_exec('git diff --name-only v2.1.6 release-2.1')); + + if (empty($files)) { + throw new Exception('Error: Unable to find any new files'); + } + + // Ensure we force update these files. + $files[] = 'index.php'; + $files[] = 'SSI.php'; + $files[] = 'proxy.php'; + $files[] = 'cron.php'; + + // Get the current year. + $current_year = date('Y', time()); + + foreach ($files as $file) { + if (empty($file) || !file_exists($file) || str_starts_with($file, 'other/')) { + continue; + } + + if (str_starts_with($file, 'Sources/') || str_starts_with($file, 'Themes/default')) { + $length = 4000; + + $replacements = [ + '~(\r?\n\s+)\* @copyright \d{4} Simple Machines and individual contributors(\s+)~' => '\1* @copyright ' . $current_year . ' Simple Machines and individual contributors\2', + '~(\r?\n\s+)\* @version \d\.\d(?:\.\d)?(\s+)~' => '\1* @version ' . self::$to_smf_version . '\2', + ]; + } else if (str_starts_with($file, 'Themes/default/languages')) { + $length = 300; + + $replacements = [ + '~(\r?\n\s*)\/\/ Version: \d\.\d(?:\.\d)?;~' => '\1// Version: ' . self::$to_smf_version . ';' + ]; + } else if (in_array($file, ['index.php', 'cron.php', 'proxy.php', 'SSI.php'])) { + $length = 4000; + + $replacements = [ + '~(\r?\n\s+)\* @copyright \d{4} Simple Machines and individual contributors(\s+)~' => '\1* @copyright ' . $current_year . ' Simple Machines and individual contributors\2', + '~(\r?\n\s+)\* @version \d\.\d(?:\.\d)?(\s+)~' => '\1* @version ' . self::$to_smf_version . '\2', + '~define\(\'SMF_VERSION\', \'([^\']+)\'\);~' => 'define(\'SMF_VERSION\', \'' . self::$to_smf_version . '\');' + ]; + } + else { + self::writeDebug('[SKIP] Unknown file {$file}'); + continue; + } + + // PHP doesn't offer a way to insert in the middle of a line. So we use a temp file. + self::writeDebug('[Updating] {$file}'); + $fr = fopen($file, 'r'); + $fw = fopen($file . '~', 'w+'); + + if ($fr === false || $fw === false) { + throw new Exception('Error: Unable to open file [' . $file . '] for read and write'); + } + + // The first read should have our header. + $contents = fread($fr, $length); + + // Perform replacements. + $contents = preg_replace(array_keys($replacements), array_values($replacements), $contents); + fwrite($fw,$contents); + + // Write out rest of the file. + while (!feof($fr)) { + fwrite($fw, fread($fr, $length)); + } + + fclose($fr); + fclose($fw); + + // Out with the old, in with the new. + unlink($file); + rename($file . '~', $file); + } + } + + /************************* + * Internal static methods + *************************/ + + /** + * Reads the argv and parses them into variables we are passing into other parts of our code. + * + */ + protected static function prepareCLIhandler(): void + { + // Read the params into a place we can handle this. + $params = $_SERVER['argv']; + array_shift($params); + + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($var, $val) = explode('=', $param); + + if (!isset(self::$cli_param_map[ltrim($var, '-')])) { + continue; + } + + self::${self::$cli_param_map[ltrim($var, '-')]} = $val; + } elseif (isset(self::$cli_param_map[ltrim($param, '-')])) { + self::${self::$cli_param_map[ltrim($param, '-')]} = true; + } + } + + // Need help, hopefully not. + if (empty($params) || self::$help) { + echo 'SMF Update Headers tool' . "\n" + . '$ php ' . basename(__FILE__) . " -s=path/to/smf/ -o=/tmp -f=3.0.1 -t=3.0.2 \n" + . '-s=/path/to/smf Where SMF has its files' . "\n" + . '-f=tag_id Tag in git for our source version.' . "\n" + . '--name=VERSION SMF version to be named.' . "\n" + . '-h, --help This help file.' . "\n" + . '-d, --debug Prints out more debug info.' . "\n" + + . "\n"; + + die; + } + unset($params); + + // Defaults. + self::$smf_root = self::$smf_root === '' ? realpath($_SERVER['PWD']) : realpath(self::$smf_root); + } + + /** + * Write a debug output. + * + * @param string $msg + */ + protected static function writeDebug(string $msg): void + { + if (self::$debug) { + fwrite(STDOUT, $msg . "\n"); + flush(); + } + } +} From 3d009fcdb5ba1c518c1df55720418d4ff4d3ff69 Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Sat, 20 Sep 2025 17:42:43 -0700 Subject: [PATCH 6/9] Ensure other files are built --- updateHeaders.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/updateHeaders.php b/updateHeaders.php index fbeccee..a6d4ea1 100644 --- a/updateHeaders.php +++ b/updateHeaders.php @@ -142,12 +142,15 @@ public static function run() $files[] = 'SSI.php'; $files[] = 'proxy.php'; $files[] = 'cron.php'; + $files[] = 'other/upgrade-helper.php'; + $files[] = 'other/upgrade.php'; + $files[] = 'other/install.php'; // Get the current year. $current_year = date('Y', time()); foreach ($files as $file) { - if (empty($file) || !file_exists($file) || str_starts_with($file, 'other/')) { + if (empty($file) || !file_exists($file)) { continue; } @@ -164,7 +167,7 @@ public static function run() $replacements = [ '~(\r?\n\s*)\/\/ Version: \d\.\d(?:\.\d)?;~' => '\1// Version: ' . self::$to_smf_version . ';' ]; - } else if (in_array($file, ['index.php', 'cron.php', 'proxy.php', 'SSI.php'])) { + } else if (in_array($file, ['index.php', 'cron.php', 'proxy.php', 'SSI.php']) || str_starts_with($file, 'other/')) { $length = 4000; $replacements = [ From 38fbb2aa42f34157c11a5531a4b7d3eb3af938ab Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Mon, 22 Sep 2025 16:27:09 -0700 Subject: [PATCH 7/9] Skip files we don't really update. Add logic to trim up the patch to remove excess lines Fix our cleanup process failing because we reach a backtrack limit. --- buildPatch.php | 59 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/buildPatch.php b/buildPatch.php index f0ef758..1b6e1e6 100644 --- a/buildPatch.php +++ b/buildPatch.php @@ -515,16 +515,24 @@ protected static function convertDiffToPatch(string $diff_file, string $version, trim(substr($content[$i], 5)), ); - $operations[$counter]['path'] = $file; //$dir . '/' . trim($content[$i]); + // We only really care about php, js and css files. + if (!str_ends_with($file, '.php') && !str_ends_with($file, '.js') && !str_ends_with($file, '.css')) { + while (!str_starts_with($content[$i + 1], '--- a/')) { + $i++; + } + continue; + } + + $operations[$counter]['path'] = $file; // Is this a file deletion? if (str_starts_with($content[$i + 1], '+++ /dev/null')) { $file_operations['replace'] = ['']; $infoOperation = basename($file); - $info_operations['remove-file'][] = $file; //$dir . '/' . $infoOperation; + $info_operations['remove-file'][] = $file; } - while (!str_starts_with($content[$i], '@@')) { + while (!str_starts_with($content[$i + 1], '@@')) { $i++; } continue; @@ -556,6 +564,8 @@ protected static function convertDiffToPatch(string $diff_file, string $version, unset($operations[$counter]); $infoOperation = null; } else { + self::trimOperation($file_operations); + $operations[$counter]['operations'][$opCounter]['search'] = str_replace([''], [''], implode('', $file_operations['search'])); $operations[$counter]['operations'][$opCounter]['replace'] = str_replace([''], [''], implode('', $file_operations['replace'])); @@ -622,6 +632,39 @@ protected static function convertDiffToPatch(string $diff_file, string $version, file_put_contents($working_dir . DIRECTORY_SEPARATOR . $to_file_prefix . 'patch.xml', $ret); } + /** + * Attempts to trim our operation down a bit by removing some extra lines added from the diff conversion process. + * + * @param array $op + * @return void + */ + protected static function trimOperation(array &$op): void + { + // We start at index 0, like any sane language. + $searchLines = count($op['search']) - 1; + $findLines = count($op['replace']) - 1; + $counter = $searchLines > $findLines ? $searchLines : $findLines; + + // Trim up the end matching lines. + // When counting backwards, we want to count down from the highest index on each array. + for ($i = 0; $i < $counter && isset($op['search'][$searchLines - $i], $op['replace'][$findLines - $i]); $i++) { + if ($op['search'][$searchLines - $i] === $op['replace'][$findLines - $i]) { + unset($op['search'][$searchLines - $i], $op['replace'][$findLines - $i]); + } else { + break; + } + } + + // Trim up the starting matching lines. + for ($i = 0; $i < $counter && isset($op['search'][$i], $op['replace'][$i]); $i++) { + if ($op['search'][$i] === $op['replace'][$i]) { + unset($op['search'][$i], $op['replace'][$i]); + } else { + break; + } + } + } + /** * Given a patch file, apply our XLS template to automatically sort the contents. * @param string $output_file @@ -698,6 +741,7 @@ protected static function cleanupPatchFile(string $output_file): void $contents = file_get_contents($output_file); // Be more precise with changes to license blocks. + // Takes a change to the copyright year and version, then breaks it into 2 operations. $contents = preg_replace( '~ ', - $contents, + $contents ); // Get rid of useless ending newlines in replace statements. From 80a1e595d45fa60120fcbe52a8422e253096aa5f Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Mon, 22 Sep 2025 16:39:24 -0700 Subject: [PATCH 8/9] Too greedy --- buildPatch.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/buildPatch.php b/buildPatch.php index 1b6e1e6..19a7b1b 100644 --- a/buildPatch.php +++ b/buildPatch.php @@ -648,6 +648,11 @@ protected static function trimOperation(array &$op): void // Trim up the end matching lines. // When counting backwards, we want to count down from the highest index on each array. for ($i = 0; $i < $counter && isset($op['search'][$searchLines - $i], $op['replace'][$findLines - $i]); $i++) { + // We can't trim to much. + if (count($op['search']) < 3 || count($op['replace']) < 3) { + break; + } + if ($op['search'][$searchLines - $i] === $op['replace'][$findLines - $i]) { unset($op['search'][$searchLines - $i], $op['replace'][$findLines - $i]); } else { @@ -657,6 +662,11 @@ protected static function trimOperation(array &$op): void // Trim up the starting matching lines. for ($i = 0; $i < $counter && isset($op['search'][$i], $op['replace'][$i]); $i++) { + // We can't trim to much. + if (count($op['search']) < 3 || count($op['replace']) < 3) { + break; + } + if ($op['search'][$i] === $op['replace'][$i]) { unset($op['search'][$i], $op['replace'][$i]); } else { From 232902f848a2d2a965b8790433bf1e1590dc1a3a Mon Sep 17 00:00:00 2001 From: jdarwood007 Date: Sat, 27 Sep 2025 08:33:31 -0700 Subject: [PATCH 9/9] Fixes to get a patch file which search operations are unique --- buildPatch.php | 264 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 213 insertions(+), 51 deletions(-) diff --git a/buildPatch.php b/buildPatch.php index 19a7b1b..e58f273 100644 --- a/buildPatch.php +++ b/buildPatch.php @@ -253,9 +253,17 @@ public static function run() if (self::$patch_type === 'xml') { self::writeDebug('[patch] Converting to xml'); self::convertDiffToPatch($tmp_dir . $to_file_prefix . 'patch.diff', self::$to_smf_version, $tmp_dir, $to_file_prefix, $info_operations); + + if (strtolower(self::$archive_mode) !== 'no' || !self::$debug) { + self::writeDebug(msg: '[patch] Cleaning up diff'); + unlink($tmp_dir . $to_file_prefix . 'patch.diff'); + } + + // Sort the output so its more organized. + self::sortPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); - self::writeDebug(msg: '[patch] Cleaning up diff'); - unlink($tmp_dir . $to_file_prefix . 'patch.diff'); + // Apply some qualify of life fixes. + self::cleanupPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); } // Template for our package info file. @@ -265,12 +273,6 @@ public static function run() self::writeDebug(msg: '[patch] Writing info file'); file_put_contents($tmp_dir . 'package-info.xml', $infoFileContents); - // Sort the output so its more organized. - self::sortPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); - - // Apply some qualify of life fixes. - self::cleanupPatchFile($tmp_dir . $to_file_prefix . 'patch.xml'); - $tmp_file = self::$output_dir . '/' . $to_file_prefix; $build = 'patch'; @@ -434,7 +436,7 @@ protected static function packageInfoTemplate(string $version, string $file_vers This will remove the changes introduced by SMF {$version}. [b]This is generally not a good idea.[/b] - {$file_version}patch.diff + {$file_version}patch.{$extension} '{$previous_version}'));]]> END; @@ -504,26 +506,23 @@ protected static function convertDiffToPatch(string $diff_file, string $version, $operations = []; $counter = 0; $opCounter = 0; + $lineStart = 0; + $removes = 0; $infoOperation = null; // First walk each line to figure out what we are doing. for ($i = 0; $i < count($content); $i++) { + // Trigger a new file operation. if (str_starts_with($content[$i], '--- a/')) { + $rawFile = trim(substr($content[$i], 5)); $file = preg_replace( array_keys(self::$replacements), array_values(self::$replacements), - trim(substr($content[$i], 5)), + $rawFile, ); - // We only really care about php, js and css files. - if (!str_ends_with($file, '.php') && !str_ends_with($file, '.js') && !str_ends_with($file, '.css')) { - while (!str_starts_with($content[$i + 1], '--- a/')) { - $i++; - } - continue; - } - $operations[$counter]['path'] = $file; + $operations[$counter]['file'] = $rawFile; // Is this a file deletion? if (str_starts_with($content[$i + 1], '+++ /dev/null')) { @@ -538,7 +537,6 @@ protected static function convertDiffToPatch(string $diff_file, string $version, continue; } - // Appearing to start a new section, tie things off. /* * When we end a block of code, tie it off and add it as a operation * We do this when we detect: @@ -550,12 +548,15 @@ protected static function convertDiffToPatch(string $diff_file, string $version, * @copyright 2012 emanuele, Simple Machines * @license http://www.simplemachines.org/about/smf/license.php BSD */ + + // Appearing to start a new section, tie things off. if ( ( str_starts_with($content[$i], '@@') - || str_starts_with($content[$i], 'diff --git') - || !isset($content[$i + 1]) - ) && !empty($file_operations)) { + || str_starts_with($content[$i], 'diff --git') + || !isset($content[$i + 1]) + ) && !empty($file_operations) + ) { // If this was a special info operation, don't do this. if ($infoOperation !== null) { self::writeDebug("[patch] Writing file {$infoOperation}"); @@ -564,13 +565,21 @@ protected static function convertDiffToPatch(string $diff_file, string $version, unset($operations[$counter]); $infoOperation = null; } else { - self::trimOperation($file_operations); - $operations[$counter]['operations'][$opCounter]['search'] = str_replace([''], [''], implode('', $file_operations['search'])); $operations[$counter]['operations'][$opCounter]['replace'] = str_replace([''], [''], implode('', $file_operations['replace'])); + $operations[$counter]['operations'][$opCounter]['action'] = 'replace'; + $operations[$counter]['operations'][$opCounter]['lineStart'] = $lineStart; + $operations[$counter]['operations'][$opCounter]['removes'] = $removes; $opCounter++; + // Get information about where the change is going. + if (str_starts_with($content[$i], '@@')){ + preg_match('/@@ -(\d{1,10}),{0,1}(\d{0,10}) \+\d{1,10},{0,1}\d{0,10} @@/', $content[$i], $matches); + $lineStart = $matches[1] ?? 0; + $removes = $matches[2] ?? 0; + } + if (str_starts_with($content[$i], 'diff --git')) { $file = ''; $counter++; @@ -581,6 +590,13 @@ protected static function convertDiffToPatch(string $diff_file, string $version, continue; } + // Get information about where the change is going. + if (str_starts_with($content[$i], '@@')){ + preg_match('/@@ -(\d{1,10}),{0,1}(\d{0,10}) \+\d{1,10},{0,1}\d{0,10} @@/', $content[$i], $matches); + $lineStart = $matches[1] ?? 0; + $removes = $matches[2] ?? 0; + } + if (!empty($file)) { if (str_starts_with($content[$i], ' ')) { $file_operations['replace'][] = $file_operations['search'][] = substr($content[$i], 1); @@ -603,7 +619,8 @@ protected static function convertDiffToPatch(string $diff_file, string $version, 1.0'; foreach ($operations as $file) { - if (str_starts_with($file['path'], '$board' . 'dir/other')) { + // We only really care about php, js and css files. + if (str_starts_with($file['path'], '$board' . 'dir/other') || !in_array(pathinfo($file['path'], PATHINFO_EXTENSION), ['php', 'css', 'js'])) { continue; } @@ -632,45 +649,188 @@ protected static function convertDiffToPatch(string $diff_file, string $version, file_put_contents($working_dir . DIRECTORY_SEPARATOR . $to_file_prefix . 'patch.xml', $ret); } + /** + * Optimize operations on a single file. + * + * @param array $ops + * @return void + */ + protected static function performOptimizations(array &$fileOp): void { + $oldFileContents = shell_exec('git show ' . self::$from_tag . ':' . ltrim($fileOp['file'], '/')); + if (empty($oldFileContents)) { + return; + } + + $newFileContents = shell_exec('git show ' . self::$to_tag . ':' . ltrim($fileOp['file'], '/')); + if (empty($newFileContents)) { + return; + } + + array_walk($fileOp['operations'], fn($op) => self::trimOperations($op)); + + self::makeOperationsUnique($fileOp['operations'], $oldFileContents, $newFileContents); + } + /** * Attempts to trim our operation down a bit by removing some extra lines added from the diff conversion process. * + * @author sbulen * @param array $op * @return void */ - protected static function trimOperation(array &$op): void + protected static function makeOperationsUnique(array &$ops, string $oldFile, string $newFile): void { - // We start at index 0, like any sane language. - $searchLines = count($op['search']) - 1; - $findLines = count($op['replace']) - 1; - $counter = $searchLines > $findLines ? $searchLines : $findLines; - - // Trim up the end matching lines. - // When counting backwards, we want to count down from the highest index on each array. - for ($i = 0; $i < $counter && isset($op['search'][$searchLines - $i], $op['replace'][$findLines - $i]); $i++) { - // We can't trim to much. - if (count($op['search']) < 3 || count($op['replace']) < 3) { - break; + $oldFileArray = explode("\n", $oldFile); + + foreach ($ops as $ix => &$op) { + // No search string for these + if (in_array($op['action'], array('end', 'new file'))) { + continue; } - if ($op['search'][$searchLines - $i] === $op['replace'][$findLines - $i]) { - unset($op['search'][$searchLines - $i], $op['replace'][$findLines - $i]); - } else { - break; + // Keep adding lines until the search is unambiguous + // For 'replace', add to both remove & add; for before/after, etc., only to the search criterion + // If empty, add a line to prime the pump... + $line = $op['lineStart'] - 2; + if ( + empty($op['search']) + || ( + $op['action'] == 'replace' + && empty($op['replace']) + ) + ) { + $op['search'] = $oldFileArray[$line] . "\n" . $op['search']; + + if ($op['action'] == 'replace') { + $op['replace'] = $oldFileArray[$line] . "\n" . $op['replace']; + } + + $line--; + + // Keep status current... + $op['lineStart']--; + + if (isset($op['removes'])) { + $op['removes']++; + } + } + + $count = substr_count($oldFile, $op['search']); + + if ($op['action'] == 'replace') { + $uniqueness = substr_count($newFile, $op['replace']); } + + // Cannot intrude upon updates from prior snippet... + $compareLine = ($ops[$ix - 1]['lineStart'] ?? 0) + ($ops[$ix - 1]['removes'] ?? 0) - 2; + + while (($count > 1 || ($op['action'] == 'replace' && $uniqueness > 1)) && $line > 0) { + if ($line > $compareLine) { + $op['search'] = $oldFileArray[$line] . "\n" . $op['search']; + + if ($op['action'] == 'replace') { + $op['replace'] = $oldFileArray[$line] . "\n" . $op['replace']; + } + + $line--; + + // Keep status current... + $op['lineStart']--; + + if (isset($op['removes'])) { + $op['removes']++; + } + + $count = substr_count($oldFile, $op['search']); + + if ($op['action'] == 'replace') { + $uniqueness = substr_count($newFile, $op['replace']); + } + } else { + // These must be resolved by hand at this point... + self::writeDebug('[ERROR] Cannot disambiguate operation'); + var_dump($op, $line, $compareLine); + die; + } + } + } + } + + private static int $contextLines = 3; + + /** + * Trim away some extra context. + * + * @author sbulen + * @param array $op + * @return void + */ + protected static function trimOperations(array &$op): void { + if (empty($op['action']) || $op['action'] !== 'replace') { + return; } - // Trim up the starting matching lines. - for ($i = 0; $i < $counter && isset($op['search'][$i], $op['replace'][$i]); $i++) { - // We can't trim to much. - if (count($op['search']) < 3 || count($op['replace']) < 3) { - break; + for ($i = 1; $i <= self::$contextLines; $i++) { + self::removeBottomLine($op); + self::removeTopLine($op); + } + } + + /** + * Remove Bottom Line - & make sure it's common + * + * @author sbulen + * @param array $op + * @return void + */ + protected static function removeBottomLine(array &$op): void + { + static $codeLine = '/(?<=\n|^)(.*\n?)$/D'; + + $sLine = preg_match($codeLine, $op['search'], $sMatch); + $rLine = preg_match($codeLine, $op['replace'], $rMatch); + + if ($sLine && $rLine && $sMatch[1] === $rMatch[1]) { + $op['search'] = substr($op['search'], 0, strlen($op['search']) - strlen($sMatch[1])); + $op['replace'] = substr($op['replace'], 0, strlen($op['replace']) - strlen($rMatch[1])); + + // Keep status current... + if (isset($op['removes'])) { + $op['removes']--; } + } + } - if ($op['search'][$i] === $op['replace'][$i]) { - unset($op['search'][$i], $op['replace'][$i]); - } else { - break; + /** + * Remove Top Line - & make sure it's common + * + * @author sbulen + * @param mixed $op + * @return void + */ + protected static function removeTopLine(array &$op): void + { + // Get top lines from both... + $eolSearch = strpos($op['search'], "\n"); + $eolReplace = strpos($op['replace'], "\n"); + + if ($eolSearch !== false && $eolReplace !== false) { + $topSearch = substr($op['search'], 0, $eolSearch + 1); + $topReplace = substr($op['replace'], 0, $eolReplace + 1); + + if ($topSearch === $topReplace) { + // Don't remove comment lines, folks like those + if (substr(ltrim($topSearch), 0, 2) != '//') { + $op['search'] = substr($op['search'], $eolSearch + 1); + $op['replace'] = substr($op['replace'], $eolReplace + 1); + + // Keep status current... + $op['lineStart']++; + + if (isset($op['removes'])) { + $op['removes']--; + } + } } } } @@ -700,6 +860,8 @@ protected static function sortPatchFile(string $output_file): void */ protected static function xlsTemplate(): string { + $version = self::$to_smf_version; + return << - <!-- 2.1.6 updates for + <!-- {$version} updates for -->