diff --git a/composer.json b/composer.json index 4e49d06d064..81f4c447706 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "laravel-zero/framework": "^10.0" }, "require-dev": { + "desilva/psalm-coverage": "dev-master", "driftingly/rector-laravel": "^0.14.1", + "hyde/monorepo-dev-tools": "dev-master", "hyde/realtime-compiler": "dev-master", "hyde/testing": "dev-master", "jetbrains/phpstorm-attributes": "^1.0", @@ -30,8 +32,7 @@ "php-parallel-lint/php-parallel-lint": "^1.3", "phpstan/phpstan": "^1.8", "rector/rector": "^0.15.1", - "squizlabs/php_codesniffer": "^3.7", - "desilva/psalm-coverage": "dev-master" + "squizlabs/php_codesniffer": "^3.7" }, "autoload": { "psr-4": { @@ -65,6 +66,10 @@ "type": "path", "url": "./packages/*" }, + { + "type": "path", + "url": "./monorepo/DevTools" + }, { "type": "vcs", "url": "https://github.com/caendesilva/psalm-coverage" diff --git a/composer.lock b/composer.lock index 54a4542b072..b4aba0371a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "3f585f8c80714ba92d5ef81bbbbda3a1", + "content-hash": "9194ad421963a9d22d666a6ad2b1de18", "packages": [ { "name": "brick/math", @@ -7726,6 +7726,32 @@ }, "time": "2020-07-09T08:09:16+00:00" }, + { + "name": "hyde/monorepo-dev-tools", + "version": "dev-master", + "dist": { + "type": "path", + "url": "./monorepo/DevTools", + "reference": "05678dfdc4948bcfe869951ab312d831ff27302c" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Hyde\\MonorepoDevTools\\MonorepoDevToolsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Hyde\\MonorepoDevTools\\": "src/" + } + }, + "description": "Internal devtools for the monorepo", + "transport-options": { + "relative": true + } + }, { "name": "hyde/realtime-compiler", "version": "dev-master", @@ -10836,9 +10862,10 @@ "hyde/framework": 20, "hyde/publications": 20, "hyde/ui-kit": 20, + "desilva/psalm-coverage": 20, + "hyde/monorepo-dev-tools": 20, "hyde/realtime-compiler": 20, - "hyde/testing": 20, - "desilva/psalm-coverage": 20 + "hyde/testing": 20 }, "prefer-stable": true, "prefer-lowest": false, diff --git a/monorepo/DevTools/.gitignore b/monorepo/DevTools/.gitignore new file mode 100644 index 00000000000..61ead86667c --- /dev/null +++ b/monorepo/DevTools/.gitignore @@ -0,0 +1 @@ +/vendor diff --git a/monorepo/DevTools/composer.json b/monorepo/DevTools/composer.json new file mode 100644 index 00000000000..fca78984e5d --- /dev/null +++ b/monorepo/DevTools/composer.json @@ -0,0 +1,17 @@ +{ + "name": "hyde/monorepo-dev-tools", + "description": "Internal devtools for the monorepo", + "autoload": { + "psr-4": { + "Hyde\\MonorepoDevTools\\": "src/" + } + }, + "require": {}, + "extra": { + "laravel": { + "providers": [ + "Hyde\\MonorepoDevTools\\MonorepoDevToolsServiceProvider" + ] + } + } +} diff --git a/monorepo/DevTools/src/MonorepoDevToolsServiceProvider.php b/monorepo/DevTools/src/MonorepoDevToolsServiceProvider.php new file mode 100644 index 00000000000..0ca2d214b15 --- /dev/null +++ b/monorepo/DevTools/src/MonorepoDevToolsServiceProvider.php @@ -0,0 +1,22 @@ +commands([ + MonorepoReleaseCommand::class, + ]); + } +} diff --git a/monorepo/DevTools/src/MonorepoReleaseCommand.php b/monorepo/DevTools/src/MonorepoReleaseCommand.php new file mode 100644 index 00000000000..75f5ea95dbd --- /dev/null +++ b/monorepo/DevTools/src/MonorepoReleaseCommand.php @@ -0,0 +1,388 @@ +title('Preparing a new syndicated HydePHP release!'); + $this->dryRun = $this->option('dry-run'); + + $this->fetchAndCheckoutMaster(); + $this->getCurrentVersion(); + $this->askForNewVersion(); + $this->newLine(); + $this->createNewBranch(); + + $this->updateVersionConstant(); + if ($this->newVersionType === 'patch') { + $this->commitFrameworkVersion(); + } + + if ($this->newVersionType === 'major') { + $this->warn('This is a major release, please make sure to update the framework version in the Hyde composer.json file!'); + } elseif ($this->newVersionType === 'minor') { + $this->warn('Please make sure to update the framework version in the Hyde composer.json file!'); + } + + if ($this->newVersionType === 'patch') { + $this->comment('Skipping release notes preparation for patch release.'); + } else { + $this->prepareReleaseNotes(); + } + + if ($this->newVersionType !== 'patch') { + $this->makeMonorepoCommit(); + } + + $this->prepareFrameworkPR(); + + if ($this->newVersionType !== 'patch') { + $this->prepareHydePR(); + } + + $this->prepareMonorepoPR(); + + $this->info('All done!'); + + return Command::SUCCESS; + } + + protected function fetchAndCheckoutMaster(): void + { + $this->info('Fetching and checking out master branch...'); + + $this->runUnlessDryRun('echo hi'); + $this->runUnlessDryRun('git checkout master'); + $this->runUnlessDryRun('git pull'); + + $this->exitIfFailed(); + + // $this->info('Checking that the working directory is clean...'); + $state = $this->runUnlessDryRun('git status --porcelain'); + if ($state !== null && $state !== '') { + $this->fail('Working directory is not clean, aborting.'); + } + + $this->exitIfFailed(); + $this->newLine(); + } + + protected function getCurrentVersion(): void + { + $this->currentVersion = trim(shell_exec('git describe --abbrev=0 --tags')); + $this->currentVersionParts = explode('.', ltrim($this->currentVersion, 'v')); + $frameworkVersion = HydeKernel::VERSION; + + $this->info("Current version: $this->currentVersion (Framework: v$frameworkVersion)"); + } + + protected function askForNewVersion(): void + { + $this->newVersionType = $this->choice('What type of release is this?', static::VERSION_TYPES, 1); + + $major = $this->currentVersionParts[0]; + $minor = $this->currentVersionParts[1]; + $patch = $this->currentVersionParts[2]; + + switch ($this->newVersionType) { + case 'major': + $major++; + $minor = 0; + $patch = 0; + break; + case 'minor': + $minor++; + $patch = 0; + break; + case 'patch': + $patch++; + break; + } + + $this->newVersion = $major.'.'.$minor.'.'.$patch; + + $this->info("New version: v$this->newVersion ($this->newVersionType)"); + } + + protected function runUnlessDryRun(string $command, bool $allowSilent = false): string|null|false + { + if ($this->dryRun) { + $this->gray("DRY RUN: $command"); + + return null; + } + + $state = shell_exec($command); + + if ($allowSilent === false) { + if ($state === false || ($state === null)) { + $this->fail("Command failed: $command"); + } + } + + return $state; + } + + protected function fail(string $message): void + { + $this->newLine(); + $this->error($message); + $this->newLine(); + $this->failed = true; + } + + protected function exitIfFailed(): void + { + if ($this->failed) { + exit(1); + } + } + + protected function prepareReleaseNotes(): void + { + $this->output->write('Transforming upcoming release notes... '); + + $version = $this->newVersion; + $baseDir = __DIR__.'/../../../'; + + $notes = file_get_contents($baseDir.'RELEASE_NOTES.md'); + + $notes = str_replace("\r", '', $notes); + + // remove default release notes + $defaults = [ + '- for new features.', + '- for changes in existing functionality.', + '- for soon-to-be removed features.', + '- for now removed features.', + '- for any bug fixes.', + '- in case of vulnerabilities.', + ]; + + foreach ($defaults as $default) { + $notes = str_replace($default, 'DEFAULT', $notes); + } + + $notes = str_replace('Keep an Unreleased section at the top to track upcoming changes. + +This serves two purposes: + +1. People can see what changes they might expect in upcoming releases +2. At release time, you can move the Unreleased section changes into a new release version section.', '', $notes); + + $notes = trim($notes); + + $notes = str_replace('## [Unreleased]', "## [$version](https://github.com/hydephp/develop/releases/tag/$version)", $notes); + $notes = str_replace('YYYY-MM-DD', date('Y-m-d'), $notes); + $notes = $notes."\n"; + + // remove empty sections + $notes = preg_replace('/### (Added|Changed|Deprecated|Removed|Fixed|Security)\nDEFAULT/', '', $notes); + + // remove ### About if it's empty + $notes = str_replace("### About\n\n\n", "\n", $notes); + + // remove empty lines + $notes = preg_replace('/\n{3,}/', "\n", $notes); + + $this->releaseBody = $notes; + + $this->line('Done.'); + + $this->output->write('Resetting upcoming release notes stub... '); + file_put_contents($baseDir.'RELEASE_NOTES.md', <<<'MARKDOWN' + ## [Unreleased] - YYYY-MM-DD + + ### About + + Keep an Unreleased section at the top to track upcoming changes. + + This serves two purposes: + + 1. People can see what changes they might expect in upcoming releases + 2. At release time, you can move the Unreleased section changes into a new release version section. + + ### Added + - for new features. + + ### Changed + - for changes in existing functionality. + + ### Deprecated + - for soon-to-be removed features. + + ### Removed + - for now removed features. + + ### Fixed + - for any bug fixes. + + ### Security + - in case of vulnerabilities. + + MARKDOWN); + + $this->line('Done.'); + + $this->output->write('Updating changelog with the upcoming release notes... '); + + $changelog = file_get_contents($baseDir.'/CHANGELOG.md'); + + $needle = ''; + + $changelog = substr_replace($changelog, $needle."\n\n".$notes, strpos($changelog, $needle), strlen($needle)); + file_put_contents($baseDir.'/CHANGELOG.md', $changelog); + + $this->line('Done.'); + } + + protected function updateVersionConstant(): void + { + $this->output->write('Updating version constant... '); + + $baseDir = __DIR__.'/../../../'; + $version = ltrim($this->newVersion, 'v'); + + $kernelPath = $baseDir.'/packages/framework/src/Foundation/HydeKernel.php'; + $hydeKernel = file_get_contents($kernelPath); + $hydeKernel = preg_replace('/final public const VERSION = \'(.*)\';/', "final public const VERSION = '$version';", $hydeKernel); + file_put_contents($kernelPath, $hydeKernel); + + $this->line('Done.'); + } + + protected function commitFrameworkVersion(): void + { + $this->output->write('Committing framework version change... '); + + $this->runUnlessDryRun('git add packages/framework/src/Foundation/HydeKernel.php', true); + $this->runUnlessDryRun('git commit -m "Framework version v'.$this->newVersion.'"'); + + $this->exitIfFailed(); + + $this->line('Done.'); + } + + protected function makeMonorepoCommit(): void + { + $this->output->write('Committing framework version change... '); + + $this->runUnlessDryRun('git add .', true); + $this->runUnlessDryRun('git commit -m "Version v'.$this->newVersion.'"'); + + $this->exitIfFailed(); + + $this->line('Done.'); + } + + protected function prepareFrameworkPR(): void + { + $this->preparePackagePR('framework'); + } + + protected function prepareHydePR(): void + { + $this->preparePackagePR('hyde'); + } + + protected function getTitle(): string + { + return "HydePHP v$this->newVersion - ".date('Y-m-d'); + } + + protected function getCompanionBody(): string + { + return sprintf('Please see the release notes in the development monorepo [`Release v%s`](https://github.com/hydephp/develop/releases/tag/v%s)', $this->newVersion, $this->newVersion); + } + + protected function preparePackagePR(string $package): void + { + // Create link to draft pull request merging develop into master + $link = sprintf('https://github.com/hydephp/'.$package.'/compare/master...develop?expand=1&draft=1&title=%s&body=%s', + urlencode($this->getTitle()), + $this->newVersionType === 'patch' ? '' : $this->getCompanionBody() + ); + + $this->info("Opening $package pull request link in browser. Please review and submit the PR once all changes are propagated."); + $this->runUnlessDryRun((PHP_OS_FAMILY === 'Windows' ? 'explorer' : 'open').' '.escapeshellarg($link), true); + } + + protected function prepareMonorepoPR(): void + { + $title = $this->newVersionType === 'patch' + ? "Framework version v$this->newVersion" + : "HydePHP v$this->newVersion - ".date('Y-m-d'); + + $body = $this->releaseBody; + + // Inject "version" before version in PR body + $body = preg_replace('/## \[(.*)]/', '## Version [v$1]', $body, 1); + + $link = sprintf('https://github.com/hydephp/develop/compare/master...'.$this->branch.'?expand=1&draft=1&title=%s&body=%s', + urlencode($title), + $this->newVersionType === 'patch' ? 'Framework patch release' : urlencode($body) + ); + + if ($this->dryRun) { + $this->info('Opening release pull request link in browser. Please review and submit the PR.'); + } else { + if (PHP_OS_FAMILY === 'Windows') { + // Seems to be the most reliable way to get the encoding right + shell_exec('powershell -Command "Start-Process \''.$link.'\'"'); + } else { + shell_exec('open'.' '.escapeshellarg($link)); + } + } + } + + protected function createNewBranch(): void + { + $prefix = $this->newVersionType === 'patch' ? 'framework' : 'release'; + $name = "$prefix-v$this->newVersion"; + $this->branch = $name; + + $this->info("Creating new branch $name... "); + $this->runUnlessDryRun('git checkout -b '.$name, true); + + // Verify changed to new branch + $state = $this->runUnlessDryRun('git branch --show-current'); + + if ($this->dryRun !== true && trim($state ?? '') !== $name) { + $this->fail("Failed to checkout new branch $name, aborting."); + } + + $this->exitIfFailed(); + + $this->line('Checked out new branch.'); + } +}