├── .atomignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── .releaseflowrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs └── assets │ ├── full-git-flow.png │ └── simplified.png ├── package-lock.json ├── package.json ├── src ├── Git.js ├── Phase.js ├── Release.js ├── Sequence.js ├── Step.js ├── cli.js ├── defaults │ ├── DefaultErrorFactory.js │ ├── DefaultLogger.js │ ├── get-bump.js │ └── index.js ├── execCommand.js ├── phases │ ├── Finish.js │ ├── Publish.js │ └── Start.js └── plugins │ ├── bump-package-json │ └── index.js │ └── generate-changelog │ ├── ChangelogEntry.js │ ├── changelog-template.js │ └── index.js ├── test ├── Git.spec.js ├── Phase.spec.js ├── Release.spec.js ├── Sequence.spec.js ├── Step.spec.js ├── defaults │ ├── DefaultErrorFactory.spec.js │ ├── DefaultLogger.spec.js │ └── get-bump.spec.js ├── execCommand.spec.js ├── phases │ ├── Finish.spec.js │ ├── Publish.spec.js │ └── Start.spec.js └── plugins │ ├── ChangelogEntry.spec.js │ ├── bump-package-json.spec.js │ ├── changelog-template.spec.js │ └── generate-changelog.spec.js └── tsconfig.json /.atomignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | lib 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | indent_style = space 11 | indent_size = 2 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | lib/* 3 | dist/* 4 | coverage/* 5 | .nyc_output/* 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:mocha/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["@typescript-eslint", "mocha"], 17 | "rules": { 18 | "no-unused-vars": "error", 19 | "mocha/no-exclusive-tests": "error" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ["master"] 9 | pull_request: 10 | branches: ["master"] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [12.x, 14.x, 16.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v3 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm i -g npm@8 29 | - run: npm ci 30 | - run: npm run check 31 | - run: npm run test-cov 32 | - uses: codecov/codecov-action@v3 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/node,osx,linux,bower,windows,vagrant 2 | 3 | ### Node ### 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | 9 | # Runtime data 10 | pids 11 | *.pid 12 | *.seed 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directory 30 | # https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git 31 | node_modules 32 | 33 | 34 | ### OSX ### 35 | .DS_Store 36 | .AppleDouble 37 | .LSOverride 38 | 39 | # Icon must end with two \r 40 | Icon 41 | 42 | # Thumbnails 43 | ._* 44 | 45 | # Files that might appear in the root of a volume 46 | .DocumentRevisions-V100 47 | .fseventsd 48 | .Spotlight-V100 49 | .TemporaryItems 50 | .Trashes 51 | .VolumeIcon.icns 52 | 53 | # Directories potentially created on remote AFP share 54 | .AppleDB 55 | .AppleDesktop 56 | Network Trash Folder 57 | Temporary Items 58 | .apdisk 59 | 60 | 61 | ### Linux ### 62 | *~ 63 | 64 | # KDE directory preferences 65 | .directory 66 | 67 | # Linux trash folder which might appear on any partition or disk 68 | .Trash-* 69 | 70 | 71 | ### Bower ### 72 | bower_components 73 | .bower-cache 74 | .bower-registry 75 | .bower-tmp 76 | 77 | 78 | ### Windows ### 79 | # Windows image file caches 80 | Thumbs.db 81 | ehthumbs.db 82 | 83 | # Folder config file 84 | Desktop.ini 85 | 86 | # Recycle Bin used on file shares 87 | $RECYCLE.BIN/ 88 | 89 | # Windows Installer files 90 | *.cab 91 | *.msi 92 | *.msm 93 | *.msp 94 | 95 | # Windows shortcuts 96 | *.lnk 97 | 98 | .nyc_output/ 99 | lib/ 100 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !lib/**/* 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | lib/* 3 | dist/* 4 | coverage/* 5 | .nyc_output/* 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.releaseflowrc: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | logLevel: "debug", 3 | developmentBranch: "master", 4 | plugins: [ 5 | "bump-package-json", 6 | "generate-changelog", 7 | function () { 8 | require("child_process").execSync(`npm run compile`); 9 | }, 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # [v1.2.2](https://github.com/mcasimir/release-flow/compare/v1.2.1...v1.2.2) 3 | 4 | ## Fixes 5 | 6 | - correct sort for tags history retrieving ([9f7c3a5](https://github.com/mcasimir/release-flow/commits/9f7c3a5b3c2474fc1b3bfa0a3d08e204e068932f)) 7 | 8 | 9 | 10 | # [v1.2.1](https://github.com/mcasimir/release-flow/compare/v1.2.0...v1.2.1) 11 | 12 | ## Fixes 13 | 14 | - **phase** - fixed version regexp ([7f85c23](https://github.com/mcasimir/release-flow/commits/7f85c230d092c5b0a093acc4e175fe6c19e32178)) 15 | - **cli** - added shabang on top of cli bin ([e54f38b](https://github.com/mcasimir/release-flow/commits/e54f38bd5d37829f81b67a7814ce2b9cad9d2f3c)) 16 | 17 | 18 | 19 | # [v1.2.0](https://github.com/mcasimir/release-flow/compare/v1.1.1...v1.2.0) 20 | 21 | ## Features 22 | 23 | - **cli** - allow full release command ([ffd0b60](https://github.com/mcasimir/release-flow/commits/ffd0b60eab22183e03df5990071e6d66fb65f995)) 24 | 25 | ## Fixes 26 | 27 | - **bump-package-json** - inserting newline at the end of file ([e35726e](https://github.com/mcasimir/release-flow/commits/e35726e4bb36c539920a663031701e160b74b94c), [6fc63ae](https://github.com/mcasimir/release-flow/commits/6fc63aef22c4209868f6314bbf1fb913477ef268)) 28 | 29 | 30 | 31 | # [v1.1.1](https://github.com/mcasimir/release-flow/compare/v1.1.0...v1.1.1) 32 | 33 | ## Fixes 34 | 35 | - fixes compare links ([4c63ed7](https://github.com/mcasimir/release-flow/commits/4c63ed75ba3dabbeb2ea3076285cda9869c48233)) 36 | - fixes commit compare link ([5743558](https://github.com/mcasimir/release-flow/commits/574355890b0a8a2eb88f6f1f184af345c477eeb3)) 37 | 38 | 39 | 40 | # [v1.1.0](https://github.com/mcasimir/release-flow/compare/v1.0.1...v1.1.0) 41 | 42 | ## Features 43 | 44 | - **phase** - allow dev===prod branching model ([3a896e1](https://github.com/mcasimir/release-flow/commits/3a896e1a72e45233952ec6fa8b36dc6382a5a46c)) 45 | - **phase** - implemented finish ([42020b9](https://github.com/mcasimir/release-flow/commits/42020b972586e92b9422c94fe4e03ab69399f9cb)) 46 | - **phase** - validate unpushed commits on start ([4ebf9bb](https://github.com/mcasimir/release-flow/commits/4ebf9bb8cbc39792b08cfbda6bdda32f66e0de41)) 47 | - **release** - implemented Publish phase ([89784ee](https://github.com/mcasimir/release-flow/commits/89784ee1ef4467f14f4df1a7f8958d66b1ed6f45)) 48 | - **phase** - added replace step ([ffaf425](https://github.com/mcasimir/release-flow/commits/ffaf42502a4493eb36ada2d0334b6eb6a471b05e)) 49 | - **git** - added checkout tag and merge ([6059fac](https://github.com/mcasimir/release-flow/commits/6059fac0499b5496742b538f9a4b04baf5513fb0)) 50 | 51 | ## Fixes 52 | 53 | - **git** - fix hasUnpushedChanges command ([7b32c97](https://github.com/mcasimir/release-flow/commits/7b32c979f333c269834d76f3af08ccd0812c3685)) 54 | 55 | 56 | 57 | # [v1.0.1](https://github.com/mcasimir/release-flow/compare/v1.0.0...v1.0.1) 58 | 59 | 60 | 61 | # [v1.0.0](https://github.com/mcasimir/release-flow/commits/v1.0.0) 62 | 63 | ## Features 64 | 65 | - open release branch ([54e859f](https://github.com/mcasimir/release-flow/commits/54e859f)) 66 | - plugins interface ([f214e89](https://github.com/mcasimir/release-flow/commits/f214e89)) 67 | - added cli ([24582bc](https://github.com/mcasimir/release-flow/commits/24582bc)) 68 | - squash commits with same message in changelog ([1aac643](https://github.com/mcasimir/release-flow/commits/1aac643)) 69 | - generate changelog ([4ed812c](https://github.com/mcasimir/release-flow/commits/4ed812c)) 70 | - guess release number ([065766c](https://github.com/mcasimir/release-flow/commits/065766c)) 71 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Maurizio Casimirri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # release-flow 2 | 3 | [![Build Status](https://travis-ci.org/mcasimir/release-flow.svg?branch=master)](https://travis-ci.org/mcasimir/release-flow) [![codecov](https://codecov.io/gh/mcasimir/release-flow/branch/master/graph/badge.svg)](https://codecov.io/gh/mcasimir/release-flow) 4 | 5 | ## Git flow conventional releases 6 | 7 | `release-flow` is a command line tool that simplifies the developer side of software release process taking over tedious and error prone tasks. 8 | 9 | `release-flow` mixes [git flow releases](http://danielkummer.github.io/git-flow-cheatsheet/) with conventional commits to make release process safe and painless. 10 | 11 | ### Features 12 | 13 | - Based on commit conventions 14 | - Complements perfectly with CI tools 15 | - Flexible branching model 16 | - Pluggable design 17 | - Completely configurable and customizable 18 | - Stand-alone (gulp/grunt integration is possible) 19 | - Suitable for any kind of project and language (small apps, opensource projects, libs, enterprise applications) 20 | - Built in plugins for Changelog generation and NPM bumps 21 | 22 | ### Installation 23 | 24 | Globally (use from console) 25 | 26 | ```sh 27 | npm i -g release-flow 28 | ``` 29 | 30 | As project dependency (use through npm script or programmatically) 31 | 32 | ```sh 33 | npm i --save-dev release-flow 34 | ``` 35 | 36 | In your `package.json` 37 | 38 | ```json 39 | "scripts": { 40 | "release": "release-flow" 41 | } 42 | ``` 43 | 44 | ### Usage 45 | 46 | #### Start a release (from your development branch) 47 | 48 | ```sh 49 | release-flow start 50 | ``` 51 | 52 | Effect: 53 | 54 | - Fetches remote changes 55 | - Compute the next version bump from commits (ie. `feat commit === minor`) 56 | - Validates the operation (no uncommitted/untracked changes, no existing tag for the version) 57 | - Creates and checks out a new release branch 58 | - Commits (without pushing) any eventual changes made to start the release (ie. changelog, bump package.json) 59 | 60 | #### Publish a release (from the new release branch) 61 | 62 | ```sh 63 | release-flow publish 64 | ``` 65 | 66 | #### Finalize a release (from the release branch) 67 | 68 | ```sh 69 | release-flow finish 70 | ``` 71 | 72 | Effect: 73 | 74 | - Fetches remote changes 75 | - Validates the operation (no uncommitted/untracked changes) 76 | - Merges release branch on master 77 | - Tags master after the release version 78 | - Merges back to development (if different from master) 79 | 80 | #### Start/Publish/Finish with one command (from your development branch) 81 | 82 | ```sh 83 | release-flow full 84 | ``` 85 | 86 | Effect: 87 | 88 | Same then issuing `release-flow start`, `release-flow publish` and `release-flow finish` in sequence. 89 | 90 | **NOTE:** This approach is especially suitable for libraries and small projects that does not require a QA phase on the release branch. 91 | 92 | #### Supported Branching model 93 | 94 | `release-flow` supports both the canonical `git-flow` branching model with develop/master and a 95 | simplified branching with just master. 96 | 97 | ##### Git flow model (default) 98 | 99 | ```js 100 | // releaseflowrc 101 | module.exports = { 102 | developmentBranch: "develop", 103 | productionBranch: "master", 104 | }; 105 | ``` 106 | 107 | ![full-git-flow](https://github.com/mcasimir/release-flow/raw/master/docs/assets/full-git-flow.png) 108 | 109 | ##### Simplified model 110 | 111 | ```js 112 | // releaseflowrc 113 | module.exports = { 114 | developmentBranch: "master", 115 | productionBranch: "master", 116 | }; 117 | ``` 118 | 119 | ![simplified-git-flow](https://github.com/mcasimir/release-flow/raw/master/docs/assets/simplified.png) 120 | 121 | #### Commit conventions 122 | 123 | Release flow uses conventional commits to simplify the release process (computing next version, generating changelogs). 124 | 125 | Conventional commits are commits with a specific message format: 126 | 127 | ``` 128 | type([scope]): message [BREAKING] 129 | ``` 130 | 131 | ie. 132 | 133 | - fix(homepage): fixed title alignment 134 | - feat: implemented user login 135 | - feat(api): BREAKING changed endpoint to list users 136 | 137 | ##### Default bump detection logic 138 | 139 | - Has one commit whose message contains `BREAKING` → `major` 140 | - Has one commit whose type is feat → `minor` 141 | - Otherwise → `patch` 142 | 143 | #### Configuration 144 | 145 | `release-flow` loads a `releaseflowrc` javascript file to allow configuration. 146 | 147 | The following is an extract of the default configuration file: 148 | 149 | ```js 150 | export default { 151 | developmentBranch: "develop", 152 | productionBranch: "master", 153 | releaseBranchPrefix: "release/", 154 | tagPrefix: "v", 155 | remoteName: "origin", 156 | logLevel: "info", 157 | initialVersion: "1.0.0", 158 | repoHttpUrl: null, 159 | ErrorFactory: DefaultErrorFactory, 160 | Logger: DefaultLogger, 161 | repoHttpProtocol: "https", 162 | getBump: getBump, 163 | plugins: [], 164 | }; 165 | ``` 166 | 167 | #### Included Plugins 168 | 169 | ##### Bump package json 170 | 171 | Bumps package json version on start. 172 | 173 | ```js 174 | // releaseflowrc 175 | module.exports = { 176 | plugins: ["bump-package-json"], 177 | }; 178 | ``` 179 | 180 | ##### Generate changelog 181 | 182 | Generates a changelog for the release and prepend it `CHANGELOG.md` or the choosen path on start. 183 | 184 | ```js 185 | // releaseflowrc 186 | module.exports = { 187 | changelogPath: 'CHANGELOG.md' 188 | changelogTemplate: release => 'changelog contents' 189 | plugins: [ 190 | 'generate-changelog' 191 | ] 192 | }; 193 | ``` 194 | 195 | #### Advanced usage and plugin creation 196 | 197 | A plugin is just a function of the form `install(release) => null`. To register it is enough to pass it in releaseflowrc 198 | 199 | ```js 200 | // releaseflowrc 201 | module.exports = { 202 | plugins: [ 203 | (release) => { 204 | // ... do something 205 | }, 206 | ], 207 | }; 208 | ``` 209 | 210 | Tiplcally a plugin adds some `step` to a release phase (one of start, publish or finish). 211 | 212 | A step is an object with a `name` and a `run()` function. 213 | 214 | To attach a step to a phase is possible to use array methods like `push` or `splice` on the `release.phases.[start/publish/finish].steps` array or use the `release.phases.[start/publish/finish].before` method to insert the step before another specific step: 215 | 216 | ```js 217 | // releaseflowrc 218 | module.exports = { 219 | plugins: [ 220 | (release) => { 221 | let logVersion = { 222 | name: "logVersion", 223 | run(release) { 224 | console.log(release.version); 225 | }, 226 | }; 227 | 228 | release.phases.start.before("commit", logVersion); 229 | }, 230 | ], 231 | }; 232 | ``` 233 | -------------------------------------------------------------------------------- /docs/assets/full-git-flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcasimir/release-flow/8a56780662f1beb49dafa896984a6cdbb8ffcb67/docs/assets/full-git-flow.png -------------------------------------------------------------------------------- /docs/assets/simplified.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcasimir/release-flow/8a56780662f1beb49dafa896984a6cdbb8ffcb67/docs/assets/simplified.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "release-flow", 3 | "version": "1.2.2", 4 | "description": "Git flow conventional releases", 5 | "main": "lib/Release.js", 6 | "engines": { 7 | "node": ">=12", 8 | "npm": ">=8" 9 | }, 10 | "files": [ 11 | "lib" 12 | ], 13 | "scripts": { 14 | "test": "mocha -r ts-node/register 'test/**/*.spec.js'", 15 | "test-cov": "nyc npm run test", 16 | "check": "depcheck . && prettier --check . && eslint . --quiet", 17 | "reformat": "prettier --write .", 18 | "clean": "rm -Rf ./lib", 19 | "release": "npm run compile && ts-node ./lib/cli.js full", 20 | "compile": "tsc -p tsconfig.json", 21 | "prepare": "npm run compile" 22 | }, 23 | "bin": { 24 | "release-flow": "./lib/cli.js" 25 | }, 26 | "nyc": { 27 | "extends": "@istanbuljs/nyc-config-typescript", 28 | "check-coverage": true, 29 | "all": true, 30 | "include": [ 31 | "src/**/!(*.test.*).[tj]s?(x)" 32 | ], 33 | "exclude": [ 34 | "src/_tests_/**/*.*" 35 | ], 36 | "reporter": [ 37 | "html", 38 | "lcov", 39 | "text", 40 | "text-summary" 41 | ], 42 | "report-dir": "coverage" 43 | }, 44 | "author": "mcasimir (https://github.com/mcasimir)", 45 | "repository": "mcasimir/release-flow", 46 | "license": "ISC", 47 | "dependencies": { 48 | "chalk": "1.1.3", 49 | "conventional-commits-filter": "1.0.0", 50 | "conventional-commits-parser": "1.2.2", 51 | "memoize-decorator": "1.0.2", 52 | "prepend-file": "1.3.0", 53 | "semver": "5.3.0", 54 | "yargs": "4.8.1" 55 | }, 56 | "devDependencies": { 57 | "@istanbuljs/nyc-config-typescript": "^1.0.2", 58 | "@typescript-eslint/eslint-plugin": "^5.36.1", 59 | "@typescript-eslint/parser": "^5.36.1", 60 | "depcheck": "^1.4.3", 61 | "eslint": "^8.23.0", 62 | "eslint-plugin-mocha": "^10.1.0", 63 | "mocha": "^10.0.0", 64 | "nyc": "^15.1.0", 65 | "prettier": "2.7.1", 66 | "sinon": "^14.0.0", 67 | "ts-node": "^10.9.1", 68 | "typescript": "^4.8.2" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Git.js: -------------------------------------------------------------------------------- 1 | import memoize from "memoize-decorator"; 2 | import conventionalCommitsFilter from "conventional-commits-filter"; 3 | import conventionalCommitsParser from "conventional-commits-parser"; 4 | import execCommand from "./execCommand"; 5 | import { valid as semverValid, compare as semverCompare } from "semver"; 6 | 7 | const COMMIT_SEPARATOR = "[----COMMIT--END----]"; 8 | const HASH_DELIMITER = "-hash-"; 9 | const GIT_DEFAULT_OPTIONS = { 10 | remoteName: "origin", 11 | repoHttpProtocol: "http", 12 | }; 13 | 14 | const TAG_HISTORY_RE = /^([0-9a-f]{5,40})\s+\(tag: refs\/tags\/([^,)]+)/; 15 | 16 | export default class Git { 17 | constructor(options = {}) { 18 | this.options = Object.assign(GIT_DEFAULT_OPTIONS, options); 19 | this.conventionalCommitsFilter = 20 | options.conventionalCommitsFilter || conventionalCommitsFilter; 21 | this.conventionalCommitsParser = 22 | options.conventionalCommitsParser || conventionalCommitsParser; 23 | this.execCommand = options.execCommand || execCommand; 24 | } 25 | 26 | @memoize 27 | getRemoteUrl() { 28 | return this.execCommand( 29 | `git config --get remote.${this.options.remoteName}.url` 30 | ); 31 | } 32 | 33 | @memoize 34 | getRepoHttpUrl() { 35 | if (this.options.repoHttpUrl) { 36 | return this.options.repoHttpUrl; 37 | } 38 | 39 | let protocol = this.options.repoHttpProtocol; 40 | let remoteUrl = this._remoteUrlToHttpUrl(this.getRemoteUrl()); 41 | 42 | return `${protocol}://${remoteUrl}`; 43 | } 44 | 45 | _remoteUrlToHttpUrl(remoteUrl) { 46 | return remoteUrl 47 | .replace(/^[^@]*@/, "") 48 | .replace(/:/g, "/") 49 | .replace(/\.git$/, ""); 50 | } 51 | 52 | openBranch(branchName) { 53 | this.execCommand(`git checkout -b ${branchName}`); 54 | } 55 | 56 | checkout(branchName) { 57 | this.execCommand(`git checkout ${branchName}`); 58 | } 59 | 60 | commitAll(message) { 61 | this.execCommand("git add ."); 62 | this.execCommand(`git commit -m '${message}'`); 63 | } 64 | 65 | pushCurrentBranch() { 66 | this.pushRef(this.getCurrentBranch()); 67 | } 68 | 69 | pushRef(refName) { 70 | this.execCommand(`git push ${this.options.remoteName} ${refName}`); 71 | } 72 | 73 | tag(refName) { 74 | this.execCommand(`git tag ${refName}`); 75 | } 76 | 77 | merge(refName) { 78 | this.execCommand(`git merge ${refName}`); 79 | } 80 | 81 | link(path) { 82 | path = (path || "").replace(/^\//, ""); 83 | let base = this.getRepoHttpUrl().replace(/\/$/, ""); 84 | return base + "/" + path; 85 | } 86 | 87 | commitLink(commit) { 88 | return this.link(`/commits/${commit}`); 89 | } 90 | 91 | compareLink(from, to) { 92 | return this.link(`/compare/${from}...${to}`); 93 | } 94 | 95 | fetchHeadsAndTags() { 96 | return this.execCommand( 97 | [ 98 | "git fetch", 99 | this.options.remoteName, 100 | `refs/heads/*:refs/remotes/${this.options.remoteName}/*`, 101 | "+refs/tags/*:refs/tags/*", 102 | ].join(" ") 103 | ); 104 | } 105 | 106 | getCurrentBranch() { 107 | return this.execCommand("git rev-parse --abbrev-ref HEAD"); 108 | } 109 | 110 | hasUntrackedChanges() { 111 | return Boolean(this.execCommand("git status --porcelain").length); 112 | } 113 | 114 | hasUnpushedCommits() { 115 | let refName = this.getCurrentBranch(); 116 | return Boolean( 117 | this.execCommand( 118 | [ 119 | "git --no-pager cherry -v", 120 | `${this.options.remoteName}/${refName} ${refName}`, 121 | ].join(" ") 122 | ).length 123 | ); 124 | } 125 | 126 | _parseTagHistoryLine(line) { 127 | let tagMatch = line.match(TAG_HISTORY_RE); 128 | if (tagMatch) { 129 | return { 130 | sha: tagMatch[1], 131 | name: tagMatch[2], 132 | }; 133 | } 134 | } 135 | 136 | @memoize 137 | getLocalTags() { 138 | let tagHistory = this.execCommand( 139 | 'git log --no-walk --tags --pretty="%h %d %s" --decorate=full', 140 | { 141 | splitLines: true, 142 | } 143 | ) 144 | .map((line) => { 145 | return this._parseTagHistoryLine(line); 146 | }) 147 | .filter((line) => line) 148 | .filter((tag) => { 149 | return semverValid(tag.name); 150 | }); 151 | 152 | return tagHistory.sort((tag1, tag2) => { 153 | return semverCompare(tag1.name, tag2.name); 154 | }); 155 | } 156 | 157 | hasLocalTags() { 158 | return Boolean(this.getLocalTags().length); 159 | } 160 | 161 | @memoize 162 | getLastLocalTagSha() { 163 | let tags = this.getLocalTags(); 164 | let lastTag = tags[tags.length - 1]; 165 | return lastTag && lastTag.sha; 166 | } 167 | 168 | @memoize 169 | getLastLocalTagName() { 170 | let tags = this.getLocalTags(); 171 | let lastTag = tags[tags.length - 1]; 172 | return lastTag && lastTag.name; 173 | } 174 | 175 | hasLocalTag(tagName) { 176 | let found = this.getLocalTags().find((tag) => { 177 | return tag.name === tagName; 178 | }); 179 | return Boolean(found); 180 | } 181 | 182 | isCurrentBranch(branchName) { 183 | return this.getCurrentBranch() === branchName; 184 | } 185 | 186 | getRawCommits(fromSha) { 187 | let range = fromSha ? `${fromSha}..` : ""; 188 | return this.execCommand( 189 | [ 190 | "git --no-pager log", 191 | `--pretty='format:%B%n${HASH_DELIMITER}%n%H${COMMIT_SEPARATOR}'`, 192 | range, 193 | ].join(" ") 194 | ) 195 | .split(COMMIT_SEPARATOR) 196 | .filter((line) => line); 197 | } 198 | 199 | _parseRawCommit(rawCommit, options = {}) { 200 | let commit = this.conventionalCommitsParser.sync(rawCommit, options); 201 | 202 | if ( 203 | (commit.header && commit.header.match("BREAKING")) || 204 | (commit.footer && commit.footer.match("BREAKING")) 205 | ) { 206 | commit.breaking = true; 207 | } 208 | 209 | if (commit.hash) { 210 | commit.shortHash = commit.hash.slice(0, 7); 211 | } 212 | 213 | return commit; 214 | } 215 | 216 | conventionalCommits(fromSha, options = {}) { 217 | let rawCommits = this.getRawCommits(fromSha); 218 | let commits = rawCommits.map((rawCommit) => 219 | this._parseRawCommit(rawCommit, options) 220 | ); 221 | return this.conventionalCommitsFilter(commits); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /src/Phase.js: -------------------------------------------------------------------------------- 1 | import Sequence from "./Sequence"; 2 | 3 | export default class Phase { 4 | constructor() { 5 | this.steps = this.constructor.steps || new Sequence(); 6 | } 7 | 8 | step(step) { 9 | this.steps.push(step); 10 | } 11 | 12 | before(stepName, callback) { 13 | let idx = this.steps.findIndex(function (step) { 14 | return step.name === stepName; 15 | }); 16 | 17 | if (idx !== -1) { 18 | this.steps.splice(idx, 0, callback); 19 | } 20 | } 21 | 22 | replace(stepName, callback) { 23 | let idx = this.steps.findIndex(function (step) { 24 | return step.name === stepName; 25 | }); 26 | 27 | if (idx !== -1) { 28 | this.steps.splice(idx, 1, callback); 29 | } 30 | } 31 | 32 | run(...args) { 33 | return this.steps.run(...args); 34 | } 35 | } 36 | 37 | export { default as Step } from "./Step"; 38 | -------------------------------------------------------------------------------- /src/Release.js: -------------------------------------------------------------------------------- 1 | import Git from "./Git"; 2 | import Start from "./phases/Start"; 3 | import Publish from "./phases/Publish"; 4 | import Finish from "./phases/Finish"; 5 | import defaults from "./defaults"; 6 | 7 | export default class Release { 8 | static plugins = {}; 9 | 10 | static registerPlugin(name, fn) { 11 | this.plugins[name] = fn; 12 | } 13 | 14 | constructor(options) { 15 | options = Object.assign({}, defaults, options); 16 | this.options = options; 17 | this.phases = { 18 | start: new Start(), 19 | publish: new Publish(), 20 | finish: new Finish(), 21 | }; 22 | 23 | this.logger = new options.Logger({ logLevel: options.logLevel }); 24 | this.errorFactory = new options.ErrorFactory(); 25 | this.git = new Git(options); 26 | 27 | for (let plugin of options.plugins) { 28 | this.plugin(plugin); 29 | } 30 | } 31 | 32 | start() { 33 | return this.phases.start.run(this); 34 | } 35 | 36 | publish() { 37 | return this.phases.publish.run(this); 38 | } 39 | 40 | finish() { 41 | return this.phases.finish.run(this); 42 | } 43 | 44 | async full() { 45 | await this.start(); 46 | await this.publish(); 47 | await this.finish(); 48 | } 49 | 50 | error(...args) { 51 | return this.errorFactory.createError(...args); 52 | } 53 | 54 | plugin(fnOrString) { 55 | if (typeof fnOrString === "function") { 56 | fnOrString(this); 57 | } else { 58 | Release.plugins[fnOrString](this); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Sequence.js: -------------------------------------------------------------------------------- 1 | export default class Sequence extends Array { 2 | run(args) { 3 | args = args || {}; 4 | return this.reduce((acc, curr) => { 5 | return acc.then(function () { 6 | let promise; 7 | 8 | try { 9 | promise = 10 | curr.run instanceof Function 11 | ? curr.run(args) 12 | : Promise.resolve(curr(args)); 13 | } catch (e) { 14 | promise = Promise.reject(e); 15 | } 16 | 17 | return promise; 18 | }); 19 | }, Promise.resolve(args)); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Step.js: -------------------------------------------------------------------------------- 1 | import Sequence from "./Sequence"; 2 | 3 | export default function Step(name) { 4 | return (target, methodName, descriptor) => { 5 | let stepName = name || methodName; 6 | target.constructor.steps = target.constructor.steps || new Sequence(); 7 | 8 | target.constructor.steps.push({ 9 | name: stepName, 10 | run: target[methodName].bind(target), 11 | }); 12 | 13 | return descriptor; 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import yargs from "yargs"; 4 | import { resolve } from "path"; 5 | import Release from "./Release"; 6 | import bumpPackageJson from "./plugins/bump-package-json"; 7 | import generateChangelog from "./plugins/generate-changelog"; 8 | 9 | Release.registerPlugin("bump-package-json", bumpPackageJson); 10 | Release.registerPlugin("generate-changelog", generateChangelog); 11 | 12 | let program = yargs 13 | .options({ 14 | config: { 15 | alias: ["c"], 16 | describe: "configuration file path (JSON or Javascript)", 17 | type: "string", 18 | normalize: true, 19 | default: ".releaseflowrc", 20 | }, 21 | }) 22 | .locale("en") 23 | .usage("Usage: $0 [options]") 24 | .command("start", "Start a release") 25 | .command("publish", "Push a release") 26 | .command("finish", "Finish a release") 27 | .command("full", "Perform the full release process at once") 28 | .demand(1) 29 | .strict() 30 | .help() 31 | .version(); 32 | 33 | let argv = program.argv; 34 | 35 | if (argv._.length > 1) { 36 | program.showHelp(); 37 | process.exit(1); 38 | } 39 | 40 | let options = {}; 41 | let configLoadError = null; 42 | 43 | if (argv.config) { 44 | try { 45 | options = require(resolve(process.cwd(), argv.config)); 46 | } catch (e) { 47 | configLoadError = e; 48 | } 49 | } 50 | 51 | let release = new Release(options); 52 | 53 | if (configLoadError) { 54 | release.logger.warn(configLoadError.message); 55 | release.logger.warn("Using default configuration"); 56 | } 57 | 58 | let command = argv._[0]; 59 | 60 | release[command](release) 61 | .then(function () { 62 | process.exit(0); 63 | }) 64 | .catch(function (err) { 65 | release.logger.error(err.message); 66 | process.exit(1); 67 | }); 68 | -------------------------------------------------------------------------------- /src/defaults/DefaultErrorFactory.js: -------------------------------------------------------------------------------- 1 | export default class DefaultErrorFactory { 2 | createError(message, data = {}) { 3 | let error = new Error(message); 4 | Object.assign(error, data); 5 | return error; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/defaults/DefaultLogger.js: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | const LEVELS = { 4 | error: 0, 5 | warn: 1, 6 | info: 2, 7 | debug: 3, 8 | }; 9 | 10 | const COLORS = { 11 | error: "red", 12 | warn: "yellow", 13 | info: "white", 14 | debug: "gray", 15 | }; 16 | 17 | const DEFAULT_OPTIONS = { 18 | logLevel: "info", 19 | }; 20 | 21 | export default class DefaultLogger { 22 | constructor(options) { 23 | options = Object.assign({}, DEFAULT_OPTIONS, options); 24 | this.level = options.logLevel; 25 | this.console = options.console || console; 26 | } 27 | 28 | error(message, metadata) { 29 | this.log("error", message, metadata); 30 | } 31 | 32 | warn(message, metadata) { 33 | this.log("warn", message, metadata); 34 | } 35 | 36 | info(message, metadata) { 37 | this.log("info", message, metadata); 38 | } 39 | 40 | debug(message, metadata) { 41 | this.log("debug", message, metadata); 42 | } 43 | 44 | log(level, message, metadata) { 45 | if (LEVELS[this.level] >= LEVELS[level]) { 46 | let openColor = chalk.styles[COLORS[level]].open; 47 | let closeColor = chalk.styles[COLORS[level]].close; 48 | 49 | let args = [ 50 | openColor + chalk.bold(`${level}:`) + ` ${message}` + closeColor, 51 | metadata, 52 | ]; 53 | 54 | if (metadata === null || metadata === undefined) { 55 | args.pop(); 56 | } 57 | 58 | this.console.log(...args); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/defaults/get-bump.js: -------------------------------------------------------------------------------- 1 | export default function (commits) { 2 | let bump = "patch"; 3 | commits.forEach(function (commit) { 4 | if (commit.breaking) { 5 | bump = "major"; 6 | } else if (commit.type === "feat" && bump === "patch") { 7 | bump = "minor"; 8 | } 9 | }); 10 | return bump; 11 | } 12 | -------------------------------------------------------------------------------- /src/defaults/index.js: -------------------------------------------------------------------------------- 1 | import getBump from "./get-bump"; 2 | import DefaultErrorFactory from "./DefaultErrorFactory"; 3 | import DefaultLogger from "./DefaultLogger"; 4 | 5 | export default { 6 | developmentBranch: "develop", 7 | productionBranch: "master", 8 | releaseBranchPrefix: "release/", 9 | tagPrefix: "v", 10 | remoteName: "origin", 11 | logLevel: "info", 12 | initialVersion: "1.0.0", 13 | repoHttpUrl: null, 14 | ErrorFactory: DefaultErrorFactory, 15 | Logger: DefaultLogger, 16 | repoHttpProtocol: "https", 17 | getBump: getBump, 18 | plugins: [], 19 | }; 20 | -------------------------------------------------------------------------------- /src/execCommand.js: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | 3 | export default function execCommand(cmd, options = {}) { 4 | let stdout = execSync(cmd).toString().trim(); 5 | 6 | if (options.splitLines) { 7 | stdout = stdout 8 | .split("\n") 9 | .map(function (line) { 10 | return line.trim(); 11 | }) 12 | .filter(function (line) { 13 | return line; 14 | }); 15 | } 16 | 17 | return stdout; 18 | } 19 | -------------------------------------------------------------------------------- /src/phases/Finish.js: -------------------------------------------------------------------------------- 1 | import Phase, { Step } from "../Phase"; 2 | 3 | export default class Finish extends Phase { 4 | @Step() 5 | fetch(release) { 6 | release.logger.debug("fetching tags and heads"); 7 | try { 8 | release.git.fetchHeadsAndTags(); 9 | } catch (e) { 10 | throw release.error("Unable to fetch tags and heads"); 11 | } 12 | } 13 | 14 | @Step() 15 | getReleaseInfo(release) { 16 | release.branchName = release.git.getCurrentBranch(); 17 | release.name = release.branchName.slice( 18 | release.options.releaseBranchPrefix.length 19 | ); 20 | release.tagName = `${release.options.tagPrefix}${release.name}`; 21 | } 22 | 23 | @Step() 24 | validateReleaseBranch(release) { 25 | let currentBranch = release.git.getCurrentBranch(); 26 | if (!currentBranch.startsWith(release.options.releaseBranchPrefix)) { 27 | throw release.error( 28 | "You can only finish a release from a release branch" 29 | ); 30 | } 31 | 32 | if (release.git.hasUntrackedChanges()) { 33 | throw release.error("You have untracked changes"); 34 | } 35 | 36 | if (release.git.hasUnpushedCommits()) { 37 | throw release.error("You have unpushed changes"); 38 | } 39 | } 40 | 41 | @Step() 42 | checkoutProduction(release) { 43 | release.git.checkout(release.options.productionBranch); 44 | } 45 | 46 | @Step() 47 | validateProductionBranch(release) { 48 | if (release.git.hasUntrackedChanges()) { 49 | throw release.error("You have untracked changes"); 50 | } 51 | 52 | if (release.git.hasUnpushedCommits()) { 53 | throw release.error("You have unpushed changes"); 54 | } 55 | } 56 | 57 | @Step() 58 | mergeToProduction(release) { 59 | release.git.merge(release.branchName); 60 | release.git.pushRef(release.options.productionBranch); 61 | } 62 | 63 | @Step() 64 | tagProduction(release) { 65 | release.git.tag(release.tagName); 66 | release.git.pushRef(release.tagName); 67 | } 68 | 69 | @Step() 70 | mergeBackToDevelopment(release) { 71 | if ( 72 | release.options.developmentBranch !== release.options.productionBranch 73 | ) { 74 | release.git.checkout(release.options.developmentBranch); 75 | release.git.merge(release.branchName); 76 | release.git.pushRef(release.options.developmentBranch); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/phases/Publish.js: -------------------------------------------------------------------------------- 1 | import Phase, { Step } from "../Phase"; 2 | 3 | export default class Publish extends Phase { 4 | @Step() 5 | validate(release) { 6 | let currentBranch = release.git.getCurrentBranch(); 7 | if (!currentBranch.startsWith(release.options.releaseBranchPrefix)) { 8 | throw release.error( 9 | "You can only publish a release from a release branch" 10 | ); 11 | } 12 | 13 | if (release.git.hasUntrackedChanges()) { 14 | throw release.error("You have untracked changes"); 15 | } 16 | } 17 | 18 | @Step() 19 | push(release) { 20 | release.git.pushRef(release.git.getCurrentBranch()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/phases/Start.js: -------------------------------------------------------------------------------- 1 | import semver from "semver"; 2 | import Phase, { Step } from "../Phase"; 3 | 4 | export default class Start extends Phase { 5 | @Step() 6 | fetch(release) { 7 | release.logger.debug("fetching tags and heads"); 8 | try { 9 | release.git.fetchHeadsAndTags(); 10 | } catch (e) { 11 | throw release.error("Unable to fetch tags and heads"); 12 | } 13 | } 14 | 15 | @Step() 16 | getPreviousVersion(release) { 17 | release.logger.debug("finding previous version"); 18 | let lastTagName = release.git.getLastLocalTagName(); 19 | if (lastTagName) { 20 | let versionMatch = lastTagName.match(/\d+\.\d+\.\d+.*/); 21 | release.previousVersion = versionMatch && versionMatch[0]; 22 | release.previousReleaseName = `${release.options.tagPrefix}${release.previousVersion}`; 23 | } else { 24 | release.previousVersion = null; 25 | release.previousReleaseName = null; 26 | } 27 | release.logger.debug(`previous version was ${release.previousVersion}`); 28 | } 29 | 30 | @Step() 31 | getCommits(release) { 32 | let sha = release.git.getLastLocalTagSha(); 33 | release.logger.debug(`getting commits from ${sha}`); 34 | release.commits = release.git.conventionalCommits(sha); 35 | release.logger.debug(`${release.commits.length} commits found`); 36 | if (!release.commits.length) { 37 | throw release.error("Nothing to release"); 38 | } 39 | } 40 | 41 | @Step() 42 | getNextVersion(release) { 43 | release.logger.debug("getting next version"); 44 | 45 | if (release.previousVersion) { 46 | release.bump = release.options.getBump(release.commits); 47 | release.logger.debug(`bumping ${release.bump} based on convetions`); 48 | release.nextVersion = semver.inc(release.previousVersion, release.bump); 49 | } else { 50 | release.logger.debug("no previousVersion, assuming initialVersion"); 51 | release.nextVersion = release.options.initialVersion; 52 | } 53 | 54 | release.name = `${release.options.tagPrefix}${release.nextVersion}`; 55 | 56 | release.logger.debug(`next version will be ${release.nextVersion}`); 57 | } 58 | 59 | @Step() 60 | validate(release) { 61 | if (!release.git.isCurrentBranch(release.options.developmentBranch)) { 62 | throw release.error( 63 | `Current branch should be ${release.options.developmentBranch}` 64 | ); 65 | } 66 | 67 | if (release.git.hasUntrackedChanges()) { 68 | throw release.error("You have untracked changes"); 69 | } 70 | 71 | if (release.git.hasUnpushedCommits(release.options.developmentBranch)) { 72 | throw release.error("You have unpushed changes"); 73 | } 74 | 75 | if (release.git.hasLocalTag(release.name)) { 76 | throw release.error(`Tag ${release.name} already exists`); 77 | } 78 | } 79 | 80 | @Step() 81 | openReleaseBranch(release) { 82 | release.git.openBranch( 83 | `${release.options.releaseBranchPrefix}${release.nextVersion}` 84 | ); 85 | } 86 | 87 | @Step() 88 | commit(release) { 89 | release.git.commitAll(`Release ${release.name}`); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/plugins/bump-package-json/index.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync, readFileSync } from "fs"; 2 | import { resolve } from "path"; 3 | 4 | export default function installPlugin(release) { 5 | release.phases.start.before("commit", { 6 | name: "bumpPackageJson", 7 | run(context) { 8 | let packageJsonPath = resolve(process.cwd(), "package.json"); 9 | let packageJson = JSON.parse(readFileSync(packageJsonPath)); 10 | packageJson.version = context.nextVersion; 11 | 12 | writeFileSync( 13 | packageJsonPath, 14 | JSON.stringify(packageJson, null, " ") + "\n" 15 | ); 16 | }, 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /src/plugins/generate-changelog/ChangelogEntry.js: -------------------------------------------------------------------------------- 1 | export default class ChangelogEntry { 2 | constructor(subject, options = {}) { 3 | this.subject = subject; 4 | this.scope = options.scope; 5 | this.subjectLink = options.subjectLink; 6 | this.links = options.links || []; 7 | this.children = []; 8 | } 9 | 10 | addLink(name, url) { 11 | this.links.push({ name: name, url: url }); 12 | } 13 | 14 | addChild(child) { 15 | this.children.push(child); 16 | } 17 | 18 | isLeaf() { 19 | return !this.children.length; 20 | } 21 | 22 | traverse(previsit, postvisit) { 23 | if (previsit) { 24 | previsit(this); 25 | } 26 | for (let i = 0; i < this.children.length; i++) { 27 | this.children[i].traverse(previsit, postvisit); 28 | } 29 | if (postvisit) { 30 | postvisit(this); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/plugins/generate-changelog/changelog-template.js: -------------------------------------------------------------------------------- 1 | export default function (context) { 2 | let release = context.release; 3 | let lines = [``]; 4 | let level = 0; 5 | 6 | release.changes.traverse( 7 | (entry) => { 8 | level++; 9 | 10 | if (level !== 1 && !entry.isLeaf()) { 11 | lines.push(""); 12 | } 13 | 14 | let titleBullet = 15 | (entry.isLeaf() && level !== 1 ? "-" : Array(level + 1).join("#")) + 16 | " "; 17 | 18 | let subject = entry.subject; 19 | let scope = entry.scope || ""; 20 | let links = entry.links 21 | .map(function (link) { 22 | return `[${link.name}](${link.url})`; 23 | }) 24 | .join(", "); 25 | 26 | if (scope) { 27 | scope = `**${scope}** - `; 28 | } 29 | 30 | if (links) { 31 | links = ` (${links})`; 32 | } 33 | 34 | if (entry.subjectLink) { 35 | subject = `[${subject}](${entry.subjectLink})`; 36 | } 37 | 38 | lines.push(`${titleBullet}${scope}${subject}${links}`); 39 | 40 | if (!entry.isLeaf()) { 41 | if (entry.children[0].isLeaf()) { 42 | lines.push(""); 43 | } 44 | } 45 | }, 46 | () => { 47 | level--; 48 | } 49 | ); 50 | 51 | return lines.join("\n") + "\n\n"; 52 | } 53 | -------------------------------------------------------------------------------- /src/plugins/generate-changelog/index.js: -------------------------------------------------------------------------------- 1 | import prependFile from "prepend-file"; 2 | import ChangelogEntry from "./ChangelogEntry"; 3 | import { resolve } from "path"; 4 | import changelogTemplate from "./changelog-template"; 5 | 6 | export default function installPlugin(release) { 7 | release.options.changelogTemplate = 8 | release.options.changelogTemplate || changelogTemplate; 9 | 10 | release.options.changelogPath = 11 | release.options.changelogPath || resolve(process.cwd(), "CHANGELOG.md"); 12 | 13 | release.phases.start.before("commit", { 14 | name: "getChangelogEntries", 15 | run(release) { 16 | release.logger.debug("getting changelog entries"); 17 | let commits = release.commits; 18 | let changes = new ChangelogEntry(release.name); 19 | 20 | changes.subjectLink = release.previousReleaseName 21 | ? release.git.compareLink(release.previousReleaseName, release.name) 22 | : release.git.commitLink(release.name); 23 | 24 | let breaking = new ChangelogEntry("Breaking Changes"); 25 | let features = new ChangelogEntry("Features"); 26 | let fixes = new ChangelogEntry("Fixes"); 27 | 28 | let headersMap = {}; 29 | 30 | commits.forEach(function (commit) { 31 | if (headersMap[commit.header]) { 32 | let change = headersMap[commit.header]; 33 | change.addLink(commit.shortHash, release.git.commitLink(commit.hash)); 34 | } else if ( 35 | commit.type === "feat" || 36 | commit.type === "fix" || 37 | commit.breaking 38 | ) { 39 | let change = new ChangelogEntry(commit.subject); 40 | headersMap[commit.header] = change; 41 | 42 | if (commit.scope) { 43 | change.scope = commit.scope; 44 | } 45 | 46 | change.addLink(commit.shortHash, release.git.commitLink(commit.hash)); 47 | 48 | if (commit.type === "feat") { 49 | features.addChild(change); 50 | } 51 | if (commit.type === "fix") { 52 | fixes.addChild(change); 53 | } 54 | if (commit.breaking) { 55 | breaking.addChild(change); 56 | } 57 | } 58 | }); 59 | 60 | if (!breaking.isLeaf()) { 61 | changes.addChild(breaking); 62 | } 63 | 64 | if (!features.isLeaf()) { 65 | changes.addChild(features); 66 | } 67 | 68 | if (!fixes.isLeaf()) { 69 | changes.addChild(fixes); 70 | } 71 | 72 | release.changes = changes; 73 | release.logger.debug("Changes:", release.changes); 74 | }, 75 | }); 76 | 77 | release.phases.start.before("commit", { 78 | name: "generateChangelogContent", 79 | run(release) { 80 | release.logger.debug("generating changelog"); 81 | release.changelog = release.options.changelogTemplate({ 82 | release: release, 83 | }); 84 | release.logger.debug("changelog", release.changelog); 85 | }, 86 | }); 87 | 88 | release.phases.start.before("commit", { 89 | name: "writeChangelog", 90 | run(release) { 91 | release.logger.debug("writing changelog"); 92 | prependFile.sync(release.options.changelogPath, release.changelog); 93 | }, 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /test/Git.spec.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from "sinon"; 2 | import assert, { equal, deepEqual } from "assert"; 3 | import Git from "../src/Git"; 4 | 5 | describe("Git", function () { 6 | describe("getRemoteUrl", function () { 7 | it("gets remoteUrl based on the remoteName option", function () { 8 | let git = new Git({ 9 | execCommand: spy(), 10 | remoteName: "abc", 11 | }); 12 | 13 | git.getRemoteUrl(); 14 | 15 | assert(git.execCommand.calledWith("git config --get remote.abc.url")); 16 | }); 17 | }); 18 | 19 | describe("getRepoHttpUrl", function () { 20 | it("gets repoHttpUrl based on the remoteUrl", function () { 21 | let git = new Git({ 22 | execCommand() { 23 | return "remoteurl.com"; 24 | }, 25 | remoteName: "abc", 26 | }); 27 | 28 | equal(git.getRepoHttpUrl(), "http://remoteurl.com"); 29 | }); 30 | 31 | it("returns options.repoHttpUrl if present", function () { 32 | let git = new Git({ 33 | execCommand() { 34 | return "remoteurl.com"; 35 | }, 36 | remoteName: "abc", 37 | repoHttpUrl: "xyz", 38 | }); 39 | 40 | equal(git.getRepoHttpUrl(), "xyz"); 41 | }); 42 | }); 43 | 44 | describe("openBranch", function () { 45 | it("runs git checkout -b on the passed branch", function () { 46 | let git = new Git({ 47 | execCommand: spy(), 48 | }); 49 | 50 | git.openBranch("abc"); 51 | 52 | assert(git.execCommand.calledWith("git checkout -b abc")); 53 | }); 54 | }); 55 | 56 | describe("checkout", function () { 57 | it("runs git checkout on the passed branch", function () { 58 | let git = new Git({ 59 | execCommand: spy(), 60 | }); 61 | 62 | git.checkout("abc"); 63 | 64 | assert(git.execCommand.calledWith("git checkout abc")); 65 | }); 66 | }); 67 | 68 | describe("commitAll", function () { 69 | it("runs git add", function () { 70 | let git = new Git({ 71 | execCommand: spy(), 72 | }); 73 | 74 | git.commitAll(); 75 | 76 | assert(git.execCommand.calledWith("git add .")); 77 | }); 78 | 79 | it("runs git commit with the right message", function () { 80 | let git = new Git({ 81 | execCommand: spy(), 82 | }); 83 | 84 | git.commitAll("xyz"); 85 | 86 | assert(git.execCommand.calledWith("git commit -m 'xyz'")); 87 | }); 88 | }); 89 | 90 | describe("pushCurrentBranch", function () { 91 | it("pushes current branch as ref", function () { 92 | let git = new Git({ 93 | execCommand: spy(), 94 | }); 95 | spy(git, "pushRef"); 96 | stub(git, "getCurrentBranch").callsFake(() => { 97 | return "xyz"; 98 | }); 99 | 100 | git.pushCurrentBranch(); 101 | 102 | assert(git.pushRef.calledWith("xyz")); 103 | }); 104 | }); 105 | 106 | describe("pushRef", function () { 107 | it("pushes a reference on the specified remote", function () { 108 | let git = new Git({ 109 | execCommand: spy(), 110 | remoteName: "abc", 111 | }); 112 | 113 | git.pushRef("xyz"); 114 | 115 | assert(git.execCommand.calledWith("git push abc xyz")); 116 | }); 117 | }); 118 | 119 | describe("link", function () { 120 | it("returns link url on repo http for the given path", function () { 121 | let git = new Git({ 122 | execCommand: spy(), 123 | repoHttpUrl: "http://abc.com/", 124 | }); 125 | 126 | equal(git.link("xyz"), "http://abc.com/xyz"); 127 | }); 128 | }); 129 | 130 | describe("commitLink", function () { 131 | it("returns link for a commit", function () { 132 | let git = new Git({ 133 | execCommand: spy(), 134 | repoHttpUrl: "http://abc.com/", 135 | }); 136 | 137 | equal(git.commitLink("xyz"), "http://abc.com/commits/xyz"); 138 | }); 139 | }); 140 | 141 | describe("compareLink", function () { 142 | it("returns link for comparison between references", function () { 143 | let git = new Git({ 144 | execCommand: spy(), 145 | repoHttpUrl: "http://abc.com/", 146 | }); 147 | 148 | equal(git.compareLink("xyz", "abc"), "http://abc.com/compare/xyz...abc"); 149 | }); 150 | }); 151 | 152 | describe("fetchHeadsAndTags", function () { 153 | it("fetches from specified remote", function () { 154 | let git = new Git({ 155 | execCommand: spy(), 156 | remoteName: "abc", 157 | }); 158 | 159 | git.fetchHeadsAndTags(); 160 | 161 | assert( 162 | git.execCommand.calledWith( 163 | "git fetch abc refs/heads/*:refs/remotes/abc/* +refs/tags/*:refs/tags/*" 164 | ) 165 | ); 166 | }); 167 | }); 168 | 169 | describe("getCurrentBranch", function () { 170 | it("calls git rev-parse --abbrev-ref HEAD", function () { 171 | let git = new Git({ 172 | execCommand: spy(), 173 | }); 174 | 175 | git.getCurrentBranch(); 176 | 177 | assert(git.execCommand.calledWith("git rev-parse --abbrev-ref HEAD")); 178 | }); 179 | }); 180 | 181 | describe("hasUntrackedChanges", function () { 182 | it("calls git status --porcelain", function () { 183 | let git = new Git({ 184 | execCommand: stub().returns(""), 185 | }); 186 | 187 | git.hasUntrackedChanges(); 188 | 189 | assert(git.execCommand.calledWith("git status --porcelain")); 190 | }); 191 | 192 | it("returns true if git status --porcelain is not empty", function () { 193 | let git = new Git({ 194 | execCommand: stub().withArgs("git status --porcelain").returns("abc"), 195 | }); 196 | 197 | equal(git.hasUntrackedChanges(), true); 198 | }); 199 | 200 | it("returns false if git status --porcelain is empty", function () { 201 | let git = new Git({ 202 | execCommand: stub().withArgs("git status --porcelain").returns(""), 203 | }); 204 | 205 | equal(git.hasUntrackedChanges(), false); 206 | }); 207 | }); 208 | 209 | describe("hasUnpushedCommits", function () { 210 | it("calls git --no-pager cherry -v", function () { 211 | let git = new Git({ 212 | execCommand: stub().returns(""), 213 | }); 214 | 215 | stub(git, "getCurrentBranch").returns("b1"); 216 | 217 | git.options.remoteName = "a"; 218 | git.hasUnpushedCommits(); 219 | 220 | assert(git.execCommand.calledWith("git --no-pager cherry -v a/b1 b1")); 221 | }); 222 | 223 | it("returns true if git --no-pager cherry -v is not empty", function () { 224 | let git = new Git({ 225 | execCommand: stub().withArgs("git --no-pager cherry -v").returns("abc"), 226 | }); 227 | 228 | equal(git.hasUnpushedCommits(), true); 229 | }); 230 | 231 | it("returns false if git --no-pager cherry -v is empty", function () { 232 | let git = new Git({ 233 | execCommand: stub().withArgs("git --no-pager cherry -v").returns(""), 234 | }); 235 | 236 | equal(git.hasUnpushedCommits(), false); 237 | }); 238 | }); 239 | 240 | describe("_parseTagHistoryLine", function () { 241 | it("returns an object for a valid line", function () { 242 | let git = new Git(); 243 | 244 | deepEqual( 245 | git._parseTagHistoryLine("d3963ed (tag: refs/tags/v1.3.0) abc bcd"), 246 | { 247 | name: "v1.3.0", 248 | sha: "d3963ed", 249 | } 250 | ); 251 | }); 252 | }); 253 | 254 | describe("getLocalTags", function () { 255 | it("runs git log", function () { 256 | let git = new Git({ 257 | execCommand: stub().returns([]), 258 | }); 259 | 260 | git.getLocalTags(); 261 | 262 | assert( 263 | git.execCommand.calledWith( 264 | 'git log --no-walk --tags --pretty="%h %d %s" --decorate=full' 265 | ) 266 | ); 267 | }); 268 | 269 | it("excludes lines that are not tag refs", function () { 270 | let git = new Git({ 271 | execCommand: stub() 272 | .withArgs( 273 | 'git log --no-walk --tags --pretty="%h %d %s" --decorate=full' 274 | ) 275 | .returns(["d3963ed (tag: refs/heads/v1.3.0) abc bcd"]), 276 | }); 277 | 278 | deepEqual(git.getLocalTags(), []); 279 | }); 280 | 281 | it("includes lines that are tag refs", function () { 282 | let git = new Git({ 283 | execCommand: stub() 284 | .withArgs( 285 | 'git log --no-walk --tags --pretty="%h %d %s" --decorate=full' 286 | ) 287 | .returns([ 288 | "d3963ed (tag: refs/tags/v1.3.0) abc bcd", 289 | "d3963ed (tag: refs/tags/v1.3.1) abc bcd", 290 | ]), 291 | }); 292 | 293 | deepEqual(git.getLocalTags(), [ 294 | { name: "v1.3.0", sha: "d3963ed" }, 295 | { name: "v1.3.1", sha: "d3963ed" }, 296 | ]); 297 | }); 298 | 299 | it("filters non semantic tags", function () { 300 | let git = new Git({ 301 | execCommand: stub() 302 | .withArgs( 303 | 'git log --no-walk --tags --pretty="%h %d %s" --decorate=full' 304 | ) 305 | .returns([ 306 | "d3963ed (tag: refs/tags/vfoo) abc bcd", 307 | "d3963ed (tag: refs/tags/vbar) abc bcd", 308 | "d3963ed (tag: refs/tags/v1.2.3) abc bcd", 309 | "d3963ed (tag: refs/tags/1.3.3) abc bcd", 310 | ]), 311 | }); 312 | 313 | deepEqual(git.getLocalTags(), [ 314 | { name: "v1.2.3", sha: "d3963ed" }, 315 | { name: "1.3.3", sha: "d3963ed" }, 316 | ]); 317 | }); 318 | 319 | it("sorts by semantic version", function () { 320 | let git = new Git({ 321 | execCommand: stub() 322 | .withArgs( 323 | 'git log --no-walk --tags --pretty="%h %d %s" --decorate=full' 324 | ) 325 | .returns([ 326 | "d3963ed (tag: refs/tags/v1.3.1) abc bcd", 327 | "d3963ed (tag: refs/tags/v1.3.0) abc bcd", 328 | ]), 329 | }); 330 | 331 | deepEqual(git.getLocalTags(), [ 332 | { name: "v1.3.0", sha: "d3963ed" }, 333 | { name: "v1.3.1", sha: "d3963ed" }, 334 | ]); 335 | }); 336 | }); 337 | 338 | describe("hasLocalTags", function () { 339 | it("returns true if getLocalTags length > 0", function () { 340 | let git = new Git(); 341 | stub(git, "getLocalTags").returns([{ name: "v1.3.1", sha: "d3963ed" }]); 342 | equal(git.hasLocalTags(), true); 343 | }); 344 | 345 | it("returns false if getLocalTags length === 0", function () { 346 | let git = new Git(); 347 | stub(git, "getLocalTags").returns([]); 348 | equal(git.hasLocalTags(), false); 349 | }); 350 | }); 351 | 352 | describe("getLastLocalTagSha", function () { 353 | it("returns the last sha from getLocalTags", function () { 354 | let git = new Git(); 355 | stub(git, "getLocalTags").returns([ 356 | { name: "v1.3.1", sha: "1" }, 357 | { name: "v1.3.2", sha: "2" }, 358 | ]); 359 | equal(git.getLastLocalTagSha(), "2"); 360 | }); 361 | }); 362 | 363 | describe("getLastLocalTagName", function () { 364 | it("returns the last tag name from getLocalTags", function () { 365 | let git = new Git(); 366 | stub(git, "getLocalTags").returns([ 367 | { name: "v1.3.1", sha: "1" }, 368 | { name: "v1.3.2", sha: "2" }, 369 | ]); 370 | equal(git.getLastLocalTagName(), "v1.3.2"); 371 | }); 372 | }); 373 | 374 | describe("hasLocalTag", function () { 375 | it("returns true if tag is present", function () { 376 | let git = new Git(); 377 | stub(git, "getLocalTags").returns([{ name: "v1.3.1", sha: "1" }]); 378 | equal(git.hasLocalTag("v1.3.1"), true); 379 | }); 380 | 381 | it("returns false if tag is missing", function () { 382 | let git = new Git(); 383 | stub(git, "getLocalTags").returns([{ name: "v1.3.1", sha: "1" }]); 384 | equal(git.hasLocalTag("v1.3.2"), false); 385 | }); 386 | }); 387 | 388 | describe("isCurrentBranch", function () { 389 | it("returns true if branch matches", function () { 390 | let git = new Git(); 391 | stub(git, "getCurrentBranch").returns("foo"); 392 | equal(git.isCurrentBranch("foo"), true); 393 | }); 394 | 395 | it("returns false if branch does not match", function () { 396 | let git = new Git(); 397 | stub(git, "getCurrentBranch").returns("foo"); 398 | equal(git.isCurrentBranch("bar"), false); 399 | }); 400 | }); 401 | 402 | describe("merge", function () { 403 | it("merges a local branch", function () { 404 | let git = new Git({ 405 | execCommand: spy(), 406 | }); 407 | 408 | git.merge("xyz"); 409 | 410 | assert(git.execCommand.calledWith("git merge xyz")); 411 | }); 412 | }); 413 | 414 | describe("tag", function () { 415 | it("creates an anonymous tag", function () { 416 | let git = new Git({ 417 | execCommand: spy(), 418 | }); 419 | 420 | git.tag("xyz"); 421 | 422 | assert(git.execCommand.calledWith("git tag xyz")); 423 | }); 424 | }); 425 | 426 | describe("getRawCommits", function () { 427 | it("calls execCommand with right argument", function () { 428 | let git = new Git({ 429 | execCommand: stub().returns(""), 430 | }); 431 | 432 | git.getRawCommits("123"); 433 | 434 | assert( 435 | git.execCommand.calledWith( 436 | "git --no-pager log " + 437 | "--pretty='format:%B%n-hash-%n%H[----COMMIT--END----]' 123.." 438 | ) 439 | ); 440 | }); 441 | }); 442 | 443 | describe("_parseRawCommit", function () { 444 | it("calls parser with raw commit", function () { 445 | let git = new Git({ 446 | conventionalCommitsParser: { 447 | sync: stub().returns({}), 448 | }, 449 | }); 450 | 451 | let commit = { a: "xyz" }; 452 | let options = { b: 123 }; 453 | 454 | git._parseRawCommit(commit, options); 455 | 456 | assert(git.conventionalCommitsParser.sync.calledWith(commit, options)); 457 | }); 458 | 459 | it("sets breaking if commit header matches BREAKING", function () { 460 | let git = new Git({ 461 | conventionalCommitsParser: { 462 | sync: stub().returns({ 463 | header: "abc BREAKING bcd", 464 | }), 465 | }, 466 | }); 467 | 468 | let commit = git._parseRawCommit({}); 469 | 470 | assert(commit.breaking); 471 | }); 472 | 473 | it("sets breaking if commit footer matches BREAKING", function () { 474 | let git = new Git({ 475 | conventionalCommitsParser: { 476 | sync: stub().returns({ 477 | footer: "abc BREAKING bcd", 478 | }), 479 | }, 480 | }); 481 | 482 | let commit = git._parseRawCommit({}); 483 | 484 | assert(commit.breaking); 485 | }); 486 | 487 | it("derives shortHash from hash", function () { 488 | let git = new Git({ 489 | conventionalCommitsParser: { 490 | sync: stub().returns({ 491 | hash: "1234567891011121314151617181920", 492 | }), 493 | }, 494 | }); 495 | 496 | let commit = git._parseRawCommit({}); 497 | 498 | equal(commit.shortHash, "1234567"); 499 | }); 500 | }); 501 | 502 | describe("conventionalCommits", function () { 503 | it("calls get rawCommits with provided sha", function () { 504 | let git = new Git({ 505 | conventionalCommitsFilter: spy(), 506 | }); 507 | 508 | stub(git, "getRawCommits").returns([]); 509 | stub(git, "_parseRawCommit").returns({}); 510 | 511 | git.conventionalCommits("xyz", {}); 512 | 513 | assert(git.getRawCommits.calledWith("xyz")); 514 | }); 515 | 516 | it("parses all the commits", function () { 517 | let git = new Git({ 518 | conventionalCommitsFilter: spy(), 519 | }); 520 | 521 | stub(git, "getRawCommits").returns([{ a: 1 }, { b: 2 }]); 522 | stub(git, "_parseRawCommit").returns({}); 523 | 524 | git.conventionalCommits("xyz", {}); 525 | 526 | assert(git._parseRawCommit.calledWith({ a: 1 })); 527 | assert(git._parseRawCommit.calledWith({ b: 2 })); 528 | }); 529 | 530 | it("filters commits with conventionalCommitsFilter", function () { 531 | let git = new Git({ 532 | conventionalCommitsFilter: spy(), 533 | }); 534 | 535 | stub(git, "getRawCommits").returns([{ a: 1 }, { b: 2 }]); 536 | stub(git, "_parseRawCommit").returns({ a: 1 }); 537 | 538 | git.conventionalCommits("xyz", {}); 539 | 540 | assert(git.conventionalCommitsFilter.calledWith([{ a: 1 }, { a: 1 }])); 541 | }); 542 | 543 | it("returns anything conventionalCommitsFilter returns", function () { 544 | let git = new Git({ 545 | conventionalCommitsFilter: stub().returns(123), 546 | }); 547 | 548 | stub(git, "getRawCommits").returns([]); 549 | stub(git, "_parseRawCommit"); 550 | equal(git.conventionalCommits(), 123); 551 | }); 552 | }); 553 | }); 554 | -------------------------------------------------------------------------------- /test/Phase.spec.js: -------------------------------------------------------------------------------- 1 | import { stub } from "sinon"; 2 | import assert, { deepEqual } from "assert"; 3 | import Phase, { Step as PhaseStep } from "../src/Phase"; 4 | import Step from "../src/Step"; 5 | import Sequence from "../src/Sequence"; 6 | 7 | describe("Phase", function () { 8 | it("exports Step", function () { 9 | assert(PhaseStep === Step); 10 | }); 11 | 12 | describe("new Phase()", function () { 13 | it("grabs steps from extending class scope (if any)", function () { 14 | class X extends Phase { 15 | static steps = [1, 2, 3]; 16 | } 17 | 18 | let x = new X(); 19 | 20 | deepEqual(x.steps, X.steps); 21 | }); 22 | 23 | it("creates new Sequence if steps are not found in class", function () { 24 | let phase = new Phase(); 25 | 26 | assert(phase.steps instanceof Sequence); 27 | }); 28 | }); 29 | 30 | describe("step", function () { 31 | it("adds a step", function () { 32 | let phase = new Phase(); 33 | 34 | phase.step({ name: "A" }); 35 | phase.step({ name: "B" }); 36 | 37 | deepEqual(phase.steps, [{ name: "A" }, { name: "B" }]); 38 | }); 39 | }); 40 | 41 | describe("before", function () { 42 | it("insert callback before the given step", function () { 43 | let phase = new Phase(); 44 | 45 | phase.step({ name: "A" }); 46 | phase.step({ name: "B" }); 47 | 48 | phase.before("B", { name: "C" }); 49 | 50 | deepEqual(phase.steps, [{ name: "A" }, { name: "C" }, { name: "B" }]); 51 | }); 52 | }); 53 | 54 | describe("replace", function () { 55 | it("replace the given step", function () { 56 | let phase = new Phase(); 57 | 58 | phase.step({ name: "A" }); 59 | phase.step({ name: "B" }); 60 | 61 | phase.replace("B", { name: "C" }); 62 | 63 | deepEqual(phase.steps, [{ name: "A" }, { name: "C" }]); 64 | }); 65 | }); 66 | 67 | describe("run", function () { 68 | it("calls steps run", function () { 69 | let phase = new Phase(); 70 | 71 | stub(phase.steps, "run"); 72 | 73 | phase.run({ x: 1 }); 74 | 75 | assert(phase.steps.run.calledWith({ x: 1 })); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /test/Release.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { equal, deepEqual } from "assert"; 2 | import { stub } from "sinon"; 3 | import Release from "../src/Release"; 4 | 5 | describe("Release", function () { 6 | it("has plugins map", function () { 7 | deepEqual(Release.plugins, {}); 8 | }); 9 | 10 | describe("static registerPlugin", function () { 11 | it("registers a plugin", function () { 12 | let pluginsBackup = Release.plugins; 13 | let modifiedPlugins = {}; 14 | Release.plugins = modifiedPlugins; 15 | Release.registerPlugin("x", "y"); 16 | Release.plugins = pluginsBackup; 17 | equal(modifiedPlugins.x, "y"); 18 | }); 19 | }); 20 | 21 | describe("start", function () { 22 | it("runs start phase", function () { 23 | let release = new Release(); 24 | release.phases.start.run = stub(); 25 | release.start(); 26 | assert(release.phases.start.run.calledWith(release)); 27 | }); 28 | }); 29 | 30 | describe("publish", function () { 31 | it("runs publish phase", function () { 32 | let release = new Release(); 33 | release.phases.publish.run = stub(); 34 | release.publish(); 35 | assert(release.phases.publish.run.calledWith(release)); 36 | }); 37 | }); 38 | 39 | describe("finish", function () { 40 | it("runs finish phase", function () { 41 | let release = new Release(); 42 | release.phases.finish.run = stub(); 43 | release.finish(); 44 | assert(release.phases.finish.run.calledWith(release)); 45 | }); 46 | }); 47 | 48 | describe("full", function () { 49 | it("invokes start, publish and finish", async function () { 50 | let release = new Release(); 51 | release.start = stub().returns(Promise.resolve()); 52 | release.publish = stub().returns(Promise.resolve()); 53 | release.finish = stub().returns(Promise.resolve()); 54 | 55 | await release.full(); 56 | 57 | assert(release.start.called); 58 | assert(release.publish.called); 59 | assert(release.finish.called); 60 | }); 61 | }); 62 | 63 | describe("error", function () { 64 | it("invokes error factory", function () { 65 | let release = new Release(); 66 | 67 | stub(release.errorFactory, "createError"); 68 | 69 | release.error(1, 2, 3); 70 | 71 | assert(release.errorFactory.createError.calledWith(1, 2, 3)); 72 | }); 73 | }); 74 | 75 | describe("new Release()", function () { 76 | it("attaches all the plugins", function () { 77 | stub(Release.prototype, "plugin"); 78 | 79 | let release = new Release({ 80 | plugins: ["x", "y", "z"], 81 | }); 82 | 83 | let stubbedMethod = release.plugin; 84 | 85 | Release.prototype.plugin.restore(); 86 | 87 | assert(stubbedMethod.calledWith("x")); 88 | assert(stubbedMethod.calledWith("y")); 89 | assert(stubbedMethod.calledWith("z")); 90 | }); 91 | }); 92 | 93 | describe("plugin", function () { 94 | it("invokes anonymous plugins", function () { 95 | let release = new Release(); 96 | let fn = stub(); 97 | release.plugin(fn); 98 | assert(fn.calledWith(release)); 99 | }); 100 | 101 | it("invokes named plugins", function () { 102 | let release = new Release(); 103 | let fn = stub(); 104 | 105 | let pluginsBackup = Release.plugins; 106 | let modifiedPlugins = {}; 107 | Release.plugins = modifiedPlugins; 108 | Release.registerPlugin("fn", fn); 109 | 110 | release.plugin("fn"); 111 | Release.plugins = pluginsBackup; 112 | 113 | assert(fn.calledWith(release)); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /test/Sequence.spec.js: -------------------------------------------------------------------------------- 1 | import { spy } from "sinon"; 2 | import assert from "assert"; 3 | import Sequence from "../src/Sequence"; 4 | 5 | describe("Sequence", function () { 6 | describe("run", function () { 7 | it("calls all the functions", async function () { 8 | let seq = new Sequence(); 9 | 10 | seq.push(spy()); 11 | seq.push(spy()); 12 | seq.push(spy()); 13 | 14 | let [fn1, fn2, fn3] = seq; 15 | 16 | await seq.run(); 17 | 18 | assert(fn1.called); 19 | assert(fn2.called); 20 | assert(fn3.called); 21 | }); 22 | 23 | it("works with runnables", async function () { 24 | let seq = new Sequence(); 25 | 26 | let runnable1 = { run: spy() }; 27 | let runnable2 = { run: spy() }; 28 | let runnable3 = { run: spy() }; 29 | 30 | seq.push(runnable1); 31 | seq.push(runnable2); 32 | seq.push(runnable3); 33 | 34 | await seq.run(); 35 | 36 | assert(runnable1.run.called); 37 | assert(runnable2.run.called); 38 | assert(runnable3.run.called); 39 | }); 40 | 41 | it("rejects if one of the runnables throws", async function () { 42 | let seq = new Sequence(); 43 | let err = new Error("success"); 44 | seq.push(() => { 45 | throw err; 46 | }); 47 | 48 | try { 49 | await seq.run(); 50 | assert.fail("seq.run was expected to throw"); 51 | } catch (e) { 52 | if (e.message !== "success") { 53 | throw e; 54 | } 55 | } 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/Step.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { equal } from "assert"; 2 | import Step from "../src/Step"; 3 | import Sequence from "../src/Sequence"; 4 | 5 | describe("@Step", function () { 6 | it("Initializes step sequence", function () { 7 | class TestClass1 { 8 | @Step() 9 | doSomething() { 10 | // 11 | } 12 | } 13 | 14 | assert(TestClass1.steps instanceof Sequence); 15 | }); 16 | 17 | it("Adds a named step to target class property", function () { 18 | class TestClass2 { 19 | @Step("do-something") 20 | doSomething() { 21 | // 22 | } 23 | } 24 | 25 | equal(TestClass2.steps[0].name, "do-something"); 26 | }); 27 | 28 | it("Adds a named step from method name to target class property", function () { 29 | class TestClass3 { 30 | @Step() 31 | doSomething() { 32 | // 33 | } 34 | } 35 | 36 | equal(TestClass3.steps[0].name, "doSomething"); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/defaults/DefaultErrorFactory.spec.js: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | import DefaultErrorFactory from "../../src/defaults/DefaultErrorFactory"; 3 | 4 | describe("DefaultErrorFactory", function () { 5 | describe("create", function () { 6 | it("Creates a generic error", function () { 7 | let errorFactory = new DefaultErrorFactory(); 8 | let error = errorFactory.createError("Abc"); 9 | assert(error instanceof Error); 10 | assert(error.message === "Abc"); 11 | assert(error.name === "Error"); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /test/defaults/DefaultLogger.spec.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from "sinon"; 2 | import assert, { equal, deepEqual } from "assert"; 3 | import DefaultLogger from "../../src/defaults/DefaultLogger"; 4 | import { bold, red, yellow, white, gray } from "chalk"; 5 | 6 | describe("DefaultLogger", function () { 7 | before(function () { 8 | if (process.env.CI) { 9 | this.skip(); 10 | } 11 | }); 12 | 13 | describe("new DefaultLogger()", function () { 14 | it("has default level == info", function () { 15 | let logger = new DefaultLogger(); 16 | equal(logger.level, "info"); 17 | }); 18 | 19 | it("allows to override default level", function () { 20 | let logger = new DefaultLogger({ 21 | logLevel: "warn", 22 | }); 23 | equal(logger.level, "warn"); 24 | }); 25 | }); 26 | 27 | describe("error", function () { 28 | it("calls log with level = error", function () { 29 | let logger = new DefaultLogger(); 30 | stub(logger, "log"); 31 | logger.error("message", { a: 1 }); 32 | assert(logger.log.calledWith("error", "message", { a: 1 })); 33 | }); 34 | }); 35 | 36 | describe("warn", function () { 37 | it("calls log with level = warn", function () { 38 | let logger = new DefaultLogger(); 39 | stub(logger, "log"); 40 | logger.warn("message", { a: 1 }); 41 | assert(logger.log.calledWith("warn", "message", { a: 1 })); 42 | }); 43 | }); 44 | 45 | describe("info", function () { 46 | it("calls log with level = info", function () { 47 | let logger = new DefaultLogger(); 48 | stub(logger, "log"); 49 | logger.info("message", { a: 1 }); 50 | assert(logger.log.calledWith("info", "message", { a: 1 })); 51 | }); 52 | }); 53 | 54 | describe("debug", function () { 55 | it("calls log with level = debug", function () { 56 | let logger = new DefaultLogger(); 57 | stub(logger, "log"); 58 | logger.debug("message", { a: 1 }); 59 | assert(logger.log.calledWith("debug", "message", { a: 1 })); 60 | }); 61 | }); 62 | 63 | describe("log", function () { 64 | it("ignores log level under the current level", function () { 65 | let logger = new DefaultLogger({ 66 | logLevel: "error", 67 | console: { 68 | log: spy(), 69 | }, 70 | }); 71 | 72 | logger.info("msg"); 73 | 74 | assert(logger.console.log.called === false); 75 | }); 76 | 77 | it("logs for log level equal or to the current level", function () { 78 | let logger = new DefaultLogger({ 79 | logLevel: "error", 80 | console: { 81 | log: spy(), 82 | }, 83 | }); 84 | 85 | logger.error("err"); 86 | assert(logger.console.log.called === true); 87 | }); 88 | 89 | it("logs for log level above or to the current level", function () { 90 | let logger = new DefaultLogger({ 91 | logLevel: "warn", 92 | console: { 93 | log: spy(), 94 | }, 95 | }); 96 | 97 | logger.error("err"); 98 | 99 | assert(logger.console.log.called === true); 100 | }); 101 | 102 | it("logs error in red", function () { 103 | let logger = new DefaultLogger({ 104 | logLevel: "debug", 105 | console: { 106 | log: spy(), 107 | }, 108 | }); 109 | 110 | logger.error("msg"); 111 | 112 | deepEqual(logger.console.log.args[0], [red(bold("error:") + " msg")]); 113 | }); 114 | 115 | it("logs warn in yellow", function () { 116 | let logger = new DefaultLogger({ 117 | logLevel: "debug", 118 | console: { 119 | log: spy(), 120 | }, 121 | }); 122 | 123 | logger.warn("msg"); 124 | 125 | deepEqual(logger.console.log.args[0], [yellow(bold("warn:") + " msg")]); 126 | }); 127 | }); 128 | 129 | it("logs info in white", function () { 130 | let logger = new DefaultLogger({ 131 | logLevel: "debug", 132 | console: { 133 | log: spy(), 134 | }, 135 | }); 136 | 137 | logger.info("msg"); 138 | 139 | deepEqual(logger.console.log.args[0], [white(bold("info:") + " msg")]); 140 | }); 141 | 142 | it("logs debug in gray", function () { 143 | let logger = new DefaultLogger({ 144 | logLevel: "debug", 145 | console: { 146 | log: spy(), 147 | }, 148 | }); 149 | 150 | logger.debug("msg"); 151 | 152 | deepEqual(logger.console.log.args[0], [gray(bold("debug:") + " msg")]); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /test/defaults/get-bump.spec.js: -------------------------------------------------------------------------------- 1 | import getBump from "../../src/defaults/get-bump"; 2 | import { equal } from "assert"; 3 | 4 | describe("get-bump", function () { 5 | it("returns major if there is a breaking commit", function () { 6 | let bump = getBump([{ breaking: true }]); 7 | equal(bump, "major"); 8 | }); 9 | 10 | it("returns major if there is a breaking commit and a feature", function () { 11 | let bump = getBump([{ breaking: true }, { type: "feat" }]); 12 | equal(bump, "major"); 13 | }); 14 | 15 | it("returns minor if there is a feature", function () { 16 | let bump = getBump([{ type: "feat" }]); 17 | equal(bump, "minor"); 18 | }); 19 | 20 | it("returns patch by default", function () { 21 | let bump = getBump([]); 22 | equal(bump, "patch"); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/execCommand.spec.js: -------------------------------------------------------------------------------- 1 | import execCommand from "../src/execCommand"; 2 | import { stub } from "sinon"; 3 | import { equal, deepEqual } from "assert"; 4 | import childProcess from "child_process"; 5 | 6 | describe("execCommand", function () { 7 | it("trims execSync stdout", function () { 8 | equal(execCommand("echo '\n my string '"), "my string"); 9 | }); 10 | 11 | it("returns stdout as an array of lines with splitLines option", function () { 12 | stub(childProcess, "execSync").callsFake(function () { 13 | return new Buffer(" line 1\nline 2\n \nline 3"); 14 | }); 15 | 16 | deepEqual(execCommand("abc", { splitLines: true }), [ 17 | "line 1", 18 | "line 2", 19 | "line 3", 20 | ]); 21 | }); 22 | 23 | afterEach(function () { 24 | if (childProcess.execSync.restore) { 25 | childProcess.execSync.restore(); 26 | } 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/phases/Finish.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { fail, equal, throws, doesNotThrow } from "assert"; 2 | import { stub } from "sinon"; 3 | import Finish from "../../src/phases/Finish"; 4 | import Release from "../../src/Release"; 5 | 6 | describe("Finish", function () { 7 | beforeEach(function () { 8 | this.release = new Release(); 9 | stub(this.release.logger, "debug"); 10 | }); 11 | 12 | describe("fetch", function () { 13 | it("fetches heads and tags", function () { 14 | stub(this.release.git, "fetchHeadsAndTags"); 15 | 16 | let phase = new Finish(); 17 | phase.fetch(this.release); 18 | 19 | assert(this.release.git.fetchHeadsAndTags.called); 20 | }); 21 | 22 | it("breaks in case of error", function () { 23 | stub(this.release.git, "fetchHeadsAndTags").throws(); 24 | 25 | let phase = new Finish(); 26 | try { 27 | phase.fetch(this.release); 28 | fail("Should not get here"); 29 | } catch (e) { 30 | equal(e.message, "Unable to fetch tags and heads"); 31 | } 32 | }); 33 | }); 34 | 35 | describe("getReleaseInfo", function () { 36 | it("derives info from current branch", function () { 37 | this.release.options.releaseBranchPrefix = "xyz~"; 38 | this.release.options.tagPrefix = "vx"; 39 | 40 | stub(this.release.git, "getCurrentBranch").returns("xyz~foo"); 41 | 42 | let phase = new Finish(); 43 | phase.getReleaseInfo(this.release); 44 | 45 | equal(this.release.branchName, "xyz~foo"); 46 | equal(this.release.name, "foo"); 47 | equal(this.release.tagName, "vxfoo"); 48 | }); 49 | }); 50 | 51 | describe("validateReleaseBranch", function () { 52 | it("throws if currentBranch is not release branch", function () { 53 | this.release.options.releaseBranchPrefix = "xyz~"; 54 | 55 | stub(this.release.git, "getCurrentBranch").returns("foo"); 56 | stub(this.release.git, "hasUntrackedChanges").returns(false); 57 | stub(this.release.git, "hasUnpushedCommits").returns(false); 58 | 59 | let phase = new Finish(); 60 | 61 | throws(() => { 62 | phase.validateReleaseBranch(this.release); 63 | }, /You can only finish a release from a release branch$/); 64 | }); 65 | 66 | it("does not throw if currentBranch is a release branch", function () { 67 | this.release.options.releaseBranchPrefix = "xyz~"; 68 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 69 | stub(this.release.git, "hasUntrackedChanges").returns(false); 70 | stub(this.release.git, "hasUnpushedCommits").returns(false); 71 | 72 | let phase = new Finish(); 73 | 74 | doesNotThrow(() => { 75 | phase.validateReleaseBranch(this.release); 76 | }, /You can only finish a release from a release branch$/); 77 | }); 78 | 79 | it("throws if hasUntrackedChanges", function () { 80 | this.release.options.releaseBranchPrefix = "xyz~"; 81 | 82 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 83 | stub(this.release.git, "hasUntrackedChanges").returns(true); 84 | stub(this.release.git, "hasUnpushedCommits").returns(false); 85 | 86 | let phase = new Finish(); 87 | 88 | throws(() => { 89 | phase.validateReleaseBranch(this.release); 90 | }, /You have untracked changes$/); 91 | }); 92 | 93 | it("does not throw if has not hasUntrackedChanges", function () { 94 | this.release.options.releaseBranchPrefix = "xyz~"; 95 | 96 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 97 | stub(this.release.git, "hasUntrackedChanges").returns(false); 98 | stub(this.release.git, "hasUnpushedCommits").returns(false); 99 | 100 | let phase = new Finish(); 101 | 102 | doesNotThrow(() => { 103 | phase.validateReleaseBranch(this.release); 104 | }, /You have untracked changes$/); 105 | }); 106 | 107 | it("throws if hasUnpushedCommits", function () { 108 | this.release.options.releaseBranchPrefix = "xyz~"; 109 | 110 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 111 | stub(this.release.git, "hasUntrackedChanges").returns(false); 112 | stub(this.release.git, "hasUnpushedCommits").returns(true); 113 | 114 | let phase = new Finish(); 115 | 116 | throws(() => { 117 | phase.validateReleaseBranch(this.release); 118 | }, /You have unpushed changes$/); 119 | }); 120 | 121 | it("does not throw if has not hasUnpushedCommits", function () { 122 | this.release.options.releaseBranchPrefix = "xyz~"; 123 | 124 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 125 | stub(this.release.git, "hasUntrackedChanges").returns(false); 126 | stub(this.release.git, "hasUnpushedCommits").returns(false); 127 | 128 | let phase = new Finish(); 129 | 130 | doesNotThrow(() => { 131 | phase.validateReleaseBranch(this.release); 132 | }, /You have unpushed changes$/); 133 | }); 134 | }); 135 | 136 | describe("checkoutProduction", function () { 137 | it("calls checkout on production branch", function () { 138 | this.release.options.productionBranch = "prod"; 139 | 140 | stub(this.release.git, "checkout"); 141 | 142 | let phase = new Finish(); 143 | phase.checkoutProduction(this.release); 144 | 145 | assert(this.release.git.checkout.calledWith("prod")); 146 | }); 147 | }); 148 | 149 | describe("validateProductionBranch", function () { 150 | it("throws if hasUntrackedChanges", function () { 151 | stub(this.release.git, "hasUntrackedChanges").returns(true); 152 | stub(this.release.git, "hasUnpushedCommits").returns(false); 153 | 154 | let phase = new Finish(); 155 | 156 | throws(() => { 157 | phase.validateProductionBranch(this.release); 158 | }, /You have untracked changes$/); 159 | }); 160 | 161 | it("does not throw if has not hasUntrackedChanges", function () { 162 | stub(this.release.git, "hasUntrackedChanges").returns(false); 163 | stub(this.release.git, "hasUnpushedCommits").returns(false); 164 | 165 | let phase = new Finish(); 166 | 167 | doesNotThrow(() => { 168 | phase.validateProductionBranch(this.release); 169 | }, /You have untracked changes$/); 170 | }); 171 | 172 | it("throws if hasUnpushedCommits", function () { 173 | stub(this.release.git, "hasUntrackedChanges").returns(false); 174 | stub(this.release.git, "hasUnpushedCommits").returns(true); 175 | 176 | let phase = new Finish(); 177 | 178 | throws(() => { 179 | phase.validateProductionBranch(this.release); 180 | }, /You have unpushed changes$/); 181 | }); 182 | 183 | it("does not throw if has not hasUnpushedCommits", function () { 184 | stub(this.release.git, "hasUntrackedChanges").returns(false); 185 | stub(this.release.git, "hasUnpushedCommits").returns(false); 186 | 187 | let phase = new Finish(); 188 | 189 | doesNotThrow(() => { 190 | phase.validateProductionBranch(this.release); 191 | }, /You have unpushed changes$/); 192 | }); 193 | }); 194 | 195 | describe("mergeToProduction", function () { 196 | it("merges from derived branchName", function () { 197 | stub(this.release.git, "merge"); 198 | stub(this.release.git, "pushRef"); 199 | this.release.branchName = "xyz"; 200 | 201 | let phase = new Finish(); 202 | phase.mergeToProduction(this.release); 203 | 204 | assert(this.release.git.merge.calledWith("xyz")); 205 | }); 206 | 207 | it("pushes productionBranch", function () { 208 | stub(this.release.git, "merge"); 209 | stub(this.release.git, "pushRef"); 210 | this.release.options.productionBranch = "prod123"; 211 | 212 | let phase = new Finish(); 213 | phase.mergeToProduction(this.release); 214 | 215 | assert(this.release.git.pushRef.calledWith("prod123")); 216 | }); 217 | }); 218 | 219 | describe("tagProduction", function () { 220 | it("tags productionBranch with tagName", function () { 221 | stub(this.release.git, "tag"); 222 | stub(this.release.git, "pushRef"); 223 | this.release.tagName = "tag123"; 224 | 225 | let phase = new Finish(); 226 | phase.tagProduction(this.release); 227 | 228 | assert(this.release.git.tag.calledWith("tag123")); 229 | }); 230 | 231 | it("pushes tagName", function () { 232 | stub(this.release.git, "tag"); 233 | stub(this.release.git, "pushRef"); 234 | this.release.tagName = "tag123"; 235 | 236 | let phase = new Finish(); 237 | phase.tagProduction(this.release); 238 | 239 | assert(this.release.git.pushRef.calledWith("tag123")); 240 | }); 241 | }); 242 | 243 | describe("mergeBackToDevelopment", function () { 244 | it("checks out developmentBranch if dev !== prod", function () { 245 | stub(this.release.git, "checkout"); 246 | stub(this.release.git, "merge"); 247 | stub(this.release.git, "pushRef"); 248 | this.release.options.developmentBranch = "devxyz"; 249 | this.release.options.productionBranch = "prodxyz"; 250 | 251 | let phase = new Finish(); 252 | phase.mergeBackToDevelopment(this.release); 253 | 254 | assert(this.release.git.checkout.calledWith("devxyz")); 255 | }); 256 | 257 | it("merges from derived branchName if dev !== prod", function () { 258 | stub(this.release.git, "checkout"); 259 | stub(this.release.git, "merge"); 260 | stub(this.release.git, "pushRef"); 261 | this.release.options.developmentBranch = "devxyz"; 262 | this.release.options.productionBranch = "prodxyz"; 263 | this.release.branchName = "xyz"; 264 | 265 | let phase = new Finish(); 266 | phase.mergeBackToDevelopment(this.release); 267 | 268 | assert(this.release.git.merge.calledWith("xyz")); 269 | }); 270 | 271 | it("pushes developmentBranch if dev !== prod", function () { 272 | stub(this.release.git, "checkout"); 273 | stub(this.release.git, "merge"); 274 | stub(this.release.git, "pushRef"); 275 | this.release.options.developmentBranch = "devxyz"; 276 | this.release.options.productionBranch = "prodxyz"; 277 | 278 | let phase = new Finish(); 279 | phase.mergeBackToDevelopment(this.release); 280 | 281 | assert(this.release.git.pushRef.calledWith("devxyz")); 282 | }); 283 | 284 | it("does not run checkout, merge and pushRef if dev == prod", function () { 285 | stub(this.release.git, "checkout"); 286 | stub(this.release.git, "merge"); 287 | stub(this.release.git, "pushRef"); 288 | 289 | this.release.options.developmentBranch = "abc"; 290 | this.release.options.productionBranch = "abc"; 291 | 292 | let phase = new Finish(); 293 | phase.mergeBackToDevelopment(this.release); 294 | 295 | assert(!this.release.git.checkout.called); 296 | assert(!this.release.git.merge.called); 297 | assert(!this.release.git.pushRef.called); 298 | }); 299 | }); 300 | }); 301 | -------------------------------------------------------------------------------- /test/phases/Publish.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { throws, doesNotThrow } from "assert"; 2 | import { stub } from "sinon"; 3 | import Publish from "../../src/phases/Publish"; 4 | import Release from "../../src/Release"; 5 | 6 | describe("Publish", function () { 7 | beforeEach(function () { 8 | this.release = new Release(); 9 | stub(this.release.logger, "debug"); 10 | }); 11 | 12 | describe("validate", function () { 13 | it("throws if currentBranch is not release branch", function () { 14 | this.release.options.releaseBranchPrefix = "xyz~"; 15 | 16 | stub(this.release.git, "getCurrentBranch").returns("foo"); 17 | 18 | let phase = new Publish(); 19 | 20 | throws(() => { 21 | phase.validate(this.release); 22 | }, /You can only publish a release from a release branch$/); 23 | }); 24 | 25 | it("does not throw if currentBranch is a release branch", function () { 26 | this.release.options.releaseBranchPrefix = "xyz~"; 27 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 28 | stub(this.release.git, "hasUntrackedChanges").returns(false); 29 | 30 | let phase = new Publish(); 31 | 32 | doesNotThrow(() => { 33 | phase.validate(this.release); 34 | }, /You can only publish a release from a release branch$/); 35 | }); 36 | 37 | it("throws if hasUntrackedChanges", function () { 38 | this.release.options.releaseBranchPrefix = "xyz~"; 39 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 40 | stub(this.release.git, "hasUntrackedChanges").returns(true); 41 | 42 | let phase = new Publish(); 43 | 44 | throws(() => { 45 | phase.validate(this.release); 46 | }, /You have untracked changes$/); 47 | }); 48 | 49 | it("does not throw if has not hasUntrackedChanges", function () { 50 | this.release.options.releaseBranchPrefix = "xyz~"; 51 | stub(this.release.git, "getCurrentBranch").returns("xyz~1.0.0"); 52 | stub(this.release.git, "hasUntrackedChanges").returns(false); 53 | 54 | let phase = new Publish(); 55 | 56 | doesNotThrow(() => { 57 | phase.validate(this.release); 58 | }, /You have untracked changes$/); 59 | }); 60 | }); 61 | 62 | describe("push", function () { 63 | it("pushes the currentBranch", function () { 64 | stub(this.release.git, "getCurrentBranch").returns("foo"); 65 | stub(this.release.git, "pushRef"); 66 | let phase = new Publish(); 67 | 68 | phase.push(this.release); 69 | 70 | assert(this.release.git.pushRef.calledWith("foo")); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/phases/Start.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { throws, doesNotThrow, fail, equal, deepEqual } from "assert"; 2 | import Start from "../../src/phases/Start"; 3 | import { stub } from "sinon"; 4 | import Release from "../../src/Release"; 5 | 6 | describe("Start", function () { 7 | beforeEach(function () { 8 | this.release = new Release(); 9 | stub(this.release.logger, "debug"); 10 | }); 11 | 12 | describe("fetch", function () { 13 | it("fetches heads and tags", function () { 14 | stub(this.release.git, "fetchHeadsAndTags"); 15 | 16 | let phase = new Start(); 17 | phase.fetch(this.release); 18 | 19 | assert(this.release.git.fetchHeadsAndTags.called); 20 | }); 21 | 22 | it("breaks in case of error", function () { 23 | stub(this.release.git, "fetchHeadsAndTags").throws(); 24 | 25 | let phase = new Start(); 26 | try { 27 | phase.fetch(this.release); 28 | fail("Should not get here"); 29 | } catch (e) { 30 | equal(e.message, "Unable to fetch tags and heads"); 31 | } 32 | }); 33 | }); 34 | 35 | describe("getPreviousVersion", function () { 36 | it("set previousVersion if tagname", function () { 37 | stub(this.release.git, "getLastLocalTagName").returns("v1.2.3"); 38 | 39 | let phase = new Start(); 40 | phase.getPreviousVersion(this.release); 41 | equal(this.release.previousVersion, "1.2.3"); 42 | }); 43 | 44 | it("set previousReleaseName if tagname", function () { 45 | stub(this.release.git, "getLastLocalTagName").returns("v1.2.3"); 46 | this.release.options.tagPrefix = "xyz"; 47 | let phase = new Start(); 48 | phase.getPreviousVersion(this.release); 49 | equal(this.release.previousReleaseName, "xyz1.2.3"); 50 | }); 51 | 52 | it("does not set previousVersion if no tagname", function () { 53 | stub(this.release.git, "getLastLocalTagName").returns(null); 54 | 55 | let phase = new Start(); 56 | phase.getPreviousVersion(this.release); 57 | equal(this.release.previousVersion, null); 58 | }); 59 | 60 | it("does not set previousReleaseName if no tagname", function () { 61 | stub(this.release.git, "getLastLocalTagName").returns(null); 62 | let phase = new Start(); 63 | phase.getPreviousVersion(this.release); 64 | equal(this.release.previousReleaseName, null); 65 | }); 66 | }); 67 | 68 | describe("getCommits", function () { 69 | it("fetches conventionalCommits with lastLocalTagSha", function () { 70 | stub(this.release.git, "getLastLocalTagSha").returns("xyz"); 71 | stub(this.release.git, "conventionalCommits").returns([{}]); 72 | 73 | let phase = new Start(); 74 | phase.getCommits(this.release); 75 | 76 | assert(this.release.git.conventionalCommits.calledWith("xyz")); 77 | }); 78 | 79 | it("sets release commits", function () { 80 | stub(this.release.git, "getLastLocalTagSha"); 81 | stub(this.release.git, "conventionalCommits").returns(["123"]); 82 | 83 | let phase = new Start(); 84 | phase.getCommits(this.release); 85 | 86 | deepEqual(this.release.commits, ["123"]); 87 | }); 88 | 89 | it("breaks release if no commits found", function () { 90 | stub(this.release.git, "getLastLocalTagSha"); 91 | stub(this.release.git, "conventionalCommits").returns([]); 92 | 93 | let phase = new Start(); 94 | try { 95 | phase.getCommits(this.release); 96 | fail("Should not get here"); 97 | } catch (e) { 98 | equal(e.message, "Nothing to release"); 99 | } 100 | }); 101 | }); 102 | 103 | describe("getNextVersion", function () { 104 | it("sets initialVersion if no previousVersion", function () { 105 | this.release.options.initialVersion = "2.3.4"; 106 | 107 | let phase = new Start(); 108 | phase.getNextVersion(this.release); 109 | 110 | equal(this.release.nextVersion, "2.3.4"); 111 | }); 112 | 113 | it("if previousVersion calls getBump with commits", function () { 114 | this.release.previousVersion = "2.3.4"; 115 | stub(this.release.options, "getBump").returns("patch"); 116 | 117 | this.release.commits = ["abc", "bcd"]; 118 | 119 | let phase = new Start(); 120 | phase.getNextVersion(this.release); 121 | 122 | assert(this.release.options.getBump.calledWith(["abc", "bcd"])); 123 | }); 124 | 125 | it("bumps version according to getBump", function () { 126 | this.release.previousVersion = "2.3.4"; 127 | stub(this.release.options, "getBump").returns("patch"); 128 | 129 | let phase = new Start(); 130 | phase.getNextVersion(this.release); 131 | 132 | equal(this.release.nextVersion, "2.3.5"); 133 | }); 134 | 135 | it("sets release.name", function () { 136 | this.release.options.tagPrefix = "xyz"; 137 | this.release.previousVersion = "2.3.4"; 138 | stub(this.release.options, "getBump").returns("patch"); 139 | 140 | let phase = new Start(); 141 | phase.getNextVersion(this.release); 142 | 143 | equal(this.release.name, "xyz2.3.5"); 144 | }); 145 | }); 146 | 147 | describe("validate", function () { 148 | it("throws if currentBranch is not developmentBranch", function () { 149 | this.release.options.developmentBranch = "abc"; 150 | 151 | stub(this.release.git, "isCurrentBranch").returns(false); 152 | stub(this.release.git, "hasUntrackedChanges").returns(false); 153 | stub(this.release.git, "hasUnpushedCommits").returns(false); 154 | stub(this.release.git, "hasLocalTag").returns(false); 155 | 156 | let phase = new Start(); 157 | 158 | throws(() => { 159 | phase.validate(this.release); 160 | }, /Current branch should be abc$/); 161 | }); 162 | 163 | it("does not throw if currentBranch is developmentBranch", function () { 164 | this.release.options.developmentBranch = "abc"; 165 | 166 | stub(this.release.git, "isCurrentBranch").returns(true); 167 | stub(this.release.git, "hasUntrackedChanges").returns(false); 168 | stub(this.release.git, "hasUnpushedCommits").returns(false); 169 | stub(this.release.git, "hasLocalTag").returns(false); 170 | 171 | let phase = new Start(); 172 | 173 | doesNotThrow(() => { 174 | phase.validate(this.release); 175 | }, /Current branch should be abc$/); 176 | }); 177 | 178 | it("throws if hasUntrackedChanges", function () { 179 | stub(this.release.git, "isCurrentBranch").returns(true); 180 | stub(this.release.git, "hasUntrackedChanges").returns(true); 181 | stub(this.release.git, "hasUnpushedCommits").returns(false); 182 | stub(this.release.git, "hasLocalTag").returns(false); 183 | 184 | let phase = new Start(); 185 | 186 | throws(() => { 187 | phase.validate(this.release); 188 | }, /You have untracked changes$/); 189 | }); 190 | 191 | it("does not throw if has not hasUntrackedChanges", function () { 192 | stub(this.release.git, "isCurrentBranch").returns(true); 193 | stub(this.release.git, "hasUntrackedChanges").returns(false); 194 | stub(this.release.git, "hasUnpushedCommits").returns(false); 195 | stub(this.release.git, "hasLocalTag").returns(false); 196 | 197 | let phase = new Start(); 198 | 199 | doesNotThrow(() => { 200 | phase.validate(this.release); 201 | }, /You have untracked changes$/); 202 | }); 203 | 204 | it("throws if hasUnpushedCommits", function () { 205 | stub(this.release.git, "isCurrentBranch").returns(true); 206 | stub(this.release.git, "hasUntrackedChanges").returns(false); 207 | stub(this.release.git, "hasUnpushedCommits").returns(true); 208 | stub(this.release.git, "hasLocalTag").returns(false); 209 | 210 | let phase = new Start(); 211 | 212 | throws(() => { 213 | phase.validate(this.release); 214 | }, /You have unpushed changes$/); 215 | }); 216 | 217 | it("does not throw if has not hasUnpushedCommits", function () { 218 | stub(this.release.git, "isCurrentBranch").returns(true); 219 | stub(this.release.git, "hasUntrackedChanges").returns(false); 220 | stub(this.release.git, "hasUnpushedCommits").returns(false); 221 | stub(this.release.git, "hasLocalTag").returns(false); 222 | 223 | let phase = new Start(); 224 | 225 | doesNotThrow(() => { 226 | phase.validate(this.release); 227 | }, /You have unpushed changes$/); 228 | }); 229 | 230 | it("throws if hasLocalTag with release name", function () { 231 | stub(this.release.git, "isCurrentBranch").returns(true); 232 | stub(this.release.git, "hasUntrackedChanges").returns(false); 233 | stub(this.release.git, "hasUnpushedCommits").returns(false); 234 | stub(this.release.git, "hasLocalTag").returns(true); 235 | 236 | this.release.name = "abc"; 237 | 238 | let phase = new Start(); 239 | 240 | throws(() => { 241 | phase.validate(this.release); 242 | }, /Tag abc already exists$/); 243 | }); 244 | 245 | it("does not throw if has not LocalTag with release name", function () { 246 | stub(this.release.git, "isCurrentBranch").returns(true); 247 | stub(this.release.git, "hasUntrackedChanges").returns(false); 248 | stub(this.release.git, "hasUnpushedCommits").returns(false); 249 | stub(this.release.git, "hasLocalTag").returns(false); 250 | 251 | this.release.name = "abc"; 252 | 253 | let phase = new Start(); 254 | 255 | doesNotThrow(() => { 256 | phase.validate(this.release); 257 | }, /Tag abc already exists$/); 258 | }); 259 | }); 260 | 261 | describe("openReleaseBranch", function () { 262 | it("calls git.openBranch with the releaseBranchPrefix and nextVersion", function () { 263 | this.release.options.releaseBranchPrefix = "xyz~"; 264 | this.release.nextVersion = "1.2.3"; 265 | stub(this.release.git, "openBranch"); 266 | 267 | let phase = new Start(); 268 | phase.openReleaseBranch(this.release); 269 | assert(this.release.git.openBranch.calledWith("xyz~1.2.3")); 270 | }); 271 | }); 272 | 273 | describe("commit", function () { 274 | it("invokes git commitAll with release message", function () { 275 | this.release.name = "xyz123"; 276 | 277 | stub(this.release.git, "commitAll"); 278 | 279 | let phase = new Start(); 280 | phase.commit(this.release); 281 | 282 | assert(this.release.git.commitAll.calledWith("Release xyz123")); 283 | }); 284 | }); 285 | }); 286 | -------------------------------------------------------------------------------- /test/plugins/ChangelogEntry.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { equal, deepEqual } from "assert"; 2 | import ChangelogEntry from "../../src/plugins/generate-changelog/ChangelogEntry"; 3 | 4 | describe("ChangelogEntry", function () { 5 | describe("new ChangelogEntry()", function () { 6 | it("sets subject", function () { 7 | let ce = new ChangelogEntry("Changed something"); 8 | equal(ce.subject, "Changed something"); 9 | }); 10 | 11 | it("init links to empty array", function () { 12 | let ce = new ChangelogEntry("..."); 13 | deepEqual(ce.links, []); 14 | }); 15 | 16 | it("init children to empty array", function () { 17 | let ce = new ChangelogEntry("..."); 18 | deepEqual(ce.children, []); 19 | }); 20 | 21 | it("init allows to set scope", function () { 22 | let ce = new ChangelogEntry("...", { 23 | scope: "abc", 24 | }); 25 | equal(ce.scope, "abc"); 26 | }); 27 | 28 | it("init allows to set subjectLink", function () { 29 | let ce = new ChangelogEntry("...", { 30 | subjectLink: "abc", 31 | }); 32 | equal(ce.subjectLink, "abc"); 33 | }); 34 | 35 | it("init allows to set links", function () { 36 | let ce = new ChangelogEntry("...", { 37 | links: ["abc", "bcd"], 38 | }); 39 | 40 | deepEqual(ce.links, ["abc", "bcd"]); 41 | }); 42 | }); 43 | 44 | describe("addLink", function () { 45 | it("adds a link", function () { 46 | let ce = new ChangelogEntry("..."); 47 | 48 | ce.addLink("abc", "bcd"); 49 | 50 | deepEqual(ce.links, [ 51 | { 52 | name: "abc", 53 | url: "bcd", 54 | }, 55 | ]); 56 | }); 57 | }); 58 | 59 | describe("addChild", function () { 60 | it("adds a child", function () { 61 | let ce = new ChangelogEntry("..."); 62 | 63 | ce.addChild("abc"); 64 | 65 | deepEqual(ce.children, ["abc"]); 66 | }); 67 | }); 68 | 69 | describe("isLeaf", function () { 70 | it("returns true if ChangelogEntry has no children", function () { 71 | let ce = new ChangelogEntry("..."); 72 | assert(ce.isLeaf()); 73 | }); 74 | 75 | it("returns false if ChangelogEntry has children", function () { 76 | let ce = new ChangelogEntry("..."); 77 | ce.addChild("abc"); 78 | assert(!ce.isLeaf()); 79 | }); 80 | }); 81 | 82 | describe("traverse", function () { 83 | it("calls previsit on all children in right order", function () { 84 | let root = new ChangelogEntry("A"); 85 | 86 | let b = new ChangelogEntry("B"); 87 | let c = new ChangelogEntry("C"); 88 | 89 | root.addChild(b); 90 | root.addChild(c); 91 | 92 | b.addChild(new ChangelogEntry("D")); 93 | 94 | let visits = []; 95 | root.traverse((entry) => visits.push(entry.subject)); 96 | 97 | deepEqual(visits, ["A", "B", "D", "C"]); 98 | }); 99 | 100 | it("calls postvisit on all children in right order", function () { 101 | let root = new ChangelogEntry("A"); 102 | 103 | let b = new ChangelogEntry("B"); 104 | let c = new ChangelogEntry("C"); 105 | 106 | root.addChild(b); 107 | root.addChild(c); 108 | 109 | b.addChild(new ChangelogEntry("D")); 110 | 111 | let visits = []; 112 | root.traverse(null, (entry) => visits.push(entry.subject)); 113 | 114 | deepEqual(visits, ["D", "B", "C", "A"]); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/plugins/bump-package-json.spec.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import assert, { equal } from "assert"; 3 | import Release from "../../src/Release"; 4 | import plugin from "../../src/plugins/bump-package-json"; 5 | 6 | describe("plugins", function () { 7 | describe("bumpPackageJson", function () { 8 | beforeEach(function () { 9 | fs.renameSync("package.json", "package.json.bkp"); 10 | fs.writeFileSync("package.json", "{}"); 11 | }); 12 | afterEach(function () { 13 | fs.unlinkSync("package.json"); 14 | fs.renameSync("package.json.bkp", "package.json"); 15 | }); 16 | it("installs a step", function () { 17 | let release = new Release({ 18 | plugins: [plugin], 19 | }); 20 | let step = release.phases.start.steps.find( 21 | (step) => step.name === "bumpPackageJson" 22 | ); 23 | assert(step); 24 | }); 25 | it("writes nextVersion on package json", function () { 26 | let release = new Release({ 27 | plugins: [plugin], 28 | }); 29 | release.nextVersion = "1.2.3"; 30 | let step = release.phases.start.steps.find( 31 | (step) => step.name === "bumpPackageJson" 32 | ); 33 | step.run(release); 34 | const packageJson = fs.readFileSync("package.json", "utf-8"); 35 | equal( 36 | packageJson, 37 | `{ 38 | "version": "1.2.3" 39 | } 40 | ` 41 | ); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/plugins/changelog-template.spec.js: -------------------------------------------------------------------------------- 1 | import ChangelogEntry from "../../src/plugins/generate-changelog/ChangelogEntry"; 2 | import changelogTemplate from "../../src/plugins/generate-changelog/changelog-template"; 3 | 4 | import { equal } from "assert"; 5 | 6 | describe("changelog-template", function () { 7 | it("Adds an anchor for release name", function () { 8 | let context = { 9 | release: { 10 | name: "release1", 11 | changes: { 12 | traverse() { 13 | // 14 | }, 15 | }, 16 | }, 17 | }; 18 | 19 | let changelog = changelogTemplate(context); 20 | 21 | equal( 22 | changelog, 23 | ` 24 | 25 | ` 26 | ); 27 | }); 28 | 29 | it("Adds h1 entry for each top level leaf", function () { 30 | let root = new ChangelogEntry("root"); 31 | 32 | let context = { 33 | release: { 34 | name: "release1", 35 | changes: root, 36 | }, 37 | }; 38 | 39 | let changelog = changelogTemplate(context); 40 | 41 | equal( 42 | changelog, 43 | ` 44 | # root 45 | 46 | ` 47 | ); 48 | }); 49 | 50 | it("Render subject for non leaf entries as title", function () { 51 | let root = new ChangelogEntry("root"); 52 | root.addChild(new ChangelogEntry("child1")); 53 | 54 | let context = { 55 | release: { 56 | name: "release1", 57 | changes: root, 58 | }, 59 | }; 60 | 61 | let changelog = changelogTemplate(context); 62 | 63 | equal( 64 | changelog, 65 | ` 66 | # root 67 | 68 | - child1 69 | 70 | ` 71 | ); 72 | }); 73 | 74 | it("Adds a space before >2nd level titles", function () { 75 | let root = new ChangelogEntry("root"); 76 | let child1 = new ChangelogEntry("child1"); 77 | let child2 = new ChangelogEntry("child2"); 78 | root.addChild(child1); 79 | child1.addChild(child2); 80 | 81 | let context = { 82 | release: { 83 | name: "release1", 84 | changes: root, 85 | }, 86 | }; 87 | 88 | let changelog = changelogTemplate(context); 89 | 90 | equal( 91 | changelog, 92 | ` 93 | # root 94 | 95 | ## child1 96 | 97 | - child2 98 | 99 | ` 100 | ); 101 | }); 102 | 103 | it("Does not add a space after leaf entries", function () { 104 | let root = new ChangelogEntry("root"); 105 | let child1 = new ChangelogEntry("child1"); 106 | let child2 = new ChangelogEntry("child2"); 107 | let child3 = new ChangelogEntry("child3"); 108 | root.addChild(child1); 109 | child1.addChild(child2); 110 | child1.addChild(child3); 111 | 112 | let context = { 113 | release: { 114 | name: "release1", 115 | changes: root, 116 | }, 117 | }; 118 | 119 | let changelog = changelogTemplate(context); 120 | 121 | equal( 122 | changelog, 123 | ` 124 | # root 125 | 126 | ## child1 127 | 128 | - child2 129 | - child3 130 | 131 | ` 132 | ); 133 | }); 134 | 135 | it("renders one link", function () { 136 | let root = new ChangelogEntry("root"); 137 | root.addLink("link 1", "link-1-url"); 138 | 139 | let context = { 140 | release: { 141 | name: "release1", 142 | changes: root, 143 | }, 144 | }; 145 | 146 | let changelog = changelogTemplate(context); 147 | 148 | equal( 149 | changelog, 150 | ` 151 | # root ([link 1](link-1-url)) 152 | 153 | ` 154 | ); 155 | }); 156 | 157 | it("renders two or more link comma separated", function () { 158 | let root = new ChangelogEntry("root"); 159 | root.addLink("link 1", "link-1-url"); 160 | root.addLink("link 2", "link-2-url"); 161 | 162 | let context = { 163 | release: { 164 | name: "release1", 165 | changes: root, 166 | }, 167 | }; 168 | 169 | let changelog = changelogTemplate(context); 170 | 171 | equal( 172 | changelog, 173 | ` 174 | # root ([link 1](link-1-url), [link 2](link-2-url)) 175 | 176 | ` 177 | ); 178 | }); 179 | 180 | it("renders scope in bold", function () { 181 | let root = new ChangelogEntry("root"); 182 | root.scope = "feat"; 183 | 184 | let context = { 185 | release: { 186 | name: "release1", 187 | changes: root, 188 | }, 189 | }; 190 | 191 | let changelog = changelogTemplate(context); 192 | 193 | equal( 194 | changelog, 195 | ` 196 | # **feat** - root 197 | 198 | ` 199 | ); 200 | }); 201 | 202 | it("renders subject link", function () { 203 | let root = new ChangelogEntry("root"); 204 | root.subjectLink = "xyz"; 205 | 206 | let context = { 207 | release: { 208 | name: "release1", 209 | changes: root, 210 | }, 211 | }; 212 | 213 | let changelog = changelogTemplate(context); 214 | 215 | equal( 216 | changelog, 217 | ` 218 | # [root](xyz) 219 | 220 | ` 221 | ); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /test/plugins/generate-changelog.spec.js: -------------------------------------------------------------------------------- 1 | import assert, { deepEqual, equal } from "assert"; 2 | import { stub } from "sinon"; 3 | import fs from "fs"; 4 | 5 | import Release from "../../src/Release"; 6 | import ChangelogEntry from "../../src/plugins/generate-changelog/ChangelogEntry"; 7 | import plugin from "../../src/plugins/generate-changelog"; 8 | 9 | describe("plugins", function () { 10 | describe("generateChangelog", function () { 11 | afterEach(function () { 12 | if (fs.existsSync("XYZ.md")) { 13 | fs.unlinkSync("XYZ.md"); 14 | } 15 | }); 16 | describe("installPlugin", function () { 17 | it("installs steps in right order", function () { 18 | let release = new Release({ 19 | changelogPath: "XYZ.md", 20 | plugins: [plugin], 21 | }); 22 | 23 | let steps = release.phases.start.steps; 24 | let names = steps.slice(-4, -1).map((step) => step.name); 25 | 26 | deepEqual(names, [ 27 | "getChangelogEntries", 28 | "generateChangelogContent", 29 | "writeChangelog", 30 | ]); 31 | }); 32 | }); 33 | 34 | describe("getChangelogEntries", function () { 35 | it("assign root to release.changes", function () { 36 | let release = new Release({ 37 | changelogPath: "XYZ.md", 38 | plugins: [plugin], 39 | }); 40 | 41 | release.commits = []; 42 | 43 | let step = release.phases.start.steps.find( 44 | (step) => step.name === "getChangelogEntries" 45 | ); 46 | 47 | step.run(release); 48 | assert(release.changes instanceof ChangelogEntry); 49 | }); 50 | 51 | it("set root subject after release name", function () { 52 | let release = new Release({ 53 | changelogPath: "XYZ.md", 54 | plugins: [plugin], 55 | }); 56 | 57 | release.name = "release123"; 58 | release.commits = []; 59 | 60 | let step = release.phases.start.steps.find( 61 | (step) => step.name === "getChangelogEntries" 62 | ); 63 | 64 | step.run(release); 65 | equal(release.changes.subject, "release123"); 66 | }); 67 | 68 | it("for first release sets root subjectLink after release name", function () { 69 | let release = new Release({ 70 | changelogPath: "XYZ.md", 71 | plugins: [plugin], 72 | }); 73 | 74 | release.name = "release123"; 75 | 76 | stub(release.git, "commitLink").callsFake( 77 | (commit) => `commitlink://${commit}` 78 | ); 79 | 80 | release.commits = []; 81 | 82 | let step = release.phases.start.steps.find( 83 | (step) => step.name === "getChangelogEntries" 84 | ); 85 | 86 | step.run(release); 87 | equal(release.changes.subjectLink, "commitlink://release123"); 88 | }); 89 | 90 | it("sets root subjectLink as compareLink between prev and release", function () { 91 | let release = new Release({ 92 | changelogPath: "XYZ.md", 93 | plugins: [plugin], 94 | }); 95 | 96 | release.previousReleaseName = "release1"; 97 | release.name = "release2"; 98 | 99 | stub(release.git, "compareLink").callsFake( 100 | (commit1, commit2) => `comparelink://${commit1}..${commit2}` 101 | ); 102 | 103 | release.commits = []; 104 | 105 | let step = release.phases.start.steps.find( 106 | (step) => step.name === "getChangelogEntries" 107 | ); 108 | 109 | step.run(release); 110 | equal(release.changes.subjectLink, "comparelink://release1..release2"); 111 | }); 112 | 113 | it("does not add any children since no commit fits", function () { 114 | let release = new Release({ 115 | changelogPath: "XYZ.md", 116 | plugins: [plugin], 117 | }); 118 | 119 | release.commits = []; 120 | 121 | let step = release.phases.start.steps.find( 122 | (step) => step.name === "getChangelogEntries" 123 | ); 124 | 125 | step.run(release); 126 | deepEqual(release.changes.children, []); 127 | }); 128 | 129 | it("adds a list of feat entries if one is present", function () { 130 | let release = new Release({ 131 | changelogPath: "XYZ.md", 132 | plugins: [plugin], 133 | }); 134 | 135 | release.name = "release2"; 136 | 137 | release.commits = [ 138 | { 139 | header: "feat: commit1", 140 | subject: "commit1", 141 | type: "feat", 142 | }, 143 | ]; 144 | 145 | let step = release.phases.start.steps.find( 146 | (step) => step.name === "getChangelogEntries" 147 | ); 148 | 149 | step.run(release); 150 | equal(release.changes.children[0].subject, "Features"); 151 | }); 152 | 153 | it("adds a list of fix entries if one is present", function () { 154 | let release = new Release({ 155 | changelogPath: "XYZ.md", 156 | plugins: [plugin], 157 | }); 158 | 159 | release.name = "release2"; 160 | 161 | release.commits = [ 162 | { 163 | header: "fix: commit1", 164 | subject: "commit1", 165 | type: "fix", 166 | }, 167 | ]; 168 | 169 | let step = release.phases.start.steps.find( 170 | (step) => step.name === "getChangelogEntries" 171 | ); 172 | 173 | step.run(release); 174 | equal(release.changes.children[0].subject, "Fixes"); 175 | }); 176 | 177 | it("adds a list of breaking changes entries if one is present", function () { 178 | let release = new Release({ 179 | changelogPath: "XYZ.md", 180 | plugins: [plugin], 181 | }); 182 | 183 | release.name = "release2"; 184 | 185 | release.commits = [ 186 | { 187 | header: "breaking: commit1", 188 | subject: "commit1", 189 | breaking: true, 190 | }, 191 | ]; 192 | 193 | let step = release.phases.start.steps.find( 194 | (step) => step.name === "getChangelogEntries" 195 | ); 196 | 197 | step.run(release); 198 | equal(release.changes.children[0].subject, "Breaking Changes"); 199 | }); 200 | 201 | it("adds scope to changelog if commit has one", function () { 202 | let release = new Release({ 203 | changelogPath: "XYZ.md", 204 | plugins: [plugin], 205 | }); 206 | 207 | release.name = "release2"; 208 | 209 | release.commits = [ 210 | { 211 | header: "feat: commit1", 212 | subject: "commit1", 213 | type: "feat", 214 | scope: "xyz", 215 | shortHash: "12345", 216 | hash: "12345678910", 217 | }, 218 | ]; 219 | 220 | let step = release.phases.start.steps.find( 221 | (step) => step.name === "getChangelogEntries" 222 | ); 223 | 224 | step.run(release); 225 | equal(release.changes.children[0].children[0].scope, "xyz"); 226 | }); 227 | 228 | it("adds link to commit for any entry", function () { 229 | let release = new Release({ 230 | changelogPath: "XYZ.md", 231 | plugins: [plugin], 232 | }); 233 | 234 | release.name = "release2"; 235 | 236 | release.commits = [ 237 | { 238 | header: "feat: commit1", 239 | subject: "commit1", 240 | type: "feat", 241 | scope: "xyz", 242 | shortHash: "12345", 243 | hash: "12345678910", 244 | }, 245 | ]; 246 | 247 | let step = release.phases.start.steps.find( 248 | (step) => step.name === "getChangelogEntries" 249 | ); 250 | 251 | stub(release.git, "commitLink").callsFake( 252 | (commit) => `commitlink://${commit}` 253 | ); 254 | 255 | step.run(release); 256 | let commitEntry = release.changes.children[0].children[0]; 257 | deepEqual(commitEntry.links[0], { 258 | name: "12345", 259 | url: "commitlink://12345678910", 260 | }); 261 | }); 262 | 263 | it("squashes commits with identical header", function () { 264 | let release = new Release({ 265 | changelogPath: "XYZ.md", 266 | plugins: [plugin], 267 | }); 268 | 269 | release.name = "release2"; 270 | 271 | release.commits = [ 272 | { 273 | header: "feat: commit1", 274 | subject: "commit1", 275 | type: "feat", 276 | scope: "xyz", 277 | shortHash: "12345", 278 | hash: "12345678910", 279 | }, 280 | { 281 | header: "feat: commit1", 282 | subject: "commit1", 283 | type: "feat", 284 | scope: "xyz", 285 | shortHash: "67890", 286 | hash: "67890123456", 287 | }, 288 | ]; 289 | 290 | let step = release.phases.start.steps.find( 291 | (step) => step.name === "getChangelogEntries" 292 | ); 293 | 294 | stub(release.git, "commitLink").callsFake( 295 | (commit) => `commitlink://${commit}` 296 | ); 297 | 298 | step.run(release); 299 | 300 | equal(release.changes.children[0].children.length, 1); 301 | 302 | let commitEntry = release.changes.children[0].children[0]; 303 | 304 | deepEqual(commitEntry.links, [ 305 | { 306 | name: "12345", 307 | url: "commitlink://12345678910", 308 | }, 309 | { 310 | name: "67890", 311 | url: "commitlink://67890123456", 312 | }, 313 | ]); 314 | }); 315 | }); 316 | 317 | describe("generateChangelogContent", function () { 318 | it("processes changes through changelog template", function () { 319 | let changelogTemplateStub = stub().returns("abc"); 320 | 321 | let release = new Release({ 322 | changelogPath: "XYZ.md", 323 | changelogTemplate: changelogTemplateStub, 324 | plugins: [plugin], 325 | }); 326 | 327 | let step = release.phases.start.steps.find( 328 | (step) => step.name === "generateChangelogContent" 329 | ); 330 | 331 | step.run(release); 332 | 333 | assert(changelogTemplateStub.calledWith({ release: release })); 334 | equal(release.changelog, "abc"); 335 | }); 336 | }); 337 | 338 | describe("writeChangelog", function () { 339 | it("prepends changelog contents to changelog", function () { 340 | let release = new Release({ 341 | changelogPath: "XYZ.md", 342 | plugins: [plugin], 343 | }); 344 | 345 | release.changelog = "abc"; 346 | 347 | let step = release.phases.start.steps.find( 348 | (step) => step.name === "writeChangelog" 349 | ); 350 | 351 | step.run(release); 352 | 353 | deepEqual(fs.readFileSync("XYZ.md", "utf8"), "abc"); 354 | }); 355 | }); 356 | }); 357 | }); 358 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "lib", 4 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 5 | "module": "commonjs" /* Specify what module code is generated. */, 6 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 7 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 8 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 9 | "strict": true /* Enable all strict type-checking options. */, 10 | "skipLibCheck": true /* Skip type checking all .d.ts files. */, 11 | "experimentalDecorators": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["./src/**/*.spec.*"] 15 | } 16 | --------------------------------------------------------------------------------