├── .editorconfig ├── .ember-cli ├── .gitignore ├── .jshintrc ├── .npmignore ├── .npmrc ├── .travis.yml ├── .yarnrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── blueprints └── ember-cli-release │ ├── files │ └── config │ │ └── release.js │ └── index.js ├── index.js ├── lib ├── commands │ └── release.js ├── strategies │ ├── date.js │ └── semver.js └── utils │ ├── find-by.js │ ├── git.js │ ├── npm.js │ ├── slice.js │ ├── tag-prefix.js │ └── with-args.js ├── package.json ├── tests ├── .jshintrc ├── acceptance │ └── commands │ │ └── release-nodetest.js ├── fixtures │ ├── project-with-aborted-hooks-config │ │ ├── bower.json │ │ ├── config │ │ │ └── release.js │ │ └── package.json │ ├── project-with-bad-config │ │ ├── config │ │ │ └── release.js │ │ └── foo.json │ ├── project-with-bad-strategy-config │ │ ├── bower.json │ │ ├── config │ │ │ └── release.js │ │ └── package.json │ ├── project-with-config │ │ ├── bower.json │ │ ├── config │ │ │ └── release.js │ │ └── package.json │ ├── project-with-different-manifests │ │ ├── bar.json │ │ ├── config │ │ │ └── release.js │ │ └── foo.json │ ├── project-with-hooks-config │ │ ├── bower.json │ │ ├── config │ │ │ └── release.js │ │ └── package.json │ ├── project-with-no-config │ │ ├── bower.json │ │ ├── config │ │ │ └── .gitkeep │ │ └── package.json │ ├── project-with-no-versions │ │ ├── bower.json │ │ ├── config │ │ │ └── .gitkeep │ │ └── package.json │ ├── project-with-options-strategy-config │ │ ├── bower.json │ │ ├── config │ │ │ └── release.js │ │ └── package.json │ ├── project-with-publish-config │ │ ├── config │ │ │ └── release.js │ │ └── package.json │ └── project-with-strategy-config │ │ ├── bower.json │ │ ├── config │ │ └── release.js │ │ └── package.json ├── helpers │ └── mock.js ├── runner.js └── unit │ └── strategies │ ├── date-nodetest.js │ └── semver-nodetest.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.js] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.hbs] 21 | indent_style = space 22 | indent_size = 2 23 | 24 | [*.css] 25 | indent_style = space 26 | indent_size = 2 27 | 28 | [*.html] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | [*.{diff,md}] 33 | trim_trailing_whitespace = false 34 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | 7 | # dependencies 8 | /node_modules 9 | /bower_components 10 | 11 | # misc 12 | /.sass-cache 13 | /connect.lock 14 | /coverage/* 15 | /libpeerconnection.log 16 | npm-debug.log 17 | testem.log 18 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "-Promise" 6 | ], 7 | "browser": true, 8 | "boss": true, 9 | "curly": true, 10 | "debug": false, 11 | "devel": true, 12 | "eqeqeq": true, 13 | "evil": true, 14 | "expr": true, 15 | "forin": false, 16 | "immed": false, 17 | "laxbreak": false, 18 | "newcap": true, 19 | "noarg": true, 20 | "noempty": false, 21 | "nonew": false, 22 | "nomen": false, 23 | "onevar": false, 24 | "plusplus": false, 25 | "regexp": false, 26 | "undef": true, 27 | "sub": true, 28 | "strict": false, 29 | "white": false, 30 | "eqnull": true, 31 | "esnext": true, 32 | "unused": true 33 | } 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components/ 2 | tests/ 3 | 4 | .jshintrc 5 | .bowerrc 6 | .editorconfig 7 | .ember-cli 8 | .travis.yml 9 | .npmignore 10 | **/.gitkeep 11 | bower.json 12 | Brocfile.js 13 | testem.json 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.yarnpkg.com 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | # we recommend testing addons with the same minimum supported node version as Ember CLI 5 | # so that your addon works for all apps 6 | - "6" 7 | 8 | sudo: false 9 | dist: trusty 10 | 11 | cache: 12 | yarn: true 13 | 14 | before_install: 15 | - curl -o- -L https://yarnpkg.com/install.sh | bash 16 | - export PATH=$HOME/.yarn/bin:$PATH 17 | 18 | install: 19 | - yarn install --no-lockfile --non-interactive 20 | 21 | script: 22 | - yarn test 23 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry "https://registry.yarnpkg.com" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### v1.0.0-beta.2 (June 18, 2016) 4 | 5 | * Fixed deprecation warning (@thomblake) 6 | * Fixed output typos (@elwayman02) 7 | 8 | ### v1.0.0-beta.1 (January 17, 2016) 9 | 10 | * Fixed issue with not being able to specify `publish` option in config 11 | 12 | ### v1.0.0-beta.0 (December 29, 2015) 13 | 14 | * Added ability to specify custom tagging strategy 15 | * Added support for SemVer prerelease versions 16 | * Added support for NPM publish 17 | 18 | ### v0.2.8 (October 19, 2015) 19 | 20 | * Hook argument readme clarification (@rwjblue) 21 | * Removed require of ember-cli classes 22 | * Added `init` hook 23 | 24 | ### v0.2.7 (September 24, 2015) 25 | 26 | * Prevent untracked files from being added to the release commit 27 | 28 | ### v0.2.6 (August 21, 2015) 29 | 30 | * Ensure trailing newline when rewriting manifest files (@mmun) 31 | 32 | ### v0.2.5 (July 13, 2015) 33 | 34 | * Fixed `SilentError` deprecation warning (@rwjblue) 35 | 36 | ### v0.2.4 (July 5, 2015) 37 | 38 | * Added `afterPush` hook 39 | * Added `beforeCommit` hook (@lukemelia, @chrislopresto) 40 | * Fixed issue with config file not being loaded (@lukemelia, @chrislopresto) 41 | * Added ability to specify manifest files 42 | 43 | ### v0.2.3 (April 20, 2015) 44 | 45 | * Fixed issue with main blueprint when installing from within an addon 46 | 47 | ### v0.2.2 (April 14, 2015) 48 | 49 | * Fixed issue with push success messages not printing 50 | 51 | ### v0.2.1 (April 13, 2015) 52 | 53 | * Commit changes to working tree before tagging 54 | * Replace versions in bower.json and package.json 55 | * Added ability to specify options in a config file 56 | 57 | ### v0.2.0 (March 11, 2015) 58 | 59 | * Create lightweight tags by default, changed `--message` option to `--annotation` 60 | * Fixed issue with `currentTag` git method not reporting lightweight tags 61 | 62 | ### v0.1.1 (March 11, 2015) 63 | 64 | * Updated git-tools to v0.4.1 65 | * Fixed issue with quotes in tag message 66 | * Removed unused addon files/directories 67 | 68 | ### v0.1.0 (March 10, 2015) 69 | 70 | * Initial release 71 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Lytics, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ember-cli-release 2 | 3 | [![Build Status](https://travis-ci.org/lytics/ember-cli-release.svg?branch=master)](https://travis-ci.org/lytics/ember-cli-release) 4 | [![NPM Version](https://badge.fury.io/js/ember-cli-release.svg)](http://badge.fury.io/js/ember-cli-release) 5 | [![Ember Observer Score](http://emberobserver.com/badges/ember-cli-release.svg)](http://emberobserver.com/addons/ember-cli-release) 6 | 7 | > **Deprecated**: ember-cli-release is deprecated. We recommend using [release-it](https://github.com/release-it/release-it#release-it-) instead. If you would like to automate changelog generation as well, you might want to consider [rwjblue's setup script](https://github.com/rwjblue/create-rwjblue-release-it-setup#create-rwjblue-release-it-setup). The script automates the setup of release-it with [lerna-changelog](https://github.com/lerna/lerna-changelog) integration. 8 | 9 | Ember CLI addon that defines a `release` command for bumping the version of your app or addon. It's a streamlined alternative to the [`npm version` command](https://docs.npmjs.com/cli/version), with a number of additional advantages: 10 | 11 | - Non-SemVer tagging strategies 12 | - Config file for: 13 | - Custom defaults 14 | - Promise-friendly hooks 15 | - `bower.json` version replacement 16 | - Annotated tag support 17 | 18 | [![Introduction to ember-cli-release at Global Ember Meetup](https://i.vimeocdn.com/video/552191750_640x360.png)](https://vimeo.com/152244691) 19 | 20 | ## Installation 21 | 22 | ```sh 23 | $ ember install ember-cli-release 24 | ``` 25 | 26 | This will also generate the config file `config/release.js` which can be used to provide default options ([see below](#options)). 27 | 28 | ## Usage 29 | 30 | This addon revolves around git tags, and so relies heavily on shelling out to run git commands (unlike the wonderful [`git-repo-info`](https://github.com/rwjblue/git-repo-info)). 31 | 32 | When invoked with no options: 33 | 34 | ```sh 35 | $ ember release 36 | ``` 37 | 38 | It will: 39 | 40 | 1. Assume that the project uses the [SemVer](http://semver.org/) versioning scheme 41 | 2. Find the latest tag that is SemVer compliant and increment its PATCH version 42 | 3. Replace the `version` property of `package.json` and `bower.json` with the new version 43 | 4. Commit all changes to the working tree 44 | 5. Create a lightweight git tag with the new version 45 | 6. Push the branch and the new tag to `origin` 46 | 47 | See the [examples section](#examples) for more ways to use the command. 48 | 49 | ## Options 50 | 51 | Options can be specified on the command line or in `config/release.js` unless marked with an asterisk (`*`). Options specified on the command line **always take precedence** over options in the config file. Run `ember help` to see CLI aliases. 52 | 53 | - `local` 54 | 55 | Default: `false` 56 | 57 | Whether to only create the git tag locally or not. 58 | 59 | - `remote` 60 | 61 | Default: `'origin'` 62 | 63 | The git remote to push new tags to, ignored if `local` is true. 64 | 65 | - `tag`\* 66 | 67 | Default: `null` 68 | 69 | Optional name of the tag to create, overrides versioning strategies. 70 | 71 | - `annotation` 72 | 73 | Default: `null` 74 | 75 | Message to add when creating a tag, [indicates that the tag should be annotated](http://git-scm.com/book/tr/v2/Git-Basics-Tagging#Annotated-Tags), where `%@` is replaced with tag name. 76 | 77 | - `message` 78 | 79 | Default: `'Released %@'` 80 | 81 | Message to use when committing changes to the working tree (including changes to `package.json` and `bower.json`), where `%@` is replaced with tag name. 82 | 83 | - `manifest` 84 | 85 | Default: `[ 'package.json', 'bower.json' ]` 86 | 87 | A set of JSON manifest files to replace the top-level `version` key in with the new tag name. 88 | 89 | - `publish` 90 | 91 | Default: `false` 92 | 93 | Whether to publish the package to NPM after tagging or not. Uses the currently logged in NPM user and the registry as defined in the project's [`package.json`](https://docs.npmjs.com/files/package.json#publishconfig). 94 | 95 | - `yes`\* 96 | 97 | Default: `false` 98 | 99 | Whether to skip confirmation prompts or not (answer 'yes' to all questions). 100 | 101 | - `strategy` 102 | 103 | Default: `'semver'` 104 | 105 | The versioning strategy to use, either `semver` or `date`. 106 | 107 | - `major`\* 108 | 109 | Default: `false` 110 | 111 | Increment the **major** SemVer version, takes precedence over `minor`. Only used when the `strategy` option is `'semver'`. 112 | 113 | - `minor`\* 114 | 115 | Default: `false` 116 | 117 | Increment the **minor** SemVer version, if both `major` and `minor` are false, **patch** is incremented. Only used when the `strategy` option is `'semver'`. 118 | 119 | - `premajor`\* 120 | 121 | Default: `''` 122 | 123 | Increment the **major** SemVer version, and add given prerelease identifier with version of `0`. Only used when the `strategy` option is `'semver'`, and ignored if `major` or `minor` are specified. 124 | 125 | - `preminor`\* 126 | 127 | Default: `''` 128 | 129 | Increment the **minor** SemVer version, and add given prerelease identifier with version of `0`. Only used when the `strategy` option is `'semver'`, and ignored if `major`, `minor`, or `premajor` are specified. 130 | 131 | - `prerelease`\* 132 | 133 | Default: `false` 134 | 135 | When using SemVer, has multiple behaviors: 136 | 137 | - Latest version contains a prerelease identifier 138 | - If value is omitted or a string that matches the current identifier, increment the prerelease version. 139 | - If value is a string that differs from the current identifier, change the identifier to the one given and reset the prerelease version to `0`. 140 | - Latest version does not contain a prerelease identifier 141 | - Increment the **patch** version and append the given prerelease identifier (the value must be a string). 142 | 143 | Only used when the `strategy` option is `'semver'`, and ignored if `major`, `minor`, `premajor`, or `preminor` are specified. 144 | 145 | - `format` 146 | 147 | Default: `'YYYY.MM.DD'` 148 | 149 | The format string used when creating a tag based on the current date using [`moment().format()`](http://momentjs.com/docs/#/displaying/format/). Only used when the `strategy` option is `'date'`. 150 | 151 | - `timezone` 152 | 153 | Default: `'UTC'` 154 | 155 | The timezone to consider the current date in. Only used when the `strategy` option is `'date'`. 156 | 157 | ## Hooks 158 | 159 | A set of lifecycle hooks exists as a means to inject additional behavior into the release process. Lifecycle hooks can be specified in `config/release.js`. All hooks can return a thenable that will be resolved before continuing the release process. Throwing from a hook or rejecting a promise returned by a hook will halt the release process and print the error. 160 | 161 | Hooks are passed two arguments: 162 | 163 | - `project` - a reference to the current ember-cli project 164 | - `tags` - an object containing tag information, which will always have a `next` property and depending on the strategy you are using, may also have a `latest` property. Note that these values will be the exact values used for the tag, which by default includes a `v` prefix. 165 | 166 | There are three lifecycle hooks available: 167 | 168 | - `init` 169 | 170 | Called after the new version has been computed but before any changes are made to the filesystem or repository. Use this hook if you need to verify that the local environment is setup for releasing, and abort if not. 171 | 172 | ###### Example Usage 173 | 174 | Aborting: 175 | 176 | ```js 177 | // config/release.js 178 | module.exports = { 179 | init: function() { 180 | if (!process.env.SUPER_SECRET_KEY) { 181 | throw 'Super secret key missing!'; 182 | } 183 | } 184 | }; 185 | ``` 186 | 187 | - `beforeCommit` 188 | 189 | Called after the new version has been replaced in manifest files but before the changes have been committed. Use this hook if you need to update the version number in additional files, or build the project to update dist files. Note that this hook runs regardless of whether a commit will be made. 190 | 191 | ###### Example Usage 192 | 193 | Version replacement: 194 | 195 | ```js 196 | // config/release.js 197 | var path = require('path'); 198 | var xmlpoke = require('xmlpoke'); 199 | 200 | module.exports = { 201 | beforeCommit: function(project, tags) { 202 | xmlpoke(path.join(project.root, 'cordova/config.xml'), function(xml) { 203 | xml.errorOnNoMatches(); 204 | xml.addNamespace('w', 'http://www.w3.org/ns/widgets'); 205 | xml.set('w:widget/@version', tags.next); 206 | }); 207 | } 208 | }; 209 | ``` 210 | 211 | Building: 212 | 213 | ```js 214 | // config/release.js 215 | var BuildTask = require('ember-cli/lib/tasks/build'); 216 | 217 | module.exports = { 218 | // Build the project in the production environment, outputting to dist/ 219 | beforeCommit: function(project) { 220 | var task = new BuildTask({ 221 | project: project, 222 | ui: project.ui, 223 | analytics: project.cli.analytics 224 | }); 225 | 226 | return task.run({ 227 | environment: 'production', 228 | outputPath: 'dist/' 229 | }); 230 | } 231 | }; 232 | ``` 233 | 234 | - `afterPush` 235 | 236 | Called after successfully pushing all changes to the specified remote, but before exiting. Use this hook for post-release tasks like cleanup or sending notifications from your CI server. 237 | 238 | ###### Example Usage 239 | 240 | Notification: 241 | 242 | ```js 243 | // config/release.js 244 | var Slack = require('node-slack'); 245 | 246 | // Look for slack configuration in the CI environment 247 | var isCI = process.env.CI; 248 | var hookURL = process.env.SLACK_HOOK_URL; 249 | 250 | module.exports = { 251 | // Notify the #dev channel when a new release is created 252 | afterPush: function(project, tags) { 253 | if (isCI && hookURL) { 254 | var slack = new Slack(hookURL); 255 | 256 | return slack.send({ 257 | text: 'ZOMG, ' + project.name() + ' ' + tags.next + ' RELEASED!!1!', 258 | channel: '#dev', 259 | username: 'Mr. CI' 260 | }); 261 | } 262 | } 263 | }; 264 | ``` 265 | 266 | - `afterPublish` 267 | 268 | Called after successfully publishing the package to NPM, but before exiting. Use this hook exactly as `afterPush` is used when performing a publish. Note that this hook is not run when `--publish` option is not set. 269 | 270 | ## Custom Tagging Strategy 271 | 272 | If your app does not use SemVer or date-based tags, you may specify a custom method for generating the next tag by making the `strategy` property a function in `config/release.js`. The function takes three arguments: the project instance, an array of existing git tags, and an options hash with all option values. It must return a non-empty string specifying the next tag, or a promise that resolves with the tag name. For example: 273 | 274 | ```js 275 | // config/release.js 276 | module.exports = { 277 | // Emulate Subversion-style build numbers 278 | strategy: function(project, tags, options) { 279 | var builds = tags 280 | .map(function(tag) { return +tag; }) 281 | .filter(function(build) { return !isNaN(build); }) 282 | .sort() 283 | .reverse(); 284 | 285 | return builds[0] + 1; 286 | } 287 | }; 288 | ``` 289 | 290 | Alternatively, if the custom strategy requires additional CLI options, an object can be specified with `availableOptions`, `getLatestTag`, and `getNextTag` properties: 291 | 292 | ```js 293 | // config/release.js 294 | module.exports = { 295 | strategy: { 296 | availableOptions: [ 297 | { 298 | name: 'channel', 299 | type: String, 300 | default: 'stable', 301 | description: "the release's channel" 302 | }, 303 | ], 304 | 305 | getLatestTag: function(project, tags, options) { 306 | // Find the latest tag in the `tags` array 307 | var latest = '...'; 308 | 309 | return latest; 310 | }, 311 | 312 | getNextTag: function(project, tags, options) { 313 | // Generate an identifier 314 | var next = '...'; 315 | 316 | // Prepend the specified channel 317 | return options.channel + '-' + next; 318 | } 319 | } 320 | }; 321 | ``` 322 | 323 | ## Workflow 324 | 325 | These are the steps that take place when running the `release` command: 326 | 327 | 1. Abort if HEAD is already at a tag 328 | 2. Abort if `publish` option is `true` and no NPM user is logged in or `strategy` is not 'semver' 329 | 3. Calculate new version 330 | 1. Use `tag` option if present 331 | 2. Invoke custom tagging strategy if specified 332 | 3. Otherwise, generate new version using `strategy` option (default: 'semver') 333 | - SemVer 334 | 1. Look for latest tag using `node-semver` ordering 335 | 2. Increment based on `major`, `minor`, or `patch` (default: `patch`) 336 | - Date 337 | 1. Create tag name based on current date and `format` option (default: `YYYY.MM.DD`) 338 | 2. Look for existing tag of same name, append `.X` where X is an incrementing integer 339 | 3. Print new version name 340 | 4. Invoke the `init` hook 341 | 5. If working tree is dirty, prompt user that their changes will be included in release commit 342 | 6. Replace `version` property of files specified by the `manifest` option (default: `package.json`/`bower.json`) 343 | 7. Invoke the `beforeCommit` hook 344 | 8. Commit changes 345 | 1. Skip if working tree is unmodified 346 | 2. Stage all changes and commit with `message` option as the commit message 347 | 9. Create tag 348 | 1. Prompt to continue with new tag name 349 | 2. Tag the latest commit with new version using the `annotation` option if specified 350 | 10. Push to remote 351 | 1. Skip if `local` option is `true` (default: `false`) 352 | 2. Push current branch and tags to remote specified by `remote` option 353 | 11. Invoke the `afterPush` hook 354 | 12. Publish package to NPM using current credentials if `publish` option is `true` (default: `false`) 355 | 13. Invoke the `afterPublish` hook 356 | 357 | ## Examples 358 | 359 | To create a new tag based on the date in east cost time with a custom format: 360 | 361 | ```sh 362 | > ember release --strategy=date --format="YYYY-MM-DD" --timezone="America/New_York" 363 | ``` 364 | 365 | Or to create a specific tag (no versioning strategy) with annotation, locally only: 366 | 367 | ```sh 368 | > ember release --local --tag="what_am_i_doing" --annotation="First version wooooo!" 369 | ``` 370 | 371 | To create a series of SemVer prereleases, use the `--premajor` (or `--preminor`) option followed by any number of `--prerelease`s, and finally `--major` (or `--minor`): 372 | 373 | ```sh 374 | # v1.3.2 375 | > ember release --premajor alpha 376 | # v2.0.0-alpha.0 377 | > ember release --prerelease 378 | # v2.0.0-alpha.1 379 | > ember release --prerelease beta 380 | # v2.0.0-beta.0 381 | > ember release --prerelease 382 | # v2.0.0-beta.1 383 | > ember release --major 384 | # v2.0.0 385 | ``` 386 | 387 | ## Contributing 388 | 389 | Pull requests welcome, but they must be fully tested (and pass all existing tests) to be considered. Discussion issues also welcome. 390 | 391 | ## Running Tests 392 | 393 | ```sh 394 | $ npm test 395 | ``` 396 | -------------------------------------------------------------------------------- /blueprints/ember-cli-release/files/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | // var RSVP = require('rsvp'); 3 | 4 | // For details on each option run `ember help release` 5 | module.exports = { 6 | // local: true, 7 | // remote: 'some_remote', 8 | // annotation: "Release %@", 9 | // message: "Bumped version to %@", 10 | // manifest: [ 'package.json', 'bower.json', 'someconfig.json' ], 11 | // publish: true, 12 | // strategy: 'date', 13 | // format: 'YYYY-MM-DD', 14 | // timezone: 'America/Los_Angeles', 15 | // 16 | // beforeCommit: function(project, versions) { 17 | // return new RSVP.Promise(function(resolve, reject) { 18 | // // Do custom things here... 19 | // }); 20 | // } 21 | }; 22 | -------------------------------------------------------------------------------- /blueprints/ember-cli-release/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | // This suppresses a silent error about needing an entity type 5 | normalizeEntityName: function() { 6 | }, 7 | 8 | // This allows the main blueprint to install to the addon's root dir 9 | supportsAddon: function() { 10 | return true; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* jshint node: true */ 2 | 'use strict'; 3 | 4 | module.exports = { 5 | name: 'ember-cli-release', 6 | 7 | includedCommands: function() { 8 | return { 9 | release: require('./lib/commands/release') 10 | }; 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /lib/commands/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | var GitRepo = require('../utils/git'); 4 | var NPM = require('../utils/npm'); 5 | var tagPrefix = require('../utils/tag-prefix'); 6 | var findBy = require('../utils/find-by'); 7 | var withArgs = require('../utils/with-args'); 8 | var slice = require('../utils/slice'); 9 | var fs = require('fs'); 10 | var path = require('path'); 11 | var chalk = require('chalk'); 12 | var merge = require('merge'); 13 | var makeArray = require('make-array'); 14 | var nopt = require('nopt'); 15 | var requireDir = require('require-dir'); 16 | var RSVP = require('rsvp'); 17 | var SilentError = require('silent-error'); 18 | 19 | var Promise = RSVP.Promise; 20 | var resolve = RSVP.resolve; 21 | var resolveAll = RSVP.all; 22 | var resolveHash = RSVP.hash; 23 | var denodeify = RSVP.denodeify; 24 | var readFile = denodeify(fs.readFile); 25 | var writeFile = denodeify(fs.writeFile); 26 | 27 | var readJSON = function(filePath, options) { 28 | return readFile(filePath, options).then(function(data) { 29 | return JSON.parse(data); 30 | }); 31 | }; 32 | var writeJSON = function(filePath, object, options) { 33 | var contents = JSON.stringify(object, null, 2) + '\n'; 34 | return writeFile(filePath, contents, options); 35 | }; 36 | 37 | var configPath = 'config/release.js'; 38 | 39 | var availableHooks = [ 40 | 'init', 41 | 'beforeCommit', 42 | 'afterPush', 43 | 'afterPublish', 44 | ]; 45 | 46 | var availableOptions = [ 47 | { 48 | name: 'local', 49 | type: Boolean, 50 | aliases: [ 'l' ], 51 | default: false, 52 | description: "whether release commit and tags are locally or not (not pushed to a remote)", 53 | validInConfig: true, 54 | }, 55 | { 56 | name: 'remote', 57 | type: String, 58 | aliases: [ 'r' ], 59 | default: 'origin', 60 | description: "the git origin to push tags to, ignored if the '--local' option is true", 61 | validInConfig: true, 62 | }, 63 | { 64 | name: 'tag', 65 | type: String, 66 | aliases: [ 't' ], 67 | description: "the name of the git tag to create", 68 | }, 69 | { 70 | name: 'annotation', 71 | type: String, 72 | aliases: [ 'a' ], 73 | description: "a message passed to the `--message` option of `git tag`, indicating that to the tag should be created with the `--annotated` option (default is lightweight), the string '%@' is replaced with the tag name", 74 | validInConfig: true, 75 | }, 76 | { 77 | name: 'message', 78 | type: String, 79 | aliases: [ 'm' ], 80 | default: 'Released %@', 81 | description: "a message passed to the `--message` option of `git commit`, the string '%@' is replaced with the tag name", 82 | validInConfig: true, 83 | }, 84 | { 85 | name: 'manifest', 86 | type: Array, 87 | default: [ 'package.json', 'bower.json' ], 88 | description: "a set of JSON files to replace the top-level `version` property in with the new tag name", 89 | validInConfig: true, 90 | }, 91 | { 92 | name: 'publish', 93 | type: Boolean, 94 | aliases: [ 'p' ], 95 | default: false, 96 | description: "whether to publish package to NPM after tagging or not", 97 | validInConfig: true, 98 | }, 99 | { 100 | name: 'yes', 101 | type: Boolean, 102 | aliases: [ 'y' ], 103 | default: false, 104 | description: "whether to skip confirmation prompts or not (answer 'yes' to all questions)", 105 | }, 106 | { 107 | name: 'strategy', 108 | type: String, 109 | aliases: [ 's' ], 110 | default: 'semver', 111 | description: "strategy for auto-generating the tag name, either 'semver' or 'date', ignored if the 'name' option is specified", 112 | validInConfig: true, 113 | }, 114 | ]; 115 | 116 | function hasModifications(status) { 117 | var modifications = status.split('\n').filter(function(str) { 118 | return !!str && str.indexOf('??') !== 0; 119 | }); 120 | 121 | return !!modifications.length; 122 | } 123 | 124 | module.exports = { 125 | name: 'release', 126 | description: 'Create a new git tag at HEAD', 127 | works: 'insideProject', 128 | 129 | run: function(options) { 130 | var command = this; 131 | var ui = this.ui; 132 | var project = this.project; 133 | var repo = this.git(); 134 | var strategies = this.strategies(); 135 | var hooks = this.config().hooks; 136 | var pushQueue = []; 137 | 138 | function proceedPrompt(message) { 139 | if (options.yes) { 140 | return resolve(); 141 | } 142 | 143 | return ui.prompt({ 144 | type: 'confirm', 145 | name: 'proceed', 146 | message: chalk.yellow(message + ", proceed?"), 147 | choices: [ 148 | { key: 'y', value: true }, 149 | { key: 'n', value: false } 150 | ] 151 | }).then(function(response) { 152 | if (!response.proceed) { 153 | throw new SilentError("Aborted."); 154 | } 155 | }); 156 | } 157 | 158 | function getTaggingStrategy() { 159 | var strategyName = options.strategy; 160 | 161 | if (typeof strategyName === 'object') { 162 | return strategyName; 163 | } 164 | 165 | if (!(strategyName in strategies)) { 166 | throw new SilentError("Unknown tagging strategy: '" + strategyName + "'"); 167 | } 168 | 169 | return strategies[strategyName]; 170 | } 171 | 172 | function abortIfAtTag() { 173 | return repo.currentTag().then(function(currentTag) { 174 | if (currentTag) { 175 | throw new SilentError("Skipped tagging, HEAD already at tag: " + currentTag); 176 | } 177 | }); 178 | } 179 | 180 | function abortIfCannotPublish() { 181 | if (!options.publish) { return; } 182 | 183 | if (options.local) { 184 | throw new SilentError("The --publish and --local options are incompatible."); 185 | } 186 | 187 | if (options.strategy !== 'semver') { 188 | throw new SilentError("Publishing to NPM requires SemVer."); 189 | } 190 | 191 | var npm = command.npm(); 192 | 193 | // Second argument prevents the function from printing the username 194 | return npm.whoami([], true).then(function(username) { 195 | var registry = npm.config.get('registry'); 196 | 197 | ui.writeLine(chalk.green("Using NPM registry " + registry + " as user '" + username + "'")); 198 | }).catch(function(error) { 199 | // Fail gracefully if the user is not logged in 200 | if (error && error.code === 'ENEEDAUTH') { 201 | throw new SilentError("Must be logged in to perform NPM publish."); 202 | } 203 | 204 | // Fail hard on an unexpected error 205 | throw error; 206 | }); 207 | } 208 | 209 | function promptIfWorkingTreeDirty() { 210 | return repo.status().then(function(status) { 211 | if (hasModifications(status)) { 212 | return proceedPrompt("Your working tree contains modifications that will be added to the release commit"); 213 | } 214 | }); 215 | } 216 | 217 | function getTags() { 218 | if (options.tag) { 219 | // Use tag name if specified 220 | return { 221 | next: options.tag 222 | }; 223 | } else { 224 | // Otherwise fetch all tags to pass to the tagging strategy 225 | return repo.tags().then(function(tags) { 226 | var strategy = getTaggingStrategy(); 227 | var tagNames = tags.map(function(tag) { 228 | return tag.name; 229 | }); 230 | 231 | return resolveHash({ 232 | latest: strategy.getLatestTag ? strategy.getLatestTag(project, tagNames, options): null, 233 | next: strategy.getNextTag(project, tagNames, options), 234 | }); 235 | }).then(function(tags) { 236 | if (!tags || typeof tags.next !== 'string') { 237 | throw new SilentError("Tagging strategy must return a non-empty tag name"); 238 | } 239 | 240 | return tags; 241 | }); 242 | } 243 | } 244 | 245 | function printLatestTag(latestTag) { 246 | if (latestTag) { 247 | ui.writeLine(chalk.green('Latest tag: ' + latestTag)); 248 | } 249 | } 250 | 251 | function replaceVersionsInManifests(nextVersion) { 252 | return resolveAll(options.manifest.map(function(fileName) { 253 | var filePath = path.join(project.root, fileName); 254 | 255 | return readJSON(filePath, 'utf8').then(function(pkg) { 256 | // Skip replace if 'version' key does not exist 257 | if (pkg.version) { 258 | pkg.version = nextVersion; 259 | 260 | return writeJSON(filePath, pkg, 'utf8'); 261 | } 262 | }, function(error) { 263 | // Ignore if the file doesn't exist 264 | if (error && error.code === 'ENOENT') { return; } 265 | 266 | throw error; 267 | }); 268 | })); 269 | } 270 | 271 | function createCommit(nextTag) { 272 | return repo.status().then(function(status) { 273 | // Don't bother committing if for some reason the working tree is clean 274 | if (hasModifications(status)) { 275 | return repo.currentBranch().then(function(branchName) { 276 | if (!branchName) { 277 | throw new SilentError("Must have a branch checked out to commit to"); 278 | } 279 | 280 | // Allow the name to be in the message 281 | var message = options.message.replace(/%@/g, nextTag); 282 | 283 | return repo.commitAll(message).then(function() { 284 | pushQueue.push(branchName); 285 | }).then(function() { 286 | ui.writeLine(chalk.green("Successfully committed changes '" + message + "' locally.")); 287 | }); 288 | }); 289 | } 290 | }); 291 | } 292 | 293 | function promptToCreateTag(nextTag) { 294 | return proceedPrompt("About to create tag '" + nextTag + "'" + (options.local ? "" : " and push to remote '" + options.remote + "'")); 295 | } 296 | 297 | function createTag(tagName) { 298 | var message = null; 299 | 300 | if (options.annotation) { 301 | // Allow the tag name to be in the message 302 | message = options.annotation.replace(/%@/g, tagName); 303 | } 304 | 305 | return repo.createTag(tagName, message).then(function() { 306 | pushQueue.push(tagName); 307 | }).then(function() { 308 | ui.writeLine(chalk.green("Successfully created git tag '" + tagName + "' locally.")); 309 | }); 310 | } 311 | 312 | function pushChanges() { 313 | if (options.local || !pushQueue.length) { return; } 314 | 315 | return resolveAll(pushQueue.map(function(treeish) { 316 | return repo.push(options.remote, treeish).then(function() { 317 | ui.writeLine(chalk.green("Successfully pushed '" + treeish + "' to remote '" + options.remote + "'.")); 318 | }); 319 | })); 320 | } 321 | 322 | function performPublish() { 323 | if (!options.publish) { return; } 324 | 325 | var npm = command.npm(); 326 | var filePath = path.join(project.root, 'package.json'); 327 | 328 | return readJSON(filePath, 'utf8').then(function(pkg) { 329 | return proceedPrompt("About to publish " + pkg.name + "@" + pkg.version); 330 | }).then(function() { 331 | ui.writeLine(chalk.green("Publishing...")); 332 | return npm.publish([ project.root ]); 333 | }).then(function() { 334 | ui.writeLine(chalk.green("Publish successful.")); 335 | }); 336 | } 337 | 338 | function executeHook(hookName /* ...args */) { 339 | var args = slice(arguments, 1); 340 | var hook = hooks[hookName]; 341 | 342 | // The first arg to all hooks is the project 343 | args.unshift(project); 344 | 345 | if (hook) { 346 | return new Promise(function(resolve, reject) { 347 | // Handle errors thrown directly from hook 348 | try { 349 | resolve(hook.apply(hooks, args)); 350 | } catch(error) { 351 | reject(error); 352 | } 353 | }).catch(function(error) { 354 | // Preserve stack traces in hook errors if a real error is thrown, 355 | // otherwise wrap friendly errors in `SilentError` 356 | if (!(error instanceof Error)) { 357 | error = new SilentError(error); 358 | } 359 | 360 | // Provide more context in the error message 361 | error.message = "Error encountered in `" + hookName + '` hook: "' + error.message + '"'; 362 | 363 | throw error; 364 | }); 365 | } else { 366 | return resolve(); 367 | } 368 | } 369 | 370 | return resolve() 371 | .then(abortIfAtTag) 372 | .then(abortIfCannotPublish) 373 | .then(getTags) 374 | .then(function(tags) { 375 | return executeHook('init', tags) 376 | .then(promptIfWorkingTreeDirty) 377 | .then(withArgs(printLatestTag, tags.latest)) 378 | .then(withArgs(replaceVersionsInManifests, tagPrefix.strip(tags.next))) 379 | .then(withArgs(executeHook, 'beforeCommit', tags)) 380 | .then(withArgs(createCommit, tags.next)) 381 | .then(withArgs(promptToCreateTag, tags.next)) 382 | .then(withArgs(createTag, tags.next)) 383 | .then(pushChanges) 384 | .then(withArgs(executeHook, 'afterPush', tags)) 385 | .then(performPublish) 386 | .then(function() { 387 | if (options.publish) { 388 | return executeHook('afterPublish', tags); 389 | } 390 | }); 391 | }); 392 | }, 393 | 394 | // Merge options specified on the command line with those defined in the config 395 | init: function() { 396 | this._super.init && this._super.init.apply(this, arguments); 397 | var baseOptions = this.baseOptions(); 398 | var optionsFromConfig = this.config().options; 399 | var mergedOptions = baseOptions.map(function(availableOption) { 400 | var option = merge(true, availableOption); 401 | 402 | if ((optionsFromConfig[option.name] !== undefined) && (option.default !== undefined)) { 403 | option.default = optionsFromConfig[option.name]; 404 | option.description = option.description + ' (configured in ' + configPath + ')'; 405 | } 406 | 407 | return option; 408 | }); 409 | 410 | // Merge custom strategy options if specified 411 | var strategy = optionsFromConfig.strategy; 412 | if (typeof strategy === 'object' && Array.isArray(strategy.availableOptions)) { 413 | mergedOptions = mergedOptions.concat(strategy.availableOptions); 414 | } 415 | 416 | this.registerOptions({ 417 | availableOptions: mergedOptions 418 | }); 419 | }, 420 | 421 | // Combine base options with strategy specific options 422 | baseOptions: function() { 423 | if (this._baseOptions) { 424 | return this._baseOptions; 425 | } 426 | 427 | var strategies = this.strategies(); 428 | var strategyOptions = Object.keys(strategies).reduce(function(result, strategyName) { 429 | var options = strategies[strategyName].availableOptions; 430 | 431 | if (Array.isArray(options)) { 432 | // Add strategy qualifier to option descriptions 433 | options.forEach(function(option) { 434 | option.description = "when strategy is '" + strategyName + "', " + option.description; 435 | }); 436 | 437 | result = result.concat(options); 438 | } 439 | 440 | return result; 441 | }, []); 442 | 443 | return this._baseOptions = availableOptions.concat(strategyOptions); 444 | }, 445 | 446 | strategies: function() { 447 | if (this._strategies) { 448 | return this._strategies; 449 | } 450 | 451 | return this._strategies = requireDir('../strategies'); 452 | }, 453 | 454 | git: function() { 455 | if (this._repo) { 456 | return this._repo; 457 | } 458 | 459 | return this._repo = new GitRepo(this.project.root); 460 | }, 461 | 462 | npm: function() { 463 | if (this._npm) { 464 | return this._npm; 465 | } 466 | 467 | return this._npm = new NPM({}); 468 | }, 469 | 470 | config: function() { 471 | if (!this._parsedConfig) { 472 | var ui = this.ui; 473 | var fullConfigPath = path.join(this.project.root, configPath); 474 | var config = {}; 475 | var strategy; 476 | 477 | if (fs.existsSync(fullConfigPath)) { 478 | config = require(fullConfigPath); 479 | } 480 | 481 | // Preserve strategy if it's a function 482 | if (typeof config.strategy === 'function') { 483 | strategy = { 484 | getNextTag: config.strategy 485 | }; 486 | } else if (typeof config.strategy === 'object') { 487 | if (typeof config.strategy.getNextTag === 'function') { 488 | strategy = config.strategy; 489 | } else { 490 | ui.writeLine(chalk.yellow("Warning: a custom `strategy` object must define a `getNextTag` function, ignoring")); 491 | } 492 | } 493 | 494 | // Extract hooks 495 | var hooks = availableHooks.reduce(function(result, hookName){ 496 | if (typeof config[hookName] === 'function') { 497 | result[hookName] = config[hookName]; 498 | delete config[hookName]; 499 | } else if (config[hookName] !== undefined) { 500 | ui.writeLine(chalk.yellow("Warning: `" + hookName + "` is not a function in " + configPath + ", ignoring")); 501 | } 502 | 503 | return result; 504 | }, {}); 505 | 506 | var baseOptions = this.baseOptions(); 507 | var configOptions = baseOptions.filter(function(option) { 508 | return option.validInConfig; 509 | }); 510 | var optionTypeMap = configOptions.reduce(function(result, option) { 511 | result[option.name] = option.type; 512 | return result; 513 | }, {}); 514 | 515 | // Extract whitelisted options 516 | var options = Object.keys(config).reduce(function(result, optionName) { 517 | if (findBy(configOptions, 'name', optionName)) { 518 | result[optionName] = optionTypeMap[optionName] === Array ? makeArray(config[optionName]) : config[optionName]; 519 | } else if (findBy(baseOptions, 'name', optionName)) { 520 | ui.writeLine(chalk.yellow("Warning: cannot specify option `" + optionName + "` in " + configPath + ", ignoring")); 521 | } else { 522 | ui.writeLine(chalk.yellow("Warning: invalid option `" + optionName + "` in " + configPath + ", ignoring")); 523 | } 524 | 525 | return result; 526 | }, {}); 527 | 528 | // Coerce options into their expected type; this is not done for us since 529 | // the options are not coming from the CLI arg string 530 | nopt.clean(options, optionTypeMap); 531 | 532 | // If the strategy was a function, it got stomped on 533 | if (strategy) { 534 | options.strategy = strategy; 535 | } 536 | 537 | this._parsedConfig = { 538 | options: options, 539 | hooks: hooks 540 | }; 541 | } 542 | 543 | return this._parsedConfig; 544 | } 545 | }; 546 | -------------------------------------------------------------------------------- /lib/strategies/date.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | var moment = require('moment-timezone'); 4 | 5 | var dateStrategy = { 6 | availableOptions: [ 7 | { 8 | name: 'format', 9 | type: String, 10 | aliases: [ 'f' ], 11 | default: 'YYYY.MM.DD', 12 | description: "the format used to generate the tag", 13 | validInConfig: true, 14 | }, 15 | { 16 | name: 'timezone', 17 | type: String, 18 | aliases: [ 'z' ], 19 | default: 'UTC', 20 | description: "the timezone to consider the current date in", 21 | validInConfig: true, 22 | }, 23 | ], 24 | 25 | getNextTag: function dateStrategyNextTag(project, tags, options) { 26 | var format = options.format || 'YYYY.MM.DD'; 27 | var timezone = options.timezone || 'UTC'; 28 | var now = dateStrategy.getCurrentDate(); 29 | var tagName = moment(now).tz(timezone).format(format); 30 | var patch = 0; 31 | 32 | while (true) { 33 | if (tags.indexOf(appendPatch(tagName, patch)) === -1) { break; } 34 | patch++; 35 | } 36 | 37 | return appendPatch(tagName, patch); 38 | }, 39 | 40 | // Expose for testing :( 41 | getCurrentDate: function() { 42 | return Date.now(); 43 | } 44 | }; 45 | 46 | function appendPatch(tag, patch) { 47 | return patch ? tag + '.' + patch : tag; 48 | } 49 | 50 | module.exports = dateStrategy; 51 | -------------------------------------------------------------------------------- /lib/strategies/semver.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | var semver = require('semver'); 4 | var tagPrefix = require('../utils/tag-prefix'); 5 | 6 | var initialVersion = '0.1.0'; 7 | 8 | var semverStrategy = { 9 | availableOptions: [ 10 | { 11 | name: 'major', 12 | type: Boolean, 13 | aliases: [ 'j' ], 14 | description: "specifies that the major version number should be incremented" 15 | }, 16 | { 17 | name: 'minor', 18 | type: Boolean, 19 | aliases: [ 'i' ], 20 | description: "specifies that the minor version number should be incremented, ignored if '--major' option is true" 21 | }, 22 | { 23 | name: 'premajor ', 24 | type: String, 25 | description: "specifies that the major version number should be incremented with the given pre-release identifier added" 26 | }, 27 | { 28 | name: 'preminor', 29 | type: String, 30 | description: "specifies that the minor version number should be incremented with the given pre-release identifier added" 31 | }, 32 | { 33 | name: 'prerelease', 34 | type: [ String, Boolean ], 35 | aliases: [ 'e' ], 36 | description: "specifies that the named pre-release version number should be incremented" 37 | }, 38 | ], 39 | 40 | getLatestTag: function semverStrategyLatestTag(project, tags) { 41 | var versions = tags 42 | .map(function(tagName) { 43 | return tagPrefix.strip(tagName); 44 | }) 45 | .filter(function(tagName) { 46 | return semver.valid(tagName); 47 | }) 48 | .sort(semver.compare) 49 | .reverse(); 50 | 51 | var latestVersion = versions[0]; 52 | var hasPrefix = tags.indexOf(tagPrefix.prepend(latestVersion)) !== -1; 53 | 54 | // If tags use a prefix, prepend it to the tag 55 | return hasPrefix ? tagPrefix.prepend(latestVersion): latestVersion; 56 | }, 57 | 58 | getNextTag: function semverStrategyNextTag(project, tags, options) { 59 | var latestVersion, nextVersion, hasPrefix, releaseType, preId; 60 | var latestTag = semverStrategy.getLatestTag(project, tags); 61 | 62 | if (tags.length && !latestTag) { 63 | throw "The repository has no tags that are SemVer compliant, you must specify a tag name with the --tag option."; 64 | } 65 | 66 | if (latestTag) { 67 | if (options.major) { 68 | releaseType = 'major'; 69 | } else if (options.minor) { 70 | releaseType = 'minor'; 71 | } else if (options.premajor != null) { 72 | releaseType = 'premajor'; 73 | preId = options.premajor; 74 | } else if (options.preminor != null) { 75 | releaseType = 'preminor'; 76 | preId = options.preminor; 77 | } else if (options.prerelease) { 78 | releaseType = 'prerelease'; 79 | // Option parsing doesn't distinguish between string/boolean when no value is given 80 | preId = options.prerelease !== 'true' ? options.prerelease : undefined; 81 | } else { 82 | releaseType = 'patch'; 83 | } 84 | 85 | latestVersion = tagPrefix.strip(latestTag); 86 | 87 | if (!preId && releaseType.indexOf('pre') === 0 && !isPrerelease(latestVersion)) { 88 | throw "A prerelese identifier must be specified when using the --" + releaseType + " option"; 89 | } 90 | 91 | nextVersion = semver.inc(latestVersion, releaseType, preId); 92 | hasPrefix = tags.indexOf(tagPrefix.prepend(latestVersion)) !== -1; 93 | } else { 94 | nextVersion = initialVersion; 95 | hasPrefix = true; 96 | } 97 | 98 | // If tags use a prefix, prepend it to the tag 99 | return hasPrefix ? tagPrefix.prepend(nextVersion): nextVersion; 100 | } 101 | }; 102 | 103 | function isPrerelease(version) { 104 | var parsed = semver.parse(version); 105 | return parsed && parsed.prerelease && parsed.prerelease.length; 106 | } 107 | 108 | module.exports = semverStrategy; 109 | -------------------------------------------------------------------------------- /lib/utils/find-by.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | module.exports = function findBy(array, key, value) { 4 | for (var i = 0, l = array.length; i < l; i++) { 5 | if (array[i] && array[i][key] === value) { 6 | return array[i]; 7 | } 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /lib/utils/git.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | 'use strict'; 4 | 5 | var Repo = require('git-tools'); 6 | var RSVP = require('rsvp'); 7 | 8 | var RepoPrototype = Repo.prototype; 9 | 10 | /** 11 | * Add methods to the git-tools class, these should eventually be made into a PR 12 | */ 13 | 14 | RepoPrototype.currentTag = function(callback) { 15 | var self = this; 16 | 17 | this.exec("rev-parse", "HEAD", function(error, data) { 18 | if (error) { 19 | return callback(error); 20 | } 21 | 22 | // Look up the tag name using the current SHA 23 | self.exec("name-rev", "--tags", "--name-only", data, function(error, data) { 24 | var tagName = null; 25 | 26 | if (error) { 27 | return callback(error); 28 | } 29 | 30 | // Git outputs 'undefined' if the SHA does not match a tag (not JS related) 31 | if (data !== 'undefined') { 32 | // Annotated tags will be appendded with the distance from the tag using 33 | // ~ notation, in which case sha^0 is distance zero 34 | var match = data.match(/(.*?)(\^0|~\d+)?$/); 35 | 36 | if (!match[2] || match[2] === '^0') { 37 | tagName = match[1]; 38 | } 39 | } 40 | 41 | callback(null, tagName); 42 | }); 43 | }); 44 | }; 45 | 46 | RepoPrototype.createTag = function(tagName, message, callback) { 47 | if (typeof message === 'function') { 48 | callback = message; 49 | message = null; 50 | } 51 | 52 | // Create an annotated tag if a message is supplied 53 | if (message) { 54 | this.exec("tag", "--annotate", "--message", message, tagName, callback); 55 | } else { 56 | this.exec("tag", tagName, callback); 57 | } 58 | }; 59 | 60 | RepoPrototype.pushTags = function(remote, callback) { 61 | this.exec("push", remote, "--tags", callback); 62 | }; 63 | 64 | RepoPrototype.push = function(remote, treeish, callback) { 65 | this.exec("push", remote, treeish, callback); 66 | }; 67 | 68 | RepoPrototype.status = function(callback) { 69 | this.exec("status", "--porcelain", callback); 70 | }; 71 | 72 | RepoPrototype.addAll = function(callback) { 73 | this.exec("add", "--all", callback); 74 | }; 75 | 76 | RepoPrototype.commit = function(message, callback) { 77 | this.exec("commit", "--message", message, callback); 78 | }; 79 | 80 | RepoPrototype.commitAll = function(message, callback) { 81 | this.exec("commit", "--all", "--message", message, callback); 82 | }; 83 | 84 | /** 85 | * Create promise-aware wrapper for git-tools class 86 | */ 87 | 88 | function DenodeifiedRepo(path) { 89 | this._repo = new Repo(path); 90 | } 91 | 92 | DenodeifiedRepo.toString = function() { 93 | return 'DenodeifiedRepo'; 94 | }; 95 | 96 | var DenodeifiedRepoPrototype = DenodeifiedRepo.prototype = Object.create(null); 97 | 98 | DenodeifiedRepoPrototype.constructor = DenodeifiedRepo; 99 | 100 | // Cherry-pick only the methods needed, since some are only used internally (e.g. `exec`) 101 | var repoMethods = [ 102 | 'tags', 103 | 'status', 104 | 'currentBranch', 105 | 'currentTag', 106 | 'createTag', 107 | 'commitAll', 108 | 'push' 109 | ]; 110 | 111 | repoMethods.forEach(function(methodName) { 112 | var func = RSVP.denodeify(RepoPrototype[methodName]); 113 | 114 | DenodeifiedRepoPrototype[methodName] = function denodeifiedMethod() { 115 | return func.apply(this._repo, arguments); 116 | }; 117 | }); 118 | 119 | module.exports = DenodeifiedRepo; 120 | -------------------------------------------------------------------------------- /lib/utils/npm.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | var npm = require('npm'); 4 | var RSVP = require('rsvp'); 5 | var slice = require('./slice'); 6 | 7 | var resolve = RSVP.resolve; 8 | var denodeify = RSVP.denodeify; 9 | var load = denodeify(npm.load); 10 | 11 | // Promise friendly wrapper around the singleton `npm` module. Does not attempt 12 | // to maintain same API (commands are top level). 13 | function DenodeifiedNPM(options) { 14 | this.options = options; 15 | } 16 | 17 | DenodeifiedNPM.toString = function() { 18 | return 'DenodeifiedNPM'; 19 | }; 20 | 21 | var DenodeifiedNPMPrototype = DenodeifiedNPM.prototype = Object.create(null); 22 | 23 | DenodeifiedNPMPrototype.constructor = DenodeifiedNPM; 24 | 25 | // No sense in wrapping every single method (including aliases) 26 | var commandNames = [ 27 | 'whoami', 28 | 'publish', 29 | ]; 30 | 31 | // Properties in `npm.commands` throw when they are accessed before `npm.load` is called 32 | commandNames.forEach(function(commandName) { 33 | DenodeifiedNPMPrototype[commandName] = function() { 34 | var args = slice(arguments); 35 | var promise = this.config ? resolve() : load(this.options); 36 | 37 | return promise.then(function() { 38 | var fn = denodeify(npm.commands[commandName]); 39 | 40 | return fn.apply(npm.commands, args); 41 | }); 42 | }; 43 | }); 44 | 45 | // The `config` property changes when `npm.load` is called, make sure it's always up to date 46 | Object.defineProperty(DenodeifiedNPMPrototype, 'config', { 47 | get: function() { 48 | return npm.config.loaded ? npm.config : null; 49 | }, 50 | enumerable: true, 51 | }); 52 | 53 | module.exports = DenodeifiedNPM; 54 | -------------------------------------------------------------------------------- /lib/utils/slice.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | module.exports = Function.prototype.call.bind(Array.prototype.slice); 4 | -------------------------------------------------------------------------------- /lib/utils/tag-prefix.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | var defaultPrefix = 'v'; 4 | 5 | module.exports = { 6 | char: defaultPrefix, 7 | 8 | has: function(tag, prefix) { 9 | return tag[0] === (prefix || defaultPrefix); 10 | }, 11 | 12 | strip: function(tag, prefix) { 13 | return tag.replace(new RegExp('^' + (prefix || defaultPrefix)), ''); 14 | }, 15 | 16 | prepend: function(tag, prefix) { 17 | return (prefix || defaultPrefix) + tag; 18 | } 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /lib/utils/with-args.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | var slice = require('./slice'); 4 | 5 | module.exports = function withArgs(fn /* ...args */) { 6 | var args = slice(arguments, 1); 7 | 8 | return function invoker() { 9 | return fn.apply(this, args); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-cli-release", 3 | "version": "1.0.0-beta.2", 4 | "description": "Ember CLI addon for managing release versions.", 5 | "directories": { 6 | "test": "tests" 7 | }, 8 | "scripts": { 9 | "test": "node tests/runner.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/lytics/ember-cli-release.git" 14 | }, 15 | "engines": { 16 | "node": ">= 6" 17 | }, 18 | "author": "Steven Lindberg ", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/lytics/ember-cli-release/issues" 22 | }, 23 | "dependencies": { 24 | "chalk": "^2.4.1", 25 | "git-tools": "^0.3.0", 26 | "make-array": "^1.0.3", 27 | "merge": "^1.2.0", 28 | "moment-timezone": "^0.5.17", 29 | "nopt": "^4.0.1", 30 | "npm": "^6.1.0", 31 | "require-dir": "^1.0.0", 32 | "rsvp": "^4.8.2", 33 | "semver": "^5.5.0", 34 | "silent-error": "^1.1.0" 35 | }, 36 | "devDependencies": { 37 | "chai": "^4.1.2", 38 | "ember-cli": "~2.3.0", 39 | "ember-cli-dependency-checker": "^2.1.1", 40 | "fs-extra": "^6.0.1", 41 | "glob": "^7.1.2", 42 | "mocha": "^5.2.0", 43 | "rimraf": "^2.6.2" 44 | }, 45 | "keywords": [ 46 | "ember-addon", 47 | "ember-cli", 48 | "release", 49 | "git", 50 | "tag" 51 | ], 52 | "ember-addon": { 53 | "configPath": "tests/dummy/config" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": [ 3 | "document", 4 | "window", 5 | "location", 6 | "setTimeout", 7 | "$", 8 | "-Promise", 9 | "define", 10 | "console", 11 | "visit", 12 | "exists", 13 | "fillIn", 14 | "click", 15 | "keyEvent", 16 | "triggerEvent", 17 | "find", 18 | "findWithAssert", 19 | "wait", 20 | "DS", 21 | "andThen", 22 | "currentURL", 23 | "currentPath", 24 | "currentRouteName", 25 | "describe", 26 | "it", 27 | "before", 28 | "beforeEach", 29 | "after", 30 | "afterEach" 31 | ], 32 | "node": false, 33 | "browser": false, 34 | "boss": true, 35 | "curly": false, 36 | "debug": false, 37 | "devel": false, 38 | "eqeqeq": true, 39 | "evil": true, 40 | "expr": true, 41 | "forin": false, 42 | "immed": false, 43 | "laxbreak": false, 44 | "newcap": true, 45 | "noarg": true, 46 | "noempty": false, 47 | "nonew": false, 48 | "nomen": false, 49 | "onevar": false, 50 | "plusplus": false, 51 | "regexp": false, 52 | "undef": true, 53 | "sub": true, 54 | "strict": false, 55 | "white": false, 56 | "eqnull": true, 57 | "esnext": true 58 | } 59 | -------------------------------------------------------------------------------- /tests/acceptance/commands/release-nodetest.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | 'use strict'; 4 | 5 | var expect = require('chai').expect; 6 | var fs = require('fs-extra'); 7 | var path = require('path'); 8 | var merge = require('merge'); 9 | var rimraf = require('rimraf'); 10 | var MockUI = require('ember-cli/tests/helpers/mock-ui'); 11 | var MockAnalytics = require('ember-cli/tests/helpers/mock-analytics'); 12 | var Command = require('ember-cli/lib/models/command'); 13 | var ReleaseCommand = require('../../../lib/commands/release'); 14 | var GitRepo = require('../../../lib/utils/git'); 15 | var NPM = require('../../../lib/utils/npm'); 16 | var Mock = require('../../helpers/mock'); 17 | var EOL = require('os').EOL; 18 | var RSVP = require('rsvp'); 19 | 20 | var rootPath = process.cwd(); 21 | var fixturePath = path.join(rootPath, 'tests/fixtures'); 22 | 23 | function copyFixture(name) { 24 | fs.copySync(path.join(fixturePath, name), '.'); 25 | } 26 | 27 | describe("release command", function() { 28 | var ui, analytics, project, repo, npm; 29 | 30 | function fileExists(filePath) { 31 | return fs.existsSync(path.join(project.root, filePath)); 32 | } 33 | 34 | function fileContents(filePath) { 35 | return fs.readFileSync(path.join(project.root, filePath), { encoding: 'utf8' }); 36 | } 37 | 38 | beforeEach(function() { 39 | ui = new MockUI(); 40 | analytics = new MockAnalytics(); 41 | repo = new Mock(GitRepo); 42 | npm = new Mock(NPM); 43 | 44 | rimraf.sync('tmp'); 45 | fs.mkdirSync('tmp'); 46 | process.chdir('tmp'); 47 | 48 | // Our tests copy config fixtures around, so we need to ensure 49 | // each test gets the current config/release.js result 50 | var configPath = path.resolve('config/release.js'); 51 | if (require.cache[configPath]) { 52 | delete require.cache[configPath]; 53 | } 54 | 55 | project = { 56 | root: path.resolve('.'), 57 | require: function(module) { 58 | if (module === 'ember-cli/lib/errors/silent') { 59 | return Error; 60 | } else { 61 | throw new Error('Module not found (fake implementation)'); 62 | } 63 | }, 64 | hasDependencies: function () { 65 | return true; 66 | }, 67 | isEmberCLIProject: function(){ 68 | return true; 69 | } 70 | }; 71 | }); 72 | 73 | afterEach(function() { 74 | process.chdir(rootPath); 75 | }); 76 | 77 | function makeResponder(value) { 78 | return function() { 79 | return value; 80 | }; 81 | } 82 | 83 | function createCommand(options) { 84 | options || (options = {}); 85 | 86 | merge(options, { 87 | ui: ui, 88 | analytics: analytics, 89 | project: project, 90 | environment: {}, 91 | settings: {}, 92 | git: function() { 93 | return repo; 94 | }, 95 | npm: function() { 96 | return npm; 97 | }, 98 | }); 99 | 100 | var TestReleaseCommand = Command.extend(ReleaseCommand); 101 | 102 | return new TestReleaseCommand(options); 103 | } 104 | 105 | describe("when HEAD is at a tag", function() { 106 | it("should exit immediately if HEAD is at a tag", function() { 107 | var cmd = createCommand(); 108 | 109 | repo.respondTo('currentTag', makeResponder('v1.3.0')); 110 | 111 | return cmd.validateAndRun().catch(function(error) { 112 | expect(error.message).to.equals('Skipped tagging, HEAD already at tag: v1.3.0'); 113 | }); 114 | }); 115 | }); 116 | 117 | describe("when HEAD is not at a tag", function() { 118 | describe("when working copy has modifications", function() { 119 | beforeEach(function() { 120 | repo.respondTo('currentTag', makeResponder(null)); 121 | }); 122 | 123 | it("should warn of local changes and allow aborting", function() { 124 | var cmd = createCommand(); 125 | 126 | ui.waitForPrompt().then(function() { 127 | ui.inputStream.write('n' + EOL); 128 | }); 129 | 130 | repo.respondTo('tags', makeResponder([])); 131 | repo.respondTo('status', makeResponder(' M app/foo.js')); 132 | 133 | return cmd.validateAndRun([ '--local' ]).then(function() { 134 | expect(ui.output).to.contain("Your working tree contains modifications that will be added to the release commit, proceed?"); 135 | }).catch(function(error) { 136 | expect(error.message).to.equals("Aborted."); 137 | }); 138 | }); 139 | 140 | it("should not warn or commit if only untracked files are present", function() { 141 | var cmd = createCommand(); 142 | 143 | repo.respondTo('tags', makeResponder([])); 144 | repo.respondTo('status', makeResponder('?? not-in-repo.txt')); 145 | repo.respondTo('status', makeResponder('?? not-in-repo.txt')); 146 | repo.respondTo('createTag', makeResponder(null)); 147 | 148 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 149 | expect(ui.output).to.not.contain("Your working tree contains modifications that will be added to the release commit, proceed?"); 150 | }); 151 | }); 152 | }); 153 | 154 | describe("when working copy has no modifications", function() { 155 | beforeEach(function() { 156 | repo.respondTo('currentTag', makeResponder(null)); 157 | }); 158 | 159 | describe("when repo has no existing tags", function() { 160 | var defaultTag = 'v0.1.0'; 161 | 162 | beforeEach(function() { 163 | repo.respondTo('tags', makeResponder([])); 164 | repo.respondTo('status', makeResponder('')); 165 | repo.respondTo('status', makeResponder('')); 166 | }); 167 | 168 | it("should create a default semver tag", function() { 169 | var createdTagName; 170 | var cmd = createCommand(); 171 | 172 | ui.waitForPrompt().then(function() { 173 | ui.inputStream.write('y' + EOL); 174 | }); 175 | 176 | repo.respondTo('createTag', function(tagName, message) { 177 | createdTagName = tagName; 178 | 179 | return null; 180 | }); 181 | 182 | return cmd.validateAndRun([ '--local' ]).then(function() { 183 | expect(createdTagName).to.equal(defaultTag); 184 | expect(ui.output).to.contain("Successfully created git tag '" + defaultTag + "' locally."); 185 | }); 186 | }); 187 | }); 188 | 189 | describe("when repo has existing tags", function() { 190 | var nextTag = 'v1.0.2'; 191 | var tags = [ 192 | { 193 | name: 'v1.0.0', 194 | sha: '7d1743e11a45f3863af1942b310412cbcd753271', 195 | date: new Date(Date.UTC(2013, 1, 15, 14, 35, 10)) 196 | }, 197 | { 198 | name: 'v1.0.1', 199 | sha: '0ace3a0a3a2c36acd44fc3acb2b0d57fde2faf6c', 200 | date: new Date(Date.UTC(2013, 2, 3, 4, 2, 33)) 201 | } 202 | ]; 203 | 204 | beforeEach(function() { 205 | repo.respondTo('tags', makeResponder(tags)); 206 | repo.respondTo('status', makeResponder('')); 207 | }); 208 | 209 | describe("when working copy is not changed", function() { 210 | beforeEach(function() { 211 | repo.respondTo('status', makeResponder('')); 212 | }); 213 | 214 | describe("when an invalid semver tag exists", function() { 215 | var invalidTag = { 216 | name: 'v6.0.990.1', 217 | sha: '7a6d7bb7e5beb1bce2923dd6aea82f8a8e77438b', 218 | date: new Date(Date.UTC(2013, 1, 15, 15, 0, 0)) 219 | }; 220 | 221 | beforeEach(function() { 222 | tags.splice(1, 0, invalidTag); 223 | repo.respondTo('tags', makeResponder(tags)); 224 | }); 225 | 226 | it("should ignore invalid tags", function() { 227 | var cmd = createCommand(); 228 | 229 | repo.respondTo('createTag', makeResponder(null)); 230 | 231 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 232 | expect(ui.output).to.contain("Latest tag: " + tags[tags.length - 1].name); 233 | }); 234 | }); 235 | 236 | afterEach(function() { 237 | tags.splice(1, 1); 238 | }); 239 | }); 240 | 241 | it("should confirm tag creation and allow aborting", function() { 242 | var cmd = createCommand(); 243 | 244 | ui.waitForPrompt().then(function() { 245 | ui.inputStream.write('n' + EOL); 246 | }); 247 | 248 | return cmd.validateAndRun([ '--local' ]).then(function() { 249 | expect(ui.output).to.contain("About to create tag '" + nextTag + "', proceed?"); 250 | }).catch(function(error) { 251 | expect(error.message).to.equals("Aborted."); 252 | }); 253 | }); 254 | 255 | it("should skip confirmation prompts when --yes option is set", function() { 256 | var cmd = createCommand(); 257 | 258 | repo.respondTo('createTag', makeResponder(null)); 259 | 260 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 261 | expect(ui.output).to.contain("Successfully created git tag '" + nextTag + "' locally."); 262 | }); 263 | }); 264 | 265 | it("should print the latest tag if returned by versioning strategy", function() { 266 | var cmd = createCommand(); 267 | 268 | repo.respondTo('createTag', makeResponder(null)); 269 | 270 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 271 | expect(ui.output).to.contain("Latest tag: " + tags[tags.length - 1].name); 272 | }); 273 | }); 274 | 275 | it("should replace the 'version' property in package.json and bower.json", function() { 276 | copyFixture('project-with-no-config'); 277 | var cmd = createCommand(); 278 | 279 | repo.respondTo('createTag', makeResponder(null)); 280 | 281 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 282 | var pkg = JSON.parse(fs.readFileSync('./package.json')); 283 | var bower = JSON.parse(fs.readFileSync('./bower.json')); 284 | 285 | var rawVersion = nextTag.replace(/^v/, ''); 286 | 287 | expect(pkg.version).to.equal(rawVersion); 288 | expect(bower.version).to.equal(rawVersion); 289 | }); 290 | }); 291 | 292 | it("should replace the 'version' property in the files specified by the 'manifest' option", function() { 293 | copyFixture('project-with-different-manifests'); 294 | var cmd = createCommand(); 295 | 296 | repo.respondTo('createTag', makeResponder(null)); 297 | 298 | return cmd.validateAndRun([ '--local', '--yes', '--manifest=foo.json', '--manifest=bar.json' ]).then(function() { 299 | var foo = JSON.parse(fs.readFileSync('./foo.json')); 300 | var bar = JSON.parse(fs.readFileSync('./bar.json')); 301 | 302 | var rawVersion = nextTag.replace(/^v/, ''); 303 | 304 | expect(foo.version).to.equal(rawVersion); 305 | expect(bar.version).to.equal(rawVersion); 306 | }); 307 | }); 308 | 309 | it("should not add a 'version' property in package.json and bower.json if it doesn't exsist", function() { 310 | copyFixture('project-with-no-versions'); 311 | var cmd = createCommand(); 312 | 313 | repo.respondTo('createTag', makeResponder(null)); 314 | 315 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 316 | var pkg = JSON.parse(fs.readFileSync('./package.json')); 317 | var bower = JSON.parse(fs.readFileSync('./bower.json')); 318 | 319 | expect(pkg.version).to.be.undefined; 320 | expect(bower.version).to.be.undefined; 321 | }); 322 | }); 323 | 324 | it("should ensure package.json is normalized with a trailing newline", function() { 325 | copyFixture('project-with-config'); 326 | var cmd = createCommand(); 327 | 328 | repo.respondTo('createTag', makeResponder(null)); 329 | 330 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 331 | var pkgSource = fs.readFileSync('./package.json', { encoding: 'utf8' }); 332 | 333 | expect(pkgSource[pkgSource.length - 2]).to.equal('}'); 334 | expect(pkgSource[pkgSource.length - 1]).to.equal('\n'); 335 | }); 336 | }); 337 | 338 | it("should use the tag name specified by the --tag option", function() { 339 | var createdTagName, createdTagMessage; 340 | var cmd = createCommand(); 341 | 342 | ui.waitForPrompt().then(function() { 343 | ui.inputStream.write('y' + EOL); 344 | }); 345 | 346 | repo.respondTo('createTag', function(tagName, message) { 347 | createdTagName = tagName; 348 | createdTagMessage = message; 349 | 350 | return null; 351 | }); 352 | 353 | return cmd.validateAndRun([ '--tag', 'foo', '--local' ]).then(function() { 354 | expect(createdTagName).to.equal('foo'); 355 | expect(createdTagMessage).to.be.null; 356 | expect(ui.output).to.contain("Successfully created git tag '" + createdTagName + "' locally."); 357 | }); 358 | }); 359 | 360 | it("should use the message specified by the --annotation option", function() { 361 | var createdTagName, createdTagMessage; 362 | var cmd = createCommand(); 363 | 364 | ui.waitForPrompt().then(function() { 365 | ui.inputStream.write('y' + EOL); 366 | }); 367 | 368 | repo.respondTo('createTag', function(tagName, message) { 369 | createdTagName = tagName; 370 | createdTagMessage = message; 371 | 372 | return null; 373 | }); 374 | 375 | return cmd.validateAndRun([ '--annotation', 'Tag %@', '--local' ]).then(function() { 376 | expect(createdTagName).to.equal(nextTag); 377 | expect(createdTagMessage ).to.equal('Tag ' + nextTag); 378 | expect(ui.output).to.contain("Successfully created git tag '" + createdTagName + "' locally."); 379 | }); 380 | }); 381 | 382 | it("should use the strategy specified by the --strategy option, passing tags and options", function() { 383 | var tagNames = tags.map(function(tag) { return tag.name; }); 384 | var createdTagName, strategyTags, strategyOptions; 385 | 386 | var cmd = createCommand({ 387 | strategies: function() { 388 | return { 389 | foo: { 390 | availableOptions: [ 391 | { 392 | name: 'bar', 393 | type: Boolean, 394 | }, 395 | { 396 | name: 'baz', 397 | type: String, 398 | }, 399 | ], 400 | getNextTag: function(project, tags, options) { 401 | strategyTags = tags; 402 | strategyOptions = options; 403 | 404 | return 'foo'; 405 | } 406 | } 407 | }; 408 | } 409 | }); 410 | 411 | ui.waitForPrompt().then(function() { 412 | ui.inputStream.write('y' + EOL); 413 | }); 414 | 415 | repo.respondTo('createTag', function(tagName) { 416 | createdTagName = tagName; 417 | 418 | return null; 419 | }); 420 | 421 | return cmd.validateAndRun([ '--strategy', 'foo', '--local', '--bar', '--baz', 'quux' ]).then(function() { 422 | expect(createdTagName).to.equal('foo'); 423 | expect(strategyTags).to.deep.equal(tagNames); 424 | expect(strategyOptions.bar).to.be.true; 425 | expect(strategyOptions.baz).to.equal('quux'); 426 | expect(ui.output).to.contain("Successfully created git tag '" + createdTagName + "' locally."); 427 | }); 428 | }); 429 | 430 | it("should push tags to the remote specified by the --remote option if the --local option is false", function() { 431 | var pushRemote, tagName; 432 | var cmd = createCommand(); 433 | 434 | ui.waitForPrompt().then(function() { 435 | ui.inputStream.write('y' + EOL); 436 | }); 437 | 438 | repo.respondTo('createTag', makeResponder(null)); 439 | 440 | repo.respondTo('push', function(remote, tag) { 441 | pushRemote = remote; 442 | tagName = tag; 443 | 444 | return null; 445 | }); 446 | 447 | return cmd.validateAndRun([ '--remote', 'foo' ]).then(function() { 448 | expect(pushRemote).to.equal('foo'); 449 | expect(tagName).to.equal(nextTag); 450 | expect(ui.output).to.contain("About to create tag '" + nextTag + "' and push to remote '" + pushRemote + "', proceed?"); 451 | expect(ui.output).to.contain("Successfully created git tag '" + nextTag + "' locally."); 452 | expect(ui.output).to.contain("Successfully pushed '" + nextTag + "' to remote '" + pushRemote + "'."); 453 | }); 454 | }); 455 | }); 456 | 457 | describe("when publishing", function() { 458 | it("should abort if --local option is set", function() { 459 | var cmd = createCommand(); 460 | 461 | return cmd.validateAndRun([ '--publish', '--local' ]).catch(function(error) { 462 | expect(error.message).to.equal("The --publish and --local options are incompatible."); 463 | }); 464 | }); 465 | 466 | it("should abort if --strategy option is not 'semver'", function() { 467 | var cmd = createCommand(); 468 | 469 | return cmd.validateAndRun([ '--publish', '--strategy', 'date' ]).catch(function(error) { 470 | expect(error.message).to.equal("Publishing to NPM requires SemVer."); 471 | }); 472 | }); 473 | 474 | it("should abort if NPM user is not logged in", function() { 475 | var cmd = createCommand(); 476 | 477 | npm.respondTo('whoami', function() { 478 | return RSVP.reject({ 479 | code: 'ENEEDAUTH' 480 | }); 481 | }); 482 | 483 | return cmd.validateAndRun([ '--publish' ]).catch(function(error) { 484 | expect(error.message).to.equal("Must be logged in to perform NPM publish."); 485 | }); 486 | }); 487 | 488 | it("should print the NPM registry and user", function() { 489 | var cmd = createCommand(); 490 | var username = 'foo'; 491 | var registry = 'bar'; 492 | 493 | npm.respondTo('whoami', makeResponder(username)); 494 | npm.respondTo('config', function() { 495 | return { 496 | get: function(option) { 497 | return option === 'registry' ? registry : null; 498 | } 499 | }; 500 | }); 501 | repo.respondTo('status', makeResponder('')); 502 | 503 | ui.waitForPrompt().then(function() { 504 | ui.inputStream.write('n' + EOL); 505 | }); 506 | 507 | return cmd.validateAndRun([ '--publish' ]).then(function() { 508 | expect(ui.output).to.equal("Using NPM registry " + registry + " as user '" + username + "'"); 509 | }).catch(function() {}); 510 | }); 511 | 512 | it("should confirm publish and print package name/version", function() { 513 | copyFixture('project-with-no-config'); 514 | var cmd = createCommand(); 515 | var packageName = 'foo'; 516 | var packageVersion = '1.0.2'; 517 | 518 | npm.respondTo('whoami', makeResponder('')); 519 | npm.respondTo('config', function() { 520 | return { 521 | get: function() {} 522 | }; 523 | }); 524 | repo.respondTo('status', makeResponder('')); 525 | repo.respondTo('createTag', makeResponder(null)); 526 | repo.respondTo('commitAll', makeResponder(null)); 527 | repo.respondTo('push', makeResponder(null)); 528 | 529 | ui.waitForPrompt().then(function() { 530 | ui.inputStream.write('y' + EOL); 531 | 532 | return ui.waitForPrompt(); 533 | }).then(function() { 534 | ui.inputStream.write('n' + EOL); 535 | }); 536 | 537 | return cmd.validateAndRun([ '--publish' ]).catch(function(error) { 538 | expect(ui.output).to.contain("About to publish " + packageName + "@" + packageVersion + ", proceed?"); 539 | expect(error.message).to.equal("Aborted."); 540 | }); 541 | }); 542 | 543 | it("should publish to NPM using package.json at the project root", function() { 544 | copyFixture('project-with-no-config'); 545 | var cmd = createCommand(); 546 | var publishCalled = false; 547 | 548 | npm.respondTo('whoami', makeResponder('')); 549 | npm.respondTo('config', function() { 550 | return { 551 | get: function() {} 552 | }; 553 | }); 554 | repo.respondTo('status', makeResponder('')); 555 | repo.respondTo('createTag', makeResponder(null)); 556 | repo.respondTo('commitAll', makeResponder(null)); 557 | repo.respondTo('push', makeResponder(null)); 558 | npm.respondTo('publish', function(args) { 559 | publishCalled = true; 560 | expect(args[0]).to.equal(project.root); 561 | }); 562 | 563 | return cmd.validateAndRun([ '--publish', '--yes' ]).then(function() { 564 | expect(publishCalled).to.be.true; 565 | expect(ui.output).to.contain("Publish successful."); 566 | }); 567 | }); 568 | 569 | it("should publish if specified in config.js", function() { 570 | copyFixture('project-with-publish-config'); 571 | var cmd = createCommand(); 572 | var publishCalled = false; 573 | 574 | npm.respondTo('whoami', makeResponder('')); 575 | npm.respondTo('config', function() { 576 | return { 577 | get: function() {} 578 | }; 579 | }); 580 | repo.respondTo('status', makeResponder('')); 581 | repo.respondTo('createTag', makeResponder(null)); 582 | repo.respondTo('commitAll', makeResponder(null)); 583 | repo.respondTo('push', makeResponder(null)); 584 | npm.respondTo('publish', function(args) { 585 | publishCalled = true; 586 | expect(args[0]).to.equal(project.root); 587 | }); 588 | 589 | return cmd.validateAndRun([ '--yes' ]).then(function() { 590 | expect(publishCalled).to.be.true; 591 | expect(ui.output).to.contain("Publish successful."); 592 | }); 593 | }); 594 | 595 | it("should print the error if NPM publish failed", function() { 596 | copyFixture('project-with-no-config'); 597 | var cmd = createCommand(); 598 | 599 | npm.respondTo('whoami', makeResponder('')); 600 | npm.respondTo('config', function() { 601 | return { 602 | get: function() {} 603 | }; 604 | }); 605 | repo.respondTo('status', makeResponder('')); 606 | repo.respondTo('createTag', makeResponder(null)); 607 | repo.respondTo('commitAll', makeResponder(null)); 608 | repo.respondTo('push', makeResponder(null)); 609 | npm.respondTo('publish', function() { 610 | return RSVP.reject(new Error('nope')); 611 | }); 612 | 613 | return cmd.validateAndRun([ '--publish', '--yes' ]).catch(function(error) { 614 | expect(error.message).to.equal("nope"); 615 | }); 616 | }); 617 | }); 618 | 619 | describe("lifecycle hooks", function () { 620 | beforeEach(function() { 621 | repo.respondTo('currentBranch', makeResponder('master')); 622 | }); 623 | 624 | it("should print a warning about non-function hooks", function() { 625 | copyFixture('project-with-bad-config'); 626 | var cmd = createCommand(); 627 | 628 | repo.respondTo('status', makeResponder(' M package.json')); 629 | repo.respondTo('createTag', makeResponder(null)); 630 | repo.respondTo('commitAll', makeResponder(null)); 631 | 632 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 633 | expect(ui.output).to.contain("Warning: `beforeCommit` is not a function"); 634 | }); 635 | }); 636 | 637 | it("should execute hooks in the correct order", function () { 638 | copyFixture('project-with-hooks-config'); 639 | var cmd = createCommand(); 640 | var assertionCount = 0; 641 | 642 | expect(fileExists('init.txt'), 'init not called yet').to.be.false; 643 | 644 | repo.respondTo('status', function() { 645 | expect(fileExists('init.txt'), 'init called').to.be.true; 646 | assertionCount++; 647 | return ' M package.json'; 648 | }); 649 | repo.respondTo('commitAll', function() { 650 | expect(fileExists('before-commit.txt'), 'beforeCommit called').to.be.true; 651 | assertionCount++; 652 | }); 653 | repo.respondTo('createTag', makeResponder(null)); 654 | repo.respondTo('push', function() { 655 | expect(fileExists('after-push.txt'), 'afterPush not called yet').to.be.false; 656 | assertionCount++; 657 | }); 658 | repo.respondTo('push', makeResponder(null)); 659 | 660 | return cmd.validateAndRun([ '--yes' ]).then(function() { 661 | expect(fileExists('after-push.txt'), 'afterPush called').to.be.true; 662 | expect(fileExists('after-publish.txt'), 'afterPublish not called').to.be.false; 663 | expect(assertionCount, 'all assertions ran').to.equal(3); 664 | }); 665 | }); 666 | 667 | it("should call `afterPublish` hook when --publish option is set", function () { 668 | copyFixture('project-with-hooks-config'); 669 | var cmd = createCommand(); 670 | var assertionCount = 0; 671 | 672 | npm.respondTo('whoami', makeResponder('')); 673 | npm.respondTo('config', function() { 674 | return { 675 | get: function() {} 676 | }; 677 | }); 678 | repo.respondTo('status', makeResponder('')); 679 | repo.respondTo('createTag', makeResponder(null)); 680 | repo.respondTo('push', makeResponder(null)); 681 | npm.respondTo('publish', function() { 682 | expect(fileExists('after-push.txt'), 'afterPush called').to.be.true; 683 | expect(fileExists('after-publish.txt'), 'afterPublish not called yet').to.be.false; 684 | assertionCount++; 685 | }); 686 | 687 | return cmd.validateAndRun([ '--publish', '--yes' ]).then(function() { 688 | expect(fileExists('after-publish.txt'), 'afterPublish called').to.be.true; 689 | expect(assertionCount, 'all assertions ran').to.equal(1); 690 | }); 691 | }); 692 | 693 | it("should pass the correct values into hooks", function () { 694 | copyFixture('project-with-hooks-config'); 695 | var cmd = createCommand(); 696 | 697 | repo.respondTo('status', makeResponder(' M package.json')); 698 | repo.respondTo('commitAll', makeResponder(null)); 699 | repo.respondTo('createTag', makeResponder(null)); 700 | repo.respondTo('push', makeResponder(null)); 701 | repo.respondTo('push', makeResponder(null)); 702 | 703 | return cmd.validateAndRun([ '--yes' ]).then(function() { 704 | expect(fileContents('init.txt')).to.equal(nextTag); 705 | expect(fileContents('before-commit.txt')).to.equal(nextTag); 706 | expect(fileContents('after-push.txt')).to.equal(nextTag); 707 | }); 708 | }); 709 | 710 | it("should allow aborting directly from hooks", function () { 711 | copyFixture('project-with-aborted-hooks-config'); 712 | var cmd = createCommand(); 713 | 714 | return cmd.validateAndRun([ '--tag', 'immediate' ]).catch(function(error) { 715 | expect(error.message).to.equals('Error encountered in `init` hook: "nope"'); 716 | }); 717 | }); 718 | 719 | it("should allow aborting from promise returned by hooks", function () { 720 | copyFixture('project-with-aborted-hooks-config'); 721 | var cmd = createCommand(); 722 | 723 | return cmd.validateAndRun([ '--tag', 'promise' ]).catch(function(error) { 724 | expect(error.message).to.equals('Error encountered in `init` hook: "nope"'); 725 | }); 726 | }); 727 | }); 728 | 729 | describe("configuration via config/release.js", function () { 730 | beforeEach(function() { 731 | repo.respondTo('status', makeResponder('')); 732 | }); 733 | 734 | it("should print a warning about unknown options", function() { 735 | copyFixture('project-with-bad-config'); 736 | var cmd = createCommand(); 737 | 738 | repo.respondTo('createTag', makeResponder(null)); 739 | 740 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 741 | expect(ui.output).to.contain("Warning: cannot specify option `minor`"); 742 | expect(ui.output).to.contain("Warning: invalid option `foo`"); 743 | }); 744 | }); 745 | 746 | it("should allow flexible option values", function() { 747 | copyFixture('project-with-bad-config'); 748 | var cmd = createCommand(); 749 | 750 | repo.respondTo('createTag', makeResponder(null)); 751 | 752 | // This tests that the `manifest` option can be specified as a single string 753 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 754 | var foo = JSON.parse(fs.readFileSync('./foo.json')); 755 | 756 | var rawVersion = nextTag.replace(/^v/, ''); 757 | expect(foo.version).to.equal(rawVersion); 758 | }); 759 | }); 760 | 761 | it("should use the strategy specified by the config file", function() { 762 | var createdTagName; 763 | 764 | copyFixture('project-with-config'); 765 | var cmd = createCommand(); 766 | 767 | repo.respondTo('createTag', function(tagName) { 768 | createdTagName = tagName; 769 | return null; 770 | }); 771 | 772 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 773 | expect(createdTagName).to.match(/\d{4}\.\d{2}\.\d{2}/); 774 | expect(ui.output).to.contain("Successfully created git tag '" + createdTagName + "' locally."); 775 | }); 776 | }); 777 | 778 | it("should use the strategy specified on the command line over one in the config file", function() { 779 | var createdTagName; 780 | 781 | copyFixture('project-with-config'); 782 | var cmd = createCommand(); 783 | 784 | repo.respondTo('createTag', function(tagName) { 785 | createdTagName = tagName; 786 | return null; 787 | }); 788 | 789 | return cmd.validateAndRun([ '--strategy', 'semver', '--local', '--yes' ]).then(function() { 790 | expect(createdTagName).to.equal(nextTag); 791 | expect(ui.output).to.contain("Successfully created git tag '" + createdTagName + "' locally."); 792 | }); 793 | }); 794 | 795 | it("should use the strategy defined in the config file", function() { 796 | var tagNames = tags.map(function(tag) { return tag.name; }); 797 | var tagName = 'foo'; 798 | 799 | copyFixture('project-with-strategy-config'); 800 | var cmd = createCommand(); 801 | 802 | repo.respondTo('createTag', makeResponder(null)); 803 | 804 | return cmd.validateAndRun([ '--local', '--yes' ]).then(function() { 805 | expect(JSON.parse(fileContents('tags.json'))).to.deep.equal(tagNames); 806 | expect(ui.output).to.contain("Successfully created git tag '" + tagName + "' locally."); 807 | }); 808 | }); 809 | 810 | it("should use the strategy and options defined in the config file", function() { 811 | var tagName = 'foo'; 812 | 813 | copyFixture('project-with-options-strategy-config'); 814 | var cmd = createCommand(); 815 | 816 | repo.respondTo('createTag', makeResponder(null)); 817 | 818 | return cmd.validateAndRun([ '--local', '--yes', '--foo', 'bar' ]).then(function() { 819 | expect(JSON.parse(fileContents('options.json'))).to.have.property('foo', 'bar'); 820 | expect(ui.output).to.contain("Successfully created git tag '" + tagName + "' locally."); 821 | }); 822 | }); 823 | 824 | it("should abort if the strategy defined in the config file does not return a valid value", function() { 825 | var tagNames = tags.map(function(tag) { return tag.name; }); 826 | var tagName = 'foo'; 827 | 828 | copyFixture('project-with-bad-strategy-config'); 829 | var cmd = createCommand(); 830 | 831 | repo.respondTo('createTag', makeResponder(null)); 832 | 833 | return cmd.validateAndRun([ '--local', '--yes' ]).catch(function(error) { 834 | expect(error.message).to.equal("Tagging strategy must return a non-empty tag name"); 835 | }); 836 | }); 837 | }); 838 | 839 | describe("when working copy is changed", function() { 840 | beforeEach(function() { 841 | repo.respondTo('status', makeResponder('M package.json')); 842 | }); 843 | 844 | describe("when repo is in detached HEAD state", function() { 845 | beforeEach(function() { 846 | repo.respondTo('currentBranch', makeResponder(null)); 847 | }); 848 | 849 | it("should abort with an informative message", function() { 850 | var cmd = createCommand(); 851 | 852 | return cmd.validateAndRun([]).catch(function(error) { 853 | expect(error.message).to.equals("Must have a branch checked out to commit to"); 854 | }); 855 | }); 856 | }); 857 | 858 | describe("when a branch is currently checked out", function() { 859 | beforeEach(function() { 860 | repo.respondTo('currentBranch', makeResponder('master')); 861 | }); 862 | 863 | it("should create a new commit with the correct message name", function() { 864 | var commitMessage; 865 | var cmd = createCommand(); 866 | 867 | repo.respondTo('commitAll', function(message) { 868 | commitMessage = message; 869 | 870 | return null; 871 | }); 872 | 873 | repo.respondTo('createTag', makeResponder(null)); 874 | 875 | return cmd.validateAndRun([ '--message', 'Foo %@', '--local', '--yes' ]).then(function() { 876 | expect(commitMessage).to.equal('Foo ' + nextTag); 877 | expect(ui.output).to.contain("Successfully committed changes '" + commitMessage + "' locally."); 878 | }); 879 | }); 880 | 881 | it("should push the commit to the remote specified by the --remote option if the --local option is false", function() { 882 | var pushRemote, branchName; 883 | var cmd = createCommand(); 884 | 885 | repo.respondTo('commitAll', makeResponder(null)); 886 | repo.respondTo('createTag', makeResponder(null)); 887 | repo.respondTo('push', function(remote, branch) { 888 | pushRemote = remote; 889 | branchName = branch; 890 | 891 | return null; 892 | }); 893 | repo.respondTo('push', makeResponder(null)); 894 | 895 | return cmd.validateAndRun([ '--yes' ]).then(function() { 896 | expect(ui.output).to.contain("Successfully pushed '" + branchName + "' to remote '" + pushRemote + "'."); 897 | }); 898 | }); 899 | }); 900 | }); 901 | }); 902 | }); 903 | }); 904 | }); 905 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-aborted-hooks-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-aborted-hooks-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | var RSVP = require('rsvp'); 3 | 4 | module.exports = { 5 | init: function(project, versions) { 6 | if (versions.next === 'immediate') { 7 | throw 'nope'; 8 | } 9 | 10 | if (versions.next === 'promise') { 11 | return RSVP.reject('nope'); 12 | } 13 | 14 | return RSVP.resolve('yep'); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-aborted-hooks-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-bad-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | module.exports = { 4 | foo: 'bar', 5 | minor: true, 6 | manifest: 'foo.json', 7 | beforeCommit: null 8 | }; 9 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-bad-config/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-bad-strategy-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-bad-strategy-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | module.exports = { 3 | strategy: function() { 4 | return null; 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-bad-strategy-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | module.exports = { 4 | strategy: 'date' 5 | }; 6 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-different-manifests/bar.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-different-manifests/config/release.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; -------------------------------------------------------------------------------- /tests/fixtures/project-with-different-manifests/foo.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-hooks-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-hooks-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | var RSVP = require('rsvp'); 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | 6 | module.exports = { 7 | init: function(project, versions) { 8 | return writeFile(project.root, 'init.txt', versions.next); 9 | }, 10 | beforeCommit: function(project, versions) { 11 | return writeFile(project.root, 'before-commit.txt', versions.next); 12 | }, 13 | afterPush: function(project, versions) { 14 | return writeFile(project.root, 'after-push.txt', versions.next); 15 | }, 16 | afterPublish: function(project, versions) { 17 | return writeFile(project.root, 'after-publish.txt', versions.next); 18 | }, 19 | }; 20 | 21 | function writeFile(rootPath, filePath, contents) { 22 | return new RSVP.Promise(function(resolve, reject) { 23 | fs.writeFile(path.join(rootPath, filePath), contents, resolve); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-hooks-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-no-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-no-config/config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipshapecode/ember-cli-release/c98ce81128da20ce75e743e04a49b67a6516fc2d/tests/fixtures/project-with-no-config/config/.gitkeep -------------------------------------------------------------------------------- /tests/fixtures/project-with-no-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foo", 3 | "version": "v1.0.1" 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-no-versions/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-no-versions/config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shipshapecode/ember-cli-release/c98ce81128da20ce75e743e04a49b67a6516fc2d/tests/fixtures/project-with-no-versions/config/.gitkeep -------------------------------------------------------------------------------- /tests/fixtures/project-with-no-versions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-options-strategy-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-options-strategy-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | var RSVP = require('rsvp'); 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | 6 | module.exports = { 7 | strategy: { 8 | availableOptions: [ 9 | { 10 | name: 'foo', 11 | type: String, 12 | }, 13 | ], 14 | 15 | getNextTag: function(project, tags, options) { 16 | writeFile(project.root, 'options.json', JSON.stringify(options)); 17 | 18 | return 'foo'; 19 | } 20 | } 21 | }; 22 | 23 | function writeFile(rootPath, filePath, contents) { 24 | fs.writeFileSync(path.join(rootPath, filePath), contents); 25 | } 26 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-options-strategy-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-publish-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | module.exports = { 4 | publish: true 5 | }; 6 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-publish-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-strategy-config/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/fixtures/project-with-strategy-config/config/release.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | var RSVP = require('rsvp'); 3 | var fs = require('fs'); 4 | var path = require('path'); 5 | 6 | module.exports = { 7 | strategy: function(project, tags) { 8 | return writeFile(project.root, 'tags.json', JSON.stringify(tags)).then(function() { 9 | return 'foo'; 10 | }); 11 | } 12 | }; 13 | 14 | function writeFile(rootPath, filePath, contents) { 15 | return new RSVP.Promise(function(resolve) { 16 | fs.writeFile(path.join(rootPath, filePath), contents, resolve); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /tests/fixtures/project-with-strategy-config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v1.0.1" 3 | } -------------------------------------------------------------------------------- /tests/helpers/mock.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | 'use strict'; 4 | 5 | var RSVP = require('rsvp'); 6 | 7 | function Mock(constructor) { 8 | var mock = this; 9 | var callbacks = {}; 10 | var proto = constructor.prototype; 11 | var className = constructor.toString(); 12 | var methodNames = []; 13 | var propNames = []; 14 | 15 | Object.keys(proto).forEach(function(propName) { 16 | if (typeof proto[propName] === 'function') { 17 | // Ignore the constructor 18 | if (proto[propName] !== proto) { 19 | methodNames.push(propName); 20 | } 21 | } else { 22 | propNames.push(propName); 23 | } 24 | }); 25 | 26 | mock.respondTo = function(methodName, callback) { 27 | if (!callbacks[methodName]) { 28 | callbacks[methodName] = []; 29 | } 30 | 31 | callbacks[methodName].push(callback); 32 | }; 33 | 34 | methodNames.forEach(function(methodName) { 35 | mock[methodName] = function() { 36 | var methodCallbacks = callbacks[methodName]; 37 | 38 | if (!methodCallbacks || !methodCallbacks.length) { 39 | throw new Error(className + " method '" + methodName + "' called but no handler was provided"); 40 | } 41 | 42 | var response = methodCallbacks.shift().apply(null, arguments); 43 | 44 | return RSVP.Promise.resolve(response); 45 | }; 46 | }); 47 | 48 | propNames.forEach(function(propName) { 49 | Object.defineProperty(mock, propName, { 50 | get: function() { 51 | var propCallbacks = callbacks[propName]; 52 | 53 | if (!propCallbacks || !propCallbacks.length) { 54 | throw new Error(className + " getter '" + propName + "' called but no handler was provided"); 55 | } 56 | 57 | var value = propCallbacks.shift().apply(null, arguments); 58 | 59 | return value; 60 | }, 61 | enumerable: true, 62 | }); 63 | }); 64 | } 65 | 66 | module.exports = Mock; 67 | -------------------------------------------------------------------------------- /tests/runner.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | 'use strict'; 4 | 5 | var glob = require('glob'); 6 | var Mocha = require('mocha'); 7 | 8 | var mocha = new Mocha({ 9 | reporter: 'spec' 10 | }); 11 | 12 | var arg = process.argv[2]; 13 | var root = 'tests/'; 14 | 15 | function addFiles(mocha, files) { 16 | glob.sync(root + files).forEach(mocha.addFile.bind(mocha)); 17 | } 18 | 19 | addFiles(mocha, '/**/*-nodetest.js'); 20 | 21 | if (arg === 'all') { 22 | addFiles(mocha, '/**/*-nodetest-slow.js'); 23 | } 24 | 25 | mocha.run(function(failures) { 26 | process.on('exit', function() { 27 | process.exit(failures); 28 | }); 29 | }); -------------------------------------------------------------------------------- /tests/unit/strategies/date-nodetest.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | 'use strict'; 4 | 5 | var expect = require('chai').expect; 6 | 7 | var dateStrategy = require('../../../lib/strategies/date'); 8 | 9 | describe("date strategy", function() { 10 | // Fri, 15 Feb 2013 14:00:00 GMT 11 | var date = new Date(Date.UTC(2013, 1, 15, 14)); 12 | var tagNames = [ '2012.12.5', '2012.12.28', '2013.1.5', '2013.1.25' ]; 13 | var project = {}; 14 | 15 | beforeEach(function() { 16 | // Testing dates is the worst 17 | this.oldCurrentDate = dateStrategy.getCurrentDate; 18 | dateStrategy.getCurrentDate = function() { 19 | return date; 20 | }; 21 | }); 22 | 23 | afterEach(function() { 24 | dateStrategy.getCurrentDate = this.oldCurrentDate; 25 | }); 26 | 27 | it("should generate a tag using the current date in UTC using the default format 'YYYY.MM.DD'", function() { 28 | var tagName = dateStrategy.getNextTag(project, tagNames, {}); 29 | 30 | expect(tagName).to.equal('2013.02.15'); 31 | }); 32 | 33 | it("should generate a tag using the format specified by the 'format' option", function() { 34 | var tagName = dateStrategy.getNextTag(project, tagNames, { format: 'x' }); 35 | 36 | expect(tagName).to.equal('1360936800000'); 37 | }); 38 | 39 | it("should use the date in the timezone specified by the 'timezone' option", function() { 40 | var tagName = dateStrategy.getNextTag(project, tagNames, { timezone: 'Australia/Sydney' }); 41 | 42 | expect(tagName).to.equal('2013.02.16'); 43 | }); 44 | 45 | it("should add a patch number if the generated tag already exists", function() { 46 | var tags, tagName; 47 | 48 | tags = tagNames.concat('2013.02.15'); 49 | 50 | tagName = dateStrategy.getNextTag(project, tags, {}); 51 | expect(tagName).to.equal('2013.02.15.1'); 52 | 53 | tags = tags.concat('2013.02.15.1'); 54 | 55 | tagName = dateStrategy.getNextTag(project, tags, {}); 56 | expect(tagName).to.equal('2013.02.15.2'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/unit/strategies/semver-nodetest.js: -------------------------------------------------------------------------------- 1 | /* jshint node:true */ 2 | 3 | 'use strict'; 4 | 5 | var expect = require('chai').expect; 6 | 7 | var semverStrategy = require('../../../lib/strategies/semver'); 8 | 9 | describe("semver strategy", function() { 10 | var tagNames = [ '2.0.0', '2.1.0', '3.0.0', '3.0.1', '3.1.0', '3.1.1', 'v6.0.990.1' ]; 11 | var project = {}; 12 | 13 | it("should provide a default tag", function() { 14 | var tagName = semverStrategy.getNextTag(project, [], {}); 15 | 16 | expect(tagName).to.equal('v0.1.0'); 17 | }); 18 | 19 | it("should return the latest tag if available", function() { 20 | var tagName = semverStrategy.getLatestTag(project, tagNames, {}); 21 | 22 | expect(tagName).to.equal('3.1.1'); 23 | }); 24 | 25 | it("should default to incrementing the patch version", function() { 26 | var tagName = semverStrategy.getNextTag(project, tagNames, {}); 27 | 28 | expect(tagName).to.equal('3.1.2'); 29 | }); 30 | 31 | it("should increment the minor version", function() { 32 | var tagName = semverStrategy.getNextTag(project, tagNames, { minor: true }); 33 | 34 | expect(tagName).to.equal('3.2.0'); 35 | }); 36 | 37 | it("should increment the minor version", function() { 38 | var tagName = semverStrategy.getNextTag(project, tagNames, { major: true }); 39 | 40 | expect(tagName).to.equal('4.0.0'); 41 | }); 42 | 43 | it("should increment the major version and add a prerelease identifier", function() { 44 | var tagName = semverStrategy.getNextTag(project, tagNames, { premajor: 'alpha' }); 45 | 46 | expect(tagName).to.equal('4.0.0-alpha.0'); 47 | }); 48 | 49 | it("should increment the minor version and add a prerelease identifier", function() { 50 | var tagName = semverStrategy.getNextTag(project, tagNames, { preminor: 'alpha' }); 51 | 52 | expect(tagName).to.equal('3.2.0-alpha.0'); 53 | }); 54 | 55 | it("should add the prerelease version", function() { 56 | var tagName = semverStrategy.getNextTag(project, tagNames, { prerelease: 'alpha' }); 57 | 58 | expect(tagName).to.equal('3.1.2-alpha.0'); 59 | }); 60 | 61 | it("should add the prerelease version if different from the current identifier", function() { 62 | var tagName = semverStrategy.getNextTag(project, tagNames.concat('4.0.0-alpha.0'), { prerelease: 'beta' }); 63 | 64 | expect(tagName).to.equal('4.0.0-beta.0'); 65 | }); 66 | 67 | it("should increment the prerelease version", function() { 68 | var tagName = semverStrategy.getNextTag(project, tagNames.concat('4.0.0-alpha.0'), { prerelease: 'alpha' }); 69 | 70 | expect(tagName).to.equal('4.0.0-alpha.1'); 71 | }); 72 | 73 | it("should throw if tags are present but none are semver compliant", function() { 74 | expect(semverStrategy.getNextTag.bind(null, project, [ 'foo' ], {})).to.throw("The repository has no tags that are SemVer compliant, you must specify a tag name with the --tag option."); 75 | }); 76 | 77 | it("should add the 'v' prefix to tags if it's used in the latest tag", function() { 78 | var tagName = semverStrategy.getNextTag(project, [ '0.1.0', 'v0.1.1' ], {}); 79 | 80 | expect(tagName).to.equal('v0.1.2'); 81 | }); 82 | 83 | it("should go through a common release cycle", function() { 84 | var tagNames = [ 'v0.1.0' ]; 85 | var latestTagName, nextTagName; 86 | var sequence = [ 87 | [ {}, 'v0.1.1' ], 88 | [ { minor: true }, 'v0.2.0' ], 89 | [ {}, 'v0.2.1' ], 90 | [ {}, 'v0.2.2' ], 91 | [ { major: true }, 'v1.0.0' ], 92 | [ {}, 'v1.0.1' ], 93 | [ { prerelease: 'beta' }, 'v1.0.2-beta.0' ], 94 | [ {}, 'v1.0.2' ], 95 | [ { preminor: 'beta' }, 'v1.1.0-beta.0' ], 96 | [ { prerelease: true }, 'v1.1.0-beta.1' ], 97 | [ { minor: true }, 'v1.1.0' ], 98 | [ {}, 'v1.1.1' ], 99 | [ { premajor: 'alpha' }, 'v2.0.0-alpha.0' ], 100 | [ { prerelease: true }, 'v2.0.0-alpha.1' ], 101 | [ { prerelease: 'beta' }, 'v2.0.0-beta.0' ], 102 | [ { prerelease: true }, 'v2.0.0-beta.1' ], 103 | [ { prerelease: true }, 'v2.0.0-beta.2' ], 104 | [ { major: true }, 'v2.0.0' ], 105 | ]; 106 | 107 | for (var i = 0, l = sequence.length; i < l; i++) { 108 | latestTagName = semverStrategy.getLatestTag(project, tagNames, sequence[i][0]); 109 | nextTagName = semverStrategy.getNextTag(project, tagNames, sequence[i][0]); 110 | expect(latestTagName).to.equal(tagNames[i]); 111 | expect(nextTagName).to.equal(sequence[i][1]); 112 | tagNames.push(nextTagName); 113 | } 114 | }); 115 | }); 116 | --------------------------------------------------------------------------------