├── .github └── workflows │ ├── release.yml │ ├── test.build.yml │ └── test.linux.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── commandkey │ └── consts.go ├── flagkey │ └── consts.go ├── header │ └── consts.go ├── internal_test.go ├── run.go └── task │ ├── flag │ ├── any.go │ ├── any_test.go │ ├── dir.go │ ├── dir_test.go │ ├── force.go │ ├── force_test.go │ ├── ignoreempty.go │ ├── ignoreempty_test.go │ ├── newline.go │ ├── newline_test.go │ ├── type.go │ ├── type_test.go │ ├── version.go │ ├── version_test.go │ ├── versionrequired.go │ └── versionrequired_test.go │ ├── latest.go │ ├── next.go │ ├── show.go │ ├── targets.go │ ├── to.go │ └── unreleased.go ├── files ├── glob.go ├── glob_internal_test.go ├── glob_test.go ├── reader.go ├── reader_test.go ├── test │ ├── CHANGELOG.md │ ├── any │ │ └── CHANGELOG.md │ ├── sample.md │ ├── some │ │ ├── CHANGELOG.md │ │ └── nested │ │ │ ├── CHANGELOG.md │ │ │ └── directory │ │ │ └── CHANGELOG.md │ └── vendors │ │ ├── dist │ │ └── CHANGELOG.md │ │ ├── htmlcov │ │ └── CHANGELOG.md │ │ ├── jspm_packages │ │ └── CHANGELOG.md │ │ ├── node_modules │ │ └── CHANGELOG.md │ │ └── vendor │ │ └── CHANGELOG.md ├── writer.go └── writer_test.go ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── parser ├── parser.go ├── parser_test.go ├── semver.go ├── semver_test.go ├── versiontype.go └── versiontype_test.go └── utils ├── pretty.go └── pretty_test.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*.*.*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | # Tips on Go binary: 15 | # https://github.com/actions/setup-go/issues/27#issuecomment-549102955 16 | - name: Build 17 | run: | 18 | export PATH=${PATH}:`go env GOPATH`/bin 19 | make install 20 | make build 21 | env: 22 | QUILL_SIGN_P12: ${{ secrets.APPLE_P12_BASE64_ENCODED }} 23 | QUILL_SIGN_PASSWORD: ${{ secrets.APPLE_P12_PASSWORD }} 24 | 25 | - name: Create a release and upload assets 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | set -x 30 | tag_name="${GITHUB_REF##*/}" 31 | 32 | gh release create $tag_name --generate-notes 33 | gh release upload $tag_name $(find build -name "release_*.zip" -type f | tr '\n' ' ') 34 | -------------------------------------------------------------------------------- /.github/workflows/test.build.yml: -------------------------------------------------------------------------------- 1 | name: Build test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - develop 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Build 18 | run: | 19 | export PATH=${PATH}:`go env GOPATH`/bin 20 | make install 21 | make build 22 | -------------------------------------------------------------------------------- /.github/workflows/test.linux.yml: -------------------------------------------------------------------------------- 1 | name: Test on Linux 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - develop 7 | - master 8 | push: 9 | branches: 10 | - develop 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup runtime 21 | uses: actions/setup-go@v5 22 | with: 23 | node-version: ${{ matrix.node }} 24 | 25 | - name: Test 26 | run: make test 27 | 28 | - name: Upload report to Codecov 29 | run: bash <(curl -s https://codecov.io/bash) 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *tmp 2 | *.db 3 | *-build 4 | *.log 5 | *.pyc 6 | *.swp 7 | *.swo 8 | *~ 9 | 10 | # Files and directories. 11 | .DS_Store 12 | .cache 13 | .dev 14 | .envrc 15 | .env* 16 | .idea 17 | .vendor 18 | .vscode 19 | build 20 | coverage.txt 21 | quill 22 | 23 | # Only applies to top-level directories. 24 | /coverage 25 | 26 | # Exclude these files. 27 | !empty 28 | !.keep 29 | !.gitkeep 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build run test 2 | 3 | BUILD := ./build 4 | 5 | install: 6 | go install github.com/mitchellh/gox@latest 7 | curl -sSfL https://raw.githubusercontent.com/anchore/quill/main/install.sh | sh -s -- -b . v0.4.2 8 | 9 | clean: 10 | mkdir -p $(BUILD) 11 | rm -Rf $(BUILD)/* 12 | 13 | bundle-nix: 14 | cd $(BUILD) && find . -type f ! -name '*.exe' | xargs -I % sh -c "mv % release && zip %.zip release && rm -f release" 15 | 16 | bundle-windows: 17 | cd $(BUILD) && find . -type f -name '*.exe' | xargs -I % sh -c "mv % release.exe && zip %.zip release.exe && rm -f release.exe" 18 | 19 | build: clean 20 | @echo "Building.." 21 | gox -output="$(BUILD)/{{.Dir}}_{{.OS}}_{{.Arch}}" \ 22 | -osarch="darwin/amd64" \ 23 | -osarch="darwin/arm64" \ 24 | -osarch="linux/arm" \ 25 | -osarch="linux/amd64" \ 26 | -osarch="windows/amd64" 27 | 28 | @echo "Notarizing MacOS binary.." 29 | ./quill sign-and-notarize ./build/release_darwin_amd64 || true 30 | ./quill sign-and-notarize ./build/release_darwin_arm64 || true 31 | 32 | @echo "Bundling.." 33 | $(MAKE) bundle-nix 34 | $(MAKE) bundle-windows 35 | 36 | run: 37 | go run main.go 38 | 39 | test: 40 | go test -cover -count 1 -race -coverprofile=coverage.txt -covermode=atomic ./... 41 | 42 | cover: 43 | go test -cover -coverprofile coverage.log ./... && go tool cover -html=coverage.log 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/tomodian/release.svg)](https://pkg.go.dev/github.com/tomodian/release) 4 | [![Test on Linux](https://github.com/tomodian/release/actions/workflows/test.linux.yml/badge.svg)](https://github.com/tomodian/release/actions/workflows/test.linux.yml) 5 | [![Release](https://github.com/tomodian/release/actions/workflows/release.yml/badge.svg)](https://github.com/tomodian/release/actions/workflows/release.yml) 6 | [![codecov](https://codecov.io/gh/tomodian/release/branch/develop/graph/badge.svg)](https://codecov.io/gh/tomodian/release) 7 | 8 | [![GitHub All Releases](https://img.shields.io/github/downloads/tomodian/release/total?style=social)](https://github.com/tomodian/release/releases) 9 | 10 | A small command-line utility to manage CHANGELOG.md written in [keepachangelog.com](https://keepachangelog.com) format. 11 | 12 | Works nicely on any sized Git repository, even awesome on [Monorepo](https://en.wikipedia.org/wiki/Monorepo). 13 | 14 | ## Installation 15 | 16 | Please download ZIP archive from [releases](https://github.com/tomodian/release/releases) page. 17 | 18 | ## How it works 19 | 20 | Run `release` to show full list of commands and flags. 21 | 22 | ### List all CHANGELOG.md 23 | 24 | `release target` will show you all CHANGELOG.md files recursively. 25 | 26 | ```bash 27 | release target 28 | release target --dir path/to/entrypoint 29 | release t -d path/to/entrypoint 30 | ``` 31 | 32 | ### See unreleased changes 33 | 34 | `release unreleased` will grab `[Unreleased]` sections of all CHANGELOG.md files recursively. 35 | 36 | ```bash 37 | release unreleased 38 | release unreleased --dir path/to/entrypoint 39 | release u -d path/to/entrypoint 40 | ``` 41 | 42 | ### See previous versions 43 | 44 | `release show` will output all previous version histories. 45 | 46 | ```bash 47 | release show -v 0.1.0 48 | release show -v 0.1.0 --dir path/to/entrypoint 49 | release s -v 0.1.0 -d path/to/entrypoint 50 | ``` 51 | 52 | ### Show the latest released version in current directory 53 | 54 | ```bash 55 | release latest 56 | release latest --newline=false 57 | release l 58 | ``` 59 | 60 | ### Bump all [Unreleased] sections to given version 61 | 62 | By default, `release to -v X.Y.Z` will ask you for confirmation. 63 | 64 | ```bash 65 | release to -v 0.2.0 66 | 67 | # Targets 68 | ## .github/workflows/CHANGELOG.md 69 | ## CHANGELOG.md 70 | ✔ Enter `yes` to update all CHANGELOGs to version [0.8.0]: yes 71 | ``` 72 | 73 | If you want to integrate with CI pipeline, use `--force` or `-f`. 74 | 75 | ```bash 76 | release to -v 0.2.0 --force 77 | 78 | # Targets 79 | ## .github/workflows/CHANGELOG.md --> ✅ 80 | ## CHANGELOG.md --> ✅ 81 | Done👍 82 | ``` 83 | 84 | ### See next release version 85 | 86 | `release next` will suggest you the next available version. 87 | 88 | ```bash 89 | release next 90 | 91 | Latest released version: 0.8.0 92 | 93 | Suggestions for next release: 94 | - Major / Release --> 1.0.0 95 | - Minor / Feature --> 0.9.0 96 | - Patch / Hotfix --> 0.8.1 97 | ``` 98 | 99 | For CI integrations, add `--type` flag. 100 | The words `major`, `minor` and `patch` comes from [Semantic Versioning 2.0.0](https://semver.org) idiom. 101 | 102 | ```bash 103 | release next --type major 104 | 1.0.0 105 | 106 | release next --type minor 107 | 0.9.0 108 | 109 | release next --type patch 110 | 0.8.1 111 | ``` 112 | 113 | Note this command will not add newline when `--type` flag is specified. 114 | Use `--newline` flag if you prefer to see the newline. 115 | 116 | ```bash 117 | release next --type major --newline 118 | ``` 119 | 120 | [GitFlow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) idiom is also supported. 121 | 122 | ```bash 123 | release next --type release 124 | 1.0.0 125 | 126 | release next --type feature 127 | 0.9.0 128 | 129 | release next --type hotfix 130 | 0.8.1 131 | ``` 132 | 133 | ### Github-style semver `vx.y.z` 134 | 135 | The tool also supports [Github-style semver](https://semver.org/#is-v123-a-semantic-version): 136 | 137 | ```bash 138 | release show -v v0.1.0 139 | release to -v v0.2.0 140 | ``` 141 | 142 | ## Development 143 | 144 | ### Run 145 | 146 | ```bash 147 | make run 148 | ``` 149 | 150 | ### Test 151 | 152 | ```bash 153 | make test 154 | ``` 155 | 156 | ### Build 157 | 158 | ```bash 159 | make build 160 | ``` 161 | 162 | ## License 163 | 164 | [Mozilla Public License v2.0](LICENSE) 165 | -------------------------------------------------------------------------------- /cmd/commandkey/consts.go: -------------------------------------------------------------------------------- 1 | package commandkey 2 | 3 | // Subcommand keys 4 | const ( 5 | Targets = "targets" 6 | Latest = "latest" 7 | Unreleased = "unreleased" 8 | Next = "next" 9 | To = "to" 10 | ) 11 | -------------------------------------------------------------------------------- /cmd/flagkey/consts.go: -------------------------------------------------------------------------------- 1 | package flagkey 2 | 3 | // Flag keys 4 | const ( 5 | Any = "any" 6 | Directory = "dir" 7 | IgnoreEmpty = "ignore" 8 | Newline = "newline" 9 | Version = "version" 10 | Force = "force" 11 | Type = "type" 12 | ) 13 | -------------------------------------------------------------------------------- /cmd/header/consts.go: -------------------------------------------------------------------------------- 1 | package header 2 | 3 | // Headings 4 | const ( 5 | Target = "# Targets" 6 | ) 7 | -------------------------------------------------------------------------------- /cmd/internal_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/tomodian/release/cmd/commandkey" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | // See GitHub discussion for testing CLI apps. 13 | // https://github.com/urfave/cli/issues/731 14 | 15 | func TestRun(t *testing.T) { 16 | { 17 | // Success cases. 18 | pats := []string{ 19 | "", 20 | commandkey.Targets, "target", "t", 21 | commandkey.Unreleased, "u", 22 | // cmdNext, "n", 23 | commandkey.To, 24 | } 25 | 26 | for _, p := range pats { 27 | args := os.Args[0:1] 28 | args = append(args, p) 29 | 30 | require.NotPanics(t, func() { 31 | // TODO: handle errors 32 | _ = Run(args) 33 | }) 34 | } 35 | } 36 | 37 | { 38 | // Fail case for `next` task. 39 | pats := [][]string{ 40 | {"next", "--dir", "non-existent"}, 41 | } 42 | 43 | for _, p := range pats { 44 | os.Args = p 45 | 46 | require.NotNilf(t, Run(os.Args), "#%v", p) 47 | } 48 | } 49 | 50 | { 51 | // Fail case for `show` task. 52 | pats := [][]string{ 53 | {"show", "--dir", "non-existent"}, 54 | {"show", "--dir", "non-existent", "--version", "x.y.z"}, 55 | } 56 | 57 | for _, p := range pats { 58 | os.Args = p 59 | 60 | require.NotNilf(t, Run(os.Args), "#%v", p) 61 | } 62 | } 63 | 64 | { 65 | // Fail case for `to` task. 66 | pats := [][]string{ 67 | {"to", "--dir", "non-existent"}, 68 | {"to", "--dir", "non-existent", "--version", "x.x.x"}, 69 | } 70 | 71 | for _, p := range pats { 72 | os.Args = p 73 | 74 | require.NotNilf(t, Run(os.Args), "#%v", p) 75 | } 76 | } 77 | 78 | { 79 | // Fail case for `version` task. 80 | pats := [][]string{ 81 | {"version", "--dir", "non-existent"}, 82 | } 83 | 84 | for _, p := range pats { 85 | os.Args = p 86 | 87 | require.NotNilf(t, Run(os.Args), "#%v", p) 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/tomodian/release/cmd/task" 9 | 10 | "github.com/urfave/cli/v2" 11 | ) 12 | 13 | // Run it. 14 | func Run(args []string) error { 15 | wd, err := os.Getwd() 16 | 17 | if err != nil { 18 | log.Fatal(err) 19 | } 20 | 21 | app := &cli.App{ 22 | Name: "release", 23 | Usage: "Manage changelog for your release process 🚀", 24 | Commands: []*cli.Command{ 25 | task.Targets(wd), 26 | task.Latest(wd), 27 | task.Unreleased(wd), 28 | task.Show(wd), 29 | task.To(wd), 30 | task.Next(wd), 31 | }, 32 | } 33 | 34 | if err := app.Run(args); err != nil { 35 | fmt.Println("Error!") 36 | fmt.Println(err) 37 | return err 38 | } 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /cmd/task/flag/any.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func Any(workdir string) *cli.BoolFlag { 10 | return &cli.BoolFlag{ 11 | Name: flagkey.Any, 12 | Value: false, 13 | Usage: "ignore semantic versioning and grab anything inside [string]", 14 | Aliases: []string{"a"}, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cmd/task/flag/any_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestAny(t *testing.T) { 10 | got := Any("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/dir.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func Dir(workdir string) *cli.StringFlag { 10 | return &cli.StringFlag{ 11 | Name: flagkey.Directory, 12 | Value: workdir, 13 | Usage: "target `DIR`", 14 | Aliases: []string{"d"}, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cmd/task/flag/dir_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestDir(t *testing.T) { 10 | got := Dir("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/force.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func Force(workdir string) *cli.BoolFlag { 10 | return &cli.BoolFlag{ 11 | Name: flagkey.Force, 12 | Usage: "force without prompt, Mainly for CI environment", 13 | Aliases: []string{"f"}, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/task/flag/force_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestForce(t *testing.T) { 10 | got := Force("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/ignoreempty.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func IgnoreEmpty(workdir string) *cli.BoolFlag { 10 | return &cli.BoolFlag{ 11 | Name: flagkey.IgnoreEmpty, 12 | Value: false, 13 | Usage: "ignore output for changelog without changes", 14 | Aliases: []string{"i"}, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cmd/task/flag/ignoreempty_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestIgnoreEmpty(t *testing.T) { 10 | got := IgnoreEmpty("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/newline.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func Newline(workdir string, val bool) *cli.BoolFlag { 10 | return &cli.BoolFlag{ 11 | Name: flagkey.Newline, 12 | Usage: "add newline at the end of certain output", 13 | Value: val, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/task/flag/newline_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestNewline(t *testing.T) { 10 | got := Newline("/tmp", true) 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/type.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | 6 | "github.com/urfave/cli/v2" 7 | ) 8 | 9 | func Type(workdir string) *cli.StringFlag { 10 | return &cli.StringFlag{ 11 | Name: flagkey.Type, 12 | Usage: "Semver `TYPE`: X.Y.Z refers to {major}.{minor}.{patch} or {release}.{feature}.{hotfix}", 13 | Aliases: []string{"t"}, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /cmd/task/flag/type_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestType(t *testing.T) { 10 | got := Type("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/version.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/tomodian/release/cmd/flagkey" 5 | "github.com/tomodian/release/parser" 6 | 7 | "github.com/urfave/cli/v2" 8 | ) 9 | 10 | func Version(workdir string) *cli.StringFlag { 11 | return &cli.StringFlag{ 12 | Name: flagkey.Version, 13 | Usage: "target `VERSION`", 14 | Value: parser.Unreleased, 15 | Aliases: []string{"v"}, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /cmd/task/flag/version_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestVersion(t *testing.T) { 10 | got := Version("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/flag/versionrequired.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "github.com/urfave/cli/v2" 5 | ) 6 | 7 | func VersionRequired(workdir string) *cli.StringFlag { 8 | v := Version(workdir) 9 | 10 | return &cli.StringFlag{ 11 | Name: v.Name, 12 | Usage: v.Usage, 13 | Aliases: v.Aliases, 14 | Required: true, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cmd/task/flag/versionrequired_test.go: -------------------------------------------------------------------------------- 1 | package flag 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestVersionRequired(t *testing.T) { 10 | got := VersionRequired("/tmp") 11 | 12 | require.NotNil(t, got) 13 | } 14 | -------------------------------------------------------------------------------- /cmd/task/latest.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/tomodian/release/cmd/commandkey" 7 | "github.com/tomodian/release/cmd/flagkey" 8 | "github.com/tomodian/release/cmd/task/flag" 9 | "github.com/tomodian/release/files" 10 | "github.com/tomodian/release/parser" 11 | 12 | "github.com/urfave/cli/v2" 13 | ) 14 | 15 | func Latest(workdir string) *cli.Command { 16 | return &cli.Command{ 17 | Name: commandkey.Latest, 18 | Usage: "Show the latest released version in current directory", 19 | Aliases: []string{"l"}, 20 | Flags: []cli.Flag{ 21 | flag.Any(workdir), 22 | flag.Dir(workdir), 23 | flag.Newline(workdir, true), 24 | }, 25 | Action: func(c *cli.Context) error { 26 | 27 | doc, err := files.Read(fmt.Sprintf("%s/CHANGELOG.md", workdir)) 28 | 29 | if err != nil { 30 | return err 31 | } 32 | 33 | if c.Bool(flagkey.Any) { 34 | got, err := parser.LatestAny(doc) 35 | 36 | if err != nil { 37 | return err 38 | } 39 | 40 | fmt.Println(got) 41 | 42 | return nil 43 | } 44 | 45 | got, err := parser.Latest(doc) 46 | 47 | if err != nil { 48 | return err 49 | } 50 | 51 | if c.Bool(flagkey.Newline) { 52 | fmt.Println(got) 53 | return nil 54 | } 55 | 56 | fmt.Print(got) 57 | 58 | return nil 59 | }, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /cmd/task/next.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/tomodian/release/cmd/commandkey" 8 | "github.com/tomodian/release/cmd/flagkey" 9 | "github.com/tomodian/release/cmd/task/flag" 10 | "github.com/tomodian/release/files" 11 | "github.com/tomodian/release/parser" 12 | 13 | "github.com/ttacon/chalk" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | func Next(workdir string) *cli.Command { 18 | return &cli.Command{ 19 | Name: commandkey.Next, 20 | Usage: "Suggest next version by checking CHANGELOGs recursively", 21 | Aliases: []string{"n"}, 22 | Flags: []cli.Flag{ 23 | flag.Dir(workdir), 24 | flag.Type(workdir), 25 | flag.Newline(workdir, false), 26 | }, 27 | Action: func(c *cli.Context) error { 28 | 29 | latests := map[string]parser.SemanticVersion{} 30 | 31 | // Construct a map of versions. 32 | for _, p := range files.Glob(c.String(flagkey.Directory)) { 33 | doc, err := files.Read(p) 34 | 35 | if err != nil { 36 | return err 37 | } 38 | 39 | lat, err := parser.Latest(doc) 40 | 41 | if err != nil { 42 | continue 43 | } 44 | 45 | if _, exists := latests[lat]; exists { 46 | continue 47 | } 48 | 49 | v, err := parser.NewSemanticVersion(lat) 50 | 51 | if err != nil { 52 | fmt.Println(err) 53 | os.Exit(1) 54 | } 55 | 56 | latests[lat] = *v 57 | } 58 | 59 | vers := []parser.SemanticVersion{} 60 | 61 | for k := range latests { 62 | vers = append(vers, latests[k]) 63 | } 64 | 65 | vers = parser.SortVersions(vers) 66 | ver := vers[len(vers)-1] 67 | 68 | tflag := c.String(flagkey.Type) 69 | 70 | // Print all possible versions when user did not specify the specific type. 71 | if tflag == "" { 72 | fmt.Println("") 73 | fmt.Println("Latest released version:", chalk.Magenta.Color(ver.String())) 74 | fmt.Println("") 75 | fmt.Println("Suggestions for next release:") 76 | fmt.Println(" - Major / Release -->", chalk.Magenta.Color((ver.Increment(parser.MajorVersion).String()))) 77 | fmt.Println(" - Minor / Feature -->", chalk.Magenta.Color(ver.Increment(parser.MinorVersion).String())) 78 | fmt.Println(" - Patch / Hotfix -->", chalk.Magenta.Color(ver.Increment(parser.PatchVersion).String())) 79 | fmt.Println("") 80 | 81 | return nil 82 | } 83 | 84 | vtype, err := parser.AliasedVersion(tflag) 85 | 86 | if err != nil { 87 | fmt.Println(err) 88 | os.Exit(1) 89 | } 90 | 91 | var out = "" 92 | 93 | switch vtype { 94 | 95 | case parser.MajorVersion: 96 | out = ver.Increment(parser.MajorVersion).String() 97 | 98 | case parser.MinorVersion: 99 | out = ver.Increment(parser.MinorVersion).String() 100 | 101 | case parser.PatchVersion: 102 | out = ver.Increment(parser.PatchVersion).String() 103 | } 104 | 105 | if c.Bool(flagkey.Newline) { 106 | fmt.Println(out) 107 | return nil 108 | } 109 | 110 | // Note for zsh users: 111 | // - Newline is always present. 112 | // - STDOUT will be suffixed with percentage sign `%`, which could be muted in zsh configuration. 113 | // https://unix.stackexchange.com/questions/167582/why-zsh-ends-a-line-with-a-highlighted-percent-symbol 114 | fmt.Print(out) 115 | 116 | return nil 117 | }, 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /cmd/task/show.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/tomodian/release/cmd/flagkey" 7 | "github.com/tomodian/release/cmd/task/flag" 8 | "github.com/tomodian/release/files" 9 | "github.com/tomodian/release/parser" 10 | "github.com/tomodian/release/utils" 11 | 12 | "github.com/ttacon/chalk" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func Show(workdir string) *cli.Command { 17 | return &cli.Command{ 18 | Name: "show", 19 | Usage: "Show changes of given version", 20 | Aliases: []string{"s"}, 21 | Flags: []cli.Flag{ 22 | flag.VersionRequired(workdir), 23 | flag.Dir(workdir), 24 | flag.IgnoreEmpty(workdir), 25 | }, 26 | Action: func(c *cli.Context) error { 27 | 28 | for _, p := range files.Glob(c.String(flagkey.Directory)) { 29 | doc, err := files.Read(p) 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | outs, err := parser.Show(doc, c.String(flagkey.Version)) 36 | 37 | if err != nil { 38 | return err 39 | } 40 | 41 | if c.Bool(flagkey.IgnoreEmpty) && len(outs) == 0 { 42 | continue 43 | } 44 | 45 | fmt.Println(chalk.Magenta.Color(files.Rel(p))) 46 | fmt.Println("") 47 | 48 | if len(outs) == 0 { 49 | fmt.Println(utils.Pretty(utils.EmptyLine)) 50 | fmt.Println("") 51 | continue 52 | } 53 | 54 | for _, o := range outs { 55 | fmt.Println(utils.Pretty(o)) 56 | } 57 | 58 | fmt.Println("") 59 | } 60 | 61 | return nil 62 | }, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /cmd/task/targets.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/tomodian/release/cmd/commandkey" 7 | "github.com/tomodian/release/cmd/flagkey" 8 | "github.com/tomodian/release/cmd/header" 9 | "github.com/tomodian/release/cmd/task/flag" 10 | "github.com/tomodian/release/files" 11 | "github.com/tomodian/release/utils" 12 | 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | // Targets returns all CHANGELOG.md files. 17 | func Targets(workdir string) *cli.Command { 18 | return &cli.Command{ 19 | Name: commandkey.Targets, 20 | Usage: "List all CHANGELOG.md files", 21 | Aliases: []string{"target", "t"}, 22 | Flags: []cli.Flag{ 23 | flag.Dir(workdir), 24 | }, 25 | Action: func(c *cli.Context) error { 26 | fmt.Println(utils.Pretty(header.Target)) 27 | 28 | for _, p := range files.Glob(c.String(flagkey.Directory)) { 29 | fmt.Println(files.Rel(p)) 30 | } 31 | 32 | return nil 33 | }, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /cmd/task/to.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/tomodian/release/cmd/commandkey" 7 | "github.com/tomodian/release/cmd/flagkey" 8 | "github.com/tomodian/release/cmd/header" 9 | "github.com/tomodian/release/cmd/task/flag" 10 | "github.com/tomodian/release/files" 11 | "github.com/tomodian/release/parser" 12 | 13 | "github.com/manifoldco/promptui" 14 | "github.com/ttacon/chalk" 15 | "github.com/urfave/cli/v2" 16 | ) 17 | 18 | func To(workdir string) *cli.Command { 19 | return &cli.Command{ 20 | Name: commandkey.To, 21 | Usage: "Bump all [Unreleased] sections to given version", 22 | Flags: []cli.Flag{ 23 | flag.VersionRequired(workdir), 24 | flag.Dir(workdir), 25 | flag.Force(workdir), 26 | }, 27 | Action: func(c *cli.Context) error { 28 | 29 | v, err := parser.Version(c.String(flagkey.Version)) 30 | 31 | if err != nil { 32 | return err 33 | } 34 | 35 | targets := files.Glob(c.String(flagkey.Directory)) 36 | 37 | if len(targets) == 0 { 38 | fmt.Println("(nothing found)") 39 | return nil 40 | } 41 | 42 | fmt.Println(chalk.Magenta.Color(header.Target)) 43 | 44 | for _, p := range files.Glob(c.String(flagkey.Directory)) { 45 | fmt.Println(files.Rel(p)) 46 | } 47 | 48 | if !c.Bool(flagkey.Force) { 49 | agreed := "yes" 50 | 51 | prompt := promptui.Prompt{ 52 | Label: chalk.Magenta.Color(fmt.Sprintf("Enter `%s` to update all CHANGELOGs to version %s", agreed, v)), 53 | } 54 | 55 | picked, err := prompt.Run() 56 | 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if picked != agreed { 62 | fmt.Println("Cancelled") 63 | return nil 64 | } 65 | } 66 | 67 | fmt.Println("") 68 | 69 | for _, p := range files.Glob(c.String(flagkey.Directory)) { 70 | fmt.Printf("%s --> ", files.Rel(p)) 71 | 72 | doc, err := files.Read(p) 73 | 74 | if err != nil { 75 | return err 76 | } 77 | 78 | body, err := parser.To(doc, c.String(flagkey.Version)) 79 | 80 | if err != nil { 81 | return err 82 | } 83 | 84 | if len(body) == 0 { 85 | fmt.Println("skipped") 86 | continue 87 | } 88 | 89 | if err := files.Update(p, body); err != nil { 90 | fmt.Println("❌") 91 | continue 92 | } 93 | 94 | fmt.Println("✅") 95 | } 96 | 97 | fmt.Println("Done👍") 98 | 99 | return nil 100 | }, 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /cmd/task/unreleased.go: -------------------------------------------------------------------------------- 1 | package task 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/tomodian/release/cmd/commandkey" 7 | "github.com/tomodian/release/cmd/flagkey" 8 | "github.com/tomodian/release/cmd/task/flag" 9 | "github.com/tomodian/release/files" 10 | "github.com/tomodian/release/parser" 11 | "github.com/tomodian/release/utils" 12 | 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func Unreleased(workdir string) *cli.Command { 17 | return &cli.Command{ 18 | Name: commandkey.Unreleased, 19 | Usage: fmt.Sprintf("List all changes for %s", parser.Unreleased), 20 | Aliases: []string{"u"}, 21 | Flags: []cli.Flag{ 22 | flag.Dir(workdir), 23 | flag.IgnoreEmpty(workdir), 24 | }, 25 | Action: func(c *cli.Context) error { 26 | 27 | for _, p := range files.Glob(c.String(flagkey.Directory)) { 28 | doc, err := files.Read(p) 29 | 30 | if err != nil { 31 | return err 32 | } 33 | 34 | outs, err := parser.Show(doc, parser.Unreleased) 35 | 36 | if err != nil { 37 | return err 38 | } 39 | 40 | if c.Bool(flagkey.IgnoreEmpty) && len(outs) == 0 { 41 | continue 42 | } 43 | 44 | fmt.Println(utils.Pretty(files.Rel(p))) 45 | fmt.Println("") 46 | 47 | if len(outs) == 0 { 48 | fmt.Println(utils.Pretty(utils.EmptyLine)) 49 | fmt.Println("") 50 | continue 51 | } 52 | 53 | for _, o := range outs { 54 | fmt.Println(utils.Pretty(o)) 55 | } 56 | 57 | fmt.Println("") 58 | } 59 | 60 | return nil 61 | }, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /files/glob.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/bmatcuk/doublestar" 12 | ) 13 | 14 | var ( 15 | excludes = []string{ 16 | "__pycache__", 17 | "_compareTemp", 18 | "_notes", 19 | ".cache", 20 | ".dynamodb", 21 | ".eggs", 22 | ".grunt", 23 | ".idea", 24 | ".ipynb_checkpoints", 25 | ".mypy_cache", 26 | ".next", 27 | ".npm", 28 | ".parcel-cache", 29 | ".phpunit.result.cache", 30 | ".prof", 31 | ".sass-cache", 32 | ".scrapy", 33 | ".terraform", 34 | ".vagrant", 35 | ".vendor", 36 | ".vs", 37 | ".vscode", 38 | ".vuepress", 39 | "bower_components", 40 | "build", 41 | "coverage", 42 | "dist", 43 | "DocProject", 44 | "htmlcov", 45 | "jspm_packages", 46 | "node_modules", 47 | "vendor", 48 | "x64", 49 | "x86", 50 | } 51 | ) 52 | 53 | func ignore(path string) bool { 54 | slash := filepath.ToSlash(path) 55 | 56 | for _, e := range excludes { 57 | chunks := strings.Split(slash, "/") 58 | 59 | for _, c := range chunks { 60 | if c == e { 61 | return true 62 | } 63 | } 64 | } 65 | 66 | return false 67 | } 68 | 69 | // Glob seeks for all changelog files from the given directory, and returns a slice of absolute file path. 70 | // This function excludes common auto-generated directories, such as node_modules and coverage reports. 71 | func Glob(d string) []string { 72 | p := fmt.Sprintf("%s/**/CHANGELOG.md", d) 73 | 74 | paths, err := doublestar.Glob(p) 75 | 76 | if err != nil { 77 | panic("malformed path pattern") 78 | } 79 | 80 | sort.Slice(paths, func(i, j int) bool { 81 | return paths[i] < paths[j] 82 | }) 83 | 84 | outs := []string{} 85 | 86 | // Exclude vendor directories. 87 | for _, p := range paths { 88 | 89 | if ignore(p) { 90 | continue 91 | } 92 | 93 | outs = append(outs, p) 94 | } 95 | 96 | return outs 97 | } 98 | 99 | // Rel takes an arbitary path, and returns relative path from the current working directory. 100 | // It will terminate the context when the given path is broken. 101 | func Rel(path string) string { 102 | wd, err := os.Getwd() 103 | 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | 108 | rel, err := filepath.Rel(wd, path) 109 | 110 | if err != nil { 111 | log.Fatal(err) 112 | } 113 | 114 | return fmt.Sprintf("## %s", rel) 115 | } 116 | -------------------------------------------------------------------------------- /files/glob_internal_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestInternalIgnore(t *testing.T) { 10 | { 11 | // Truthy patterns. 12 | pats := append([]string{ 13 | "/node_modules", 14 | "/nested/.cache/dir", 15 | }, excludes...) 16 | 17 | for _, p := range pats { 18 | got := ignore(p) 19 | 20 | assert.Truef(t, got, "%s", p) 21 | } 22 | } 23 | 24 | { 25 | // Falsy patterns. 26 | pats := []string{ 27 | "/some/path/for/", 28 | } 29 | 30 | for _, p := range pats { 31 | got := ignore(p) 32 | 33 | assert.Falsef(t, got, "%s", p) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /files/glob_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/tomodian/release/files" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestGlob(t *testing.T) { 15 | 16 | pwd, err := os.Getwd() 17 | 18 | require.Nil(t, err) 19 | 20 | type pattern struct { 21 | path string 22 | count int 23 | } 24 | 25 | { 26 | // Fail cases, panic. 27 | pats := []pattern{ 28 | { 29 | path: "[-]", 30 | }, 31 | } 32 | 33 | for _, p := range pats { 34 | assert.Panics(t, func() { 35 | files.Glob(p.path) 36 | }) 37 | } 38 | } 39 | 40 | { 41 | // Fail case, ensure vendor directories are not included. 42 | path := fmt.Sprintf("%s/test/vendors", pwd) 43 | 44 | assert.Empty(t, files.Glob(path)) 45 | } 46 | 47 | { 48 | // Success cases. 49 | pats := []pattern{ 50 | { 51 | path: fmt.Sprintf("%s/test", pwd), 52 | count: 5, 53 | }, 54 | { 55 | path: fmt.Sprintf("%s/test/some", pwd), 56 | count: 3, 57 | }, 58 | { 59 | path: fmt.Sprintf("%s/test/some/nested", pwd), 60 | count: 2, 61 | }, 62 | { 63 | path: fmt.Sprintf("%s/test/some/nested/directory", pwd), 64 | count: 1, 65 | }, 66 | { 67 | path: fmt.Sprintf("%s/test/some/nested/directory/NotExistent", pwd), 68 | count: 0, 69 | }, 70 | } 71 | 72 | for _, p := range pats { 73 | got := files.Glob(p.path) 74 | 75 | require.Nilf(t, err, "%s", p) 76 | assert.Equalf(t, p.count, len(got), "%s", p.path) 77 | } 78 | } 79 | } 80 | 81 | func TestRel(t *testing.T) { 82 | type pattern struct { 83 | path string 84 | } 85 | 86 | { 87 | // Success cases. 88 | pwd, err := os.Getwd() 89 | 90 | require.Nil(t, err) 91 | 92 | pats := []pattern{ 93 | { 94 | path: fmt.Sprintf("%s/test", pwd), 95 | }, 96 | { 97 | path: fmt.Sprintf("%s/test/some", pwd), 98 | }, 99 | { 100 | path: fmt.Sprintf("%s/test/some/nested", pwd), 101 | }, 102 | { 103 | path: fmt.Sprintf("%s/test/some/nested/directory", pwd), 104 | }, 105 | { 106 | path: fmt.Sprintf("%s/test/some/nested/directory/NotExistent", pwd), 107 | }, 108 | } 109 | 110 | for _, p := range pats { 111 | assert.NotPanics(t, func() { 112 | files.Rel(p.path) 113 | }) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /files/reader.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | ) 7 | 8 | // Read file content of given path. 9 | func Read(path string) (string, error) { 10 | if path == "" { 11 | return "", errors.New("given path is empty") 12 | } 13 | 14 | byt, err := ioutil.ReadFile(path) 15 | 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | return string(byt), nil 21 | } 22 | -------------------------------------------------------------------------------- /files/reader_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tomodian/release/files" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestRead(t *testing.T) { 13 | { 14 | // Fail cases. 15 | pats := []string{ 16 | "", 17 | "./non-existent", 18 | "whatever", 19 | } 20 | 21 | for _, p := range pats { 22 | _, err := files.Read(p) 23 | 24 | require.NotNilf(t, err, "%s", err) 25 | } 26 | } 27 | 28 | { 29 | // Success cases. 30 | pats := []string{ 31 | "test/sample.md", 32 | } 33 | 34 | for _, p := range pats { 35 | doc, err := files.Read(p) 36 | 37 | require.Nil(t, err) 38 | assert.NotEmpty(t, doc) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /files/test/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | 9 | ## [0.2.0] 10 | 11 | ### Added 12 | 13 | - Bar 14 | 15 | ## [0.1.0] 16 | 17 | ### Added 18 | 19 | - Init 20 | -------------------------------------------------------------------------------- /files/test/any/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | 9 | ## [just-a-demo:123] 10 | 11 | ### Added 12 | 13 | - Bar 14 | -------------------------------------------------------------------------------- /files/test/sample.md: -------------------------------------------------------------------------------- 1 | world -------------------------------------------------------------------------------- /files/test/some/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | 9 | ## [0.2.0] 10 | 11 | ### Added 12 | 13 | - Bar 14 | 15 | ## [0.1.0] 16 | 17 | ### Added 18 | 19 | - Init 20 | -------------------------------------------------------------------------------- /files/test/some/nested/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | 9 | ## [0.2.0] 10 | 11 | ### Added 12 | 13 | - Bar 14 | 15 | ## [0.1.0] 16 | 17 | ### Added 18 | 19 | - Init 20 | -------------------------------------------------------------------------------- /files/test/some/nested/directory/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | 9 | ## [0.2.0] 10 | 11 | ### Added 12 | 13 | - Bar 14 | 15 | ## [0.1.0] 16 | 17 | ### Added 18 | 19 | - Init 20 | -------------------------------------------------------------------------------- /files/test/vendors/dist/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | -------------------------------------------------------------------------------- /files/test/vendors/htmlcov/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | -------------------------------------------------------------------------------- /files/test/vendors/jspm_packages/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | -------------------------------------------------------------------------------- /files/test/vendors/node_modules/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | -------------------------------------------------------------------------------- /files/test/vendors/vendor/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ### Added 6 | 7 | - Foo 8 | -------------------------------------------------------------------------------- /files/writer.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "errors" 5 | "io/ioutil" 6 | "os" 7 | ) 8 | 9 | // Update overwrites the existing file while retaining the original file permission. 10 | func Update(path, doc string) error { 11 | 12 | if path == "" || doc == "" { 13 | return errors.New("given input is empty") 14 | } 15 | 16 | info, err := os.Stat(path) 17 | 18 | if err != nil { 19 | return nil 20 | } 21 | 22 | if err := ioutil.WriteFile(path, []byte(doc), info.Mode()); err != nil { 23 | return err 24 | } 25 | 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /files/writer_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/tomodian/release/files" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestUpdate(t *testing.T) { 14 | 15 | type pattern struct { 16 | path string 17 | doc string 18 | } 19 | 20 | { 21 | // Fail cases. 22 | pats := []pattern{ 23 | { 24 | path: "", 25 | doc: "", 26 | }, 27 | { 28 | path: "./non-existent", 29 | doc: "", 30 | }, 31 | { 32 | path: "", 33 | doc: "whatever", 34 | }, 35 | } 36 | 37 | for _, p := range pats { 38 | err := files.Update(p.path, p.doc) 39 | 40 | require.NotNilf(t, err, "%s", err) 41 | } 42 | } 43 | 44 | { 45 | // Fail case, non-existent path. 46 | err := files.Update("non-existent", "foo") 47 | 48 | require.Nil(t, err) 49 | } 50 | 51 | { 52 | // Success cases. 53 | pats := []pattern{ 54 | { 55 | path: "test/sample.md", 56 | doc: "world", 57 | }, 58 | } 59 | 60 | for _, p := range pats { 61 | info, err := os.Stat(p.path) 62 | require.Nil(t, err) 63 | 64 | assert.Nil(t, files.Update(p.path, p.doc)) 65 | 66 | up, err := os.Stat(p.path) 67 | require.Nil(t, err) 68 | 69 | assert.Equal(t, info.Mode(), up.Mode()) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tomodian/release 2 | 3 | go 1.23.3 4 | 5 | require ( 6 | github.com/blang/semver/v4 v4.0.0 7 | github.com/bmatcuk/doublestar v1.3.4 8 | github.com/davecgh/go-spew v1.1.1 9 | github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb 10 | github.com/manifoldco/promptui v0.8.0 11 | github.com/stretchr/testify v1.7.0 12 | github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 13 | github.com/urfave/cli/v2 v2.3.0 14 | ) 15 | 16 | require ( 17 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect 18 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 19 | github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect 20 | github.com/kr/text v0.2.0 // indirect 21 | github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a // indirect 22 | github.com/mattn/go-colorable v0.0.9 // indirect 23 | github.com/mattn/go-isatty v0.0.4 // indirect 24 | github.com/pmezard/go-difflib v1.0.0 // indirect 25 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 26 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 27 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492 // indirect 28 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 29 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 3 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 4 | github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0= 5 | github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= 6 | github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= 7 | github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 8 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= 9 | github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 10 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= 11 | github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 13 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 14 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM= 19 | github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= 20 | github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= 21 | github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= 22 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 23 | github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= 24 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 25 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 26 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 27 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 28 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 29 | github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= 30 | github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= 31 | github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= 32 | github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= 33 | github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= 34 | github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= 35 | github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= 36 | github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= 37 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 38 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 39 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 40 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 41 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 42 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 43 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 44 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 45 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 46 | github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31 h1:OXcKh35JaYsGMRzpvFkLv/MEyPuL49CThT1pZ8aSml4= 47 | github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q= 48 | github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= 49 | github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= 50 | golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 51 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492 h1:Paq34FxTluEPvVyayQqMPgHm+vTOrIifmcYxFBx9TLg= 52 | golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 53 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 54 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 55 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 56 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 57 | gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 58 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 59 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/tomodian/release/cmd" 7 | ) 8 | 9 | func main() { 10 | _ = cmd.Run(os.Args) 11 | } 12 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestMain(t *testing.T) { 10 | assert.NotPanics(t, main) 11 | } 12 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/blang/semver/v4" 11 | ) 12 | 13 | const ( 14 | // Unreleased tag is defined in keepachangelog 1.0.0. 15 | // https://keepachangelog.com/en/1.0.0/ 16 | Unreleased = "[Unreleased]" 17 | unreleasedHeading = "## [Unreleased]" 18 | ) 19 | 20 | // Version transforms 0.1.0 to [0.1.0]. 21 | // Returns error when given input is not following SemVar. 22 | func Version(in string) (string, error) { 23 | githubStyleSemver := false 24 | 25 | if len(in) > 0 && in[0] == 'v' { 26 | in = in[1:] 27 | githubStyleSemver = true 28 | } 29 | 30 | v, err := semver.Make(in) 31 | 32 | if err != nil { 33 | return "", errors.New("given version is not compatible with Semantic Versioning") 34 | } 35 | 36 | if githubStyleSemver == true { 37 | return fmt.Sprintf("[v%s]", v.String()), nil 38 | } else { 39 | return fmt.Sprintf("[%s]", v.String()), nil 40 | } 41 | } 42 | 43 | // To returns document replaced with given version. 44 | func To(doc string, ver string) (string, error) { 45 | if doc == "" { 46 | return "", errors.New("given document is empty") 47 | } 48 | 49 | v, err := Version(ver) 50 | 51 | if err != nil { 52 | return "", err 53 | } 54 | 55 | // Check for diff and return the original when no changes. 56 | diff, err := Show(doc, Unreleased) 57 | 58 | if err != nil { 59 | return "", err 60 | } 61 | 62 | if len(diff) == 0 { 63 | return doc, nil 64 | } 65 | 66 | count := strings.Count(doc, unreleasedHeading) 67 | 68 | if count == 0 { 69 | return "", fmt.Errorf("given document does not contain %s tag", Unreleased) 70 | } 71 | 72 | if count > 1 { 73 | return "", fmt.Errorf("given document contains more than 1 %s tags", Unreleased) 74 | } 75 | 76 | template := strings.Join([]string{ 77 | Unreleased, 78 | "", 79 | fmt.Sprintf("## %s - %s", v, time.Now().Format("2006-01-02")), 80 | }, "\n") 81 | 82 | return strings.Replace(doc, Unreleased, template, 1), nil 83 | } 84 | 85 | // Show returns changes of given version. 86 | func Show(doc string, ver string) ([]string, error) { 87 | outs := []string{} 88 | 89 | if doc == "" { 90 | return outs, errors.New("given document is empty") 91 | } 92 | 93 | v := Unreleased 94 | 95 | if ver != Unreleased { 96 | ver, err := Version(ver) 97 | 98 | if err != nil { 99 | return outs, err 100 | } 101 | 102 | v = ver 103 | } 104 | 105 | found := false 106 | 107 | for _, line := range strings.Split(doc, "\n") { 108 | p := fmt.Sprintf("## %s", v) 109 | 110 | if !found && !strings.HasPrefix(line, p) { 111 | continue 112 | } 113 | 114 | // Mark as found and go to next cursor. 115 | if !found { 116 | found = true 117 | continue 118 | } 119 | 120 | // Finish when next heading found. 121 | if strings.HasPrefix(line, "## ") { 122 | break 123 | } 124 | 125 | outs = append(outs, line) 126 | } 127 | 128 | if len(outs) == 0 { 129 | return outs, nil 130 | } 131 | 132 | // Remove first line if empty. 133 | if outs[0] == "" { 134 | outs = append(outs[:0], outs[1:]...) 135 | } 136 | 137 | // Remove last line if empty. 138 | if len(outs) > 1 && outs[len(outs)-1] == "" { 139 | outs = outs[:len(outs)-1] 140 | } 141 | 142 | return outs, nil 143 | } 144 | 145 | // Latest returns the latest version stored in document. 146 | // This operation simply matches to the first h2 header. 147 | func Latest(doc string) (string, error) { 148 | 149 | re := regexp.MustCompile(`## \[([v]?\d*\.\d*\.\d*)\]`) 150 | 151 | for _, line := range strings.Split(doc, "\n") { 152 | got := re.FindStringSubmatch(line) 153 | 154 | if len(got) != 2 { 155 | continue 156 | } 157 | 158 | return got[1], nil 159 | } 160 | 161 | return "", errors.New("not found") 162 | } 163 | 164 | // LatestAny returns the latest [version] stored in document. 165 | // Unlike `Latest` which follows Semantic Versioning, this function parse arbitary string 166 | // excluding `## [Unreleased]` and string with blank spaces. 167 | // This operation simply matches to the first h2 header. 168 | func LatestAny(doc string) (string, error) { 169 | 170 | re := regexp.MustCompile(`## \[(.*)\]`) 171 | 172 | for _, line := range strings.Split(doc, "\n") { 173 | got := re.FindStringSubmatch(line) 174 | 175 | if len(got) != 2 { 176 | continue 177 | } 178 | 179 | if got[1] == "Unreleased" { 180 | continue 181 | } 182 | 183 | if strings.Contains(got[1], " ") { 184 | continue 185 | } 186 | 187 | return got[1], nil 188 | } 189 | 190 | return "", errors.New("not found") 191 | } 192 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/tomodian/release/parser" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestOpenRepository(t *testing.T) { 15 | { 16 | // Success cases. 17 | pats := []string{ 18 | "0.1.0", 19 | "0.1.0-beta", 20 | "100.200.300", 21 | "v1.2.3", 22 | } 23 | 24 | for _, p := range pats { 25 | got, err := parser.Version(p) 26 | 27 | require.Nilf(t, err, "%s", p) 28 | assert.Equal(t, fmt.Sprintf("[%s]", p), got) 29 | } 30 | } 31 | 32 | { 33 | // Fail cases. 34 | pats := []string{ 35 | "", 36 | "1", 37 | "1.0", 38 | "1.0.X", 39 | "1.0.0.0", 40 | "🍎", 41 | } 42 | 43 | for _, p := range pats { 44 | _, err := parser.Version(p) 45 | 46 | require.NotNilf(t, err, "%s", p) 47 | } 48 | } 49 | } 50 | 51 | func TestTo(t *testing.T) { 52 | type pattern struct { 53 | version string 54 | doc string 55 | } 56 | 57 | { 58 | // Success cases. 59 | pats := []pattern{ 60 | { 61 | version: "0.1.0", 62 | doc: strings.Join([]string{ 63 | "# Hello", 64 | "", 65 | "## [Unreleased]", 66 | "", 67 | "### Added", 68 | "", 69 | }, "\n"), 70 | }, 71 | } 72 | 73 | for _, p := range pats { 74 | got, err := parser.To(p.doc, p.version) 75 | require.Nil(t, err) 76 | 77 | v, err := parser.Version(p.version) 78 | require.Nil(t, err) 79 | 80 | assert.Equalf(t, 1, strings.Count(got, v), "%#v", p) 81 | } 82 | } 83 | 84 | { 85 | // Fail cases. 86 | pats := []pattern{ 87 | { 88 | version: "", 89 | doc: strings.Join([]string{}, "\n"), 90 | }, 91 | { 92 | version: "0.1.BROKEN", 93 | doc: strings.Join([]string{ 94 | "# Hello", 95 | "", 96 | "## [Unreleased]", 97 | }, "\n"), 98 | }, 99 | // { 100 | // version: "0.1.0", 101 | // doc: strings.Join([]string{ 102 | // "# Hello", 103 | // }, "\n"), 104 | // }, 105 | { 106 | version: "0.1.BROKEN", 107 | doc: strings.Join([]string{ 108 | "# Hello", 109 | "", 110 | "## [Unreleased]", 111 | "## [Unreleased]", // Duplicated 112 | }, "\n"), 113 | }, 114 | } 115 | 116 | for _, p := range pats { 117 | got, err := parser.To(p.doc, p.version) 118 | 119 | require.NotNilf(t, err, "tried %#v, got %s", p, got) 120 | } 121 | } 122 | } 123 | 124 | func TestShow(t *testing.T) { 125 | type pattern struct { 126 | version string 127 | doc string 128 | count int 129 | } 130 | 131 | { 132 | // Success cases. 133 | pats := []pattern{ 134 | { 135 | version: "0.1.0", 136 | doc: strings.Join([]string{ 137 | "# Hello", 138 | }, "\n"), 139 | count: 0, 140 | }, 141 | { 142 | version: "0.1.0", 143 | doc: strings.Join([]string{ 144 | "# Hello", 145 | "", 146 | "## [Unreleased]", 147 | "", 148 | "## [0.1.0]", 149 | "", 150 | "### Added", 151 | "- foo", 152 | }, "\n"), 153 | count: 2, 154 | }, 155 | { 156 | version: "1.2.3", 157 | doc: strings.Join([]string{ 158 | "# Hello", 159 | "", 160 | "## [Unreleased]", 161 | "", 162 | "## [1.2.3] - 2020/07/16", 163 | "", 164 | "### Added", 165 | "- foo", 166 | "", 167 | "### Deleted", 168 | "- foo", 169 | "", 170 | "## [0.1.0]", 171 | "", 172 | }, "\n"), 173 | count: 5, 174 | }, 175 | } 176 | 177 | for _, p := range pats { 178 | gots, err := parser.Show(p.doc, p.version) 179 | 180 | require.Nil(t, err) 181 | assert.Equalf(t, p.count, len(gots), "%#v", gots) 182 | } 183 | } 184 | 185 | { 186 | // Fail cases. 187 | pats := []pattern{ 188 | { 189 | version: "", 190 | doc: strings.Join([]string{}, "\n"), 191 | }, 192 | } 193 | 194 | for _, p := range pats { 195 | _, err := parser.Show(p.doc, p.version) 196 | 197 | require.NotNil(t, err) 198 | } 199 | } 200 | } 201 | 202 | func TestLatest(t *testing.T) { 203 | type pattern struct { 204 | doc string 205 | expected string 206 | } 207 | 208 | { 209 | // Success cases. 210 | pats := []pattern{ 211 | { 212 | doc: strings.Join([]string{ 213 | "## [1.2.3]", 214 | }, "\n"), 215 | expected: "1.2.3", 216 | }, 217 | { 218 | doc: strings.Join([]string{ 219 | "## [100.200.300]", 220 | }, "\n"), 221 | expected: "100.200.300", 222 | }, 223 | { 224 | doc: strings.Join([]string{ 225 | "## [Unreleased]", 226 | "## [1.2.3]", 227 | "hello", 228 | "## [1.2.3]", 229 | "hello", 230 | }, "\n"), 231 | expected: "1.2.3", 232 | }, 233 | } 234 | 235 | for _, p := range pats { 236 | got, err := parser.Latest(p.doc) 237 | 238 | require.Nil(t, err) 239 | assert.Equal(t, p.expected, got) 240 | } 241 | } 242 | 243 | { 244 | // Fail cases. 245 | pats := []pattern{ 246 | { 247 | doc: strings.Join([]string{}, "\n"), 248 | }, 249 | { 250 | doc: strings.Join([]string{ 251 | "# Hello", 252 | "## [Unreleased]", 253 | }, "\n"), 254 | }, 255 | } 256 | 257 | for _, p := range pats { 258 | _, err := parser.Latest(p.doc) 259 | 260 | require.NotNil(t, err) 261 | } 262 | } 263 | } 264 | 265 | func TestLatestAny(t *testing.T) { 266 | type pattern struct { 267 | doc string 268 | expected string 269 | } 270 | 271 | { 272 | // Success cases. 273 | pats := []pattern{ 274 | { 275 | doc: strings.Join([]string{ 276 | "## [1.2.3]", 277 | }, "\n"), 278 | expected: "1.2.3", 279 | }, 280 | { 281 | doc: strings.Join([]string{ 282 | "## [100.200.300]", 283 | }, "\n"), 284 | expected: "100.200.300", 285 | }, 286 | { 287 | doc: strings.Join([]string{ 288 | "## [Unreleased]", 289 | "## [1.2.3]", 290 | "hello", 291 | "## [1.2.3]", 292 | "hello", 293 | }, "\n"), 294 | expected: "1.2.3", 295 | }, 296 | { 297 | doc: strings.Join([]string{ 298 | "## [Unreleased]", 299 | "## [node-alpine:123]", 300 | }, "\n"), 301 | expected: "node-alpine:123", 302 | }, 303 | { 304 | doc: strings.Join([]string{ 305 | "## [Unreleased]", 306 | "## [Node-Alpine:123]", 307 | }, "\n"), 308 | expected: "Node-Alpine:123", 309 | }, 310 | } 311 | 312 | for _, p := range pats { 313 | got, err := parser.LatestAny(p.doc) 314 | 315 | require.Nil(t, err) 316 | assert.Equal(t, p.expected, got) 317 | } 318 | } 319 | 320 | { 321 | // Fail cases. 322 | pats := []pattern{ 323 | { 324 | doc: strings.Join([]string{}, "\n"), 325 | }, 326 | { 327 | doc: strings.Join([]string{ 328 | "# Hello", 329 | "## Unreleased", 330 | }, "\n"), 331 | }, 332 | { 333 | doc: strings.Join([]string{ 334 | "# Hello", 335 | "## [contains blank]", 336 | }, "\n"), 337 | }, 338 | } 339 | 340 | for _, p := range pats { 341 | _, err := parser.LatestAny(p.doc) 342 | 343 | require.NotNil(t, err) 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /parser/semver.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/facette/natsort" 10 | ) 11 | 12 | // SemanticVersion represents major/minor/patch numbers. 13 | type SemanticVersion struct { 14 | Major int 15 | Minor int 16 | Patch int 17 | } 18 | 19 | // NewSemanticVersion takes arbitary string, parse and return struct. 20 | func NewSemanticVersion(given string) (*SemanticVersion, error) { 21 | 22 | got := strings.Split(given, ".") 23 | 24 | if len(got) != 3 { 25 | return nil, errors.New("input must have 3 integers concatenated by period (e.g. 1.2.3") 26 | } 27 | 28 | out := SemanticVersion{} 29 | 30 | { 31 | var err error 32 | 33 | if out.Major, err = CastVersion("major", got[0]); err != nil { 34 | return nil, err 35 | } 36 | 37 | if out.Minor, err = CastVersion("minor", got[1]); err != nil { 38 | return nil, err 39 | } 40 | 41 | if out.Patch, err = CastVersion("patch", got[2]); err != nil { 42 | return nil, err 43 | } 44 | } 45 | 46 | return &out, nil 47 | } 48 | 49 | // IsEqual compares internal version with given version. 50 | func (c SemanticVersion) IsEqual(in *SemanticVersion) bool { 51 | if in == nil { 52 | return false 53 | } 54 | 55 | return c.Major == in.Major && c.Minor == in.Minor && c.Patch == in.Patch 56 | } 57 | 58 | // IsGreater compares internal version with given version. 59 | func (c SemanticVersion) IsGreater(in *SemanticVersion) bool { 60 | if in == nil { 61 | return false 62 | } 63 | 64 | switch { 65 | case c.Major < in.Major: 66 | return true 67 | 68 | case c.Major == in.Major && c.Minor < in.Minor: 69 | return true 70 | 71 | case c.Major == in.Major && c.Minor == in.Minor && c.Patch < in.Patch: 72 | return true 73 | } 74 | 75 | return false 76 | } 77 | 78 | // String decorator. 79 | func (c SemanticVersion) String() string { 80 | return fmt.Sprintf("%d.%d.%d", c.Major, c.Minor, c.Patch) 81 | } 82 | 83 | // Increment version according to given type. 84 | // The type must be one of Major/Minor/Patch. 85 | // 86 | // Given 1.2.3 as example: 87 | // - Increment(Major) --> 2.2.3 88 | // - Increment(Minor) --> 1.3.3 89 | // - Increment(Patch) --> 1.2.4 90 | func (c SemanticVersion) Increment(in VersionType) SemanticVersion { 91 | switch in { 92 | case MajorVersion: 93 | return SemanticVersion{Major: c.Major + 1, Minor: 0, Patch: 0} 94 | 95 | case MinorVersion: 96 | return SemanticVersion{Major: c.Major, Minor: c.Minor + 1, Patch: 0} 97 | } 98 | 99 | return SemanticVersion{Major: c.Major, Minor: c.Minor, Patch: c.Patch + 1} 100 | } 101 | 102 | // CastVersion parse and set a string into given struct. 103 | func CastVersion(name, val string) (int, error) { 104 | const failcode = -1 105 | 106 | if name == "major" { 107 | if val[0] == 'v' { 108 | val = val[1:] 109 | } 110 | } 111 | 112 | i, err := strconv.Atoi(val) 113 | 114 | if err != nil { 115 | return failcode, fmt.Errorf("%s segment must be integer", name) 116 | } 117 | 118 | if i < 0 { 119 | return failcode, fmt.Errorf("%s segment must be greater than zero", name) 120 | } 121 | 122 | return i, nil 123 | } 124 | 125 | // SortVersions in ascending order with dumb algorithm. 126 | func SortVersions(in []SemanticVersion) []SemanticVersion { 127 | 128 | vers := []string{} 129 | 130 | for _, v := range in { 131 | vers = append(vers, v.String()) 132 | } 133 | 134 | natsort.Sort(vers) 135 | 136 | out := []SemanticVersion{} 137 | 138 | for _, v := range vers { 139 | got, _ := NewSemanticVersion(v) 140 | out = append(out, *got) 141 | } 142 | 143 | return out 144 | } 145 | -------------------------------------------------------------------------------- /parser/semver_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tomodian/release/parser" 7 | 8 | "github.com/davecgh/go-spew/spew" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | var ( 14 | falsyVersions = []string{ 15 | "", 16 | "1", 17 | "1.0", 18 | "1.0.X", 19 | "1.0.0.0", 20 | "1a.2.3", 21 | "1.2a.3", 22 | "1.2.3a", 23 | "1 2 3", 24 | "v100", 25 | "v1.20", 26 | "1.20.a", 27 | "🍎", 28 | } 29 | ) 30 | 31 | func TestNewSemanticVersion(t *testing.T) { 32 | { 33 | // Success cases. 34 | type pattern struct { 35 | expected parser.SemanticVersion 36 | sample string 37 | } 38 | 39 | pats := []pattern{ 40 | { 41 | expected: parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 42 | sample: "0.0.0", 43 | }, 44 | { 45 | expected: parser.SemanticVersion{Major: 1, Minor: 1, Patch: 1}, 46 | sample: "1.1.1", 47 | }, 48 | { 49 | expected: parser.SemanticVersion{Major: 123, Minor: 456, Patch: 789}, 50 | sample: "123.456.789", 51 | }, 52 | { 53 | expected: parser.SemanticVersion{Major: 1, Minor: 2, Patch: 3}, 54 | sample: "v1.2.3", 55 | }, 56 | } 57 | 58 | for _, p := range pats { 59 | got, err := parser.NewSemanticVersion(p.sample) 60 | 61 | require.Nilf(t, err, spew.Sdump(p)) 62 | require.NotNilf(t, got, spew.Sdump(p)) 63 | 64 | assert.Equalf(t, p.expected.Major, got.Major, spew.Sdump(p, got)) 65 | assert.Equalf(t, p.expected.Minor, got.Minor, spew.Sdump(p, got)) 66 | assert.Equalf(t, p.expected.Patch, got.Patch, spew.Sdump(p, got)) 67 | } 68 | } 69 | 70 | { 71 | // Fail cases. 72 | for _, p := range falsyVersions { 73 | _, err := parser.NewSemanticVersion(p) 74 | 75 | require.NotNilf(t, err, "%s", p) 76 | } 77 | } 78 | } 79 | 80 | func TestNewSemanticVersionIsEqual(t *testing.T) { 81 | type pattern struct { 82 | a *parser.SemanticVersion 83 | b *parser.SemanticVersion 84 | } 85 | 86 | { 87 | // Truthy cases. 88 | pats := []pattern{ 89 | { 90 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 91 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 92 | }, 93 | { 94 | a: &parser.SemanticVersion{Major: 1, Minor: 2, Patch: 3}, 95 | b: &parser.SemanticVersion{Major: 1, Minor: 2, Patch: 3}, 96 | }, 97 | } 98 | 99 | for _, p := range pats { 100 | assert.Truef(t, p.a.IsEqual(p.b), spew.Sdump(p)) 101 | } 102 | } 103 | 104 | { 105 | // Falsy cases. 106 | pats := []pattern{ 107 | { 108 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 109 | b: nil, 110 | }, 111 | { 112 | a: &parser.SemanticVersion{Major: 4, Minor: 5, Patch: 6}, 113 | b: &parser.SemanticVersion{Major: 1, Minor: 2, Patch: 3}, 114 | }, 115 | { 116 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 1}, 117 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 118 | }, 119 | { 120 | a: &parser.SemanticVersion{Major: 0, Minor: 1, Patch: 0}, 121 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 122 | }, 123 | { 124 | a: &parser.SemanticVersion{Major: 1, Minor: 0, Patch: 0}, 125 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 126 | }, 127 | } 128 | 129 | for _, p := range pats { 130 | assert.Falsef(t, p.a.IsEqual(p.b), spew.Sdump(p)) 131 | } 132 | } 133 | } 134 | 135 | func TestSemanticVersionIsGreater(t *testing.T) { 136 | type pattern struct { 137 | a *parser.SemanticVersion 138 | b *parser.SemanticVersion 139 | } 140 | 141 | { 142 | // Truthy cases. 143 | pats := []pattern{ 144 | { 145 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 146 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 1}, 147 | }, 148 | { 149 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 150 | b: &parser.SemanticVersion{Major: 0, Minor: 1, Patch: 0}, 151 | }, 152 | { 153 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 154 | b: &parser.SemanticVersion{Major: 1, Minor: 0, Patch: 0}, 155 | }, 156 | { 157 | a: &parser.SemanticVersion{Major: 1, Minor: 2, Patch: 3}, 158 | b: &parser.SemanticVersion{Major: 4, Minor: 5, Patch: 6}, 159 | }, 160 | } 161 | 162 | for _, p := range pats { 163 | assert.Truef(t, p.a.IsGreater(p.b), spew.Sdump(p)) 164 | } 165 | } 166 | 167 | { 168 | // Falsy cases. 169 | pats := []pattern{ 170 | { 171 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 172 | b: nil, 173 | }, 174 | { 175 | a: &parser.SemanticVersion{Major: 4, Minor: 5, Patch: 6}, 176 | b: &parser.SemanticVersion{Major: 1, Minor: 2, Patch: 3}, 177 | }, 178 | { 179 | a: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 1}, 180 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 181 | }, 182 | { 183 | a: &parser.SemanticVersion{Major: 0, Minor: 1, Patch: 0}, 184 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 185 | }, 186 | { 187 | a: &parser.SemanticVersion{Major: 1, Minor: 0, Patch: 0}, 188 | b: &parser.SemanticVersion{Major: 0, Minor: 0, Patch: 0}, 189 | }, 190 | } 191 | 192 | for _, p := range pats { 193 | assert.Falsef(t, p.a.IsGreater(p.b), spew.Sdump(p)) 194 | } 195 | } 196 | } 197 | 198 | func TestSemanticVersionString(t *testing.T) { 199 | 200 | v := &parser.SemanticVersion{ 201 | Major: 1, 202 | Minor: 2, 203 | Patch: 3, 204 | } 205 | 206 | assert.Equal(t, "1.2.3", v.String()) 207 | } 208 | 209 | func TestSemanticVersionIncrement(t *testing.T) { 210 | 211 | type pattern struct { 212 | exp string 213 | typ parser.VersionType 214 | in parser.SemanticVersion 215 | } 216 | 217 | pats := []pattern{ 218 | { 219 | exp: "1.0.0", 220 | typ: parser.MajorVersion, 221 | in: parser.SemanticVersion{Major: 0, Minor: 1, Patch: 0}, 222 | }, 223 | { 224 | exp: "0.2.0", 225 | typ: parser.MinorVersion, 226 | in: parser.SemanticVersion{Major: 0, Minor: 1, Patch: 0}, 227 | }, 228 | { 229 | exp: "0.0.2", 230 | typ: parser.PatchVersion, 231 | in: parser.SemanticVersion{Major: 0, Minor: 0, Patch: 1}, 232 | }, 233 | { 234 | exp: "10.0.0", 235 | typ: parser.MajorVersion, 236 | in: parser.SemanticVersion{Major: 9, Minor: 0, Patch: 0}, 237 | }, 238 | { 239 | exp: "0.10.0", 240 | typ: parser.MinorVersion, 241 | in: parser.SemanticVersion{Major: 0, Minor: 9, Patch: 0}, 242 | }, 243 | { 244 | exp: "0.0.10", 245 | typ: parser.PatchVersion, 246 | in: parser.SemanticVersion{Major: 0, Minor: 0, Patch: 9}, 247 | }, 248 | } 249 | 250 | for _, p := range pats { 251 | assert.Equalf(t, p.exp, p.in.Increment(p.typ).String(), spew.Sdump(p)) 252 | } 253 | } 254 | 255 | func TestCastVersion(t *testing.T) { 256 | { 257 | // Success cases. 258 | type pattern struct { 259 | expected int 260 | sample string 261 | } 262 | 263 | pats := []pattern{ 264 | { 265 | expected: 0, 266 | sample: "0", 267 | }, 268 | { 269 | expected: 1, 270 | sample: "1", 271 | }, 272 | } 273 | 274 | for _, p := range pats { 275 | got, err := parser.CastVersion("foo", p.sample) 276 | 277 | require.NoErrorf(t, err, spew.Sdump(p)) 278 | assert.Equalf(t, p.expected, got, spew.Sdump(p)) 279 | } 280 | } 281 | 282 | { 283 | // Fail cases. 284 | pats := []string{ 285 | "", 286 | "-2", 287 | "broken", 288 | } 289 | 290 | for _, p := range pats { 291 | got, err := parser.CastVersion("foo", p) 292 | 293 | require.Errorf(t, err, spew.Sdump(p)) 294 | assert.Equalf(t, -1, got, spew.Sdump(p)) 295 | } 296 | } 297 | } 298 | 299 | func TestSortVersions(t *testing.T) { 300 | 301 | type pattern struct { 302 | exp []parser.SemanticVersion 303 | in []parser.SemanticVersion 304 | } 305 | 306 | pats := []pattern{ 307 | { 308 | exp: []parser.SemanticVersion{ 309 | {Major: 0, Minor: 0, Patch: 0}, 310 | {Major: 0, Minor: 0, Patch: 1}, 311 | {Major: 0, Minor: 0, Patch: 2}, 312 | }, 313 | in: []parser.SemanticVersion{ 314 | {Major: 0, Minor: 0, Patch: 2}, 315 | {Major: 0, Minor: 0, Patch: 1}, 316 | {Major: 0, Minor: 0, Patch: 0}, 317 | }, 318 | }, 319 | { 320 | exp: []parser.SemanticVersion{ 321 | {Major: 1, Minor: 1, Patch: 1}, 322 | {Major: 2, Minor: 11, Patch: 2}, 323 | {Major: 2, Minor: 100, Patch: 0}, 324 | }, 325 | in: []parser.SemanticVersion{ 326 | {Major: 2, Minor: 100, Patch: 0}, 327 | {Major: 1, Minor: 1, Patch: 1}, 328 | {Major: 2, Minor: 11, Patch: 2}, 329 | }, 330 | }, 331 | { 332 | exp: []parser.SemanticVersion{ 333 | {Major: 1, Minor: 0, Patch: 0}, 334 | {Major: 11, Minor: 0, Patch: 0}, 335 | {Major: 111, Minor: 0, Patch: 0}, 336 | }, 337 | in: []parser.SemanticVersion{ 338 | {Major: 111, Minor: 0, Patch: 0}, 339 | {Major: 11, Minor: 0, Patch: 0}, 340 | {Major: 1, Minor: 0, Patch: 0}, 341 | }, 342 | }, 343 | } 344 | 345 | for _, p := range pats { 346 | got := parser.SortVersions(p.in) 347 | 348 | assert.Equalf(t, p.exp, got, spew.Sdump(p.exp, got)) 349 | } 350 | } 351 | -------------------------------------------------------------------------------- /parser/versiontype.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "errors" 4 | 5 | // VersionType corresponds to Major.Minor.Patch in Semantic Versioning 2.0.0. 6 | // https://semver.org 7 | type VersionType string 8 | 9 | // String decorator. 10 | func (c VersionType) String() string { 11 | return string(c) 12 | } 13 | 14 | // Exposed keys of version types. 15 | const ( 16 | MajorVersion VersionType = "major" 17 | MinorVersion VersionType = "minor" 18 | PatchVersion VersionType = "patch" 19 | ) 20 | 21 | // AliasedVersion returns the original version type or error. 22 | // GitFlow idiom is currently available. 23 | func AliasedVersion(in string) (VersionType, error) { 24 | 25 | switch in { 26 | 27 | case MajorVersion.String(), "release": 28 | return MajorVersion, nil 29 | 30 | case MinorVersion.String(), "feature": 31 | return MinorVersion, nil 32 | 33 | case PatchVersion.String(), "hotfix": 34 | return PatchVersion, nil 35 | } 36 | 37 | return "", errors.New("given alias is not in list") 38 | } 39 | -------------------------------------------------------------------------------- /parser/versiontype_test.go: -------------------------------------------------------------------------------- 1 | package parser_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tomodian/release/parser" 7 | 8 | "github.com/davecgh/go-spew/spew" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestVersionTypeString(t *testing.T) { 14 | assert.Equal(t, string(parser.MajorVersion), parser.MajorVersion.String()) 15 | } 16 | 17 | func TestAliasedVersion(t *testing.T) { 18 | { 19 | // Success cases, MajorVersion 20 | pats := []string{ 21 | parser.MajorVersion.String(), 22 | "release", 23 | } 24 | 25 | for _, p := range pats { 26 | got, err := parser.AliasedVersion(p) 27 | 28 | require.NoErrorf(t, err, spew.Sdump(p)) 29 | assert.Equal(t, parser.MajorVersion, got, spew.Sdump(p, got)) 30 | } 31 | } 32 | 33 | { 34 | // Success cases, MinorVersion 35 | pats := []string{ 36 | parser.MinorVersion.String(), 37 | "feature", 38 | } 39 | 40 | for _, p := range pats { 41 | got, err := parser.AliasedVersion(p) 42 | 43 | require.NoErrorf(t, err, spew.Sdump(p)) 44 | assert.Equal(t, parser.MinorVersion, got, spew.Sdump(p, got)) 45 | } 46 | } 47 | 48 | { 49 | // Success cases, PatchVersion 50 | pats := []string{ 51 | parser.PatchVersion.String(), 52 | "hotfix", 53 | } 54 | 55 | for _, p := range pats { 56 | got, err := parser.AliasedVersion(p) 57 | 58 | require.NoErrorf(t, err, spew.Sdump(p)) 59 | assert.Equal(t, parser.PatchVersion, got, spew.Sdump(p, got)) 60 | } 61 | } 62 | 63 | { 64 | // Fail cases 65 | pats := []string{ 66 | "", 67 | "foo", 68 | } 69 | 70 | for _, p := range pats { 71 | got, err := parser.AliasedVersion(p) 72 | 73 | require.Errorf(t, err, spew.Sdump(p)) 74 | assert.Empty(t, got, spew.Sdump(p, got)) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /utils/pretty.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ttacon/chalk" 7 | ) 8 | 9 | // List of common lines. 10 | const ( 11 | EmptyLine = "(empty)" 12 | ) 13 | 14 | // Pretty color on terminal. 15 | func Pretty(in string) string { 16 | switch { 17 | case in == EmptyLine: 18 | return chalk.Dim.TextStyle(EmptyLine) 19 | 20 | case strings.HasPrefix(in, "# "): 21 | return chalk.Yellow.Color(in) 22 | 23 | case strings.HasPrefix(in, "## "): 24 | return chalk.Blue.Color(in) 25 | 26 | case strings.HasPrefix(in, "### "): 27 | return chalk.Cyan.Color(in) 28 | } 29 | 30 | return in 31 | } 32 | -------------------------------------------------------------------------------- /utils/pretty_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/tomodian/release/utils" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPretty(t *testing.T) { 12 | type pattern struct { 13 | expected string 14 | input string 15 | } 16 | 17 | pats := []pattern{ 18 | { 19 | expected: utils.EmptyLine, 20 | input: utils.EmptyLine, 21 | }, 22 | { 23 | expected: "Title", 24 | input: "# Title", 25 | }, 26 | { 27 | expected: "Title", 28 | input: "## Title", 29 | }, 30 | { 31 | expected: "Title", 32 | input: "### Title", 33 | }, 34 | { 35 | expected: "hello", 36 | input: "hello", 37 | }, 38 | } 39 | 40 | for _, p := range pats { 41 | got := utils.Pretty(p.input) 42 | 43 | assert.NotEmpty(t, got) 44 | assert.Contains(t, got, p.expected) 45 | } 46 | } 47 | --------------------------------------------------------------------------------