├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ ├── deploy.yaml │ ├── nightly.yaml │ └── test.yaml ├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── bin └── release ├── box.json ├── composer.json ├── composer.lock ├── config ├── commands.yaml ├── github.yaml └── services.yaml ├── docs └── logo.svg ├── features ├── bootstrap │ └── FeatureContext.php ├── changelog.feature └── semantic-versioning.feature ├── phpcs.xml ├── phpmd.xml ├── phpstan.neon ├── phpunit.xml ├── src ├── Changelog │ ├── Changelog.php │ ├── ChangelogGenerator.php │ ├── Formatter │ │ ├── Filter │ │ │ ├── Filter.php │ │ │ ├── GitHubPullRequestUrlFilter.php │ │ │ └── IssueLinkFilter.php │ │ ├── Formatter.php │ │ └── MarkdownFormatter.php │ └── PullRequestChangelogGenerator.php ├── Configuration │ ├── CredentialsConfiguration.php │ └── MissingConfigurationException.php ├── Console │ ├── Application.php │ ├── Command │ │ ├── CurrentCommand.php │ │ ├── ReleaseCommand.php │ │ └── SelfUpdateCommand.php │ ├── InteractiveInformationCollector.php │ └── VersionHelper.php ├── GitHub │ ├── GitHubClient.php │ └── GitHubRepositoryParser.php ├── Interaction │ └── InformationCollector.php ├── ReleaseAction │ ├── GitHubReleaseAction.php │ └── ReleaseAction.php ├── ReleaseManager.php ├── Vcs │ ├── Commit.php │ ├── Git.php │ ├── GitException.php │ ├── ReleaseNotFoundException.php │ ├── RepositoryNotFoundException.php │ └── VersionControlSystem.php └── Versioning │ ├── SemanticVersion.php │ ├── SemanticVersioning.php │ ├── Version.php │ └── VersioningScheme.php └── tests ├── integration ├── Console │ └── ReleaseCommandTest.php └── Vcs │ └── GitTest.php ├── system ├── ApplicationTest.php └── auth.yml └── unit ├── Changelog ├── ChangelogTest.php ├── Formatter │ ├── Filter │ │ ├── GitHubPullRequestUrlFilterTest.php │ │ └── IssueLinkFilterTest.php │ └── MarkdownFormatterTest.php └── PullRequestChangelogGeneratorTest.php ├── Console └── Command │ ├── CurrentCommandTest.php │ └── ReleaseCommandTest.php ├── GitHub └── GitHubRepositoryParserTest.php ├── ReleaseManagerTest.php ├── Vcs └── GitTest.php └── Versioning ├── SemanticVersionTest.php └── SemanticVersioningTest.php /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | 14 | [*.{yml,yaml,yaml.dist}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @leviy/developers 2 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | release: 5 | # Every published release will trigger a distribution app build which will 6 | # added to the release an asset. 7 | types: [published] 8 | 9 | env: 10 | COMPOSER_AUTH: ${{ secrets.COMPOSER_GITHUB_AUTH }} 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | # It's important to use the lowest supported PHP version when building the release-tool. 25 | # Otherwise, users can be confronted with a composer platform check error when they use 26 | # a PHP version that's lower than the version used here to build the release-tool. 27 | php-version: '8.3' 28 | 29 | - name: Composer Install 30 | uses: ramsey/composer-install@v3 31 | 32 | - name: Create build 33 | run: make dist 34 | 35 | - name: Upload release asset 36 | if: github.event_name == 'release' 37 | run: gh release upload "$TAG" build/release-tool.phar 38 | env: 39 | TAG: ${{ github.event.release.tag_name }} 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yaml: -------------------------------------------------------------------------------- 1 | name: Nightly 2 | on: 3 | schedule: 4 | - cron: '0 3 * * *' 5 | 6 | env: 7 | COMPOSER_AUTH: ${{ secrets.COMPOSER_GITHUB_AUTH }} 8 | 9 | jobs: 10 | test-next: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '8.3' 20 | 21 | - name: Composer Install 22 | uses: ramsey/composer-install@v3 23 | 24 | - name: Test 25 | run: make static-analysis unit-tests acceptance-tests 26 | 27 | security-checks: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: checkout 31 | uses: actions/checkout@v4 32 | 33 | - name: Setup PHP 34 | uses: shivammathur/setup-php@v2 35 | with: 36 | php-version: '8.3' 37 | 38 | - name: Security checks 39 | run: make security-tests 40 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: push 3 | 4 | env: 5 | COMPOSER_AUTH: ${{ secrets.COMPOSER_GITHUB_AUTH }} 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | fail-fast: true 13 | matrix: 14 | php: [ 8.3 ] 15 | 16 | steps: 17 | - name: checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php }} 24 | 25 | - name: Composer Install 26 | uses: ramsey/composer-install@v3 27 | 28 | - name: Test 29 | run: make static-analysis unit-tests acceptance-tests coding-standards 30 | 31 | - name: Integration tests 32 | run: | 33 | git config --global user.name "github-actions" 34 | git config --global user.email "github-actions@github.com" 35 | make integration-tests 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Symfony 2 | /vendor/ 3 | /build 4 | 5 | # Dependencies 6 | /bin/local-security-checker 7 | /bin/box.phar 8 | 9 | # Cache 10 | /.phpcs-cache 11 | /.phpunit.result.cache 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 LEVIY B.V. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | sources = bin/release config/ src/ 2 | 3 | .PHONY: all dist check test static-analysis unit-tests integration-tests coding-standards security-tests 4 | 5 | all: vendor 6 | 7 | vendor: composer.json composer.lock 8 | composer install 9 | 10 | build/release-tool.phar: $(sources) bin/box.phar composer.lock vendor 11 | mkdir -p build 12 | bin/box.phar compile 13 | 14 | dist: build/release-tool.phar 15 | 16 | check test: static-analysis unit-tests integration-tests acceptance-tests system-tests coding-standards security-tests 17 | 18 | static-analysis: vendor 19 | vendor/bin/phpstan analyse 20 | 21 | unit-tests: vendor 22 | vendor/bin/phpunit --testsuite unit-tests 23 | 24 | integration-tests: vendor 25 | vendor/bin/phpunit --testsuite integration-tests 26 | 27 | acceptance-tests: vendor 28 | vendor/bin/behat 29 | 30 | system-tests: vendor build/release-tool.phar 31 | vendor/bin/phpunit --testsuite system-tests 32 | 33 | coding-standards: vendor 34 | vendor/bin/phpcs -p --colors 35 | vendor/bin/phpmd src/ text phpmd.xml 36 | 37 | security-tests: vendor bin/local-security-checker 38 | bin/local-security-checker 39 | 40 | security_checker_binary = local-php-security-checker_2.0.6_linux_amd64 41 | ifeq ($(shell uname -s), Darwin) 42 | security_checker_binary = local-php-security-checker_2.0.6_darwin_amd64 43 | endif 44 | 45 | bin/local-security-checker: 46 | curl -LS https://github.com/fabpot/local-php-security-checker/releases/download/v2.0.6/$(security_checker_binary) -o bin/local-security-checker 47 | chmod a+x bin/local-security-checker 48 | 49 | bin/box.phar: 50 | curl -LS https://github.com/humbug/box/releases/download/3.16.0/box.phar -o bin/box.phar 51 | chmod a+x bin/box.phar 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Leviy logo 2 | 3 | # Release Tool 4 | 5 | Inspired by [Liip RMT](https://github.com/liip/RMT), this release tool helps you 6 | to automate the release of new versions of your software. It is written in PHP 7 | but can be used for any type of project, as long as you have PHP installed on 8 | your machine. 9 | 10 | [![Test](https://github.com/leviy/release-tool/workflows/Test/badge.svg)](https://github.com/leviy/release-tool/actions?query=workflow%3ATest) 11 | [![License](https://img.shields.io/github/license/leviy/release-tool.svg)](https://github.com/leviy/release-tool/blob/master/LICENSE.txt) 12 | [![GitHub release](https://img.shields.io/github/release/leviy/release-tool.svg)](https://github.com/leviy/release-tool/releases/latest) 13 | [![Required PHP version](https://img.shields.io/packagist/php-v/leviy/release-tool.svg)](https://github.com/leviy/release-tool/blob/master/composer.json) 14 | 15 | ## Features 16 | 17 | - Determines the next version number based on 18 | [semantic versioning](https://semver.org/) 19 | - Creates an annotated Git tag and pushes it to the remote repository 20 | - Creates a GitHub release with the name of the release and a changelog with 21 | changes since the previous version 22 | - Supports pre-release (alpha/beta/rc) versions 23 | 24 | ## Installation 25 | 26 | ### Phar (recommended) 27 | The recommended method of installing this package is using a phar file. This is because installing using Composer can possibly cause dependency conflicts. You can download the most recent phar from the [Github Releases](https://github.com/leviy/release-tool/releases/latest) page. 28 | 29 | ## Global installation (linux) 30 | Move the downloaded `release-tool.phar` file into your PATH (e.g. `~/bin/release-tool`). Make sure to set the file to executable permissions (e.g. `chmod 775 release-tool`). 31 | 32 | ### Composer 33 | Alternatively, you can install this package using [Composer](https://getcomposer.org/): 34 | 35 | ```bash 36 | composer require --dev leviy/release-tool 37 | ``` 38 | 39 | ## Configuration 40 | 41 | ### GitHub personal access token 42 | 43 | This tool requires a personal access token with `repo` scope to create GitHub 44 | releases. Create one [here](https://github.com/settings/tokens/new?scopes=repo&description=Leviy+Release+Tool) 45 | and store it in `.release-tool/auth.yml` in your home folder (`~` on Linux, user 46 | folder on Windows): 47 | 48 | ```yml 49 | credentials: 50 | github: 51 | token: 52 | ``` 53 | 54 | ## Usage 55 | 56 | > Note: these usage instructions assume that you have downloaded the 57 | > `release-tool.phar` file to your project directory. If you have installed it 58 | > in a different location, update the commands accordingly. If you have 59 | > installed the tool as a Composer dependency, use `vendor/bin/release` instead. 60 | 61 | ### Releasing a new version 62 | 63 | Use ```release-tool.phar release ``` to release a version. For example: 64 | 65 | ```bash 66 | release-tool.phar release 1.0.0 67 | ``` 68 | 69 | This will release version 1.0.0. By default, this will create a prefixed, 70 | annotated Git tag, in this case `v1.0.0`. 71 | 72 | #### Automatically generating a version number 73 | 74 | After tagging a first version, you can let the tool calculate the new version 75 | number for you based on the current version and a number of questions. To do so, 76 | omit the version from the previous command: 77 | 78 | ```bash 79 | release-tool.phar release 80 | ``` 81 | 82 | #### Pre-release versions 83 | 84 | If you want to create a pre-release (alpha/beta/rc) version, run: 85 | 86 | ```bash 87 | release-tool.phar release --pre-release 88 | ``` 89 | 90 | ### Other commands 91 | 92 | Run ```release-tool.phar list``` to see a list of available commands. 93 | 94 | ## Updating the release tool 95 | 96 | The following command will update the release tool to the latest version: 97 | 98 | ```bash 99 | release-tool.phar self-update 100 | ``` 101 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getMessage() . PHP_EOL; 21 | exit(1); 22 | } 23 | 24 | $application->run(); 25 | -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "directories": [ 3 | "src" 4 | ], 5 | "directories-bin": [ 6 | "config" 7 | ], 8 | "compression": "GZ", 9 | "force-autodiscovery": true, 10 | "git-version": "package_version", 11 | "output": "build/release-tool.phar" 12 | } 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "leviy/release-tool", 3 | "type": "library", 4 | "description": "Command line tool for releasing new versions of a project", 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.3", 8 | "beberlei/assert": "^3.2", 9 | "consolidation/self-update": "^2.0", 10 | "guzzlehttp/guzzle": "^6.3", 11 | "symfony/config": "^5.0", 12 | "symfony/console": "^5.0", 13 | "symfony/dependency-injection": "^5.0", 14 | "symfony/yaml": "^5.0" 15 | }, 16 | "require-dev": { 17 | "behat/behat": "^3.12", 18 | "leviy/coding-standard": "^4.0", 19 | "mockery/mockery": "^1.1", 20 | "phpstan/phpstan": "^1.9", 21 | "phpunit/phpunit": "^9.0", 22 | "symfony/process": "^5.0" 23 | }, 24 | "config": { 25 | "sort-packages": true, 26 | "allow-plugins": { 27 | "dealerdirect/phpcodesniffer-composer-installer": true 28 | } 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "Leviy\\ReleaseTool\\": "src" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "Leviy\\ReleaseTool\\Tests\\Unit\\": "tests/unit", 38 | "Leviy\\ReleaseTool\\Tests\\Integration\\": "tests/integration", 39 | "Leviy\\ReleaseTool\\Tests\\System\\": "tests/system" 40 | } 41 | }, 42 | "bin": [ 43 | "bin/release" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /config/commands.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | command.current: 3 | class: Leviy\ReleaseTool\Console\Command\CurrentCommand 4 | arguments: 5 | - '@vcs.git' 6 | tags: 7 | - { name: console.command, command: current } 8 | 9 | command.release: 10 | class: Leviy\ReleaseTool\Console\Command\ReleaseCommand 11 | arguments: 12 | - '@release.manager' 13 | - '@changelog_generator.pull_request' 14 | tags: 15 | - { name: console.command, command: release } 16 | 17 | command.self_update: 18 | class: Leviy\ReleaseTool\Console\Command\SelfUpdateCommand 19 | tags: 20 | - { name: console.command, command: self-update } 21 | -------------------------------------------------------------------------------- /config/github.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | public: false 4 | 5 | github.guzzle_client: 6 | class: GuzzleHttp\Client 7 | arguments: 8 | - headers: 9 | Authorization: token %credentials.github.token% 10 | 11 | github.repository_parser: 12 | class: Leviy\ReleaseTool\GitHub\GitHubRepositoryParser 13 | 14 | github.client: 15 | class: Leviy\ReleaseTool\GitHub\GitHubClient 16 | arguments: 17 | - '@github.guzzle_client' 18 | - '%github.owner%' 19 | - '%github.repo%' 20 | 21 | github.release_action.release: 22 | class: Leviy\ReleaseTool\ReleaseAction\GitHubReleaseAction 23 | arguments: 24 | - '@changelog_generator.pull_request' 25 | - '@changelog_formatter.markdown' 26 | - '@vcs.git' 27 | - '@github.client' 28 | -------------------------------------------------------------------------------- /config/services.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | public: false 4 | 5 | vcs.git: 6 | class: Leviy\ReleaseTool\Vcs\Git 7 | arguments: 8 | - 'v' 9 | 10 | release.manager: 11 | class: Leviy\ReleaseTool\ReleaseManager 12 | arguments: 13 | - '@vcs.git' 14 | - '@versioning_scheme.semantic' 15 | - ['@github.release_action.release'] 16 | 17 | versioning_scheme.semantic: 18 | class: Leviy\ReleaseTool\Versioning\SemanticVersioning 19 | 20 | changelog_generator.pull_request: 21 | class: Leviy\ReleaseTool\Changelog\PullRequestChangelogGenerator 22 | arguments: 23 | - '@vcs.git' 24 | 25 | changelog_formatter.markdown: 26 | class: Leviy\ReleaseTool\Changelog\Formatter\MarkdownFormatter 27 | arguments: 28 | - ['@changelog_formatter_filter.markdown.github_pull_request_link'] 29 | 30 | changelog_formatter_filter.markdown.github_pull_request_link: 31 | class: Leviy\ReleaseTool\Changelog\Formatter\Filter\GitHubPullRequestUrlFilter 32 | arguments: 33 | - '%github.owner%/%github.repo%' 34 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | Logo_Leviy_Logo_Blauw-2_Groot® -------------------------------------------------------------------------------- /features/bootstrap/FeatureContext.php: -------------------------------------------------------------------------------- 1 | informationCollector = Mockery::mock(InformationCollector::class); 48 | 49 | $this->versionControlSystem = Mockery::mock(Git::class); 50 | $this->versionControlSystem->shouldIgnoreMissing(); 51 | 52 | $changelogGenerator = new PullRequestChangelogGenerator($this->versionControlSystem); 53 | 54 | $this->githubClient = Mockery::spy(GitHubClient::class); 55 | 56 | $githubReleaseAction = new GitHubReleaseAction( 57 | $changelogGenerator, 58 | new MarkdownFormatter([]), 59 | $this->versionControlSystem, 60 | $this->githubClient 61 | ); 62 | 63 | $this->releaseManager = new ReleaseManager( 64 | $this->versionControlSystem, 65 | new SemanticVersioning(), 66 | [$githubReleaseAction] 67 | ); 68 | } 69 | 70 | /** 71 | * @Given the latest release on this branch is :version 72 | */ 73 | public function aReleaseOnThisBranchWithVersion(string $version): void 74 | { 75 | $this->versionControlSystem->shouldReceive('findLastVersion')->andReturn(true); 76 | $this->versionControlSystem->shouldReceive('getLastVersion')->andReturn($version); 77 | } 78 | 79 | /** 80 | * @When I release a new version 81 | * @When I release a new :type version 82 | */ 83 | public function iReleaseANewVersion(?string $type = null): void 84 | { 85 | $this->selectVersionType($type); 86 | 87 | $version = $this->releaseManager->determineNextVersion($this->informationCollector); 88 | $this->releaseManager->release($version, $this->informationCollector); 89 | } 90 | 91 | /** 92 | * @When I release version :version 93 | */ 94 | public function iReleaseVersion(string $version): void 95 | { 96 | $this->versionControlSystem->shouldReceive('getCommitsForVersion') 97 | ->with($version, Mockery::any()) 98 | ->andReturn($this->mergedPullRequests); 99 | 100 | $this->versionControlSystem->shouldReceive('getTagForVersion')->andReturn($version); 101 | $this->informationCollector->shouldReceive('askConfirmation')->andReturnTrue(); 102 | $this->releaseManager->release($version, $this->informationCollector); 103 | } 104 | 105 | /** 106 | * @When /^I release an? (release candidate|alpha|beta)(?: version)?$/ 107 | * @When I release a(n) :preReleaseType version of a new :type release 108 | */ 109 | public function iReleaseAPreReleaseVersion(string $preReleaseType, ?string $type = null): void 110 | { 111 | if ($type !== null) { 112 | $this->selectVersionType($type); 113 | } 114 | 115 | switch ($preReleaseType) { 116 | case 'alpha': 117 | $answer = 'a'; 118 | break; 119 | case 'beta': 120 | $answer = 'b'; 121 | break; 122 | case 'release candidate': 123 | $answer = 'rc'; 124 | break; 125 | default: 126 | return; 127 | } 128 | 129 | $this->informationCollector->shouldReceive('askConfirmation')->andReturnTrue(); 130 | $this->informationCollector->shouldReceive('askMultipleChoice')->andReturn($answer); 131 | 132 | $version = $this->releaseManager->determineNextPreReleaseVersion($this->informationCollector); 133 | $this->releaseManager->release($version, $this->informationCollector); 134 | } 135 | 136 | /** 137 | * @Then version :version should be released 138 | */ 139 | public function versionShouldBeReleased(string $version): void 140 | { 141 | $this->versionControlSystem->shouldHaveReceived('createVersion', [$version]); 142 | } 143 | 144 | /** 145 | * @Given the pre-release :version was created 146 | */ 147 | public function wasAPreRelease(string $version): void 148 | { 149 | $this->versionControlSystem->shouldReceive('getPreReleasesForVersion')->andReturn([$version]); 150 | $this->versionControlSystem->shouldReceive('getCommitsForVersion') 151 | ->with($version, Mockery::any()) 152 | ->andReturn($this->mergedPullRequests); 153 | 154 | $this->mergedPullRequests = []; 155 | } 156 | 157 | /** 158 | * @Then a release with title :title should be published on GitHub with the following release notes: 159 | */ 160 | public function aReleaseWithTitleShouldBePublishedOnGitHubWithTheFollowingReleaseNotes( 161 | string $version, 162 | PyStringNode $releaseNotes 163 | ) { 164 | $this->githubClient->shouldHaveReceived('createRelease', [Mockery::any(), $version, $releaseNotes->getRaw()]); 165 | } 166 | 167 | /** 168 | * @Given pull request :title with number :number was merged 169 | */ 170 | public function pullRequestWasMerged(string $title, string $number) 171 | { 172 | $this->mergedPullRequests[] = new Commit('Merge pull request #' . $number . ' from branch', $title); 173 | } 174 | 175 | private function selectVersionType(?string $type): void 176 | { 177 | switch ($type) { 178 | case 'major': 179 | $answers = [true]; 180 | break; 181 | case 'minor': 182 | $answers = [false, true]; 183 | break; 184 | case 'patch': 185 | $answers = [false, false]; 186 | break; 187 | default: 188 | $answers = []; 189 | break; 190 | } 191 | 192 | // Always respond positive to the question "Do you want to push it to the remote repository and perform 193 | // additional release steps?" 194 | $answers[] = true; 195 | 196 | $this->informationCollector->shouldReceive('askConfirmation')->andReturn(...$answers); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /features/changelog.feature: -------------------------------------------------------------------------------- 1 | Feature: Changelog 2 | 3 | Scenario: Releasing a version without any pre-releases 4 | Given pull request "Some PR" with number 1 was merged 5 | When I release version "1.1.0" 6 | Then a release with title "1.1.0" should be published on GitHub with the following release notes: 7 | """ 8 | # Changelog for 1.1.0 9 | 10 | * Some PR (pull request #1) 11 | 12 | """ 13 | 14 | Scenario: Releasing a version which is preceded by a pre-release 15 | Given pull request "First pull request" with number 1 was merged 16 | And the pre-release "1.0.0-beta.1" was created 17 | And pull request "Fix bug that came out of beta testing" with number 2 was merged 18 | When I release version "1.0.0" 19 | Then a release with title "1.0.0" should be published on GitHub with the following release notes: 20 | """ 21 | # Changelog for 1.0.0 22 | 23 | * Fix bug that came out of beta testing (pull request #2) 24 | 25 | # Changelog for 1.0.0-beta.1 26 | 27 | * First pull request (pull request #1) 28 | 29 | """ 30 | -------------------------------------------------------------------------------- /features/semantic-versioning.feature: -------------------------------------------------------------------------------- 1 | Feature: Semantic versioning 2 | In order to release a new version of a piece of software with a meaningful version number 3 | As a software developer 4 | I want to determine the next version number based on semantic versioning 5 | 6 | Rules: 7 | - A version number consists of MAJOR.MINOR.PATCH versions 8 | - Increment the MAJOR version when introducing incompatible API changes 9 | - Increment the MINOR version when adding functionality in a backwards-compatible manner 10 | - Increment the PATCH version when making backwards-compatible bug fixes 11 | - Use a label after the PATCH version to indicate an unstable pre-release 12 | 13 | Scenario: Releasing a major version 14 | Given the latest release on this branch is "1.2.1" 15 | When I release a new major version 16 | Then version "2.0.0" should be released 17 | 18 | Scenario: Releasing a minor version 19 | Given the latest release on this branch is "1.2.1" 20 | When I release a new minor version 21 | Then version "1.3.0" should be released 22 | 23 | Scenario: Releasing a patch version 24 | Given the latest release on this branch is "1.4.9" 25 | When I release a new patch version 26 | Then version "1.4.10" should be released 27 | 28 | Scenario: Releasing an alpha version of a major release 29 | Given the latest release on this branch is "1.0.0" 30 | When I release an alpha version of a new major release 31 | Then version "2.0.0-alpha.1" should be released 32 | 33 | Scenario: Releasing a consecutive alpha version 34 | Given the latest release on this branch is "2.0.0-alpha.1" 35 | When I release an alpha version 36 | Then version "2.0.0-alpha.2" should be released 37 | 38 | Scenario: Releasing a beta version 39 | Given the latest release on this branch is "2.0.0-alpha.5" 40 | When I release a beta version 41 | Then version "2.0.0-beta.1" should be released 42 | 43 | Scenario: Releasing a release candidate 44 | Given the latest release on this branch is "3.0.0-beta.3" 45 | When I release a release candidate 46 | Then version "3.0.0-rc.1" should be released 47 | 48 | Scenario: Releasing after a pre-release 49 | Given the latest release on this branch is "2.0.0-beta.2" 50 | When I release a new version 51 | Then version "2.0.0" should be released 52 | 53 | Scenario: Releasing after a minor pre-release 54 | Given the latest release on this branch is "1.2.0-rc.1" 55 | When I release a new version 56 | Then version "1.2.0" should be released 57 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | src/ 16 | tests/ 17 | 18 | -------------------------------------------------------------------------------- /phpmd.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | Ruleset for PHP Mess Detector that enforces coding standards 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - bin/release 5 | - src 6 | ignoreErrors: 7 | # Only available in ArrayNodeDefinition which is given 8 | - '#Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition::children\(\)\.#' 9 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | tests/unit 18 | 19 | 20 | 21 | tests/integration 22 | 23 | 24 | 25 | tests/system 26 | 27 | 28 | 29 | 30 | 31 | ./src/ 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Changelog/Changelog.php: -------------------------------------------------------------------------------- 1 | versions); 27 | } 28 | 29 | /** 30 | * @param string[] $changes 31 | */ 32 | public function addVersion(string $version, array $changes): void 33 | { 34 | $this->versions[$version] = $changes; 35 | } 36 | 37 | /** 38 | * @return string[] 39 | */ 40 | public function getChangesForVersion(string $version): array 41 | { 42 | return $this->versions[$version]; 43 | } 44 | 45 | /** 46 | * @param string[] $unreleasedChanges 47 | */ 48 | public function addUnreleasedChanges(array $unreleasedChanges): void 49 | { 50 | $this->unreleasedChanges = array_merge($this->unreleasedChanges, $unreleasedChanges); 51 | } 52 | 53 | /** 54 | * @return string[] 55 | */ 56 | public function getUnreleasedChanges(): array 57 | { 58 | return $this->unreleasedChanges; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Changelog/ChangelogGenerator.php: -------------------------------------------------------------------------------- 1 | pullRequestUrl = sprintf(self::PULL_REQUEST_URL, $repositorySlug); 21 | } 22 | 23 | public function filter(string $line): string 24 | { 25 | return preg_replace( 26 | '/pull request #(\d+)/', 27 | 'pull request [#$1](' . $this->pullRequestUrl . ')', 28 | $line 29 | ) ?: $line; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Changelog/Formatter/Filter/IssueLinkFilter.php: -------------------------------------------------------------------------------- 1 | pattern = $pattern; 27 | $this->url = $url; 28 | } 29 | 30 | public function filter(string $line): string 31 | { 32 | return preg_replace( 33 | $this->pattern, 34 | '[$1](' . $this->url . ')', 35 | $line 36 | ) ?: $line; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Changelog/Formatter/Formatter.php: -------------------------------------------------------------------------------- 1 | filters = $filters; 29 | } 30 | 31 | public function format(Changelog $changelog): string 32 | { 33 | $lines = []; 34 | 35 | $versions = $changelog->getVersions(); 36 | $versions = array_reverse($versions); 37 | 38 | foreach ($versions as $version) { 39 | $lines[] = sprintf('# Changelog for %s', $version); 40 | $lines[] = ''; 41 | 42 | foreach ($changelog->getChangesForVersion($version) as $change) { 43 | $lines[] = '* ' . $this->applyFilters($change); 44 | } 45 | 46 | $lines[] = ''; 47 | } 48 | 49 | return implode(PHP_EOL, $lines); 50 | } 51 | 52 | private function applyFilters(string $line): string 53 | { 54 | foreach ($this->filters as $filter) { 55 | $line = $filter->filter($line); 56 | } 57 | 58 | return $line; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Changelog/PullRequestChangelogGenerator.php: -------------------------------------------------------------------------------- 1 | versionControlSystem = $versionControlSystem; 27 | } 28 | 29 | public function getUnreleasedChangelog(): Changelog 30 | { 31 | $unreleasedCommits = $this->versionControlSystem->getCommitsSinceLastVersion(self::PULL_REQUEST_PATTERN); 32 | $unreleasedChanges = array_map([$this, 'createChangeFromCommit'], $unreleasedCommits); 33 | 34 | $filteredUnreleasedChanges = $this->filterUnreleasedChanges($unreleasedChanges); 35 | 36 | $changelog = new Changelog(); 37 | $changelog->addUnreleasedChanges($filteredUnreleasedChanges); 38 | 39 | return $changelog; 40 | } 41 | 42 | public function getChangelogForVersion(Version $version): Changelog 43 | { 44 | $versions = []; 45 | if (!$version->isPreRelease()) { 46 | $versions = $this->versionControlSystem->getPreReleasesForVersion($version->getVersion()); 47 | } 48 | 49 | $versions[] = $version->getVersion(); 50 | 51 | return array_reduce( 52 | $versions, 53 | function (Changelog $changelog, string $version): Changelog { 54 | $commits = $this->versionControlSystem->getCommitsForVersion( 55 | $version, 56 | self::PULL_REQUEST_PATTERN 57 | ); 58 | 59 | $changes = array_map( 60 | [$this, 'createChangeFromCommit'], 61 | $commits 62 | ); 63 | 64 | $filterUnreleasedChanges = $this->filterUnreleasedChanges($changes); 65 | 66 | $changelog->addVersion($version, $filterUnreleasedChanges); 67 | 68 | return $changelog; 69 | }, 70 | new Changelog() 71 | ); 72 | } 73 | 74 | /** 75 | * @param string[] $changeLogElements 76 | * @return string[] 77 | */ 78 | private function filterUnreleasedChanges(array $changeLogElements): array 79 | { 80 | $filteredChangeLogElements = []; 81 | foreach ($changeLogElements as $element) { 82 | if (strpos($element, '[MERGE]') !== false) { 83 | continue; 84 | } 85 | 86 | if (strpos($element, '[SKIP-LOG]') !== false) { 87 | continue; 88 | } 89 | 90 | $filteredChangeLogElements[] = $element; 91 | } 92 | 93 | return $filteredChangeLogElements; 94 | } 95 | 96 | /** 97 | * @SuppressWarnings(PHPMD.UnusedPrivateMethod) Method is used as callable in getChangelog() 98 | */ 99 | private function createChangeFromCommit(Commit $commit): string 100 | { 101 | $matches = []; 102 | 103 | preg_match('/' . self::PULL_REQUEST_PATTERN . '/', $commit->title, $matches); 104 | 105 | return sprintf('%s (pull request #%d)', $commit->body, $matches[1]); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Configuration/CredentialsConfiguration.php: -------------------------------------------------------------------------------- 1 | getRootNode(); 15 | 16 | $rootNode 17 | ->children() 18 | ->arrayNode('github') 19 | ->children() 20 | ->scalarNode('token') 21 | ->isRequired() 22 | ->end() 23 | ->end() 24 | ->end() 25 | ; 26 | 27 | return $treeBuilder; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Configuration/MissingConfigurationException.php: -------------------------------------------------------------------------------- 1 | buildContainer($container); 36 | } 37 | 38 | private function buildContainer(ContainerBuilder $container): void 39 | { 40 | $this->loadConfigurationFiles($container); 41 | $this->registerCompilerPasses($container); 42 | $this->parseGithubRepositoryDetails($container); 43 | $this->loadUserConfiguration($container); 44 | 45 | $container->compile(); 46 | 47 | /** @var CommandLoaderInterface $commandLoader */ 48 | $commandLoader = $container->get('console.command_loader'); 49 | $this->setCommandLoader($commandLoader); 50 | } 51 | 52 | private function loadConfigurationFiles(ContainerBuilder $container): void 53 | { 54 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); 55 | $loader->load('services.yaml'); 56 | $loader->load('github.yaml'); 57 | $loader->load('commands.yaml'); 58 | } 59 | 60 | private function registerCompilerPasses(ContainerBuilder $container): void 61 | { 62 | $container->addCompilerPass(new AddConsoleCommandPass()); 63 | } 64 | 65 | private function parseGithubRepositoryDetails(ContainerBuilder $container): void 66 | { 67 | $githubParser = new GitHubRepositoryParser(); 68 | $url = Git::execute('remote get-url origin')[0]; 69 | $container->setParameter('github.owner', $githubParser->getOwner($url)); 70 | $container->setParameter('github.repo', $githubParser->getRepository($url)); 71 | } 72 | 73 | /** 74 | * @SuppressWarnings(PHPMD.ExitExpression) 75 | */ 76 | private function loadUserConfiguration(ContainerBuilder $container): void 77 | { 78 | $homeDirectory = $this->getHomeDirectory(); 79 | 80 | $configFile = $homeDirectory . '/.release-tool/auth.yml'; 81 | 82 | if (!file_exists($configFile)) { 83 | throw new MissingConfigurationException( 84 | sprintf('The file %s needs to exist and contain a GitHub access token.', $configFile) 85 | ); 86 | } 87 | 88 | $yamlContents = file_get_contents($configFile); 89 | 90 | if (!$yamlContents) { 91 | throw new RuntimeException('Error reading the configuration file'); 92 | } 93 | 94 | /** @var mixed[] $config */ 95 | $config = Yaml::parse($yamlContents); 96 | 97 | $processor = new Processor(); 98 | $configuration = new CredentialsConfiguration(); 99 | $processedConfiguration = $processor->processConfiguration( 100 | $configuration, 101 | $config 102 | ); 103 | 104 | $container->setParameter('credentials.github.token', $processedConfiguration['github']['token']); 105 | } 106 | 107 | private function getHomeDirectory(): string 108 | { 109 | $homeDirectory = getenv('HOME') ?: getenv('USERPROFILE'); 110 | 111 | if (!$homeDirectory) { 112 | throw new RuntimeException('Unable to determine the home directory from HOME or USERPROFILE'); 113 | } 114 | 115 | return rtrim($homeDirectory, '/\\'); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/Console/Command/CurrentCommand.php: -------------------------------------------------------------------------------- 1 | versionControlSystem = $versionControlSystem; 23 | 24 | parent::__construct(); 25 | } 26 | 27 | protected function configure(): void 28 | { 29 | $this 30 | ->setName('current') 31 | ->setDescription('Displays the current version number') 32 | ->setHelp( 33 | <<%command.name% command displays the version number of the current release. 35 | 36 | %command.full_name% 37 | EOF 38 | ); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | try { 44 | $currentVersion = $this->versionControlSystem->getLastVersion(); 45 | } catch (ReleaseNotFoundException $exception) { 46 | $output->writeln('No existing version found'); 47 | 48 | return 1; 49 | } 50 | 51 | $output->writeln(sprintf('Current version: %s', $currentVersion)); 52 | 53 | return 0; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Console/Command/ReleaseCommand.php: -------------------------------------------------------------------------------- 1 | releaseManager = $releaseManager; 34 | $this->changelogGenerator = $changelogGenerator; 35 | 36 | parent::__construct(); 37 | } 38 | 39 | protected function configure(): void 40 | { 41 | $this 42 | ->setName('release') 43 | ->setDescription('Releases a new version') 44 | ->addArgument('version', InputArgument::OPTIONAL, 'The version number for the new release') 45 | ->addOption( 46 | 'pre-release', 47 | 'p', 48 | InputOption::VALUE_NONE, 49 | 'Generate a pre-release (alpha/beta/rc) version. Ignored when version is provided' 50 | ) 51 | ->addUsage('') 52 | ->addUsage('--pre-release') 53 | ->addUsage('1.0.0') 54 | ->setHelp( 55 | <<%command.name% command releases a new version of a project. Based on interactive questions, it can 57 | determine the next version number for you: 58 | 59 | %command.full_name% 60 | 61 | You can release a pre-release version by using the --pre-release option: 62 | 63 | %command.full_name% --pre-release 64 | 65 | If you wish, you can skip the interactive questions and choose the version number yourself: 66 | 67 | %command.full_name% 1.0.0 68 | EOF 69 | ); 70 | } 71 | 72 | protected function interact(InputInterface $input, OutputInterface $output): void 73 | { 74 | if ($input->getArgument('version') === null) { 75 | $style = new SymfonyStyle($input, $output); 76 | 77 | $this->findLastVersion($style); 78 | 79 | $style->newLine(); 80 | 81 | $informationCollector = new InteractiveInformationCollector($style); 82 | 83 | 84 | $isPreRelease = $input->getOption('pre-release') ? true : false; 85 | $version = $this->determineNextVersion($isPreRelease, $informationCollector); 86 | 87 | $input->setArgument('version', $version); 88 | } 89 | } 90 | 91 | protected function execute(InputInterface $input, OutputInterface $output): int 92 | { 93 | /** @var StyleInterface $style */ 94 | $style = new SymfonyStyle($input, $output); 95 | 96 | /** @var string $version */ 97 | $version = $input->getArgument('version'); 98 | 99 | if (!$this->releaseManager->isValidVersion($version)) { 100 | throw new InvalidArgumentException(sprintf('Version "%s" is not a valid version number', $version)); 101 | } 102 | 103 | $style->text(sprintf('This will release version %s.', $version)); 104 | 105 | if (!$style->confirm('Do you want to continue?')) { 106 | return 0; 107 | } 108 | 109 | $informationCollector = new InteractiveInformationCollector($style); 110 | 111 | $this->releaseManager->release($version, $informationCollector); 112 | 113 | $style->success('Version ' . $version . ' has been released.'); 114 | 115 | return 0; 116 | } 117 | 118 | protected function findLastVersion(SymfonyStyle $style): void 119 | { 120 | if ($this->releaseManager->hasVersions() === false) { 121 | $style->text('No version found.'); 122 | 123 | return; 124 | } 125 | 126 | $currentVersion = $this->releaseManager->getCurrentVersion(); 127 | $style->text('The previous version on this branch is ' . $currentVersion . '.'); 128 | 129 | $style->text('A new release will introduce the following changes:'); 130 | $style->listing($this->changelogGenerator->getUnreleasedChangelog()->getUnreleasedChanges()); 131 | } 132 | 133 | protected function determineNextVersion( 134 | bool $isPreRelease, 135 | InteractiveInformationCollector $informationCollector 136 | ): string { 137 | if ($isPreRelease) { 138 | return $this->releaseManager->determineNextPreReleaseVersion($informationCollector); 139 | } 140 | 141 | return $this->releaseManager->determineNextVersion($informationCollector); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Console/Command/SelfUpdateCommand.php: -------------------------------------------------------------------------------- 1 | \d{1,5}).(?\d+).(?\d+).0$/'; 16 | 17 | public function __construct() 18 | { 19 | parent::__construct(Application::NAME, Application::VERSION, self::REPOSITORY); 20 | } 21 | 22 | /** 23 | * The method parent::getLatestReleaseFromGithub returns an array with a normalized version number. 24 | * The normalized version is suffixed with an additional ".0" which doesn't really fit the commonly 25 | * used "major.minor.patch" format. Here we detect whether an additional ".0" is suffixed to the 26 | * version number and change it to the "major.minor.patch" format. 27 | * 28 | * @param array $options 29 | * @return array 30 | */ 31 | public function getLatestReleaseFromGithub(array $options): ?array 32 | { 33 | $latestReleaseFromGithub = parent::getLatestReleaseFromGithub($options); 34 | 35 | if ($latestReleaseFromGithub === null) { 36 | return null; 37 | } 38 | 39 | if (!preg_match(self::NORMALIZED_VERSION_REGEX, $latestReleaseFromGithub['version'], $matches)) { 40 | return $latestReleaseFromGithub; 41 | } 42 | 43 | $latestReleaseFromGithub['version'] = sprintf( 44 | '%s.%s.%s', 45 | $matches['major'], 46 | $matches['minor'], 47 | $matches['patch'], 48 | ); 49 | 50 | return $latestReleaseFromGithub; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Console/InteractiveInformationCollector.php: -------------------------------------------------------------------------------- 1 | style = $style; 19 | } 20 | 21 | public function askConfirmation(string $question): bool 22 | { 23 | return $this->style->confirm($question); 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function askMultipleChoice(string $question, array $choices, ?string $default = null): ?string 30 | { 31 | /** @var ?string $choice */ 32 | $choice = $this->style->choice($question, $choices, $default); 33 | 34 | return $choice; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Console/VersionHelper.php: -------------------------------------------------------------------------------- 1 | client = $client; 30 | $this->owner = $owner; 31 | $this->repository = $repository; 32 | } 33 | 34 | public function createRelease(Version $version, string $tag, string $body): void 35 | { 36 | $jsonRequestBody = json_encode( 37 | [ 38 | 'tag_name' => $tag, 39 | 'name' => $version->getVersion(), 40 | 'body' => $body, 41 | 'prerelease' => $version->isPreRelease(), 42 | ] 43 | ); 44 | 45 | $this->client->request( 46 | 'POST', 47 | 'https://api.github.com/repos/' . $this->owner . '/' . $this->repository . '/releases', 48 | [ 49 | 'body' => $jsonRequestBody, 50 | ] 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/GitHub/GitHubRepositoryParser.php: -------------------------------------------------------------------------------- 1 | parseUrl($url)[0]; 15 | } 16 | 17 | public function getRepository(string $url): string 18 | { 19 | return $this->parseUrl($url)[1]; 20 | } 21 | 22 | /** 23 | * @return string[] 24 | */ 25 | private function parseUrl(string $url): array 26 | { 27 | Assertion::regex($url, '#git@github.com:(.*)/(.*).git#', 'Value "%s" is not a valid GitHub SSH URL.'); 28 | 29 | $url = str_replace(['git@github.com:', '.git'], '', $url); 30 | 31 | return explode('/', $url); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Interaction/InformationCollector.php: -------------------------------------------------------------------------------- 1 | changelogGenerator = $changelogGenerator; 41 | $this->changelogFormatter = $changelogFormatter; 42 | $this->git = $git; 43 | $this->client = $client; 44 | } 45 | 46 | public function execute(Version $version): void 47 | { 48 | $changelog = $this->changelogGenerator->getChangelogForVersion($version); 49 | 50 | $body = $this->changelogFormatter->format($changelog); 51 | 52 | $tag = $this->git->getTagForVersion($version->getVersion()); 53 | 54 | $this->client->createRelease($version, $tag, $body); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ReleaseAction/ReleaseAction.php: -------------------------------------------------------------------------------- 1 | versionControlSystem = $versionControlSystem; 41 | $this->versioningScheme = $versioningScheme; 42 | $this->actions = $actions; 43 | } 44 | 45 | public function getCurrentVersion(): string 46 | { 47 | return $this->versionControlSystem->getLastVersion(); 48 | } 49 | 50 | public function hasVersions(): bool 51 | { 52 | return $this->versionControlSystem->findLastVersion() !== null; 53 | } 54 | 55 | public function release(string $versionString, InformationCollector $informationCollector): void 56 | { 57 | $this->versionControlSystem->createVersion($versionString); 58 | 59 | $question = 'A VCS tag has been created for version ' . $versionString . '. '; 60 | $question .= 'Do you want to push it to the remote repository and perform additional release steps?'; 61 | 62 | if (!$informationCollector->askConfirmation($question)) { 63 | return; 64 | } 65 | 66 | $this->versionControlSystem->pushVersion($versionString); 67 | 68 | $version = $this->versioningScheme->getVersion($versionString); 69 | 70 | array_walk( 71 | $this->actions, 72 | function (ReleaseAction $releaseAction) use ($version): void { 73 | $releaseAction->execute($version); 74 | } 75 | ); 76 | } 77 | 78 | public function determineNextVersion(InformationCollector $informationCollector): string 79 | { 80 | if ($this->versionControlSystem->findLastVersion() === null) { 81 | return '1.0.0'; 82 | } 83 | 84 | $current = $this->versionControlSystem->getLastVersion(); 85 | $currentVersion = $this->versioningScheme->getVersion($current); 86 | 87 | return $this->versioningScheme->getNextVersion($currentVersion, $informationCollector)->getVersion(); 88 | } 89 | 90 | public function determineNextPreReleaseVersion(InformationCollector $informationCollector): string 91 | { 92 | if ($this->versionControlSystem->findLastVersion() === null) { 93 | $currentVersion = $this->versioningScheme->getVersion('0.0.0'); 94 | 95 | return $this->versioningScheme 96 | ->getNextPreReleaseVersion($currentVersion, $informationCollector) 97 | ->getVersion(); 98 | } 99 | 100 | $current = $this->versionControlSystem->getLastVersion(); 101 | $currentVersion = $this->versioningScheme->getVersion($current); 102 | 103 | return $this->versioningScheme->getNextPreReleaseVersion($currentVersion, $informationCollector)->getVersion(); 104 | } 105 | 106 | public function isValidVersion(string $version): bool 107 | { 108 | return $this->versioningScheme->isValidVersion($version); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Vcs/Commit.php: -------------------------------------------------------------------------------- 1 | title = $title; 21 | $this->body = $body; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Vcs/Git.php: -------------------------------------------------------------------------------- 1 | tagPrefix = $tagPrefix; 32 | } 33 | 34 | /** 35 | * @param string[] $arguments 36 | * @return string[] 37 | * 38 | * @internal 39 | */ 40 | public static function execute(string $command, array $arguments = []): array 41 | { 42 | $command = sprintf('git %s %s', $command, implode(' ', $arguments)); 43 | 44 | $output = null; 45 | $exitCode = null; 46 | 47 | exec($command . ' 2>&1', $output, $exitCode); 48 | 49 | if ($exitCode > 0 && strpos(implode(' ', $output), 'not a git repository') > 0) { 50 | throw new RepositoryNotFoundException('Error: Repository not found in current path.'); 51 | } 52 | 53 | if ($exitCode > 0) { 54 | throw new GitException(implode(PHP_EOL, $output)); 55 | } 56 | 57 | return $output; 58 | } 59 | 60 | public function getLastVersion(): string 61 | { 62 | $tag = $this->describe(); 63 | 64 | return $this->getVersionFromTag($tag); 65 | } 66 | 67 | public function findLastVersion(): ?string 68 | { 69 | try { 70 | $tag = $this->describe(); 71 | } catch (ReleaseNotFoundException $exception) { 72 | return null; 73 | } 74 | 75 | return $this->getVersionFromTag($tag); 76 | } 77 | 78 | public function createVersion(string $version): void 79 | { 80 | self::execute( 81 | 'tag', 82 | [ 83 | '--annotate', 84 | $this->tagPrefix . $version, 85 | sprintf('--message="Release %s"', $version), 86 | ] 87 | ); 88 | } 89 | 90 | public function pushVersion(string $version): void 91 | { 92 | self::execute( 93 | 'push', 94 | [ 95 | self::REMOTE, 96 | 'refs/tags/' . $this->tagPrefix . $version, 97 | ] 98 | ); 99 | } 100 | 101 | /** 102 | * @return Commit[] 103 | */ 104 | public function getCommitsSinceLastVersion(?string $pattern = null): array 105 | { 106 | try { 107 | $range = $this->getTagForVersion($this->getLastVersion()) . '..HEAD'; 108 | } catch (ReleaseNotFoundException $exception) { 109 | $range = 'HEAD'; 110 | } 111 | 112 | return $this->getCommitsInRange($range, $pattern); 113 | } 114 | 115 | /** 116 | * 117 | * @return Commit[] 118 | */ 119 | public function getCommitsForVersion(string $version, ?string $pattern = null): array 120 | { 121 | $revisionRange = $this->getRevisionRangeForVersion($version); 122 | 123 | return $this->getCommitsInRange($revisionRange, $pattern); 124 | } 125 | 126 | public function getTagForVersion(string $version): string 127 | { 128 | return $this->tagPrefix . $version; 129 | } 130 | 131 | /** 132 | * @return string[] 133 | * 134 | * TODO: this method is based on the assumption that semantic versioning is used. What if we use a different 135 | * versioning system? 136 | */ 137 | public function getPreReleasesForVersion(string $version): array 138 | { 139 | $tag = $this->getTagForVersion($version); 140 | 141 | $preReleaseTagPattern = sprintf('%s-*', $tag); 142 | 143 | $tags = self::execute('tag', ['--list', '--sort=taggerdate', '--merged=' . $tag, $preReleaseTagPattern]); 144 | 145 | return array_map([$this, 'getVersionFromTag'], $tags); 146 | } 147 | 148 | private function getVersionFromTag(string $tag): string 149 | { 150 | return substr($tag, strlen($this->tagPrefix)); 151 | } 152 | 153 | /** 154 | * @return Commit[] 155 | */ 156 | private function getCommitsInRange(string $revisionRange, ?string $pattern = null): array 157 | { 158 | $arguments = [$revisionRange]; 159 | 160 | $arguments[] = '--format="%s%x1F%b%x1E"'; 161 | 162 | if ($pattern !== null) { 163 | $arguments[] = '--grep="' . $pattern . '"'; 164 | $arguments[] = '--extended-regexp'; 165 | } 166 | 167 | $output = self::execute('log', $arguments); 168 | $output = implode("\n", $output); 169 | $commits = explode("\x1E", $output); 170 | 171 | $commits = array_filter($commits); 172 | 173 | $commits = array_map( 174 | function (string $commit): Commit { 175 | list($title, $body) = explode("\x1F", trim($commit)); 176 | 177 | return new Commit($title, $body); 178 | }, 179 | $commits 180 | ); 181 | 182 | return $commits; 183 | } 184 | 185 | /** 186 | * Returns a revision range that describes the changes introduced in a 187 | * version. 188 | * 189 | * For example, if there is a tag v1.0.1 and a tag v1.0.0, this will return 190 | * v1.0.0..v1.0.1. 191 | */ 192 | private function getRevisionRangeForVersion(string $version): string 193 | { 194 | $tag = $this->getTagForVersion($version); 195 | 196 | try { 197 | return sprintf('%s..%s', $this->describe($tag . '^'), $tag); 198 | } catch (ReleaseNotFoundException $exception) { 199 | return $tag; 200 | } 201 | } 202 | 203 | private function describe(string $object = 'HEAD'): string 204 | { 205 | try { 206 | return self::execute( 207 | 'describe', 208 | [ 209 | '--abbrev=0', 210 | '--tags', 211 | sprintf('--match "%s%s"', $this->tagPrefix, self::VERSION_GLOB), 212 | $object, 213 | ] 214 | )[0]; 215 | } catch (GitException $exception) { 216 | throw new ReleaseNotFoundException('No release could be found', 0, $exception); 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Vcs/GitException.php: -------------------------------------------------------------------------------- 1 | major = $major; 47 | $this->minor = $minor; 48 | $this->patch = $patch; 49 | $this->preRelease = $preRelease; 50 | } 51 | 52 | public static function createFromVersionString(string $version): self 53 | { 54 | $matches = []; 55 | 56 | if (!preg_match(self::VERSION_PATTERN, $version, $matches)) { 57 | throw new InvalidArgumentException( 58 | sprintf('Version number "%s" is not a valid semantic version.', $version) 59 | ); 60 | } 61 | 62 | return new self((int) $matches[1], (int) $matches[2], (int) $matches[3], $matches[4] ?? null); 63 | } 64 | 65 | public static function isValid(string $version): bool 66 | { 67 | return preg_match(self::VERSION_PATTERN, $version) === 1; 68 | } 69 | 70 | public function createAlphaRelease(): self 71 | { 72 | return $this->createPreRelease('alpha'); 73 | } 74 | 75 | public function createBetaRelease(): self 76 | { 77 | return $this->createPreRelease('beta'); 78 | } 79 | 80 | public function createReleaseCandidate(): self 81 | { 82 | return $this->createPreRelease('rc'); 83 | } 84 | 85 | public function release(): self 86 | { 87 | if (!$this->isPreRelease()) { 88 | throw new LogicException('Trying to release a version that is not a pre-release'); 89 | } 90 | 91 | $clone = clone $this; 92 | $clone->preRelease = null; 93 | 94 | return $clone; 95 | } 96 | 97 | public function incrementPatchVersion(): self 98 | { 99 | $clone = clone $this; 100 | $clone->patch++; 101 | 102 | return $clone; 103 | } 104 | 105 | public function incrementMinorVersion(): self 106 | { 107 | $clone = clone $this; 108 | $clone->patch = 0; 109 | $clone->minor++; 110 | 111 | return $clone; 112 | } 113 | 114 | public function incrementMajorVersion(): self 115 | { 116 | $clone = clone $this; 117 | $clone->patch = $clone->minor = 0; 118 | $clone->major++; 119 | 120 | return $clone; 121 | } 122 | 123 | public function getVersion(): string 124 | { 125 | if ($this->isPreRelease()) { 126 | return sprintf('%d.%d.%d-%s', $this->major, $this->minor, $this->patch, $this->preRelease); 127 | } 128 | 129 | return sprintf('%d.%d.%d', $this->major, $this->minor, $this->patch); 130 | } 131 | 132 | public function getPreReleaseIdentifier(): ?string 133 | { 134 | return $this->preRelease; 135 | } 136 | 137 | public function isPreRelease(): bool 138 | { 139 | return !empty($this->preRelease); 140 | } 141 | 142 | private function createPreRelease(string $type): self 143 | { 144 | if ($this->preRelease !== null && strpos($this->preRelease, $type) === 0) { 145 | $identifiers = explode('.', $this->preRelease); 146 | $number = array_pop($identifiers); 147 | $identifiers[] = ++$number; 148 | 149 | return $this->withPreReleaseLabel(implode('.', $identifiers)); 150 | } 151 | 152 | return $this->withPreReleaseLabel($type . '.1'); 153 | } 154 | 155 | private function withPreReleaseLabel(string $label): self 156 | { 157 | $clone = clone $this; 158 | $clone->preRelease = $label; 159 | 160 | return $clone; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Versioning/SemanticVersioning.php: -------------------------------------------------------------------------------- 1 | 'alpha', 14 | 'beta' => 'beta', 15 | 'rc' => 'release candidate', 16 | ]; 17 | 18 | public function getVersion(string $version): Version 19 | { 20 | return SemanticVersion::createFromVersionString($version); 21 | } 22 | 23 | public function isValidVersion(string $version): bool 24 | { 25 | return SemanticVersion::isValid($version); 26 | } 27 | 28 | public function getNextVersion(Version $currentVersion, InformationCollector $informationCollector): Version 29 | { 30 | if (!$currentVersion instanceof SemanticVersion) { 31 | throw new InvalidArgumentException('Current version must be a SemanticVersion instance'); 32 | } 33 | 34 | if ($currentVersion->isPreRelease()) { 35 | return $currentVersion->release(); 36 | } 37 | 38 | if ($informationCollector->askConfirmation('Does this release contain backward incompatible changes?')) { 39 | return $currentVersion->incrementMajorVersion(); 40 | } 41 | 42 | if ($informationCollector->askConfirmation('Does this release contain new features?')) { 43 | return $currentVersion->incrementMinorVersion(); 44 | } 45 | 46 | return $currentVersion->incrementPatchVersion(); 47 | } 48 | 49 | public function getNextPreReleaseVersion( 50 | Version $currentVersion, 51 | InformationCollector $informationCollector 52 | ): Version { 53 | if (!$currentVersion->isPreRelease()) { 54 | $currentVersion = $this->getNextVersion($currentVersion, $informationCollector); 55 | } 56 | 57 | if (!$currentVersion instanceof SemanticVersion) { 58 | throw new InvalidArgumentException('Current version must be a SemanticVersion instance'); 59 | } 60 | 61 | if ($currentVersion->isPreRelease()) { 62 | $currentPreReleaseType = explode('.', (string) $currentVersion->getPreReleaseIdentifier())[0]; 63 | 64 | $defaultPreReleaseType = self::PRE_RELEASE_TYPES[$currentPreReleaseType] ?? null; 65 | } 66 | 67 | $type = $informationCollector->askMultipleChoice( 68 | 'What kind of pre-release do you want to release?', 69 | [ 70 | 'a' => 'alpha', 71 | 'b' => 'beta', 72 | 'rc' => 'release candidate', 73 | ], 74 | $defaultPreReleaseType ?? null 75 | ); 76 | 77 | if ($type === 'a') { 78 | return $currentVersion->createAlphaRelease(); 79 | } 80 | 81 | if ($type === 'b') { 82 | return $currentVersion->createBetaRelease(); 83 | } 84 | 85 | return $currentVersion->createReleaseCandidate(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Versioning/Version.php: -------------------------------------------------------------------------------- 1 | versionControlSystem = new Git('v'); 37 | $this->changelogGenerator = new PullRequestChangelogGenerator($this->versionControlSystem); 38 | 39 | $this->releaseManager = new ReleaseManager( 40 | $this->versionControlSystem, 41 | new SemanticVersioning(), 42 | [] 43 | ); 44 | 45 | $this->removeGitDirectory(); 46 | Git::execute('init'); 47 | $this->commitFile('README.md', 'Merge pull request #3 from branch'); 48 | } 49 | 50 | protected function tearDown(): void 51 | { 52 | $this->removeGitDirectory(); 53 | } 54 | 55 | public function testThatItTagsANewVersion(): void 56 | { 57 | $command = new ReleaseCommand( 58 | $this->releaseManager, 59 | new PullRequestChangelogGenerator($this->versionControlSystem) 60 | ); 61 | 62 | $commandTester = new CommandTester($command); 63 | 64 | $commandTester->setInputs(['yes', 'no']); 65 | $commandTester->execute(['version' => '1.0.0']); 66 | 67 | $this->assertContains('v1.0.0', $this->getTags()); 68 | } 69 | 70 | public function testThatItAbortsTheReleaseOnNegativeConfirmation(): void 71 | { 72 | $command = new ReleaseCommand( 73 | $this->releaseManager, 74 | new PullRequestChangelogGenerator($this->versionControlSystem) 75 | ); 76 | 77 | $commandTester = new CommandTester($command); 78 | 79 | $commandTester->setInputs(['no']); 80 | $commandTester->execute(['version' => '1.0.0']); 81 | 82 | $this->assertNotContains('v1.0.0', $this->getTags()); 83 | } 84 | 85 | public function testThatItAsksInteractiveQuestionsToDetermineTheNextVersion(): void 86 | { 87 | Git::execute( 88 | 'tag', 89 | [ 90 | '--annotate', 91 | '--message="Test tag"', 92 | 'v1.0.0', 93 | ] 94 | ); 95 | 96 | $command = new ReleaseCommand( 97 | $this->releaseManager, 98 | new PullRequestChangelogGenerator($this->versionControlSystem) 99 | ); 100 | 101 | $commandTester = new CommandTester($command); 102 | $commandTester->setInputs(['no', 'yes', 'yes', 'no']); 103 | 104 | $commandTester->execute([]); 105 | 106 | $this->assertContains('v1.1.0', $this->getTags()); 107 | } 108 | 109 | private function commitFile(string $filename, string $commitMessage = 'Commit message'): void 110 | { 111 | Git::execute('add ' . $filename); 112 | Git::execute('commit --no-gpg-sign -m "' . $commitMessage . '"'); 113 | } 114 | 115 | private function removeGitDirectory(): void 116 | { 117 | exec('rm -rf $GIT_DIR'); 118 | } 119 | 120 | /** 121 | * @return string[] 122 | */ 123 | private function getTags(): array 124 | { 125 | return Git::execute('tag'); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /tests/integration/Vcs/GitTest.php: -------------------------------------------------------------------------------- 1 | commitFile('README.md', 'Initial commit'); 18 | } 19 | 20 | protected function tearDown(): void 21 | { 22 | exec('rm -rf $GIT_DIR'); 23 | } 24 | 25 | public function testThatTheLastVersionIsReturned(): void 26 | { 27 | $this->createTag('1.0.0'); 28 | 29 | $git = new Git(); 30 | 31 | $this->assertSame('1.0.0', $git->getLastVersion()); 32 | } 33 | 34 | public function testThatTheTagPrefixIsStrippedFromTheTag(): void 35 | { 36 | $this->createTag('v1.0.0'); 37 | 38 | $git = new Git('v'); 39 | 40 | $this->assertSame('1.0.0', $git->getLastVersion()); 41 | } 42 | 43 | public function testThatNoCommitHashAndNumberOfAdditionalCommitsAreReturned(): void 44 | { 45 | $this->commitFile('phpunit.xml'); 46 | 47 | $this->createTag('1.0.0', 'HEAD^'); 48 | 49 | $git = new Git(); 50 | 51 | $this->assertSame('1.0.0', $git->getLastVersion()); 52 | } 53 | 54 | public function testThatAnExceptionIsThrownIfNoMatchingTagIsFound(): void 55 | { 56 | $this->expectException(ReleaseNotFoundException::class); 57 | 58 | $git = new Git(); 59 | 60 | $git->getLastVersion(); 61 | } 62 | 63 | public function testThatNonPrefixedTagsAreIgnored(): void 64 | { 65 | $this->createTag('foo'); 66 | 67 | $this->expectException(ReleaseNotFoundException::class); 68 | 69 | $git = new Git(); 70 | 71 | $git->getLastVersion(); 72 | } 73 | 74 | public function testThatANewVersionIsTagged(): void 75 | { 76 | $git = new Git('v'); 77 | 78 | $git->createVersion('1.2.0'); 79 | 80 | $this->assertContains('v1.2.0', $this->getTags()); 81 | } 82 | 83 | public function testThatCommitsSinceTheLastVersionAreReturned(): void 84 | { 85 | $this->createTag('1.0.0', 'HEAD'); 86 | $this->commitFile('phpunit.xml', 'New commit message'); 87 | 88 | $git = new Git(); 89 | $commits = $git->getCommitsSinceLastVersion(); 90 | 91 | $this->assertCount(1, $commits); 92 | $this->assertSame('New commit message', $commits[0]->title); 93 | } 94 | 95 | public function testThatCommitsSinceTheFirstCommitAreReturnedIfNoReleasesExist(): void 96 | { 97 | $git = new Git(); 98 | 99 | $commits = $git->getCommitsSinceLastVersion(); 100 | 101 | $this->assertCount(1, $commits); 102 | $this->assertSame('Initial commit', $commits[0]->title); 103 | } 104 | 105 | public function testThatCommitsAreFilteredByPattern(): void 106 | { 107 | $this->createTag('1.0.0', 'HEAD'); 108 | $this->commitFile('phpunit.xml', 'New commit message'); 109 | $this->commitFile('composer.json', 'Other commit message'); 110 | 111 | $git = new Git(); 112 | $commits = $git->getCommitsSinceLastVersion('Other'); 113 | 114 | $this->assertCount(1, $commits); 115 | $this->assertSame('Other commit message', $commits[0]->title); 116 | } 117 | 118 | public function testThatCommitsLeadingToAVersionAreReturned(): void 119 | { 120 | $this->createTag('v1.0.0'); 121 | $this->commitFile('phpunit.xml', 'Add phpunit.xml'); 122 | $this->commitFile('composer.json', 'Add composer.json'); 123 | $this->createTag('v1.0.1'); 124 | 125 | $git = new Git('v'); 126 | $commits = $git->getCommitsForVersion('1.0.1'); 127 | 128 | $this->assertCount(2, $commits); 129 | $this->assertSame('Add composer.json', $commits[0]->title); 130 | $this->assertSame('Add phpunit.xml', $commits[1]->title); 131 | } 132 | 133 | public function testPreReleasesForAReleaseAreReturned(): void 134 | { 135 | $this->createTag('v1.0.0-alpha.1'); 136 | $this->createTag('v1.0.0'); 137 | 138 | $git = new Git('v'); 139 | $preReleases = $git->getPreReleasesForVersion('1.0.0'); 140 | 141 | $this->assertCount(1, $preReleases); 142 | $this->assertContains('1.0.0-alpha.1', $preReleases); 143 | } 144 | 145 | public function testPreReleasesForAReleaseAreReturnedInChronologicalOrder(): void 146 | { 147 | $this->createTag('v1.0.0-alpha.1'); 148 | sleep(1); 149 | $this->createTag('v1.0.0-beta.1'); 150 | sleep(1); 151 | $this->createTag('v1.0.0-alpha.2'); 152 | sleep(1); 153 | $this->createTag('v1.0.0'); 154 | 155 | $git = new Git('v'); 156 | $preReleases = $git->getPreReleasesForVersion('1.0.0'); 157 | 158 | $this->assertSame(['1.0.0-alpha.1', '1.0.0-beta.1', '1.0.0-alpha.2'], $preReleases); 159 | } 160 | 161 | public function testTagsNotReachableFromTheCurrentCommitAreIgnored(): void 162 | { 163 | $this->commitFile('phpunit.xml', 'Add phpunit.xml'); 164 | $this->createTag('v1.0.0-alpha.1'); 165 | 166 | $this->createTag('v1.0.0'); 167 | $this->commitFile('composer.json', 'Add composer.json'); 168 | $this->createTag('v1.0.0-beta.1'); 169 | 170 | $git = new Git('v'); 171 | $preReleases = $git->getPreReleasesForVersion('1.0.0'); 172 | 173 | $this->assertContains('1.0.0-alpha.1', $preReleases); 174 | $this->assertNotContains('1.0.0-beta.1', $preReleases); 175 | } 176 | 177 | private function commitFile(string $filename, string $commitMessage = 'Commit message'): void 178 | { 179 | Git::execute('add ' . $filename); 180 | Git::execute('commit --no-gpg-sign -m "' . $commitMessage . '"'); 181 | } 182 | 183 | /** 184 | * @return string[] 185 | */ 186 | private function getTags(): array 187 | { 188 | exec('git tag', $output); 189 | 190 | return $output; 191 | } 192 | 193 | private function createTag(string $tag, ?string $head = null): void 194 | { 195 | if ($head !== null) { 196 | exec('git tag --annotate --message="Test tag" ' . $tag . ' ' . $head); 197 | 198 | return; 199 | } 200 | 201 | exec('git tag --annotate --message="Test tag" ' . $tag); 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /tests/system/ApplicationTest.php: -------------------------------------------------------------------------------- 1 | run(); 30 | 31 | $this->assertTrue($process->isSuccessful(), 'The command returned a non-zero exit code.'); 32 | $this->assertStringContainsString('Leviy Release Tool', $process->getOutput()); 33 | } 34 | 35 | public function testAsksForConfirmationBeforeReleasingAVersion(): void 36 | { 37 | $this->commitFile('README.md', 'Initial commit'); 38 | 39 | $input = new InputStream(); 40 | 41 | $process = new Process(['build/release-tool.phar', 'release', '--no-ansi', '1.0.0']); 42 | $process->setInput($input); 43 | $process->start(); 44 | 45 | // EOL simulates [Enter] 46 | // Do you want to continue? (yes/no) 47 | $input->write('no' . PHP_EOL); 48 | $input->close(); 49 | 50 | $process->wait(); 51 | 52 | $this->assertStringContainsString('This will release version 1.0.0', $process->getOutput()); 53 | $this->assertStringContainsString('Do you want to continue?', $process->getOutput()); 54 | $this->assertEmpty($this->getTags()); 55 | } 56 | 57 | public function testReleasesWithGivenVersionNumber(): void 58 | { 59 | $this->commitFile('README.md', 'Initial commit'); 60 | 61 | $input = new InputStream(); 62 | 63 | $process = new Process(['build/release-tool.phar', 'release', '1.0.0']); 64 | $process->setInput($input); 65 | $process->start(); 66 | 67 | // EOL simulates [Enter] 68 | // Do you want to continue? (yes/no) 69 | $input->write('yes' . PHP_EOL); 70 | 71 | // A VCS tag has been created for version 1.0.0. Do you want to push it to the remote repository and perform 72 | // additional release steps? (yes/no) 73 | $input->write('no' . PHP_EOL); 74 | $input->close(); 75 | 76 | $process->wait(); 77 | 78 | $this->assertTrue($process->isSuccessful(), 'The command returned a non-zero exit code.'); 79 | $this->assertSame(['v1.0.0'], $this->getTags()); 80 | } 81 | 82 | public function testReleasesWithoutGivenVersionNumber(): void 83 | { 84 | $this->commitFile('README.md', 'First commit'); 85 | 86 | $input = new InputStream(); 87 | 88 | $process = new Process(['build/release-tool.phar', 'release']); 89 | $process->setInput($input); 90 | $process->start(); 91 | 92 | // EOL simulates [Enter] 93 | // Do you want to continue? (yes/no) 94 | $input->write('no' . PHP_EOL); 95 | $input->close(); 96 | 97 | $process->wait(); 98 | 99 | $this->assertStringContainsString('This will release version 1.0.0.', $process->getOutput()); 100 | $this->assertStringContainsString('Do you want to continue?', $process->getOutput()); 101 | } 102 | 103 | public function testShowsTheChangelogBeforeAskingInteractiveQuestions(): void 104 | { 105 | $this->commitFile('README.md', 'Initial commit'); 106 | $this->createTag('v1.0.0'); 107 | $this->commitFile('phpunit.xml', 'Merge pull request #3 from branchname' . PHP_EOL . PHP_EOL . 'My PR title'); 108 | 109 | $input = new InputStream(); 110 | 111 | $process = new Process(['build/release-tool.phar', 'release']); 112 | $process->setInput($input); 113 | $process->start(); 114 | 115 | // EOL simulates [Enter] 116 | // Does this release contain backward incompatible changes? (yes/no) 117 | $input->write('no' . PHP_EOL); 118 | 119 | // Does this release contain new features? (yes/no) 120 | $input->write('yes' . PHP_EOL); 121 | 122 | // Do you want to continue? (yes/no) 123 | $input->write('no' . PHP_EOL); 124 | $input->close(); 125 | 126 | $process->wait(); 127 | 128 | $this->assertStringContainsString('My PR title (pull request #3)', $process->getOutput()); 129 | } 130 | 131 | public function testDeterminesTheVersionNumberBasedOnInteractiveQuestions(): void 132 | { 133 | $this->commitFile('README.md', 'Initial commit'); 134 | $this->createTag('v1.0.0'); 135 | $this->commitFile('phpunit.xml', 'Merge pull request #3 from branchname' . PHP_EOL . PHP_EOL . 'Foo'); 136 | 137 | $input = new InputStream(); 138 | 139 | $process = new Process(['build/release-tool.phar', 'release']); 140 | $process->setInput($input); 141 | $process->start(); 142 | 143 | // EOL simulates [Enter] 144 | // Does this release contain backward incompatible changes? (yes/no) 145 | $input->write('no' . PHP_EOL); 146 | 147 | // Does this release contain new features? (yes/no) 148 | $input->write('yes' . PHP_EOL); 149 | 150 | // Do you want to continue? (yes/no) 151 | $input->write('yes' . PHP_EOL); 152 | 153 | // A VCS tag has been created for version 1.0.0. Do you want to push it to the remote repository and perform 154 | // additional release steps? (yes/no) 155 | $input->write('no' . PHP_EOL); 156 | $input->close(); 157 | 158 | $process->wait(); 159 | 160 | $this->assertTrue($process->isSuccessful(), 'The command returned a non-zero exit code.'); 161 | $this->assertContains('v1.1.0', $this->getTags()); 162 | } 163 | 164 | public function testReturnsTheCurrentVersion(): void 165 | { 166 | $this->commitFile('README.md', 'Initial commit'); 167 | $this->createTag('v1.2.5'); 168 | 169 | $process = new Process(['build/release-tool.phar', 'current']); 170 | $process->run(); 171 | 172 | $this->assertTrue($process->isSuccessful(), 'The command returned a non-zero exit code.'); 173 | $this->assertStringContainsString('Current version: 1.2.5', $process->getOutput()); 174 | } 175 | 176 | private function commitFile(string $filename, string $commitMessage): void 177 | { 178 | Git::execute('add ' . $filename); 179 | Git::execute('commit --no-gpg-sign -m "' . $commitMessage . '"'); 180 | } 181 | 182 | private function createTag(string $tag): void 183 | { 184 | Git::execute('tag --annotate --message="Test tag" ' . $tag); 185 | } 186 | 187 | /** 188 | * @return string[] 189 | */ 190 | private function getTags(): array 191 | { 192 | return Git::execute('tag'); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /tests/system/auth.yml: -------------------------------------------------------------------------------- 1 | credentials: 2 | github: 3 | token: abc123 4 | -------------------------------------------------------------------------------- /tests/unit/Changelog/ChangelogTest.php: -------------------------------------------------------------------------------- 1 | addVersion('1.0.0', []); 16 | 17 | $this->assertSame(['1.0.0'], $changelog->getVersions()); 18 | } 19 | 20 | public function testVersionNumbersAreReturnedInTheOrderTheyWereAdded(): void 21 | { 22 | $changelog = new Changelog(); 23 | 24 | $changelog->addVersion('1.0.0-alpha.1', []); 25 | $changelog->addVersion('1.0.0-beta.1', []); 26 | $changelog->addVersion('1.0.0-beta.2', []); 27 | $changelog->addVersion('1.0.0', []); 28 | 29 | $this->assertSame(['1.0.0-alpha.1', '1.0.0-beta.1', '1.0.0-beta.2', '1.0.0'], $changelog->getVersions()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/unit/Changelog/Formatter/Filter/GitHubPullRequestUrlFilterTest.php: -------------------------------------------------------------------------------- 1 | filter('My great PR title (pull request #23)'); 16 | 17 | $this->assertSame('My great PR title (pull request [#23](https://github.com/org/repo/pull/23))', $output); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/unit/Changelog/Formatter/Filter/IssueLinkFilterTest.php: -------------------------------------------------------------------------------- 1 | filter('Fixed RT-123 by doing this thing'); 16 | 17 | $this->assertSame('Fixed [RT-123](https://issuetracker.com/issues/RT-123) by doing this thing', $output); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/unit/Changelog/Formatter/MarkdownFormatterTest.php: -------------------------------------------------------------------------------- 1 | addVersion('1.0.0', []); 20 | $changelog->addVersion('1.1.0', []); 21 | 22 | $output = $formatter->format($changelog); 23 | 24 | $this->assertStringContainsString('# Changelog for 1.0.0', $output); 25 | $this->assertStringContainsString('# Changelog for 1.1.0', $output); 26 | } 27 | 28 | public function testThatTheOutputContainsAListItemForEveryChange(): void 29 | { 30 | $formatter = new MarkdownFormatter([]); 31 | 32 | $changelog = new Changelog(); 33 | $changelog->addVersion('1.0.0', ['First change', 'Second change']); 34 | 35 | $output = $formatter->format($changelog); 36 | 37 | $this->assertStringContainsString('* First change', $output); 38 | $this->assertStringContainsString('* Second change', $output); 39 | } 40 | 41 | public function testThatVersionsAreShownInReversedOrder(): void 42 | { 43 | $formatter = new MarkdownFormatter([]); 44 | 45 | $changelog = new Changelog(); 46 | $changelog->addVersion('1.0.0-alpha.1', ['Alpha change']); 47 | $changelog->addVersion('1.0.0-beta.1', ['Beta change']); 48 | $changelog->addVersion('1.0.0', ['Release change']); 49 | 50 | $output = $formatter->format($changelog); 51 | 52 | $expected = <<assertStringContainsString($expected, $output); 67 | } 68 | 69 | public function testThatFiltersAreApplied(): void 70 | { 71 | $filter = Mockery::mock(Filter::class); 72 | $filter->shouldReceive('filter')->andReturn('Filtered change line'); 73 | 74 | $formatter = new MarkdownFormatter([$filter]); 75 | 76 | $changelog = new Changelog(); 77 | $changelog->addVersion('1.0.0', ['Some change']); 78 | 79 | $this->assertStringContainsString('Filtered change line', $formatter->format($changelog)); 80 | $this->assertStringNotContainsString('Some change', $formatter->format($changelog)); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/unit/Changelog/PullRequestChangelogGeneratorTest.php: -------------------------------------------------------------------------------- 1 | versionControlSystem = Mockery::mock(VersionControlSystem::class); 23 | } 24 | 25 | public function testThatAChangelogOfUnreleasedChangesIsReturned(): void 26 | { 27 | $generator = new PullRequestChangelogGenerator($this->versionControlSystem); 28 | 29 | $this->versionControlSystem->shouldReceive('getCommitsSinceLastVersion') 30 | ->andReturn([new Commit('Merge pull request #3 from branchname', 'Lorem ipsum')]); 31 | 32 | $changelog = $generator->getUnreleasedChangelog(); 33 | 34 | $this->assertContains('Lorem ipsum (pull request #3)', $changelog->getUnreleasedChanges()); 35 | } 36 | 37 | public function testThatAChangelogWithChangesIntroducedInAVersionIsReturned(): void 38 | { 39 | $generator = new PullRequestChangelogGenerator($this->versionControlSystem); 40 | 41 | $this->versionControlSystem->shouldReceive('getPreReleasesForVersion')->andReturn([]); 42 | 43 | $this->versionControlSystem->shouldReceive('getCommitsForVersion') 44 | ->andReturn([new Commit('Merge pull request #3 from branchname', 'Lorem ipsum')]); 45 | 46 | $changelog = $generator->getChangelogForVersion(SemanticVersion::createFromVersionString('1.0.0')); 47 | 48 | $this->assertContains('Lorem ipsum (pull request #3)', $changelog->getChangesForVersion('1.0.0')); 49 | } 50 | 51 | public function testVersionChangelogIncludesChangesFromPreReleaseVersions(): void 52 | { 53 | $generator = new PullRequestChangelogGenerator($this->versionControlSystem); 54 | 55 | $this->versionControlSystem->shouldReceive('getPreReleasesForVersion') 56 | ->andReturn(['1.0.0-alpha.1', '1.0.0-beta.1']); 57 | 58 | $this->versionControlSystem->shouldReceive('getCommitsForVersion') 59 | ->with('1.0.0-alpha.1', Mockery::any()) 60 | ->andReturn([new Commit('Merge pull request #1 from branchname', 'First PR')]); 61 | 62 | $this->versionControlSystem->shouldReceive('getCommitsForVersion') 63 | ->with('1.0.0-beta.1', Mockery::any()) 64 | ->andReturn([new Commit('Merge pull request #3 from branchname', 'Lorem ipsum')]); 65 | 66 | $this->versionControlSystem->shouldReceive('getCommitsForVersion') 67 | ->with('1.0.0', Mockery::any()) 68 | ->andReturn([new Commit('Merge pull request #5 from branchname', 'Foo bar')]); 69 | 70 | $changelog = $generator->getChangelogForVersion(SemanticVersion::createFromVersionString('1.0.0')); 71 | 72 | $this->assertSame(['1.0.0-alpha.1', '1.0.0-beta.1', '1.0.0'], $changelog->getVersions()); 73 | 74 | $this->assertSame(['First PR (pull request #1)'], $changelog->getChangesForVersion('1.0.0-alpha.1')); 75 | $this->assertSame(['Lorem ipsum (pull request #3)'], $changelog->getChangesForVersion('1.0.0-beta.1')); 76 | $this->assertSame(['Foo bar (pull request #5)'], $changelog->getChangesForVersion('1.0.0')); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/unit/Console/Command/CurrentCommandTest.php: -------------------------------------------------------------------------------- 1 | vcs = Mockery::mock(VersionControlSystem::class); 24 | } 25 | 26 | public function testThatItOutputsTheVersionNumber(): void 27 | { 28 | $command = new CurrentCommand($this->vcs); 29 | 30 | $application = new Application(); 31 | $application->add($command); 32 | 33 | $commandTester = new CommandTester($command); 34 | 35 | $this->vcs->shouldReceive('getLastVersion')->andReturn('3.2.0'); 36 | 37 | $commandTester->execute([]); 38 | 39 | $this->assertStringContainsString('Current version: 3.2.0', $commandTester->getDisplay()); 40 | $this->assertSame(0, $commandTester->getStatusCode()); 41 | } 42 | 43 | public function testThatItShowsAnErrorMessageIfNoVersionIsFound(): void 44 | { 45 | $command = new CurrentCommand($this->vcs); 46 | 47 | $application = new Application(); 48 | $application->add($command); 49 | 50 | $commandTester = new CommandTester($command); 51 | 52 | $this->vcs->shouldReceive('getLastVersion')->andThrow(ReleaseNotFoundException::class); 53 | 54 | $commandTester->execute([]); 55 | 56 | $this->assertStringContainsString('No existing version found', $commandTester->getDisplay()); 57 | $this->assertSame(1, $commandTester->getStatusCode()); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/unit/Console/Command/ReleaseCommandTest.php: -------------------------------------------------------------------------------- 1 | vcs = Mockery::spy(VersionControlSystem::class); 46 | $this->versioningStrategy = Mockery::mock(VersioningScheme::class); 47 | $this->changelogGenerator = Mockery::mock(ChangelogGenerator::class); 48 | 49 | $this->releaseManager = Mockery::spy(ReleaseManager::class); 50 | } 51 | 52 | public function testThatItFailsIfTheProvidedVersionIsInvalid(): void 53 | { 54 | $command = new ReleaseCommand($this->releaseManager, $this->changelogGenerator); 55 | $this->changelogGenerator->shouldReceive('getChangelog'); 56 | $this->releaseManager->shouldReceive('isValidVersion')->andReturnFalse(); 57 | 58 | $this->expectException(InvalidArgumentException::class); 59 | 60 | $commandTester = new CommandTester($command); 61 | 62 | $commandTester->setInputs(['yes']); 63 | $commandTester->execute(['version' => '1.2.0']); 64 | } 65 | 66 | public function testThatItReleasesANewVersion(): void 67 | { 68 | $command = new ReleaseCommand($this->releaseManager, $this->changelogGenerator); 69 | $this->changelogGenerator->shouldReceive('getChangelog'); 70 | $this->releaseManager->shouldReceive('isValidVersion')->andReturnTrue(); 71 | 72 | $commandTester = new CommandTester($command); 73 | 74 | $commandTester->setInputs(['yes']); 75 | $commandTester->execute(['version' => '1.2.0']); 76 | 77 | $this->releaseManager->shouldHaveReceived('release', ['1.2.0', Mockery::any()]); 78 | } 79 | 80 | public function testThatItAbortsTheReleaseOnNegativeConfirmation(): void 81 | { 82 | $command = new ReleaseCommand($this->releaseManager, $this->changelogGenerator); 83 | $this->changelogGenerator->shouldReceive('getChangelog'); 84 | $this->releaseManager->shouldReceive('isValidVersion')->andReturnTrue(); 85 | 86 | $commandTester = new CommandTester($command); 87 | 88 | $commandTester->setInputs(['no']); 89 | $commandTester->execute(['version' => '1.2.0']); 90 | 91 | $this->releaseManager->shouldNotHaveReceived('release'); 92 | } 93 | 94 | public function testThatItUsesTheReleaseManagerToDetermineTheNextVersion(): void 95 | { 96 | $command = new ReleaseCommand($this->releaseManager, $this->changelogGenerator); 97 | $this->changelogGenerator->shouldReceive('getUnreleasedChangelog')->andReturn(new Changelog()); 98 | $this->releaseManager->shouldReceive('isValidVersion')->andReturnTrue(); 99 | 100 | $this->releaseManager->shouldReceive('determineNextVersion')->andReturn('2.3.0'); 101 | 102 | $commandTester = new CommandTester($command); 103 | $commandTester->setInputs(['yes']); 104 | 105 | $commandTester->execute([]); 106 | 107 | $this->releaseManager->shouldHaveReceived('release', ['2.3.0', Mockery::any()]); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/unit/GitHub/GitHubRepositoryParserTest.php: -------------------------------------------------------------------------------- 1 | getOwner($url); 19 | 20 | $this->assertSame($owner, $actualOwner); 21 | } 22 | 23 | /** 24 | * @return string[] 25 | */ 26 | public function ownerProvider(): array 27 | { 28 | return [ 29 | ['git@github.com:leviy/release-tool.git', 'leviy'], 30 | ['git@github.com:symfony/acme.git', 'symfony'], 31 | ]; 32 | } 33 | 34 | /** 35 | * @dataProvider repositoryNameProvider 36 | */ 37 | public function testThatRepositoryNameIsReturned(string $url, string $repository): void 38 | { 39 | $parser = new GitHubRepositoryParser(); 40 | $actualRepository = $parser->getRepository($url); 41 | 42 | $this->assertSame($repository, $actualRepository); 43 | } 44 | 45 | /** 46 | * @return string[] 47 | */ 48 | public function repositoryNameProvider(): array 49 | { 50 | return [ 51 | ['git@github.com:leviy/release-tool.git', 'release-tool'], 52 | ['git@github.com:symfony/acme.git', 'acme'], 53 | ]; 54 | } 55 | 56 | /** 57 | * @dataProvider getInvalidUrls 58 | */ 59 | public function testThatAnExceptionIsThrownForAnInvalidUrl(string $invalidUrl): void 60 | { 61 | $this->expectException(InvalidArgumentException::class); 62 | 63 | $parser = new GitHubRepositoryParser(); 64 | $parser->getRepository($invalidUrl); 65 | } 66 | 67 | /** 68 | * @return string[][] 69 | */ 70 | public function getInvalidUrls(): array 71 | { 72 | return [ 73 | [''], 74 | ['foo'], 75 | ['https://github.com/leviy/release-tool.git'], 76 | ['github.com:leviy/release-tool.git'], 77 | ['git@github.com:leviy.git'], 78 | ['git@bitbucket.org/leviy/release-tool.git'], 79 | ]; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/unit/ReleaseManagerTest.php: -------------------------------------------------------------------------------- 1 | vcs = Mockery::spy(VersionControlSystem::class); 52 | $this->versioningStrategy = Mockery::mock(VersioningScheme::class); 53 | $this->changelogGenerator = Mockery::mock(ChangelogGenerator::class); 54 | $this->releaseAction = Mockery::spy(ReleaseAction::class); 55 | $this->informationCollector = Mockery::mock(InformationCollector::class); 56 | 57 | $this->changelogGenerator->shouldReceive('getChangelog')->andReturn(new Changelog()); 58 | } 59 | 60 | public function testThatInstantiationThrowsAnErrorWhenActionIsNotReleaseAction(): void 61 | { 62 | $this->expectException(InvalidArgumentException::class); 63 | 64 | $releaseManager = new ReleaseManager( 65 | $this->vcs, 66 | $this->versioningStrategy, 67 | ['test-action'] 68 | ); 69 | } 70 | 71 | public function testThatVersionIsCreatedAndPushed(): void 72 | { 73 | $releaseManager = new ReleaseManager( 74 | $this->vcs, 75 | $this->versioningStrategy, 76 | [] 77 | ); 78 | 79 | $this->informationCollector->shouldReceive('askConfirmation')->andReturnTrue(); 80 | $this->versioningStrategy 81 | ->shouldReceive('getVersion') 82 | ->andReturn(SemanticVersion::createFromVersionString('9.1.1')); 83 | 84 | $releaseManager->release('9.1.1', $this->informationCollector); 85 | 86 | $this->vcs->shouldHaveReceived('createVersion', ['9.1.1']); 87 | $this->vcs->shouldHaveReceived('pushVersion', ['9.1.1']); 88 | } 89 | 90 | public function testThatTheTagIsNotPushedAndReleaseStepsAreAbortedWhenUserDoesNotConfirm(): void 91 | { 92 | $releaseManager = new ReleaseManager( 93 | $this->vcs, 94 | $this->versioningStrategy, 95 | [$this->releaseAction] 96 | ); 97 | 98 | $this->informationCollector->shouldReceive('askConfirmation')->andReturnFalse(); 99 | $this->versioningStrategy 100 | ->shouldReceive('getVersion') 101 | ->andReturn(SemanticVersion::createFromVersionString('9.1.1')); 102 | 103 | $releaseManager->release('9.1.1', $this->informationCollector); 104 | 105 | $this->vcs->shouldHaveReceived('createVersion', ['9.1.1']); 106 | $this->vcs->shouldNotHaveReceived('pushVersion', ['9.1.1']); 107 | $this->releaseAction->shouldNotHaveReceived('execute'); 108 | } 109 | 110 | public function testThatAdditionalActionsAreExecutedDuringRelease(): void 111 | { 112 | $releaseManager = new ReleaseManager( 113 | $this->vcs, 114 | $this->versioningStrategy, 115 | [$this->releaseAction] 116 | ); 117 | 118 | $this->informationCollector->shouldReceive('askConfirmation')->andReturnTrue(); 119 | $this->versioningStrategy 120 | ->shouldReceive('getVersion') 121 | ->andReturn(SemanticVersion::createFromVersionString('9.1.1')); 122 | 123 | $releaseManager->release('9.1.1', $this->informationCollector); 124 | 125 | $this->releaseAction->shouldHaveReceived('execute')->once(); 126 | } 127 | 128 | public function testThatMultipleAdditionalActionsAreExecutedInGivenOrder(): void 129 | { 130 | $releaseAction = Mockery::mock(ReleaseAction::class); 131 | $additionalAction = Mockery::mock(ReleaseAction::class); 132 | 133 | $releaseManager = new ReleaseManager( 134 | $this->vcs, 135 | $this->versioningStrategy, 136 | [$releaseAction, $additionalAction] 137 | ); 138 | 139 | $this->informationCollector->shouldReceive('askConfirmation')->andReturnTrue(); 140 | $this->versioningStrategy 141 | ->shouldReceive('getVersion') 142 | ->andReturn(SemanticVersion::createFromVersionString('9.1.1')); 143 | 144 | $releaseAction->shouldReceive('execute')->once()->globally()->ordered(); 145 | $additionalAction->shouldReceive('execute')->once()->globally()->ordered(); 146 | 147 | $releaseManager->release('9.1.1', $this->informationCollector); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tests/unit/Vcs/GitTest.php: -------------------------------------------------------------------------------- 1 | expectException(RepositoryNotFoundException::class); 15 | 16 | Git::execute('status'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/unit/Versioning/SemanticVersionTest.php: -------------------------------------------------------------------------------- 1 | assertSame('1.2.3', $version->getVersion()); 17 | } 18 | 19 | public function testThatItHandlesMultiDigitVersions(): void 20 | { 21 | $version = SemanticVersion::createFromVersionString('11.20.31'); 22 | 23 | $this->assertSame('11.20.31', $version->getVersion()); 24 | } 25 | 26 | /** 27 | * @dataProvider getPreReleaseVersions 28 | */ 29 | public function testThatAPreReleaseVersionCanBeCreated(string $versionString, string $preReleaseString): void 30 | { 31 | $version = SemanticVersion::createFromVersionString($versionString); 32 | 33 | $this->assertSame($preReleaseString, $version->getPreReleaseIdentifier()); 34 | $this->assertSame($versionString, $version->getVersion()); 35 | } 36 | 37 | /** 38 | * @return string[][] 39 | */ 40 | public function getPreReleaseVersions(): array 41 | { 42 | return [ 43 | ['1.0.0-alpha', 'alpha'], 44 | ['1.0.0-alpha.1', 'alpha.1'], 45 | ['1.0.0-0.3.7', '0.3.7'], 46 | ['1.0.0-x.7.z.92', 'x.7.z.92'], 47 | ]; 48 | } 49 | 50 | /** 51 | * @dataProvider getInvalidVersions 52 | */ 53 | public function testThatAnInvalidVersionThrowsAnException(string $invalidVersion): void 54 | { 55 | $this->expectException(InvalidArgumentException::class); 56 | 57 | SemanticVersion::createFromVersionString($invalidVersion); 58 | } 59 | 60 | /** 61 | * @return string[][] 62 | */ 63 | public function getInvalidVersions(): array 64 | { 65 | return [ 66 | ['foo'], 67 | ['1'], 68 | ['1.1'], 69 | ['v1.0.0'], 70 | ]; 71 | } 72 | 73 | public function testThatThePatchVersionIsIncremented(): void 74 | { 75 | $version = SemanticVersion::createFromVersionString('1.2.13'); 76 | 77 | $newVersion = $version->incrementPatchVersion(); 78 | 79 | $this->assertSame('1.2.14', $newVersion->getVersion()); 80 | } 81 | 82 | public function testThatTheVersionInstanceIsNotChanged(): void 83 | { 84 | $version = SemanticVersion::createFromVersionString('1.2.13'); 85 | 86 | $version->incrementPatchVersion(); 87 | 88 | $this->assertSame('1.2.13', $version->getVersion()); 89 | } 90 | 91 | public function testThatTheMinorVersionIsIncremented(): void 92 | { 93 | $version = SemanticVersion::createFromVersionString('1.2.13'); 94 | 95 | $newVersion = $version->incrementMinorVersion(); 96 | 97 | $this->assertSame('1.3.0', $newVersion->getVersion()); 98 | } 99 | 100 | public function testThatTheMajorVersionIsIncremented(): void 101 | { 102 | $version = SemanticVersion::createFromVersionString('1.2.13'); 103 | 104 | $newVersion = $version->incrementMajorVersion(); 105 | 106 | $this->assertSame('2.0.0', $newVersion->getVersion()); 107 | } 108 | 109 | public function testThatAnAlphaReleaseIsCreated(): void 110 | { 111 | $version = SemanticVersion::createFromVersionString('1.0.0'); 112 | 113 | $newVersion = $version->createAlphaRelease(); 114 | 115 | $this->assertSame('1.0.0-alpha.1', $newVersion->getVersion()); 116 | } 117 | 118 | public function testThatABetaReleaseIsCreated(): void 119 | { 120 | $version = SemanticVersion::createFromVersionString('1.0.0'); 121 | 122 | $newVersion = $version->createBetaRelease(); 123 | 124 | $this->assertSame('1.0.0-beta.1', $newVersion->getVersion()); 125 | } 126 | 127 | public function testThatAReleaseCandidateIsCreated(): void 128 | { 129 | $version = SemanticVersion::createFromVersionString('1.0.0'); 130 | 131 | $newVersion = $version->createReleaseCandidate(); 132 | 133 | $this->assertSame('1.0.0-rc.1', $newVersion->getVersion()); 134 | } 135 | 136 | /** 137 | * @dataProvider getIncrementedPreReleaseIdentifiers 138 | */ 139 | public function testThatThePreReleaseIdentifierIsIncremented( 140 | string $currentVersionString, 141 | string $newVersionString 142 | ): void { 143 | $version = SemanticVersion::createFromVersionString($currentVersionString); 144 | 145 | $newVersion = $version->createAlphaRelease(); 146 | 147 | $this->assertSame($newVersionString, $newVersion->getVersion()); 148 | } 149 | 150 | /** 151 | * @return string[][] 152 | */ 153 | public function getIncrementedPreReleaseIdentifiers(): array 154 | { 155 | return [ 156 | ['1.2.3-alpha.1', '1.2.3-alpha.2'], 157 | ['1.2.3-alpha.2', '1.2.3-alpha.3'], 158 | ['1.2.3-alpha.9', '1.2.3-alpha.10'], 159 | ['1.2.3-alpha.99', '1.2.3-alpha.100'], 160 | ]; 161 | } 162 | 163 | public function testThatANewPreReleaseTypeResetsTheNumber(): void 164 | { 165 | $version = SemanticVersion::createFromVersionString('1.0.0-alpha.3'); 166 | 167 | $newVersion = $version->createBetaRelease(); 168 | 169 | $this->assertSame('1.0.0-beta.1', $newVersion->getVersion()); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/unit/Versioning/SemanticVersioningTest.php: -------------------------------------------------------------------------------- 1 | informationCollector = Mockery::mock(InformationCollector::class); 22 | } 23 | 24 | public function testThatBackwardIncompatibilitiesResultInMajorVersionBump(): void 25 | { 26 | $semver = new SemanticVersioning(); 27 | 28 | $this->informationCollector 29 | ->shouldReceive('askConfirmation') 30 | ->with('Does this release contain backward incompatible changes?') 31 | ->andReturn(true); 32 | 33 | $version = $semver->getNextVersion( 34 | SemanticVersion::createFromVersionString('1.0.0'), 35 | $this->informationCollector 36 | ); 37 | 38 | $this->assertEquals(SemanticVersion::createFromVersionString('2.0.0'), $version); 39 | } 40 | 41 | public function testThatNewFeaturesResultInMinorVersionBump(): void 42 | { 43 | $semver = new SemanticVersioning(); 44 | 45 | $this->informationCollector 46 | ->shouldReceive('askConfirmation') 47 | ->with('Does this release contain backward incompatible changes?') 48 | ->andReturn(false); 49 | 50 | $this->informationCollector 51 | ->shouldReceive('askConfirmation') 52 | ->with('Does this release contain new features?') 53 | ->andReturn(true); 54 | 55 | $version = $semver->getNextVersion( 56 | SemanticVersion::createFromVersionString('1.0.0'), 57 | $this->informationCollector 58 | ); 59 | 60 | $this->assertEquals(SemanticVersion::createFromVersionString('1.1.0'), $version); 61 | } 62 | 63 | public function testThatBugfixesResultInPatchVersionBump(): void 64 | { 65 | $semver = new SemanticVersioning(); 66 | 67 | $this->informationCollector 68 | ->shouldReceive('askConfirmation') 69 | ->with('Does this release contain backward incompatible changes?') 70 | ->andReturn(false); 71 | 72 | $this->informationCollector 73 | ->shouldReceive('askConfirmation') 74 | ->with('Does this release contain new features?') 75 | ->andReturn(false); 76 | 77 | $version = $semver->getNextVersion( 78 | SemanticVersion::createFromVersionString('1.0.0'), 79 | $this->informationCollector 80 | ); 81 | 82 | $this->assertEquals(SemanticVersion::createFromVersionString('1.0.1'), $version); 83 | } 84 | 85 | /** 86 | * @dataProvider getPreReleaseTypes 87 | */ 88 | public function testThatThePreReleaseTypeCanBeChosen(string $choice, string $type): void 89 | { 90 | $semver = new SemanticVersioning(); 91 | 92 | $this->informationCollector 93 | ->shouldReceive('askConfirmation') 94 | ->andReturn(false, true); 95 | 96 | $this->informationCollector 97 | ->shouldReceive('askMultipleChoice') 98 | ->andReturn($choice); 99 | 100 | $version = $semver->getNextPreReleaseVersion( 101 | SemanticVersion::createFromVersionString('1.0.0'), 102 | $this->informationCollector 103 | ); 104 | 105 | $this->assertEquals(SemanticVersion::createFromVersionString('1.1.0-' . $type . '.1'), $version); 106 | } 107 | 108 | /** 109 | * @return string[][] 110 | */ 111 | public function getPreReleaseTypes(): array 112 | { 113 | return [ 114 | ['a', 'alpha'], 115 | ['b', 'beta'], 116 | ['rc', 'rc'], 117 | ]; 118 | } 119 | 120 | public function testThatTheFirstPreReleaseIncreasesTheVersionNumber(): void 121 | { 122 | $semver = new SemanticVersioning(); 123 | 124 | $this->informationCollector 125 | ->shouldReceive('askConfirmation') 126 | ->andReturn(false, true); 127 | 128 | $this->informationCollector 129 | ->shouldReceive('askMultipleChoice') 130 | ->andReturn('a'); 131 | 132 | $version = $semver->getNextPreReleaseVersion( 133 | SemanticVersion::createFromVersionString('1.0.0'), 134 | $this->informationCollector 135 | ); 136 | 137 | $this->assertEquals(SemanticVersion::createFromVersionString('1.1.0-alpha.1'), $version); 138 | } 139 | 140 | public function testThatAdditionalPreReleasesDoNotIncreaseTheVersionNumber(): void 141 | { 142 | $semver = new SemanticVersioning(); 143 | 144 | $this->informationCollector 145 | ->shouldReceive('askConfirmation') 146 | ->andReturn(false, true); 147 | 148 | $this->informationCollector 149 | ->shouldReceive('askMultipleChoice') 150 | ->andReturn('a'); 151 | 152 | $version = $semver->getNextPreReleaseVersion( 153 | SemanticVersion::createFromVersionString('1.1.0-alpha.1'), 154 | $this->informationCollector 155 | ); 156 | 157 | $this->assertEquals(SemanticVersion::createFromVersionString('1.1.0-alpha.2'), $version); 158 | } 159 | 160 | public function testThatReleasingAPreReleaseDoesNotIncreaseTheVersionNumber(): void 161 | { 162 | $semver = new SemanticVersioning(); 163 | 164 | $version = $semver->getNextVersion( 165 | SemanticVersion::createFromVersionString('1.1.0-alpha.1'), 166 | $this->informationCollector 167 | ); 168 | 169 | $this->assertEquals(SemanticVersion::createFromVersionString('1.1.0'), $version); 170 | } 171 | } 172 | --------------------------------------------------------------------------------