├── .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 |
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 | [](https://github.com/leviy/release-tool/actions?query=workflow%3ATest)
11 | [](https://github.com/leviy/release-tool/blob/master/LICENSE.txt)
12 | [](https://github.com/leviy/release-tool/releases/latest)
13 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------