version); exit; } $script = $_SERVER['argv'][0]; $version = isset($_SERVER['argv'][1]) ? $_SERVER['argv'][1] : ''; $subrelease = isset($_SERVER['argv'][2]) ? $_SERVER['argv'][2] : ''; if (! preg_match("/^\d+\.\d+$/", $version)) { error("Version number should be in X.X format.\n"); } $isPre = strpos($subrelease, 'pre') === 0; if ($isPre) { $subrelease = substr($subrelease, 3); $pre = 'pre'; } else { $pre = ''; } $splitedversion = explode('.', $version); $mainversion = $splitedversion[0]; $check_version = $version . $subrelease; if ($TWV->version !== $check_version && ! $options['devmode']) { error("The version in the code " . strtolower($TWV->version) . " differs from the version provided to the script $check_version.\nThe version should be modified in lib/setup/twversion.class.php to match the released version."); } echo color("\nTiki release process started for version '$version" . ($subrelease ? " $subrelease" : '') . "'\n", 'cyan'); if ($isPre) { echo color("The script is running in 'pre-release' mode, which means that no tag will be created.\n", 'yellow'); } if (! $options['no-first-update'] && important_step('Update working copy to the last revision')) { echo "Update in progress..."; update_working_copy('.'); if (! $options['no-check-vcs'] && has_uncommited_changes('.')) { error("\rUncommitted changes exist in the working folder.\n"); } $revision = get_revision('.'); info("\r>> Checkout updated to revision $revision."); } if (empty($subrelease)) { $branch = $vcs == 'svn' ? "branches/$mainversion.x" : "$mainversion.x"; $tag = "tags/$version"; $packageVersion = $version; if (! empty($pre)) { $packageVersion .= ".$pre"; } $secdbVersion = $version; } else { $branch = $vcs == 'svn' ? "branches/$mainversion.x" : "$mainversion.x"; $tag = "tags/$version$subrelease"; $packageVersion = "$version.$pre$subrelease"; $secdbVersion = "$version$subrelease"; } if (! $options['no-readme-update'] && important_step("Update '" . README_FILENAME . "' file")) { update_readme_file($secdbVersion, $version); info('>> ' . README_FILENAME . ' file updated.'); important_step('Commit updated ' . README_FILENAME . ' file', true, "[REL] Update " . README_FILENAME . " file for $secdbVersion"); } if (! $options['no-lang-update'] && important_step("Update language files")) { passthru("$phpCommand console.php translation:getstrings"); $removeFiles = glob('lang/*/language.php.old'); foreach ($removeFiles as $rf) { unlink($rf); } unset($removeFiles); info('>> Language files updated and temporary files removed.'); important_step('Commit updated language files', true, "[REL] Update language.php files for $secdbVersion"); } if (! $options['no-changelog-update'] && important_step("Update '" . CHANGELOG_FILENAME . "' file (using final version number '$version')")) { if ($ucf = update_changelog_file($version)) { if ($ucf['nbCommits'] == 0) { info('>> Changelog updated (last commits were already inside)'); } else { if ($ucf['sameFinalVersion']) { info(">> There were already some commits for the same final version number in the changelog. Merging them with the new ones."); } info(">> Changelog updated with {$ucf['nbCommits']} new commits (revision {$ucf['firstRevision']} to {$ucf['lastRevision']}), excluding duplicates, merges and release-related commits."); } important_step("Commit new " . CHANGELOG_FILENAME, true, "[REL] Update " . CHANGELOG_FILENAME . " for $secdbVersion"); } else { error('Changelog update failed.'); } unset($ucf); } $nbCommiters = 0; if (! $options['no-copyright-update'] && important_step("Update '" . COPYRIGHTS_FILENAME . "' file (using final version number '$version')")) { if ($ucf = update_copyright_file($mainversion . '.0')) { info( "\r>> Copyrights updated: " . ($ucf['newContributors'] == 0 ? 'No new contributor, ' : "+{$ucf['newContributors']} contributor(s), ") . ($ucf['newCommits'] == 0 ? 'No new commit' : "+{$ucf['newCommits']} commit(s)") ); important_step("Commit new " . COPYRIGHTS_FILENAME, true, "[REL] Update " . COPYRIGHTS_FILENAME . " for $secdbVersion"); } else { error('Copyrights update failed.'); } } if (! $options['no-check-db'] && important_step("Check Database related files and upgrade scripts")) { $error_msg = ''; check_database_files_and_upgrade($mainversion, $error_msg) or error($error_msg . "If you want to disable this checks use --no-check-db\n"); info('>> Current database scripts successfully passed the check.'); } if (! $options['no-check-php'] && important_step("Check syntax of all PHP files")) { $error_msg = ''; $dir = '.'; check_php_syntax($dir, $error_msg, $options['no-check-php-warnings']) or error($error_msg); info('>> Current PHP code successfully passed the syntax check.'); } if (! $options['no-check-smarty'] && important_step("Check syntax of all Smarty templates")) { $error_msg = ''; require_once ROOT . '/lib/core/TikiDb.php'; require_once ROOT . '/lib/core/TikiDb/Bridge.php'; require_once ROOT . '/lib/language/Language.php'; check_smarty_syntax($error_msg); info('>> Current Smarty code successfully passed the syntax check.'); } if (! $options['no-secdb'] && important_step("Update SecDB file(s) 'db/tiki-secdb_{$version}_mysql.sql'")) { if (updateSecdb($TWV->version)) { important_step("Commit SecDB file changes", true, "[REL] SecDB for $secdbVersion"); } } if ($isPre) { if (! $options['no-packaging'] && important_step("Build packages files")) { build_packages($packageVersion); echo color("\nMake sure these tarballs are tested by at least 3 different people.\n\n", 'cyan'); } else { echo color("This was the last step.\n", 'cyan'); } } else { if (! $options['no-tagging']) { $tagAlreadyExists = tag_exists($tag, true); if ($tagAlreadyExists && important_step("The Tag '$tag' already exists: Delete the existing tag in order to create a new one")) { $commit_msg = "[REL] Deleting tag '$tag' in order to create a new one"; if ($options['no-commit']) { print "Skipping actual commit ('$commit_msg') because no-commit = true\n"; } else { delete_tag($tag, $commit_msg); $tagAlreadyExists = false; info(">> Tag '$tag' deleted."); } } if (! $tagAlreadyExists) { update_working_copy('.'); $revision = get_revision(ROOT); if (important_step("Tag release using branch '$branch' at revision $revision")) { $commit_msg = '[REL] Tagging release'; if ($options['no-commit']) { print "Skipping actual commit ('$commit_msg') because no-commit = true\n"; } else { create_tag($tag, $commit_msg, $branch, $revision); info(">> Tag '$tag' created."); } } } } if (! $options['no-packaging'] && important_step("Build packages files")) { build_packages($packageVersion); } else { info("This was the last step.\n"); } } // Helper functions /** * * Will remove old secdb files and generate a new one based on working copy files. * * @param $version string The current tiki version to use eg. 17.0 or 21.0RC1 * @return bool true on success */ function updateSecdb($version) { // first unset any preexisting files. echo(">>"); $vcs = preg_match('/' . getBinName() . '$/', $version); // if we are not creating a release skip deleting old files. if (! $vcs) { $files = glob(ROOT . '/db/tiki-secdb_*_mysql.sql'); foreach ($files as $file) { $file = escapeshellarg($file); delete_file($file); } echo(' Removed ' . count($files) . ' old secdb files.'); $excludes = []; } else { $excludes = array_keys(files_differ(ROOT)); } $file = "/db/tiki-secdb_{$version}_mysql.sql"; if (! $fp = @fopen(ROOT . $file, 'w')) { error('The SecDB file "' . ROOT . $file . '" is not writable or can\'t be created.'); return false; } $queries = []; build_secdb_queries(ROOT, $version, $queries, $excludes); if (! empty($queries)) { sort($queries); fwrite($fp, "start transaction;\n"); fwrite($fp, "DELETE FROM `tiki_secdb`;\n"); // This index was originally created with a size limit that would raise an error on some versions, // notably on 18.0. Since this file is executed before any patch in installer/schema, the fix had to // be done here. It's a quick operation because table is empty, so no harm in leaving this here forever. fwrite($fp, "ALTER TABLE `tiki_secdb` DROP PRIMARY KEY, ADD PRIMARY KEY (`filename`(171),`tiki_version`(20));\n\n"); $insertString = 'INSERT INTO `tiki_secdb` (`filename`, `md5_value`, `tiki_version`) VALUES '; $extendedInsertSize=0; $extendedInsertMaxSize=1*1024*1024 - 100; // 1MB with 100 bytes safety limit, some old versions of Mysql had max_allowed_packet=1MB foreach ($queries as $q) { if (($extendedInsertSize + strlen($q) + 2) > $extendedInsertMaxSize ) { fwrite($fp,";\n"); $extendedInsertSize=0; } if ($extendedInsertSize === 0) { fwrite($fp, $insertString ."\n"); $extendedInsertSize = strlen($insertString) + 1; } else { fwrite($fp, ",\n"); $extendedInsertSize+=2; } fwrite($fp, $q); $extendedInsertSize+=strlen($q); } fwrite($fp, ";\n"); fwrite($fp, "commit;\n"); } fclose($fp); echo(" $file was generated.\n"); if (! $vcs) { $file = escapeshellarg(ROOT . $file); // escape file name for use in command line. add($file); } return true; } /** * Similar to md5_check_dir in tiki-admin_security.php but creates the sql queries for /db/tiki-secdb_{$version}_mysql.sql * * @param string $dir * @param string $version * @param array $queries queries returned * @param array $excludes files to exclude when doing secdb on an svn or checkout */ function build_secdb_queries($dir, $version, &$queries, $excludes = []) { $d = dir($dir); $link = null; while (false !== ($e = $d->read())) { $entry = $dir . '/' . $e; if (is_link($entry)) { continue; // if is a symlink we should not run any hash } if (is_dir($entry)) { // do not descend and no CVS/Subversion files if ($e != '..' && $e != '.' && $e != 'CVS' && $e != '.git' && $e != '.gitignore' && $e != '.svn' && $entry != ROOT . '/temp' && $entry != ROOT . '/vendor_custom' && $entry != ROOT . '/_custom') { build_secdb_queries($entry, $version, $queries, $excludes); } } else { if (preg_match('/\.(sql|css|tpl|js|php)$/', $e) && realpath($entry) != __FILE__ && $entry != './db/local.php') { $file = '.' . substr($entry, strlen(ROOT)); if (in_array($entry, $excludes)) { continue; } // Escape filename. Since this requires a connection to MySQL (due to the charset), do so conditionally to reduce the risk of connection failure. if (! preg_match('/^[a-zA-Z!-9\/ _+.-@]+$/', $file)) { if (! $link) { $link = mysqli_connect(); if (mysqli_connect_errno()) { global $phpCommand, $phpCommandArguments; error( "SecDB step failed because some filenames (e.g. {$file}) need escaping but no MySQL connection has been found (" . mysqli_connect_error() . ")." . "\nTry this command line instead (replace HOST, USER and PASS by a valid MySQL host, user and password) :" . "\n\n\t" . $phpCommand . " -d mysqli.default_host=HOST -d mysqli.default_user=USER -d mysqli.default_pw=PASS " . $phpCommandArguments . "\n" ); } } $file = @mysqli_real_escape_string($link, $file); } if (is_readable($entry)) { $hash = md5_file($entry); $queries[] = "('$file', '$hash', '$version')"; } } } } $d->close(); } /** * * Deletes a directory and its contents. * * @param string $dir directory to delete. * @return string|bool returns the filename that an error occurred in, false otherwise */ function rrmdir($dir) { if (is_dir($dir)) { $objects = scandir($dir); foreach ($objects as $object) { if ($object != "." && $object != "..") { @chmod($dir . "/" . $object, 0777); if (filetype($dir . "/" . $object) === 'dir') { $error = rrmdir($dir . "/" . $object); if ($error) { return $error; } } elseif (! @unlink($dir . "/" . $object)) { return 'Could not delete ' . $dir . "/" . $object . "\n"; } } } reset($objects); @unlink($dir . '/.DS_store'); if (! @rmdir($dir)) { return 'Could not delete ' . $dir . "\n"; } } return false; } /** * * Recursivley deletes specific files or directories * * @param string $src Directory to search through * @param array $files An array of file names to delete. */ function removeFiles($src, $files) { $dir = opendir($src); while (false !== ($file = readdir($dir))) { if (($file != '.') && ($file != '..')) { $full = $src . '/' . $file; if (is_dir($full)) { $flag = false; foreach ($files as $delfile) { if (basename($full) === $delfile) { rrmdir($full); $flag = true; break; } } if (! $flag) { removeFiles($full, $files); } } else { foreach ($files as $delfile) { if (basename($full) === $delfile) { @chmod($full, 0777); unlink($full); break; } } } } } closedir($dir); } /** * * Recursively sets permissions. Files get 775 and directories 664. * * @param string $src The directory to set permissions for */ function setPermissions($src) { $dir = opendir($src); while (false !== ($file = readdir($dir))) { if (($file != '.') && ($file != '..')) { $full = $src . '/' . $file; if (is_dir($full)) { setPermissions($full); chmod($full, 0755); } else { if (is_link($full)) { continue; } chmod($full, 0664); } } } closedir($dir); } /** * * Prepares and generates the release packages. * * @param string $releaseVersion Version of tiki that is being released. */ function build_packages($releaseVersion) { global $options; $workDir = $_SERVER['HOME'] . "/tikipack"; $fileName = 'tiki-' . $releaseVersion; $relDir = $workDir . '/' . $releaseVersion; // where the tiki dir and tarballs go $sourceDir = $relDir . '/' . $fileName; // the svn export echo "Seting up $workDir directory\n"; if (! is_dir($workDir)) { if (! mkdir($workDir)) { error('Cant make ' . $workDir . "\n"); die(); } } // remove previous files if they exist. if (is_dir($relDir)) { echo "Removing previous files\n"; $shellout = rrmdir($relDir); if ($shellout) { die($shellout . "\n"); } } if (! mkdir($relDir)) { error('Cant make ' . $relDir . "\n"); die(); } // create an export in tikipack to work with echo "Exporting working copy into $sourceDir\n"; $shellout = export(ROOT, $sourceDir); if ($options['debug-packaging']) { echo $shellout . "\n"; } if (! is_file($sourceDir . '/vendor_bundled/composer.json')) { echo 'composer.json not found. Aborting.' . "\n"; die(); } if (is_file($workDir . '/composer.phar')) { if (! unlink($workDir . '/composer.phar')) { echo "Can't delete tikipack/composer.phar. Aborting." . "\n"; die(); } } echo "Downloading composer.phar" . "\n"; $checksum = file_get_contents('https://composer.github.io/installer.sig'); $composerInstaller = $workDir . '/composer-setup.php'; if (! file_put_contents($composerInstaller, file_get_contents('http://getcomposer.org/installer'))) { echo "Can't create tikipack/composer-setup.php. Aborting." . "\n"; die(); } if ($checksum !== hash_file('sha384', $composerInstaller)) { echo "Invalid composer installer checksum. Aborting." . "\n"; unlink($composerInstaller); die(); } $shellout = shell_exec('php ' . escapeshellarg($composerInstaller) . ' --quiet --2 --install-dir=' . $workDir . ' 2>&1'); if ($shellout) { echo "Composer installer failed. Aborting." . "\n"; unlink($composerInstaller); die(); } // tidy up unlink($composerInstaller); echo 'Installing dependencies through composer' . "\n"; $shellout = shell_exec('php ' . escapeshellarg($workDir . '/composer.phar') . ' install -d ' . escapeshellarg($sourceDir . '/vendor_bundled') . ' --prefer-dist --no-dev 2>&1'); if ($options['debug-packaging']) { echo $shellout . "\n"; } if ($options['debug-packaging']) { echo $shellout . "\n"; } if ( strpos($shellout, 'Fatal error:') !== false || strpos($shellout, 'Installation failed,') !== false || // symfony/dependency-injection comes in quite late in the list and is required - sometimes no error is reported even though it didn't work strpos($shellout, 'symfony/dependency-injection') === false ) { echo 'Vendor bundled packages installation Failed. Exiting' . "\n"; die(); } echo "Removing development files\n"; $shellout = rrmdir($sourceDir . '/tests'); if ($shellout) { die($shellout . "\n"); } $shellout = rrmdir($sourceDir . '/db/convertscripts'); if ($shellout) { die($shellout . "\n"); } $shellout = rrmdir($sourceDir . '/doc/devtools'); if ($shellout) { die($shellout . "\n"); } $shellout = rrmdir($sourceDir . '/bin'); if ($shellout) { die($shellout . "\n"); } removeFiles($sourceDir, ['.gitignore']); echo "Removing language file comments\n"; foreach (scandir($sourceDir . '/lang') as $strip) { if (is_file($sourceDir . '/lang/' . $strip . '/language.php')) { $shellout = shell_exec('php ' . escapeshellarg(__DIR__ . '/stripcomments.php') . ' ' . escapeshellarg($sourceDir . '/lang/' . $strip . '/language.php') . ' 2>&1'); } if ($shellout) { die($shellout . "\n"); } } echo "Setting file permissions\n"; setPermissions($sourceDir); $relDir = escapeshellarg($relDir); echo "Creating $fileName.tar.gz\n"; $shellout = shell_exec("cd $relDir; tar -pczf " . escapeshellarg($fileName . ".tar.gz") . ' ' . escapeshellarg($fileName) . " --exclude '*.DS_Store' 2>&1"); if ($options['debug-packaging']) { echo $shellout . "\n"; } echo "Creating $fileName.tar.bz2\n"; $shellout = shell_exec("cd $relDir; tar -pcjf " . escapeshellarg($fileName . ".tar.bz2") . ' ' . escapeshellarg($fileName) . " --exclude '*.DS_Store' 2>&1"); if ($options['debug-packaging']) { echo $shellout . "\n"; } echo "Creating $fileName.tar.xz\n"; $shellout = shell_exec("cd $relDir; tar -pcJf " . escapeshellarg($fileName . ".tar.xz") . ' ' . escapeshellarg($fileName) . " --exclude '*.DS_Store' 2>&1"); if ($options['debug-packaging']) { echo $shellout . "\n"; } echo "Creating $fileName.zip\n"; $shellout = shell_exec("cd $relDir; zip -ry " . escapeshellarg($fileName . ".zip") . ' ' . escapeshellarg($fileName) . ' -x "*.DS_Store" -9 2>&1'); if ($options['debug-packaging']) { echo $shellout . "\n"; } echo "Creating $fileName.7z\n"; $shellout = shell_exec("cd $relDir; 7za a " . escapeshellarg($fileName . ".7z") . ' ' . escapeshellarg($fileName) . ' -xr!*.DS_Store -mx=9 2>&1'); if (strpos($shellout, 'command not found')) { error("7za not installed. Archive creation failed.\n"); } if ($options['debug-packaging']) { echo $shellout . "\n"; } echo color("\nTo upload the 'tarballs', copy-paste and execute the following line (and change '\$SF_LOGIN' by your SF.net login):\n", 'yellow'); echo color(" cd $relDir; scp $fileName.* \$SF_LOGIN@frs.sourceforge.net:/home/pfs/project/t/ti/tikiwiki/\$RELEASEFOLDER\$\n", 'yellow'); info(">> Packages files have been built in ~/tikipack/$releaseVersion\n"); } /** * @param $dir * @param $entries * @param $regexp_pattern * @return bool */ function get_files_list($dir, &$entries, $regexp_pattern) { $d = dir($dir); while (false !== ($e = $d->read())) { $entry = $dir . '/' . $e; if (is_dir($entry)) { // do not descend and no CVS/Subversion files if ($e != '..' && $e != '.' && $e != 'CVS' && $e != '.git' && $e != '.gitignore' && $e != '.svn' && $entry != './temp/templates_c' && $entry != './vendor_bundled/vendor') { if (! get_files_list($entry, $entries, $regexp_pattern)) { return false; } } } elseif (preg_match($regexp_pattern, $e) && realpath($entry) != __FILE__) { $entries[] = $entry; } } $d->close(); return true; } /** * @param $alreadyDone * @param $toDo * @param $message */ function display_progress_percentage($alreadyDone, $toDo, $message) { $onePercent = ceil($toDo / 100); if ($alreadyDone % $onePercent === 0 || $alreadyDone == $toDo) { $percentage = ($alreadyDone >= $toDo - $onePercent) ? 100 : min(100, $alreadyDone / $onePercent); printf("\r$message", $percentage); } } function zone_is_empty() { // dummy function to keep smarty happy } /** * @param $error_msg */ function check_smarty_syntax(&$error_msg) { global $tikidomain, $prefs; $tikidomain = ''; // Initialize $prefs with some variables needed by the tra() function and smarty autosave plugin $prefs = [ 'lang_use_db' => 'n', 'language' => 'en', 'site_language' => 'en', 'feature_ajax' => 'n' ]; // Load Tiki Smarty $prefs['smarty_compilation'] = 'always'; $prefs['smarty_security'] = 'y'; $prefs['maxRecords'] = 25; $prefs['log_tpl'] = 'y'; $prefs['feature_sefurl_filter'] = 'y'; $prefs['site_layout'] = 'basic'; require_once 'vendor_bundled/vendor/smarty/smarty/libs/Smarty.class.php'; require_once 'lib/init/smarty.php'; require_once 'lib/init/initlib.php'; // needed in Smarty_Tiki define('TIKI_PATH', getcwd()); require_once 'lib/smarty_tiki/prefilter.tr.php'; require_once 'lib/smarty_tiki/prefilter.jq.php'; require_once 'lib/smarty_tiki/prefilter.log_tpl.php'; $smarty = new Smarty_Tiki(); set_error_handler('check_smarty_syntax_error_handler'); $templates_dir = TIKI_PATH . '/templates'; // tell TikiDb we don't need the database define('DB_TIKI_SETUP', 0); $errors_found = false; $entries = []; get_files_list($templates_dir, $entries, '/\.tpl$/'); $nbEntries = count($entries); for ($i = 0; $i < $nbEntries; $i++) { display_progress_percentage($i, $nbEntries, '%d%% of files passed the Smarty syntax check'); if (strpos($entries[$i], 'tiki-mods.tpl') === false) { $template_file = substr($entries[$i], strlen($templates_dir) + 1); try { $_tpl = $smarty->createTemplate($template_file, null, null, null, false); $_tpl->compileTemplateSource(); } catch (Exception $e) { echo color("\nError: " . $e->getMessage(), 'red') . "\n"; $errors_found = true; } } } restore_error_handler(); @define('DB_TIKI_SETUP', 1); // suppress notice about redefining a constant TODO better echo "\n"; if ($errors_found) { die('Fix the Smarty errors and try again please.'); } } /** * @param $errno * @param $errstr * @param string $errfile * @param int $errline * @param array $errcontext */ function check_smarty_syntax_error_handler($errno, $errstr, $errfile = '', $errline = 0, $errcontext = []) { if (strpos($errstr, 'filemtime(): stat failed for') === false) { // smarty seems to emit these for every file echo "\n" . color($errstr, 'red') . "\n"; } return true; } /** * @param $dir * @param $error_msg * @param $hide_php_warnings * @return bool */ function check_php_syntax(&$dir, &$error_msg, $hide_php_warnings) { global $phpCommand; $checkPhpCommand = $phpCommand . (ERROR_REPORTING_LEVEL > 0 ? ' -d error_reporting=' . (int)ERROR_REPORTING_LEVEL : ''); $entries = []; get_files_list($dir, $entries, '/\.php$/'); $nbEntries = count($entries); for ($i = 0; $i < $nbEntries; $i++) { display_progress_percentage($i, $nbEntries, '%d%% of files passed the PHP syntax check'); $return_var = 0; $output = null; exec("$checkPhpCommand -l {$entries[$i]} 2>&1", $output, $return_var); $fullOutput = implode("\n", $output); if (strpos($fullOutput, 'Segmentation fault') !== false) { // If php -l command segfaults, wait and retry (it seems to happen quite often on some environments for this command) echo "\r[Retrying due to a Segfault...]"; sleep(1); $i--; } elseif ($return_var !== 0) { // Handle PHP errors $fullOutput = trim($fullOutput); $error_msg = ($fullOutput == '') ? "\nPHP Parsing error in '{$entries[$i]}' ($return_var)\n" : "\n$fullOutput"; return false; } elseif (! $hide_php_warnings && ($nb_lines = count($output)) > 1 && ! preg_match(THIRD_PARTY_LIBS_PATTERN, $entries[$i])) { // Handle PHP warnings / notices (this just displays a yellow warning, it doesn't return false or an error_msg) // and exclude some third party libs when displaying warnings from the PHP syntax check, because we can't fix it directly by the way. echo "\r"; foreach ($output as $k => $line) { // Remove empty lines and last line (because in case of a simple warning, the last line simply says 'No syntax errors...') if (trim($line) == '' || $k == $nb_lines - 1) { continue; } echo color("$line\n", 'yellow'); } display_progress_percentage($i, $nbEntries, '%d%% of files passed the PHP syntax check'); } unset($output, $return_var); } echo "\n"; return true; } /** * Check the CI Pipeline for the result of the specific checks regarding the DB structure * * @param string $mainversion * @param string $error_msg * @return bool */ function check_database_files_and_upgrade($mainversion, &$error_msg) { $gitlabUrl = 'https://gitlab.com'; $gitlabRepo = $gitlabUrl . '/tikiwiki/tiki'; $branchToCheck = $mainversion . '.x'; $pipeline = gitlabGetLastFinishedPipelineByBranch($gitlabRepo, $branchToCheck); if (empty($pipeline)) { echo color('Could not retrieve pipeline information for branch ' . $branchToCheck . "\n", 'red'); echo color( 'You can check manually using ' . $gitlabRepo . '/pipelines/?scope=branches&format=json&ref=' . $branchToCheck . "\n", 'yellow' ); $error_msg .= 'Information about the CI pipeline could not be retrieved' . "\n"; return false; } echo color( 'Checking jobs for branch ' . $branchToCheck . ', pipeline: ' . $gitlabUrl . $pipeline['url'] . "\n", 'yellow' ); $jobs = gitlabGetJobStatusByPipeline($gitlabRepo, $pipeline['id']); if (empty($pipeline)) { echo color('Could not retrieve jobs information for pipeline ' . $pipeline['id'] . "\n", 'red'); echo color('You can check manually using ' . $gitlabUrl . $pipeline['url'] . "\n", 'yellow'); $error_msg .= 'Information about jobs in the CI pipeline could not be retrieved' . "\n"; return false; } $checkList = [ 'schema-naming-convention' => '1.1.2.1. Check _tiki.sql suffixes', 'db-upgrade-' => '1.1.2.2. Structure', 'schema-sql-drop' => '1.1.2.3. Drop Table', 'sql-engine' => '1.1.2.4. MyISAM', 'sql-engine-conversion' => '1.1.2.5. InnoDB', ]; $allOk = true; foreach ($checkList as $checkPrefix => $checkName) { foreach ($jobs['tiki-check'] as $jobName => $job) { if (strpos($jobName, $checkPrefix) === 0) { echo color( $checkName . ': ' . $job['status'] . ', job: ' . $jobName . ', url: ' . $gitlabUrl . $job['url'] . "\n", $job['status'] == PIPELINE_STATUS_PASSED ? 'green' : 'red' ); if ($job['status'] != PIPELINE_STATUS_PASSED) { $error_msg .= 'Issues with job ' . $jobName . ' in the CI Pipeline' . "\n"; $allOk = false; } } } } return $allOk; } /** * Lookup the ID of the last finished pipeline run for * a given branch with status 'passed' or 'failed'. * * @param string $repoUrl Url of the repo in gitlab * @param string $branch Branch to use for filtering * @param integer $page Page associated with the cycle (recursive lookup) * @return array|bool The result or false if error */ function gitlabGetLastFinishedPipelineByBranch($repoUrl, $branch, $page = 1) { $lastPipelineByBranch = $repoUrl . '/pipelines/?scope=finished&format=json&per_page=' . PIPELINES_FETCH_AMOUNT . '&page=' . $page . '&ref=' . $branch; if (getenv('TEST_GITLAB_PIPELINE')) { $lastPipelineByBranch = getenv('TEST_GITLAB_PIPELINE'); // to allow fake the answer while testing } $content = file_get_contents($lastPipelineByBranch); $jsonContent = json_decode($content, true); if (! empty($jsonContent) && empty($jsonContent['pipelines'])) { return []; } if (empty($jsonContent)) { return false; } $pipeline = array_filter( $jsonContent['pipelines'], function ($pipeline) use ($branch) { return $pipeline['ref']['name'] === $branch && ($pipeline['details']['status']['text'] === PIPELINE_STATUS_PASSED || $pipeline['details']['status']['text'] === PIPELINE_STATUS_FAILED); } ); if (empty($pipeline)) { if ($page >= 50) { return false; } return gitlabGetLastFinishedPipelineByBranch($repoUrl, $branch, ++$page); } $pipeline = reset($pipeline); return ['id' => $pipeline['id'], 'url' => $pipeline['path']]; } /** * Returns the list of stages and the jobs for each of the stages * * @param string $repoUrl Url of the repo in gitlab * @param string $pipelineId Pipeline ID from where to retrieve the list of jobs * @return array|bool The result or false if error */ function gitlabGetJobStatusByPipeline($repoUrl, $pipelineId) { $pipelineJobsUrl = $repoUrl . '/pipelines/' . $pipelineId . '?format=json'; if (getenv('TEST_GITLAB_JOBS')) { $pipelineJobsUrl = getenv('TEST_GITLAB_JOBS'); // to allow fake the answer while testing } $content = file_get_contents($pipelineJobsUrl); $jsonContent = json_decode($content, true); if (empty($jsonContent)) { return false; } $stages = []; foreach ($jsonContent['details']['stages'] as $stage) { $jobs = []; foreach ($stage['groups'] as $group) { foreach ($group['jobs'] as $job) { $jobs[$job['name']] = ['status' => $job['status']['text'], 'url' => $job['status']['details_path']]; } } $stages[$stage['name']] = $jobs; } return $stages; } /** * @return array|bool */ function get_options() { if ($_SERVER['argc'] <= 1) { return false; } $argv = []; $options = [ 'howto' => false, 'help' => false, 'http-proxy' => false, 'mirror-uri' => false, 'no-commit' => false, 'no-check-vcs' => false, 'no-check-db' => false, 'no-check-php' => false, 'no-check-php-warnings' => false, 'no-check-smarty' => false, 'no-first-update' => false, 'no-readme-update' => false, 'no-lang-update' => false, 'no-changelog-update' => false, 'no-copyright-update' => false, 'no-secdb' => false, 'no-packaging' => false, 'no-tagging' => false, 'force-yes' => false, 'debug-packaging' => false, 'only-secdb' => false, 'devmode' => false, 'use-git' => false, 'skip' => 0, ]; // Environment variables provide default values for parameter options. e.g. export TIKI_NO_SECDB=true $prefix = "TIKI-"; foreach ($options as $option => $optValue) { $envOption = $prefix . $option; $envOption = str_replace("-", "_", $envOption); if (isset($_ENV[$envOption])) { $envValue = $_ENV[$envOption]; $options[$option] = $envValue; } } foreach ($_SERVER['argv'] as $arg) { if (substr($arg, 0, 2) == '--') { if (($opt = substr($arg, 2)) != '' && isset($options[$opt])) { $options[$opt] = true; } elseif (substr($arg, 2, 11) == 'http-proxy=') { if (($proxy = substr($arg, 13)) != '') { $options[substr($arg, 2, 10)] = stream_context_create( [ 'http' => [ 'proxy' => 'tcp://' . $proxy, 'request_fulluri' => true ] ] ); } else { $options[substr($arg, 2, 10)] = true; } } elseif (substr($arg, 2, 15) == 'mirror-uri=') { if (($uri = substr($arg, 17)) != '') { $options[substr($arg, 2, 14)] = $uri; } } elseif (strpos($arg, '=') !== false) { $parts = explode('=', substr($arg, 2)); if (isset($options[$parts[0]])) { $options[$parts[0]] = $parts[1]; } } else { error("Unknown option $arg. Try using --help option.\n"); } } else { $argv[] = $arg; } } $_SERVER['argv'] = $argv; unset($argv); if ($options['http-proxy'] === true) { error("The --http-proxy option need a value. Use it this way: --http-proxy=HOST_DOMAIN:PORT_NUMBER"); } if ($_SERVER['argc'] == 2) { $_SERVER['argv'][] = ''; } return $options; } /** * @param $msg * @param bool $increment_step * @param bool $commit_msg * @return bool */ function important_step($msg, $increment_step = true, $commit_msg = false) { global $options; static $step = 0; // Auto-Skip the step if this is a commit step and if there is nothing to commit if ($commit_msg && ! has_uncommited_changes('.')) { return false; } // Increment step number if needed if ($increment_step) { $step++; } if ($step <= $options['skip']) { print "Skipping step $step\n"; return false; } if ($commit_msg && $options['no-commit']) { print "Skipping actual commit ('$commit_msg') because no-commit = true\n"; return false; } if ($options['force-yes']) { important("\n$step) $msg..."); $do_step = true; } else { important("\n$step) $msg?"); $prompt = '[Y/n/q/?] '; if (function_exists('readline')) { // readline function requires php readline extension... $c = readline($prompt); } else { echo $prompt; $c = rtrim(fgets(STDIN), "\n"); } switch (strtolower($c)) { case 'y': case '': $do_step = true; break; case 'n': info(">> Skipping step $step."); $do_step = false; break; case 'q': die; break; default: if ($c != '?') { info(color(">> Unknown answer '$c'.", 'red')); } info(">> You have to type 'y' (Yes), 'n' (No) or 'q' (Quit) and press Enter."); return important_step($msg, false); } } if ($commit_msg && $do_step && ($revision = commit($commit_msg))) { info(">> Commited revision $revision."); } return $do_step; } /** * @param $newVersion * @return array|bool */ function update_changelog_file($newVersion) { $handle = false; if (! is_readable(CHANGELOG) || ! is_writable(CHANGELOG) || ! ($handle = @fopen(CHANGELOG, "r"))) { error('The changelog file "' . CHANGELOG . '" is not readable or writable.'); } $majorVersion = substr($newVersion, 0, strpos($newVersion, '.')); $parseLogs = $sameFinalVersion = $skipBuffer = false; $lastReleaseMajorNumber = -1; $lastReleaseNumber = ''; $minRevision = $currentParsedRevision = 0; $lastReleaseLogs = []; $versionMatches = []; $newChangelog = ''; $newChangelogEnd = ''; if ($handle) { while (! feof($handle)) { $buffer = fgets($handle); if (empty($buffer)) { continue; } if (preg_match('/^Version (\d+)\.(\d+)/', $buffer, $versionMatches)) { $versionString = $versionMatches[1] . '.' . $versionMatches[2]; if ((float)$lastReleaseNumber < (float)$versionString) { $lastReleaseNumber = $versionString; if ($lastReleaseNumber === $newVersion) { // The changelog file already contains log for the same final version $sameFinalVersion = true; $skipBuffer = true; } $parseLogs = true; $lastReleaseMajorNumber = $versionMatches[1]; } } if ($parseLogs) { $matches = []; if (preg_match('/^(\d+ | ) \|/', $buffer, $matches)) { $skipBuffer = false; if ($minRevision == 0) { $minRevision = (int)$matches[1]; } $currentParsedRevision = (int)$matches[1]; } elseif (! $skipBuffer && $currentParsedRevision > 0 && $buffer[0] != '-') { if (isset($lastReleaseLogs[$currentParsedRevision])) { $lastReleaseLogs[$currentParsedRevision] .= $buffer; } else { $lastReleaseLogs[$currentParsedRevision] = $buffer; } } } if ($lastReleaseMajorNumber != -1 && $lastReleaseMajorNumber < $majorVersion) { $newChangelogEnd .= generate_changelog_version_header($lastReleaseNumber); $newChangelogEnd .= "Changelog for Tiki version " . $lastReleaseNumber . ", or older, available at:\n"; $newChangelogEnd .= "https://sourceforge.net/p/tikiwiki/code/HEAD/tree/tags/" . $lastReleaseNumber . "/changelog.txt\n\n"; break; // truncate the rest of the file } if (! $skipBuffer) { if ($lastReleaseMajorNumber == -1) { $newChangelog .= $buffer; } else { $newChangelogEnd .= $buffer; } } } fclose($handle); } $newChangelog .= generate_changelog_version_header($newVersion); $return = ['nbCommits' => 0, 'sameFinalVersion' => $sameFinalVersion]; $matches = []; if ($minRevision === 0) { // failed to get the last rev from the old file contents $minRevision = get_tag_revision($lastReleaseNumber); } if ($minRevision != 0) { if (preg_match_all('/^([A-Za-z0-9]+).\|.*\n\n(.*)\-{46}/Ums', get_logs('.', $minRevision), $matches, PREG_SET_ORDER)) { foreach ($matches as $logEntry) { // Do not keep merges and release-related logs $commitFlag = substr(trim($logEntry[2]), 0, 5); if ($commitFlag == '[MRG]' || $commitFlag == '[REL]') { continue; } // Add log entries only if they were not already listed (same revision number or same log message) in the previous version if (! isset($lastReleaseLogs[$logEntry[1]]) && ! in_array("\n" . $logEntry[2], $lastReleaseLogs)) { $newChangelog .= str_replace("\n\n", "\n", $logEntry[0]) . "\n"; $lastReleaseLogs[] = "\n" . $logEntry[2]; if ($return['nbCommits'] == 0) { $return['firstRevision'] = $logEntry[1]; } $return['lastRevision'] = $logEntry[1]; $return['nbCommits']++; } } } } return file_put_contents(CHANGELOG, $newChangelog . $newChangelogEnd) ? $return : false; } /** * Generate the header for a given version, used in the changelog * @param string $version * @return string */ function generate_changelog_version_header($version) { $majorVersion = substr($version, 0, strpos($version, '.')); $releaseNotesURL = ''; $versionHeader = << 0, 'newContributors' => 0]; foreach ($contributors as $author => $infos) { if (isset($oldContributors[$author])) { if ($oldContributors[$author] != $infos) { // Quickfix to keep old dates which may be different due to which time zone is used if (isset($oldContributors[$author]['First Commit'])) { $infos['First Commit'] = $oldContributors[$author]['First Commit']; if ( isset($oldContributors[$author]['Number of Commits']) && isset($oldContributors[$author]['Number of Commits']) && isset($infos['Number of Commits']) && $oldContributors[$author]['Number of Commits'] == $infos['Number of Commits'] ) { $infos['Last Commit'] = $oldContributors[$author]['Last Commit']; } } if (isset($infos['Number of Commits'])) { if (isset($oldContributors[$author]['Number of Commits'])) { $return['newCommits'] += ($infos['Number of Commits'] - $oldContributors[$author]['Number of Commits']); } } } } else { $return['newContributors']++; } $copyrights .= "\nNickname: $author"; $orderedKeys = ['Name', 'First Commit', 'Last Commit', 'Number of Commits', 'SF Role']; foreach ($orderedKeys as $k) { if (empty($infos[$k]) || ($k == 'Name' && $infos[$k] == $author)) { continue; } $copyrights .= "\n$k: " . $infos[$k]; } $copyrights .= "\n"; } return file_put_contents(COPYRIGHTS, $copyrights) ? $return : false; } /** * @return array|bool */ function parse_copyrights() { if (! $copyrights = @file(COPYRIGHTS)) { return false; } $return = []; $curNickname = ''; foreach ($copyrights as $line) { if (empty($line)) { continue; } if (substr($line, 0, 10) == 'Nickname: ') { $curNickname = rtrim(substr($line, 10)); $return[$curNickname] = []; } elseif ($curNickname != '' && ($pos = strpos($line, ':')) !== false) { $return[$curNickname][substr($line, 0, $pos)] = rtrim(substr($line, $pos + 2)); } } return $return; } /** * @param $path * @param $contributors * @param $minRevision * @param $maxRevision * @param int $step * @return mixed */ function get_contributors_data($path, &$contributors, $minRevision, $maxRevision, $step = 20000) { global $nbCommiters; if (empty($contributors)) { get_contributors_sf_data($contributors); info(">> Retrieved members list from Sourceforge."); } get_contributors($path, $contributors, $minRevision, $maxRevision, $step); $nbCommiters = array_filter($contributors, function ($contributor) { // Get count contributors with commits return count($contributor) > 2; }); return $contributors; } /** * @param $contributors */ function get_contributors_sf_data(&$contributors) { global $options; $matches = []; if (! function_exists('iconv')) { error("PHP 'iconv' function is not available on this system. Impossible to get SF.net data."); } $html = $options['http-proxy'] ? file_get_contents(SF_TW_MEMBERS_URL, 0, $options['http-proxy']) : file_get_contents(SF_TW_MEMBERS_URL); if (! empty($html) && preg_match('/()/sim', $html, $matches)) { $usersInfo = []; if (preg_match_all('/]*>' . str_repeat('\s*]*>(.*)<\/td>\s*', 3) . '<\/\s*tr>/Usim', $matches[0], $usersInfo, PREG_SET_ORDER)) { foreach ($usersInfo as $k => $userInfo) { $userInfo = array_map('trim', array_map('strip_tags', $userInfo)); $user = strtolower($userInfo['2']); if (empty($user)) { continue; } $contributors[$user] = [ 'Name' => html_entity_decode(iconv("ISO-8859-15", "UTF-8", $userInfo['1']), ENT_COMPAT, 'UTF-8'), 'SF Role' => $userInfo['3'] ]; } } } else { error('Impossible to get SF.net users information. If you need to use a web proxy, try the --http-proxy option.'); die; } } /** * @param $releaseVersion * @param $mainVersion * @return bool */ function update_readme_file($releaseVersion, $mainVersion) { if (! is_readable(README) || ! is_writable(README)) { error('The README file "' . README . '" is not readable or writable.'); die; } $year = gmdate('Y'); $copyrights_file = COPYRIGHTS_FILENAME; $license_file = LICENSE_FILENAME; $majorVersion = substr($mainVersion, 0, strpos($mainVersion, '.')); $release_notes_url = 'http://doc.tiki.org/Tiki' . $majorVersion; // Changed from Tiki 12 to point to http://doc.tiki.org/Tiki12 instead of http://tiki.org/ReleaseNotes30 $readme = << [ ] Examples: php doc/devtools/release.php 2.0 preRC3 php doc/devtools/release.php 2.0 RC3 php doc/devtools/release.php 2.0 Options: --howto : display the Tiki release HOWTO --help : display this help --http-proxy=HOST:PORT : use a http proxy to get copyright data on sourceforge --mirror-uri=URI : use another repository URI to update the copyrights file (to avoid retrieving data from sourceforge, which is usually slow) --no-commit : do not commit any changes back to SVN or GIT --no-check-vcs : do not check if there are uncommitted changes on the checkout used for the release --no-check-db : do not check database scripts and database upgrades --no-check-php : do not check syntax of all PHP files --no-check-php-warnings : do not display PHP warnings and notices during the PHP syntax check --no-check-smarty : do not check syntax of all Smarty templates --no-first-update : do not vcs update the checkout used for the release as the first step --no-readme-update : do not update the '" . README_FILENAME . "' file --no-lang-update : do not update lang/*/language.php files --no-changelog-update : do not update the '" . CHANGELOG_FILENAME . "' file --no-copyright-update : do not update the '" . COPYRIGHTS_FILENAME . "' file --no-secdb : do not update SecDB footprints --only-secdb : only generate a secdb database --no-packaging : do not build packages files --no-tagging : do not tag the release on the remote vcs repository --force-yes : disable the interactive mode (same as replying 'y' to all steps) --debug-packaging : display debug output while in packaging step --devmode : equivalent to no-commit + no-check-vcs + no-first-update --skip=0 : number of steps to skip when debugging (for use with -- devmode) --use-git : use git instead of snv (git working copy automatically detected) Notes: Subreleases begining with 'pre' will not be tagged. "; die; } function display_howto() { echo <<