From 2f81802e6538501b744145d6ee3494e77e709581 Mon Sep 17 00:00:00 2001 From: Pierre Vanduynslager Date: Thu, 30 Nov 2017 02:22:04 -0500 Subject: [PATCH] feat: Initial release --- .gitignore | 131 ++++++++++++ .npmrc | 1 + .travis.yml | 26 +++ .yarnrc | 1 + LICENSE | 21 ++ README.md | 376 +++++++++++++++++++++++++++++++++- index.js | 41 ++++ lib/get-auth-url.js | 21 ++ lib/get-last-release.js | 38 ++++ lib/git.js | 173 ++++++++++++++++ lib/publish.js | 108 ++++++++++ lib/resolve-config.js | 10 + lib/verify.js | 57 ++++++ package.json | 114 +++++++++++ test/get-auth-url.test.js | 26 +++ test/get-last-release.test.js | 64 ++++++ test/git.test.js | 229 +++++++++++++++++++++ test/helpers/git-utils.js | 190 +++++++++++++++++ test/helpers/gitbox.js | 75 +++++++ test/integration.test.js | 288 ++++++++++++++++++++++++++ test/publish.test.js | 269 ++++++++++++++++++++++++ test/verify.test.js | 146 +++++++++++++ 22 files changed, 2403 insertions(+), 2 deletions(-) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .travis.yml create mode 100644 .yarnrc create mode 100644 LICENSE create mode 100644 index.js create mode 100644 lib/get-auth-url.js create mode 100644 lib/get-last-release.js create mode 100644 lib/git.js create mode 100644 lib/publish.js create mode 100644 lib/resolve-config.js create mode 100644 lib/verify.js create mode 100644 package.json create mode 100644 test/get-auth-url.test.js create mode 100644 test/get-last-release.test.js create mode 100644 test/git.test.js create mode 100644 test/helpers/git-utils.js create mode 100644 test/helpers/gitbox.js create mode 100644 test/integration.test.js create mode 100644 test/publish.test.js create mode 100644 test/verify.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..aae09ec6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,131 @@ + +# Created by https://www.gitignore.io/api/macos,windows,linux,node + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.gitignore.io/api/macos,windows,linux,node + +package-lock.json +yarn.lock diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..43c97e71 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..2cf6308c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +language: node_js +services: + - docker +notifications: + email: false +node_js: + - 9 + - 8 + +# Trigger a push build on master and greenkeeper branches + PRs build on every branches +# Avoid double build on PRs (See https://github.com/travis-ci/travis-ci/issues/1147) +branches: + only: + - master + - /^greenkeeper.*$/ + +# Retry install on fail to avoid failing a build on network/disk/external errors +install: + - travis_retry npm install + +script: + - npm run test + +after_success: + - npm run codecov + - npm run semantic-release diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..acaaffdb --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +--install.no-lockfile true diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2829a1be --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Pierre-Denis Vanduynslager + +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: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. diff --git a/README.md b/README.md index 957ec561..1e36c190 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,374 @@ -# git -Set of semantic-release plugins to publish to a git repository +# @semantic-release/git + +Set of [semantic-release](https://github.com/semantic-release/semantic-release) plugins for publishing to a [git](https://git-scm.com/) repository. + +[![Travis](https://img.shields.io/travis/semantic-release/git.svg)](https://travis-ci.org/semantic-release/git) +[![Codecov](https://img.shields.io/codecov/c/github/semantic-release/git.svg)](https://codecov.io/gh/semantic-release/git) +[![Greenkeeper badge](https://badges.greenkeeper.io/semantic-release/git.svg)](https://greenkeeper.io/) + +## verifyConditions + +Verify the access to the remote Git repository, the commit `message` format and the `assets` option configuration. + +## getLastRelease + +Determine the Git tag and version of the last tagged release. + +## publish + +Update the `CHANGELOG.md` file and publish a release commit, optionnaly including addtional files. + +## Configuration + +### Git Repository authentication + +The `Git` authentication configuration is **required** and can be set either: +- with the [`GIT_CREDENTIALS` environment variable](#environment-variables) for accessing the repository via [https](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_http_protocols) +- or with [ssh keys](#set-up-the-ssh-keys) to access via [ssh](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_ssh_protocol) + +If the `GIT_CREDENTIALS` environment variable is set the remote Git repository will automatically be accessed via `https`, independently of the `repositoryUrl` format configured in `semantic-release` (the format will be automatically converted as needed). + +Using the `GIT_CREDENTIALS` environment variable is the recommended configuration. + +`GIT_CREDENTIALS` can be your Git username and passort in the format `:` or a token for certain Git providers like [Github](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/), [Bitbucket](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html) or [Gitlab](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html). + +If the `GH_TOKEN` or `GITHUB_TOKEN` environment variables are defined their value will be used as a replacement for `GIT_CREDENTIALS`. + +### Environment variables + +| Variable | Description | Default | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | +| `GIT_CREDENTIALS` | [URL encoded basic HTTP Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication#URL_encoding) credentials). | `GH_TOKEN` or `GITHUB_TOKEN` environment variables. | +| `GIT_USERNAME` | [Git username](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_your_identity) associated with the release commit. | @semantic-release-bot. | +| `GIT_EMAIL` | [Git email address](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup#_your_identity) associated with the release commit. | @semantic-release-bot email address. | + +### Options + +| Options | Description | Default | +| -------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `changelog` | Whether to create/update the `CHANGELOG.md` file. | `true` | +| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` | +| `assets` | Files to include in the release commit. See [assets](#assets). | `["package.json", "npm-shrinkwrap.json"]` | + +#### `message` + +The message for the release commit is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: + +| Parameter | Desciption | +| ------------- | ----------------------------------------------------------------------------------- | +| `branch` | The branch from which the release is done. | +| `lastRelease` | `Object` with `version`, `gitTag` and `gitHead` of the last release. | +| `nextRelease` | `Object` with `version`, `gitTag`, `gitHead` and `notes` of the release being done. | + +It is recommended to include `[skip ci]` in the commit message to not trigger a new build. +**Note**: Some CI service support the `[skip ci]` keyword only in the subject of the message. + +##### `message` examples + +The `message` `Release ${nextRelease.version} - ${new Date().toLocaleDateString('en-US', {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' })} [skip ci]\n\n${nextRelease.notes}` will generate the commit message: + +> Release v1.0.0 - Oct. 21, 2015 1:24 AM \[skip ci\]

## 1.0.0

### Features
* Generate 1.21 gigawatts of electricity
... + +#### `assets` + +Can be an `Array` or a single entry. Each entry can be either: +- a [glob](https://github.com/micromatch/micromatch#matching-features) +- or an `Object` with a `path` property containing a [glob](https://github.com/micromatch/micromatch#matching-features). + +Each entry in the `assets` `Array` is globbed individually. A [glob](https://github.com/micromatch/micromatch#matching-features) can be a `String` (`"dist/**/*.js"` or `"dist/mylib.js"`) or an `Array` of `String`s that will be globbed together (`["dist/**", "!**/*.css"]`). + +If a directory is configured, all the files under this directory and its children will be included. + +If a file has a match in `.gitignore` it will always be excluded. + +##### `assets` examples + +`'dist/*.js'`: include all `js` files in the `dist` directory, but not in its sub-directories. + +`'dist/**/*.js'`: include all `js` files in the `dist` directory and its sub-directories. + +`[['dist', '!**/*.css']]`: include all files in the `dist` directory and its sub-directories excluding the `css` files. + +`[['dist', '!**/*.css'], 'package.json']`: include `package.json` and all files in the `dist` directory and its sub-directories excluding the `css` files. + +`[['dist/**/*.{js,css}', '!**/*.min.*']]`: include all `js` and `css` files in the `dist` directory and its sub-directories excluding the minified version. + +### Usage + +Options can be set within the plugin definition in the `semantic-release` configuration file: + +```json +{ + "release": { + "publish": [ + "@semantic-release/npm", + { + "path": "@semantic-release/git", + "assets": ["package.json", "dist/**/*.{js|css}", "docs"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + }, + "@semantic-release/github" + ] + } +} +``` + +When using with the [npm](https://github.com/semantic-release/npm) plugin or the [github](https://github.com/semantic-release/github) plugin: +- The [npm](https://github.com/semantic-release/npm) plugin must be called first in order to update the `package.json` file so the [git](https://github.com/semantic-release/git) plugin can include it in the release commit. +- The [github](https://github.com/semantic-release/github) plugin must be called last to create a [Github Release](https://help.github.com/articles/about-releases/) that reference the tag created by the [git](https://github.com/semantic-release/git) plugin. + +To use with [github](https://github.com/semantic-release/github), [npm](https://github.com/semantic-release/npm) and [condition-travis](https://github.com/semantic-release/condition-travis): + +```json +{ + "release": { + "verifyConditions": ["@semantic-release/condition-travis", "@semantic-release/npm", "@semantic-release/git", "@semantic-release/github"], + "getLastRelease": "@semantic-release/npm", + "publish": ["@semantic-release/npm", "@semantic-release/git", "@semantic-release/github"] + } +} +``` + +To use with [github](https://github.com/semantic-release/github), and [condition-travis](https://github.com/semantic-release/condition-travis): + +```json +{ + "release": { + "verifyConditions": ["@semantic-release/condition-travis", "@semantic-release/git", "@semantic-release/github"], + "getLastRelease": "@semantic-release/git", + "publish": ["@semantic-release/git", "@semantic-release/github"] + } +} +``` + +### GPG signature + +Using GPG, you can [sign and verify tags and commits](https://git-scm.com/book/id/v2/Git-Tools-Signing-Your-Work). With GPG keys, the release tags and commits made by `semantic-release` are verified and other people can trust that they were really were made by your account. + +#### Generate the GPG keys + +If you already have a GPG public and private key you can skip this step and go to the [Get the GPG keys ID and the public key content](#get-the-gpg-keys-id-and-the-public-key-content) step. + +[Download and install the GPG command line tools](https://www.gnupg.org/download/#binary) for your operating system. + +Create a GPG key + +```bash +$ gpg --full-generate-key +``` + +At the prompt select the `RSA and RSA` king of key, enter `4096` for the keysize, specify how long the key should be valid, enter yout name, the email associated with your Git hosted account and finally set a long and hard to guess passphrase. + +#### Get the GPG keys ID and the public key content + +Use `Use the gpg --list-secret-keys --keyid-format LONG command to list GPG keys` to list your GPG keys and from the list, copy the GPG key ID you just created. + +```bash +$ gpg --list-secret-keys --keyid-format LONG +/Users//.gnupg/pubring.gpg +--------------------------------------- +sec rsa4096/XXXXXXXXXXXXXXXX 2017-12-01 [SC] +uid +ssb rsa4096/YYYYYYYYYYYYYYYY 2017-12-01 [E] +``` +the GPG key ID if 16 character string, on the on the `sec` line, after `rsa4096`. In this example, the GPG key ID is `XXXXXXXXXXXXXXXX`. + +Export the public key (replace XXXXXXXXXXXXXXXX with your key ID): + +```bash +$ gpg --armor --export XXXXXXXXXXXXXXXX +``` + +Copy your GPG key, beginning with -----BEGIN PGP PUBLIC KEY BLOCK----- and ending with -----END PGP PUBLIC KEY BLOCK----- + +#### Add the GPG key to your Git hosted account + +##### Add the GPG key to Github + +In Github **Settings**, click on **SSH and GPG keys** in the sidebar, then on the **New GPG Key** button. + +Paste the entire GPG key export previously and click the **Add GPG Key** button. + +See [Adding a new GPG key to your GitHub account](https://help.github.com/articles/adding-a-new-gpg-key-to-your-github-account/) for more details. + +### Use the GPG key to sign commit and tags locally + +If you want to use this GPG to also sign the commits and tags you create on your local machine you can follow the instruction at [Git Tools - Signing Your Work](https://git-scm.com/book/id/v2/Git-Tools-Signing-Your-Work) +This step is optionnal and unrelated to `semantic-release`. + +#### Add the GPG keys to your CI environment + +Make the public and private GPG key available on the CI environment. Encrypt the keys, commit it to your repository and configure the CI environment to decrypt it. + +##### Add the GPG keys to Travis CI + +Install the [Travis CLI](https://github.com/travis-ci/travis.rb#installation): + +```bash +$ gem install travis +``` + +[Login](https://github.com/travis-ci/travis.rb#login) to Travis with the CLI: + +```bash +$ travis login +``` + +Add the following [environment](https://github.com/travis-ci/travis.rb#env) variables to Travis: +- `GPG_PASSPHRASE` to Travis with the value set during the [GPG keys generation](#generate-the-gpg-keys) step +- `GPG_KEY_ID` to Travis with the value of your GPG key ID retrieved during the [GPG keys generation](#generate-the-gpg-keys) (replace XXXXXXXXXXXXXXXX with your key ID) +- `GIT_EMAIL` with the email adress you set during the [GPG keys generation](#generate-the-gpg-keys) step +- `GIT_USERNAME` with the name you set during the [GPG keys generation](#generate-the-gpg-keys) step + +```bash +$ travis env set GPG_PASSPHRASE +$ travis env set GPG_KEY_ID XXXXXXXXXXXXXXXX +$ travis env set GIT_EMAIL +$ travis env set GIT_USERNAME +``` + +From your repository root export your public and private GPG keys in the `git_gpg_keys.asc` (replace XXXXXXXXXXXXXXXX with your key ID): + +```bash +$ gpg --export -a XXXXXXXXXXXXXXXX > git_gpg_keys.asc +$ gpg --export-secret-key -a XXXXXXXXXXXXXXXX >> git_gpg_keys.asc +``` + +[Encrypt](https://github.com/travis-ci/travis.rb#encrypt) the `git_gpg_keys.asc` (public and private key) using a symmetric encryption (AES-256), and store the secret in a secure environment variable in the Travis environment: + +```bash +$ travis encrypt-file git_gpg_keys.asc +``` +The `travis encrypt-file` will encrypt the keys into the `git_gpg_keys.asc.enc` file and output in the console the command to add to your `.travis.yml` file. It should look like `openssl aes-256-cbc -K $encrypted_AAAAAAAAAAAA_key -iv $encrypted_BBBBBBBBBBBB_iv -in git_gpg_keys.asc.enc -out git_gpg_keys.asc -d`. + +Copy this command to your `.travis.yml` file in the `before_install` step. Change the output path to write the unencrypted key in `/tmp`: `-out git_gpg_keys.asc` => `/tmp/git_gpg_keys.asc`. This will avoid to commit / modify / delete the unencrypted keys by mistake on the CI. Then add the commands to decrypt the GPG keys and make it available to `git`: + +```yaml +before_install: + # Decrypt the git_gpg_keys.asc.enc key into /tmp/git_gpg_keys.asc + - openssl aes-256-cbc -K $encrypted_AAAAAAAAAAAA_key -iv $encrypted_BBBBBBBBBBBB_iv -in git_gpg_keys.asc.enc -out /tmp/git_gpg_keys.asc -d + # Make sure only the current user can read the keys + - chmod 600 /tmp/git_gpg_keys.asc + # Create a script that pass the passphrase to the gpg CLI called by git + - echo '/usr/bin/gpg2 --passphrase ${GPG_PASSPHRASE} --batch --no-tty "$@"' > /tmp/gpg-with-passphrase && chmod +x /tmp/gpg-with-passphrase + # Configure git to use the script that passes the passphrase + - git config gpg.program "/tmp/gpg-with-passphrase" + # Configure git to sign the commits and tags + - git config commit.gpgsign true + # Configure git to use your GPG key + - git config --global user.signingkey ${GPG_KEY_ID} +``` + +See [Encrypting Files](https://docs.travis-ci.com/user/encrypting-files/) for more details. + +Delete the local keys as it won't be used anymore: + +```bash +$ rm git_gpg_keys.asc +``` + +Commit the encrypted keys and the `.travis.yml` file to your repository: + +```bash +$ git add git_gpg_keys.asc.enc .travis.yml +$ git commit -m "ci(travis): Add the encrypted GPG keys" +$ git push +``` + +### Set up the SSH keys + +In order to allows `semantic-release` to push commits to your repository from the CI, you need to geneate a SSH key, add it to your Git hosted account, make it available on the CI environment. + +#### Generate the SSH keys + +In your local repository root: + +```bash +$ ssh-keygen -t rsa -b 4096 -C "" -f git_deploy_key -N "" +``` + +`your_email` must be the email associated with your Git hosted account. `ssh_passphrase` must be a long and hard to guess string. It will be used later. + +This will generate a public key in `git_deploy_key.pub` and a private key in `git_deploy_key`. + +#### Add the SSH key to your Git hosted account + +##### Add the SSH key to Github + +Open the `git_deploy_key.pub` file (public key) and copy the entire content. + +In Github **Settings**, click on **SSH and GPG keys** in the sidebar, then on the **New SSH Key** button. + +Paste the entire content of `git_deploy_key.pub` file (public key) and click the **Add SSH Key** button. + +Delete the `git_deploy_key.pub` file: + +```bash +$ rm git_deploy_key.pub +``` + +See [Adding a new SSH key to your GitHub account](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/) for more details. + +#### Add the SSH private key to your CI environment + +Make the private key available on the CI environment. Encrypt the key, commit it to your repository and configure the CI environment to decrypt it. + +##### Add the SSH private key to Travis CI + +Install the [Travis CLI](https://github.com/travis-ci/travis.rb#installation): + +```bash +$ gem install travis +``` + +[Login](https://github.com/travis-ci/travis.rb#login) to Travis with the CLI: + +```bash +$ travis login +``` + +Add the [environment](https://github.com/travis-ci/travis.rb#env) variable `SSH_PASSPHRASE` to Travis with the value set during the [SSH keys generation](#generate-the-ssh-keys) step: + +```bash +$ travis env set SSH_PASSPHRASE +``` + +[Encrypt](https://github.com/travis-ci/travis.rb#encrypt) the `git_deploy_key` (private key) using a symmetric encryption (AES-256), and store the secret in a secure environment variable in the Travis environment: + +```bash +$ travis encrypt-file git_deploy_key +``` + +The `travis encrypt-file` will encrypt the private key into the `git_deploy_key.enc` file and output in the console the command to add to your `.travis.yml` file. It should look like `openssl aes-256-cbc -K $encrypted_AAAAAAAAAAAA_key -iv $encrypted_BBBBBBBBBBBB_iv -in git_deploy_key.enc -out git_deploy_key -d`. + +Copy this command to your `.travis.yml` file in the `before_install` step. Change the output path to write the unencrypted key in `/tmp`: `-out git_deploy_key` => `/tmp/git_deploy_key`. This will avoid to commit / modify / delete the unencrypted key by mistake on the CI. Then add the commands to decrypt the ssh private key and make it available to `git`: + +```yaml +before_install: + # Decrypt the git_deploy_key.enc key into /tmp/git_deploy_key + - openssl aes-256-cbc -K $encrypted_AAAAAAAAAAAA_key -iv $encrypted_BBBBBBBBBBBB_iv -in git_deploy_key.enc -out /tmp/git_deploy_key -d + # Make sure only the current user can read the private key + - chmod 600 /tmp/git_deploy_key + # Create a script to return the passphrase environment variable to ssh-add + - echo 'echo ${SSH_PASSPHRASE}' > /tmp/askpass && chmod +x /tmp/askpass + # Start the authentication agent + - eval "$(ssh-agent -s)" + # Add the key to the authentication agent + - DISPLAY=":0.0" SSH_ASKPASS="/tmp/askpass" setsid ssh-add /tmp/git_deploy_key config.path && config.path === '@semantic-release/git' + ); + if (publishPlugin && publishPlugin.assets) { + pluginConfig.assets = publishPlugin.assets; + } + if (publishPlugin && publishPlugin.message) { + pluginConfig.message = publishPlugin.message; + } + } + await verifyGit(pluginConfig, options, logger); + verified = true; +} + +async function getLastRelease(pluginConfig, {options, logger}) { + if (!verified) { + await verifyGit(pluginConfig, options, logger); + verified = true; + } + return getLastReleaseGit(logger); +} + +async function publish(pluginConfig, {options, lastRelease, nextRelease, logger}) { + if (!verified) { + await verifyGit(pluginConfig, options, logger); + verified = true; + } + await publishGit(pluginConfig, options, lastRelease, nextRelease, logger); +} + +module.exports = {verifyConditions, getLastRelease, publish}; diff --git a/lib/get-auth-url.js b/lib/get-auth-url.js new file mode 100644 index 00000000..508a1380 --- /dev/null +++ b/lib/get-auth-url.js @@ -0,0 +1,21 @@ +const {parse, format} = require('url'); +const gitUrlParse = require('git-url-parse'); + +/** + * Generate the git repository URL with creadentials. + * If the `gitCredentials` is defined, returns a http or https URL with Basic Authentication (`https://username:passowrd@hostname:port/path.git`). + * If the `gitCredentials` is undefined, returns the `repositoryUrl`. In that case it's expected for the user to have setup the Git authentication on the CI (for example via SSH keys). + * + * @param {String} gitCredentials Basic HTTP Authentication credentials, can be `username:password` or a token for certain Git providers. + * @param {String} repositoryUrl The git repository URL. + * @return {String} The formatted Git repository URL. + */ +module.exports = (gitCredentials, repositoryUrl) => { + if (!gitCredentials) { + return repositoryUrl; + } + + const {protocols} = gitUrlParse(repositoryUrl); + const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https'; + return format(Object.assign(parse(gitUrlParse(repositoryUrl).toString(protocol)), {auth: gitCredentials})); +}; diff --git a/lib/get-last-release.js b/lib/get-last-release.js new file mode 100644 index 00000000..39a44066 --- /dev/null +++ b/lib/get-last-release.js @@ -0,0 +1,38 @@ +const semver = require('semver'); +const debug = require('debug')('semantic-release:git'); +const {unshallow, gitTags} = require('./git'); + +/** + * Last release. + * + * @typedef {Object} LastRelease + * @property {string} version The version number of the last release. + * @property {string} [gitHead] The Git reference used to make the last release. + */ + +/** + * Determine the Git tag and version of the last tagged release. + * + * - Unshallow the repository + * - Obtain all the tags referencing commits in the current branch history + * - Filter out the ones that are not valid semantic version + * - Sort the tags + * - Retrive the highest tag + * + * @param {Object} logger Global logger. + * @return {Promise} The last tagged release or `undefined` if none is found. + */ +module.exports = async logger => { + // Unshallow the repo in order to get all the tags + await unshallow(); + const tags = (await gitTags()).filter(tag => semver.valid(semver.clean(tag))).sort(semver.compare); + debug('found tags: %o', tags); + + if (tags.length > 0) { + const tag = tags[tags.length - 1]; + logger.log('Found git tag version %s', tag); + return {gitHead: tag, version: semver.valid(semver.clean(tag))}; + } + + logger.log('No git tag version found'); +}; diff --git a/lib/git.js b/lib/git.js new file mode 100644 index 00000000..48cf85a0 --- /dev/null +++ b/lib/git.js @@ -0,0 +1,173 @@ +const execa = require('execa'); +const SemanticReleaseError = require('@semantic-release/error'); +const debug = require('debug')('semantic-release:git'); + +/** + * @return {Array} List of git tags in the history of the current branch. + * @throws {Error} If the `git` command fails. + */ +async function gitTags() { + try { + return (await execa.stdout('git', ['tag', '--merge', 'HEAD'])) + .split('\n') + .map(tag => tag.trim()) + .filter(tag => Boolean(tag)); + } catch (err) { + debug(err); + throw new Error(err.stderr); + } +} + +/** + * Unshallow the git repository (retriving every commits and tags). + */ +async function unshallow() { + await execa('git', ['fetch', '--unshallow', '--tags'], {reject: false}); +} + +/** + * Retrieve the list of files modified on the local repository. + * + * @return {Array} Array of modified files path. + */ +async function getModifiedFiles() { + return (await execa.stdout('git', ['ls-files', '-m', '-o'])) + .split('\n') + .map(tag => tag.trim()) + .filter(tag => Boolean(tag)); +} + +/** + * Add a list of file to the Git index. + * If on of the files is present in the .gitignore it will be silently skipped. Other files will still be added. + * + * @param {Array} files Array of files path to add to the index, + */ +async function add(files) { + const shell = await execa('git', ['add', '--ignore-errors'].concat(files), {reject: false}); + debug('add file to git index', shell); +} + +/** + * Set Git configuration. + * + * @param {String} name Config name. + * @param {String} value Config value. + */ +async function config(name, value) { + await execa('git', ['config', name, value]); +} + +/** + * Commit to the local repository. + * + * @param {String} message Commit message. + * @throws {Error} if the commit failed. + */ +async function commit(message) { + await execa('git', ['commit', '-m', message]); +} + +/** + * Tag the commit head on the local repository. + * + * @param {String} tagName The name of the tag. + * @throws {Error} if the tag creation failed. + */ +async function tag(tagName) { + await execa('git', ['tag', tagName]); +} + +/** + * Push to the remote repository. + * + * @param {String} origin The remote repository URL. + * @param {String} branch The branch to push. + * @throws {Error} if the push failed. + */ +async function push(origin, branch) { + // Do not log result or error to not reveal credentials + try { + await execa('git', ['push', '--tags', origin, `HEAD:${branch}`]); + } catch (err) { + throw new Error(`An error occured during the git push to the remote branch ${branch}`); + } +} + +/** + * @return {String} The sha of the head commit on the local repository + */ +async function gitHead() { + try { + return await execa.stdout('git', ['rev-parse', 'HEAD']); + } catch (err) { + debug(err); + throw new Error(err.stderr); + } +} + +/** + * Verify the write access authorization to remote repository with push dry-run. + * + * @param {String} origin The remote repository URL. + * @param {String} branch The repositoru branch for which to verify write access. + * + * @return {Boolean} `true` is authorized to push, `false` otherwise. + */ +async function verifyAuth(origin, branch) { + // Do not log result or error to not reveal credentials + return (await execa('git', ['push', '--dry-run', origin, `HEAD:${branch}`], {reject: false})).code === 0; +} + +/** + * Delete a tag locally and remotely, only if reference the local head commit. + * + * @param {String} origin The remote repository URL. + * @param {String} tagName The tag name to delete. + * @throws {SemanticReleaseError} if the remote tag exists and references a commit that is not the local head commit. + */ +async function deleteHeadTag(origin, tagName) { + const {stdout: localHeadTag} = await execa('git', ['describe', '--tags', '--exact-match', 'HEAD'], { + reject: false, + }); + if (tagName === localHeadTag) { + debug('tag %s already exists in the local repo', tagName); + // Delete the local tag + const shell = await execa('git', ['tag', '-d', tagName], {reject: false}); + debug('delete local tag', shell); + } + + let {stdout: remoteHeadWithTag} = await execa('git', ['ls-remote', '--tags', origin, tagName], {reject: false}); + if (remoteHeadWithTag) { + [, remoteHeadWithTag] = remoteHeadWithTag.match(/^(\S+)/); + debug('remote head with tag %s : %s', tagName, remoteHeadWithTag); + const localHead = await gitHead(); + debug('local gitHead: %s', localHead); + if (remoteHeadWithTag !== localHead) { + throw new SemanticReleaseError( + `The tag ${tagName} already exists in the remote repository and it refers to the commit ${ + remoteHeadWithTag + } which is not the HEAD of the branch.`, + 'EGITTAGEXIST' + ); + } + debug('tag %s already exists in the remote repo', tagName); + // Delete the tag remotely + const shell = await execa('git', ['push', '-d', origin, tagName], {reject: false}); + debug('delete remote tag', shell); + } +} + +module.exports = { + unshallow, + gitTags, + verifyAuth, + getModifiedFiles, + add, + config, + gitHead, + deleteHeadTag, + commit, + tag, + push, +}; diff --git a/lib/publish.js b/lib/publish.js new file mode 100644 index 00000000..7747ef0c --- /dev/null +++ b/lib/publish.js @@ -0,0 +1,108 @@ +const {readFile, writeFile, ensureFile} = require('fs-extra'); +const {isUndefined, isPlainObject, isArray} = require('lodash'); +const {template, castArray, uniq} = require('lodash'); +const micromatch = require('micromatch'); +const dirGlob = require('dir-glob'); +const pReduce = require('p-reduce'); +const debug = require('debug')('semantic-release:git'); +const getAuthUrl = require('./get-auth-url'); +const resolveConfig = require('./resolve-config'); +const {getModifiedFiles, add, config, deleteHeadTag, commit, tag, push} = require('./git'); + +const CHANGELOG = 'CHANGELOG.md'; +const PKG_JSON = 'package.json'; +const SKW_JSON = 'npm-shrinkwrap.json'; + +/** + * Update the `CHANGELOG.md` file and publish a release commit optionnaly including addtional files. + * + * @param {Object} pluginConfig The plugin configuration. + * @param {String|Array} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs. + * @param {String} [pluginConfig.message] The message for the release commit. + * @param {String} [pluginConfig.gitUserName] The username to use for commiting (git `user.name` config). + * @param {String} [pluginConfig.gitUserEmail] The email to use for commiting (git `user.email` config). + * @param {String} [pluginConfig.changelog] If `false` do not include the `CHANGELOG.md`. + * @param {Object} options `semantic-release` configuration. + * @param {String} options.repositoryUrl The remote git repository URL. + * @param {String} options.branch The remote branch to publish to. + * @param {Object} logger Global logger. + * @param {Object} lastRelease The last release. + * @param {String} lastRelease.version The last release version. + * @param {String} lastRelease.gitHead The commit sha corresponding to the last release. + * @param {String} lastRelease.gitTag The tag corresponding to the last release. + * @param {Object} nextRelease The next release. + * @param {String} nextRelease.version The next release version. + * @param {String} nextRelease.gitHead The commit sha of the head commit. + * @param {String} nextRelease.gitTag The git tag to use to make the next release. + * @param {Object} logger Global logger. + */ +module.exports = async (pluginConfig, {branch, repositoryUrl}, lastRelease, nextRelease, logger) => { + const {gitCredentials, gitUserEmail, gitUserName, changelog, message, assets} = resolveConfig(pluginConfig); + const patterns = []; + if (changelog !== false && nextRelease.notes) { + await ensureFile(CHANGELOG); + const changelogFile = (await readFile(CHANGELOG)).toString().trim(); + if (changelogFile) { + logger.log('Update %s', CHANGELOG); + } else { + logger.log('Create %s', CHANGELOG); + } + await writeFile(CHANGELOG, `${nextRelease.notes.trim()}\n${changelogFile ? `\n${changelogFile}\n` : ''}`); + logger.log('Add %s to the release commit', CHANGELOG); + patterns.push(CHANGELOG); + } + + const modifiedFiles = await getModifiedFiles(); + + if (isUndefined(assets)) { + logger.log('Add %s to the release commit', PKG_JSON); + patterns.push(PKG_JSON); + logger.log('Add %s to the release commit', SKW_JSON); + patterns.push(SKW_JSON); + } + + patterns.push( + ...(assets || []).map(pattern => (!isArray(pattern) && isPlainObject(pattern) ? pattern.path : pattern)) + ); + + const filesToCommit = uniq( + await pReduce( + patterns, + async (result, pattern) => { + const glob = castArray(pattern); + let nonegate; + // Skip solo negated pattern (avoid to include every non js file with `!**/*.js`) + if (glob.length <= 1 && glob[0].startsWith('!')) { + nonegate = true; + debug( + 'skipping the negated glob %o as its alone in its group and would retrieve a large amount of files ', + glob[0] + ); + } + result.push(...micromatch(modifiedFiles, await dirGlob(glob), {dot: true, nonegate})); + return result; + }, + [] + ) + ); + const url = getAuthUrl(gitCredentials, repositoryUrl); + + if (filesToCommit.length > 0) { + logger.log('Found %d file(s) to commit', filesToCommit.length); + await add(filesToCommit); + await config('user.email', gitUserEmail); + await config('user.name', gitUserName); + await deleteHeadTag(url, nextRelease.gitTag); + debug('commited files: %o', filesToCommit); + await commit( + message + ? template(message)({branch, lastRelease, nextRelease}) + : `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` + ); + } + + logger.log('Creating tag %s', nextRelease.gitTag); + await tag(nextRelease.gitTag); + await push(url, branch); + logger.log('Published Github release: %s', nextRelease.gitTag); +}; diff --git a/lib/resolve-config.js b/lib/resolve-config.js new file mode 100644 index 00000000..7d02160c --- /dev/null +++ b/lib/resolve-config.js @@ -0,0 +1,10 @@ +const {castArray} = require('lodash'); + +module.exports = ({changelog, message, assets, githubToken}) => ({ + gitCredentials: process.env.GIT_CREDENTIALS || githubToken || process.env.GH_TOKEN || process.env.GITHUB_TOKEN, + gitUserName: process.env.GIT_USERNAME || 'semantic-release-bot', + gitUserEmail: process.env.GIT_EMAIL || 'semantic-release-bot@martynus.net', + changelog, + message, + assets: assets ? castArray(assets) : assets, +}); diff --git a/lib/verify.js b/lib/verify.js new file mode 100644 index 00000000..fb41f304 --- /dev/null +++ b/lib/verify.js @@ -0,0 +1,57 @@ +const {isString, isUndefined, isArray, isPlainObject, isBoolean} = require('lodash'); +const SemanticReleaseError = require('@semantic-release/error'); +const resolveConfig = require('./resolve-config'); +const getAuthUrl = require('./get-auth-url'); +const {verifyAuth} = require('./git'); + +/** + * Verify the access to the remote Git repository, the commit `message` format and the `assets` option configuration: + * - The remote repository must be writable. + * - The commit `message`, is defined, must a non empty `String`. + * - The `assets` configuration must be an `Array` of `String` (file path) or `false` (to disable). + * + * @param {Object} pluginConfig The plugin configuration. + * @param {String|Array} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs. + * @param {String} [pluginConfig.message] The commit message for the release. + * @param {Object} options `semantic-release` configuration. + * @param {String} options.repositoryUrl The remote git repository URL. + * @param {String} options.branch The remote branch to publish to. + * @param {Object} logger Global logger. + */ +module.exports = async (pluginConfig, {repositoryUrl, branch}, logger) => { + logger.log('Verify authentication for repository %s', repositoryUrl); + const {changelog, message, assets, gitCredentials} = resolveConfig(pluginConfig); + + if ( + !isUndefined(assets) && + assets !== false && + !( + isArray(assets) && + assets.every(asset => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path))) + ) + ) { + throw new SemanticReleaseError( + 'The "assets" options must be an Array of Strings or Objects with a path property.', + 'EINVALIDASSETS' + ); + } + + if (!isUndefined(changelog) && !isBoolean(changelog)) { + throw new SemanticReleaseError('The "changelog" options, if defined, must be a Boolean.', 'EINVALIDCHANGELOG'); + } + + if (!isUndefined(message) && !(isString(message) && message.trim())) { + throw new SemanticReleaseError('The "message" options, if defined, must be a non empty String.', 'EINVALIDMESSAGE'); + } + + if (!await verifyAuth(getAuthUrl(gitCredentials, repositoryUrl), branch)) { + throw new SemanticReleaseError( + `The git credentials doesn't allow to push on the branch ${branch} of ${repositoryUrl}.`, + 'EGITNOPERMISSION' + ); + } +}; + +function isStringOrStringArray(value) { + return isString(value) || (isArray(value) && value.every(isString)); +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..6580bd16 --- /dev/null +++ b/package.json @@ -0,0 +1,114 @@ +{ + "name": "@semantic-release/git", + "description": "Set of semantic-release plugins to publish to a git repository", + "version": "0.0.0-development", + "author": "Pierre Vanduynslager (https://github.com/pvdlg)", + "bugs": { + "url": "https://github.com/semantic-release/git/issues" + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "contributors": [ + "Stephan Bönnemann (http://boennemann.me)", + "Gregor Martynus (https://twitter.com/gr2m)" + ], + "dependencies": { + "@semantic-release/error": "^2.1.0", + "debug": "^3.1.0", + "dir-glob": "^2.0.0", + "execa": "^0.8.0", + "fs-extra": "^4.0.2", + "git-url-parse": "^7.0.1", + "lodash": "^4.17.4", + "micromatch": "^3.1.4", + "p-reduce": "^1.0.0", + "semver": "^5.4.1" + }, + "devDependencies": { + "ava": "^0.24.0", + "clear-module": "^2.1.0", + "codecov": "^3.0.0", + "commitizen": "^2.9.6", + "cz-conventional-changelog": "^2.0.0", + "dockerode": "^2.5.3", + "eslint-config-prettier": "^2.3.0", + "eslint-plugin-prettier": "^2.3.0", + "file-url": "^2.0.2", + "get-stream": "^3.0.0", + "git-log-parser": "^1.2.0", + "nyc": "^11.1.0", + "p-retry": "^1.0.0", + "prettier": "~1.8.2", + "semantic-release": "^11.0.0", + "sinon": "^4.1.2", + "tempy": "^0.2.1", + "xo": "^0.18.2" + }, + "engines": { + "node": ">=4" + }, + "files": [ + "lib", + "index.js" + ], + "homepage": "https://github.com/semantic-release/git#readme", + "keywords": [ + "changelog", + "commit", + "conventional-changelog", + "conventional-commits", + "git", + "release", + "semantic-release", + "version" + ], + "license": "MIT", + "main": "index.js", + "nyc": { + "include": [ + "lib/**/*.js", + "index.js" + ], + "reporter": [ + "json", + "text", + "html" + ], + "all": true + }, + "prettier": { + "printWidth": 120, + "singleQuote": true, + "bracketSpacing": false, + "trailingComma": "es5" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/semantic-release/git.git" + }, + "scripts": { + "cm": "git-cz", + "codecov": "codecov -f coverage/coverage-final.json", + "lint": "xo", + "pretest": "npm run lint", + "semantic-release": "semantic-release", + "test": "nyc ava -v" + }, + "xo": { + "extends": [ + "prettier" + ], + "plugins": [ + "prettier" + ], + "rules": { + "prettier/prettier": 2 + } + } +} diff --git a/test/get-auth-url.test.js b/test/get-auth-url.test.js new file mode 100644 index 00000000..2b5bfec0 --- /dev/null +++ b/test/get-auth-url.test.js @@ -0,0 +1,26 @@ +import test from 'ava'; +import getAuthUrl from '../lib/get-auth-url'; + +test('Return the same "repositoryUrl" is no "gitCredentials" is defined', t => { + t.is(getAuthUrl('', 'git@host.com:owner/repo.git'), 'git@host.com:owner/repo.git'); +}); + +test('Return the "https" formatted URL if "gitCredentials" is defined and repositoryUrl is a "git" URL', t => { + t.is(getAuthUrl('user:pass', 'git@host.com:owner/repo.git'), 'https://user:pass@host.com/owner/repo.git'); +}); + +test('Return the "https" formatted URL if "gitCredentials" is defined and repositoryUrl is a "https" URL', t => { + t.is(getAuthUrl('user:pass', 'https://host.com/owner/repo.git'), 'https://user:pass@host.com/owner/repo.git'); +}); + +test('Return the "http" formatted URL if "gitCredentials" is defined and repositoryUrl is a "http" URL', t => { + t.is(getAuthUrl('user:pass', 'http://host.com/owner/repo.git'), 'http://user:pass@host.com/owner/repo.git'); +}); + +test('Return the "https" formatted URL if "gitCredentials" is defined and repositoryUrl is a "git+https" URL', t => { + t.is(getAuthUrl('user:pass', 'git+https://host.com/owner/repo.git'), 'https://user:pass@host.com/owner/repo.git'); +}); + +test('Return the "http" formatted URL if "gitCredentials" is defined and repositoryUrl is a "git+http" URL', t => { + t.is(getAuthUrl('user:pass', 'git+http://host.com/owner/repo.git'), 'http://user:pass@host.com/owner/repo.git'); +}); diff --git a/test/get-last-release.test.js b/test/get-last-release.test.js new file mode 100644 index 00000000..7822301f --- /dev/null +++ b/test/get-last-release.test.js @@ -0,0 +1,64 @@ +import test from 'ava'; +import {stub} from 'sinon'; +import getLastRelease from '../lib/get-last-release'; +import {gitRepo, gitCommit, gitTagVersion, gitShallowClone} from './helpers/git-utils'; + +test.beforeEach(t => { + // Save the current working diretory + t.context.cwd = process.cwd(); + // Stub the logger functions + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(t => { + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.serial('Get the highest valid tag', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Create some commits and tags + await gitCommit('First'); + await gitTagVersion('foo'); + await gitCommit('Second'); + await gitTagVersion('v2.0.0'); + await gitCommit('Third'); + await gitTagVersion('v1.0.0'); + await gitCommit('Fourth'); + await gitTagVersion('v3.0'); + + t.deepEqual(await getLastRelease(t.context.logger), {gitHead: 'v2.0.0', version: '2.0.0'}); + t.true(t.context.log.calledWith('Found git tag version %s', 'v2.0.0')); +}); + +test.serial('Return "undefined" if no valid tag is found', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Create some commits and tags + await gitCommit('First'); + await gitTagVersion('foo'); + await gitCommit('Second'); + await gitTagVersion('v2.0.x'); + await gitCommit('Third'); + await gitTagVersion('v3.0'); + + t.falsy(await getLastRelease(t.context.logger)); + t.true(t.context.log.calledWith('No git tag version found')); +}); + +test.serial('Retrieve tags even if not included in the shallow clone', async t => { + // Create a git repository, set the current working directory at the root of the repo + const repo = await gitRepo(); + // Create some commits and tag + await gitCommit('First'); + await gitTagVersion('v2.0.0'); + await gitCommit('Second'); + await gitTagVersion('v1.0.0'); + // Create a shallow clone with only 1 commit (ommiting the commit with the tag v2.0.0) + await gitShallowClone(repo); + + t.deepEqual(await getLastRelease(t.context.logger), {gitHead: 'v2.0.0', version: '2.0.0'}); + t.true(t.context.log.calledWith('Found git tag version %s', 'v2.0.0')); +}); diff --git a/test/git.test.js b/test/git.test.js new file mode 100644 index 00000000..686dab30 --- /dev/null +++ b/test/git.test.js @@ -0,0 +1,229 @@ +import test from 'ava'; +import {outputFile, appendFile} from 'fs-extra'; +import tempy from 'tempy'; +import {unshallow, gitTags, add, getModifiedFiles, config, commit, tag, gitHead, deleteHeadTag, push} from '../lib/git'; +import { + gitRepo, + gitCommit, + gitCheckout, + gitTagVersion, + gitShallowClone, + gitGetCommit, + gitGetConfig, + gitCommitTag, + gitStaged, + gitRemoteTagHead, +} from './helpers/git-utils'; + +test.beforeEach(t => { + // Save the current working diretory + t.context.cwd = process.cwd(); +}); + +test.afterEach.always(t => { + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.serial('Unshallow repository', async t => { + // Create a git repository, set the current working directory at the root of the repo + const repo = await gitRepo(); + // Add commits to the master branch + await gitCommit('First'); + await gitCommit('Second'); + // Create a shallow clone with only 1 commit + await gitShallowClone(repo); + + // Verify the shallow clone contains only one commit + t.is((await gitGetCommit('')).length, 1); + + await unshallow(); + + // Verify the shallow clone contains all the commits + t.is((await gitGetCommit('')).length, 2); +}); + +test.serial('Do not throw error when unshallow a complete repository', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + await gitCommit('First'); + await t.notThrows(unshallow()); +}); + +test.serial('Get the tags in the history of the current branch', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commit to the master branch + await gitCommit('First'); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + // Create the new branch 'other-branch' from master + await gitCheckout('other-branch'); + // Add commit to the 'other-branch' branch + await gitCommit('Second'); + // Create the tag corresponding to version 2.0.0 + await gitTagVersion('v2.0.0'); + // Checkout master + await gitCheckout('master', false); + // Add another commit to the master branch + await gitCommit('Third'); + // Create the tag corresponding to version 3.0.0 + await gitTagVersion('v3.0.0'); + + // Verify the git tag v2.0.0 is not returned as it is not accessible on the current branch + t.deepEqual(await gitTags(), ['v1.0.0', 'v3.0.0']); +}); + +test.serial('Throws error if obtaining the tags fails', async t => { + const dir = tempy.directory(); + process.chdir(dir); + + await t.throws(gitTags()); +}); + +test.serial('Add file to index', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Create files + await outputFile('file1.js', ''); + // Add files and commit + await add(['.']); + + await t.deepEqual(await gitStaged(), ['file1.js']); +}); + +test.serial('Get the modified files, ignoring files in .gitignore but including untracked ones', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Create files + await outputFile('file1.js', ''); + await outputFile('dir/file2.js', ''); + await outputFile('file3.js', ''); + // Create .gitignore to ignore file3.js + await outputFile('.gitignore', 'file.3.js'); + // Add files and commit + await add(['.']); + await commit('Test commit'); + // Update file1.js and dir/file2.js + await appendFile('file1.js', 'Test content'); + await appendFile('dir/file2.js', 'Test content'); + // Add untracked file + await outputFile('file4.js', 'Test content'); + + await t.deepEqual(await getModifiedFiles(), ['file4.js', 'dir/file2.js', 'file1.js']); +}); + +test.serial('Returns [] if there is no modified files', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + + await t.deepEqual(await getModifiedFiles(), []); +}); + +test.serial('Set git config', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add config + await config('user.name', 'username'); + + await t.is(await gitGetConfig('user.name'), 'username'); +}); + +test.serial('Commit added files', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Create files + await outputFile('file1.js', ''); + // Add files and commit + await add(['.']); + await commit('Test commit'); + + await t.true((await gitGetCommit('')).length === 1); +}); + +test.serial('Add tag on head commit', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + const {hash} = await gitCommit('Test commit'); + + await tag('tag_name'); + + await t.is(await gitCommitTag(hash), 'tag_name'); +}); + +test.serial('Get the last commit sha', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + // Add commits to the master branch + const {hash} = await gitCommit('First'); + + const result = await gitHead(); + + t.is(result.substring(0, 7), hash); +}); + +test.serial('Throw error if the last commit sha cannot be found', async t => { + // Create a git repository, set the current working directory at the root of the repo + await gitRepo(); + + await t.throws(gitHead()); +}); + +test.serial('Push tag and commit to remote repository', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + const {hash} = await gitCommit('Test commit'); + await tag('tag_name'); + + await push(repo, 'master'); + + t.is((await gitRemoteTagHead(repo, 'tag_name')).substring(0, 7), hash); +}); + +test.serial('If push fails returns and Error without reference to the repository URL', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + await gitRepo(true); + await gitCommit('Test commit'); + await tag('tag_name'); + + const error = await t.throws(push('http://wrongurl.com/repo.git', 'master'), Error); + t.is(error.message, 'An error occured during the git push to the remote branch master'); +}); + +test.serial('Delete local and remote tag if they reference the local HEAD', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + await tag('tag_name'); + await push(repo, 'master'); + + await deleteHeadTag(repo, 'tag_name'); + + t.deepEqual(await gitTags(), []); + t.falsy(await gitRemoteTagHead(repo, 'tag_name')); +}); + +test.serial('Do not throw error if the tag to delete does not exists', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + + await t.notThrows(deleteHeadTag(repo, 'tag_name')); + t.deepEqual(await gitTags(), []); + t.falsy(await gitRemoteTagHead(repo, 'tag_name')); +}); + +test.serial('Throw a SemanticReleaseError if the tag to delete exists and reference a different sha', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Commit with tag'); + await tag('tag_name'); + await gitCommit('Commit without tag'); + await push(repo, 'master'); + + const error = await t.throws(deleteHeadTag(repo, 'tag_name')); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EGITTAGEXIST'); +}); diff --git a/test/helpers/git-utils.js b/test/helpers/git-utils.js new file mode 100644 index 00000000..2e69d5b0 --- /dev/null +++ b/test/helpers/git-utils.js @@ -0,0 +1,190 @@ +import tempy from 'tempy'; +import execa from 'execa'; +import fileUrl from 'file-url'; +import gitLogParser from 'git-log-parser'; +import getStream from 'get-stream'; + +/** + * Create a temporary git repository. + * If `withRemote` is `true`, creates a bare repository, initialize it and create a shallow clone. Change the current working directory to the clone root. + * If `withRemote` is `false`, creates a regular repository and initialize it. Change the current working directory to the repository root. + * + * @param {Boolean} withRemote `true` to create a shallow clone of a bare repository. + * @param {String} [branc='master'] The branch to initialize. + * @return {String} The path of the clone if `withRemote` is `true`, the path of the repository otherwise. + */ +export async function gitRepo(withRemote, branch = 'master') { + const dir = tempy.directory(); + + process.chdir(dir); + await execa('git', ['init'].concat(withRemote ? ['--bare'] : [])); + + if (withRemote) { + await initBareRepo(fileUrl(dir), branch); + await gitShallowClone(fileUrl(dir)); + } else { + await gitCheckout(branch); + } + return fileUrl(dir); +} + +/** + * Initialize an existing bare repository: + * - Clone the repository + * - Change the current working directory to the clone root + * - Create a default branch + * - Create an initial commits + * - Push to origin + * + * @param {String} origin The URL of the bare repository. + * @param {String} [branch='master'] the branch to initialize. + */ +export async function initBareRepo(origin, branch = 'master') { + const clone = tempy.directory(); + await execa('git', ['clone', '--no-hardlinks', origin, clone]); + process.chdir(clone); + await gitCheckout(branch); + await gitCommit('Initial commit'); + await execa('git', ['push', origin, branch]); +} + +/** + * Create commit on the current git repository. + * + * @param {String} message commit message. + * + * @returns {Commit} The created commits. + */ +export async function gitCommit(message) { + const {stdout} = await execa('git', ['commit', '-m', message, '--allow-empty', '--no-gpg-sign']); + const [, branch, hash] = /^\[(\w+)\(?.*?\)?(\w+)\] .+(?:\n|$)/.exec(stdout); + return {branch, hash, message}; +} + +/** + * Checkout a branch on the current git repository. + * + * @param {String} branch Branch name. + * @param {boolean} create `true` to create the branche ans switch, `false` to only switch. + */ +export async function gitCheckout(branch, create = true) { + await execa('git', create ? ['checkout', '-b', branch] : ['checkout', branch]); +} + +/** + * Create a tag on the head commit in the current git repository. + * + * @param {String} tagName The tag name to create. + * @param {String} [sha] The commit on which to create the tag. If undefined the tag is created on the last commit. + * + * @return {String} The commit sha of the created tag. + */ +export async function gitTagVersion(tagName, sha) { + await execa('git', sha ? ['tag', '-f', tagName, sha] : ['tag', tagName]); + return execa.stdout('git', ['rev-list', '-1', '--tags', tagName]); +} + +/** + * Create a shallow clone of a git repository and change the current working directory to the cloned repository root. + * The shallow will contain a limited number of commit and no tags. + * + * @param {String} origin The path of the repository to clone. + * @param {Number} [depth=1] The number of commit to clone. + * @return {String} The path of the cloned repository. + */ +export async function gitShallowClone(origin, branch = 'master', depth = 1) { + const dir = tempy.directory(); + + process.chdir(dir); + await execa('git', ['clone', '--no-hardlinks', '--no-tags', '-b', branch, '--depth', depth, origin, dir]); + return dir; +} + +/** + * Get Git configuration. + * + * @param {String} name Config name. + */ +export async function gitGetConfig(name) { + return execa.stdout('git', ['config', '--get', name]); +} + +/** + * Get the tag associated with a commit sha. + * + * @param {String} gitHead The commit sha for which to retrieve the associated tag. + * + * @return {String} The tag associatedwith the sha in parameter or `null`. + */ +export async function gitCommitTag(gitHead) { + return execa.stdout('git', ['describe', '--tags', '--exact-match', gitHead]); +} + +/** + * @return {Array} Array of staged files path. + */ +export async function gitStaged() { + return (await execa.stdout('git', ['status', '--porcelain'])) + .split('\n') + .filter(status => status.startsWith('A ')) + .map(status => status.match(/^A\s+(.+)$/)[1]); +} + +/** + * Get the first commit sha referenced by the tag `tagName`. + * + * @param {String} origin The repository remote URL. + * @param {String} tagName The tag name to seach for. + * @return {String} The sha of the commit associated with `tagName` on the remote repository. + */ +export async function gitRemoteTagHead(origin, tagName) { + return (await execa.stdout('git', ['ls-remote', '--tags', origin, tagName])) + .split('\n') + .filter(tag => Boolean(tag)) + .map(tag => tag.match(/^(\S+)/)[1])[0]; +} + +/** + * Get the list of files included in a commit. + * + * @param {String} [ref='HEAD'] The git reference for which to retrieve the files. + * @return {Array} The list of files path included in the commit. + */ +export async function gitCommitedFiles(ref = 'HEAD') { + return (await execa.stdout('git', ['diff-tree', '-r', '--name-only', '--no-commit-id', '-r', ref])) + .split('\n') + .filter(file => Boolean(file)); +} + +/** + * Get the list of parsed commits since a git reference. + * + * @param {String} [from='HEAD~1'] Git reference from which to seach commits. + * @return {Array} The list of parsed commits. + */ +export async function gitGetCommit(from = 'HEAD~1') { + Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}}); + return (await getStream.array(gitLogParser.parse({_: `${from ? from + '..' : ''}HEAD`}))).map(commit => { + commit.message = commit.message.trim(); + commit.gitTags = commit.gitTags.trim(); + return commit; + }); +} + +/** + * Create a git repo with a detached head from another git repository and change the current working directory to the new repository root. + * + * @param {String} origin The path of the repository to clone. + * @param {Number} head A commit sha of the origin repo that will become the detached head of the new one. + * @return {String} The path of the new repository. + */ +export async function gitDetachedHead(origin, head) { + const dir = tempy.directory(); + + process.chdir(dir); + await execa('git', ['init']); + await execa('git', ['remote', 'add', 'origin', origin]); + await execa('git', ['fetch']); + await execa('git', ['checkout', head]); + return dir; +} diff --git a/test/helpers/gitbox.js b/test/helpers/gitbox.js new file mode 100644 index 00000000..3460719e --- /dev/null +++ b/test/helpers/gitbox.js @@ -0,0 +1,75 @@ +import Docker from 'dockerode'; +import getStream from 'get-stream'; +import pRetry from 'p-retry'; +import {initBareRepo, gitShallowClone} from './git-utils'; + +const IMAGE = 'nmarus/docker-gitbox'; +const SERVER_PORT = 80; +const HOST_PORT = 2080; +const SERVER_HOST = 'localhost'; +const GIT_USERNAME = 'integration'; +const GIT_PASSWORD = 'suchsecure'; +const docker = new Docker(); +let container; + +const gitCredential = `${GIT_USERNAME}:${GIT_PASSWORD}`; + +/** + * Download the `gitbox` Docker image, create a new container and start it. + * + * @return {Promise} Promise that resolves when the container is started. + */ +async function start() { + await getStream(await docker.pull(IMAGE)); + + container = await docker.createContainer({ + Tty: true, + Image: IMAGE, + PortBindings: {[`${SERVER_PORT}/tcp`]: [{HostPort: `${HOST_PORT}`}]}, + }); + await container.start(); + + const exec = await container.exec({ + Cmd: ['ng-auth', '-u', GIT_USERNAME, '-p', GIT_PASSWORD], + AttachStdout: true, + AttachStderr: true, + }); + await exec.start(); +} + +/** + * Stop and remote the `mockserver` Docker container. + * + * @return {Promise} Promise that resolves when the container is stopped. + */ +async function stop() { + await container.stop(); + await container.remove(); +} + +/** + * Initialize a remote repository and creates a shallow clone. + * + * @param {String} name The remote repository name. + * @param {String} [branch='master'] The branch to initialize. + * @param {String} [description=`Repository ${name}`] The repository description. + * @return {Object} The `repositoryUrl` (URL without auth) and `authUrl` (URL with auth). + */ +async function createRepo(name, branch = 'master', description = `Repository ${name}`) { + const exec = await container.exec({ + Cmd: ['repo-admin', '-n', name, '-d', description], + AttachStdout: true, + AttachStderr: true, + }); + await exec.start(); + + const repositoryUrl = `http://${SERVER_HOST}:${HOST_PORT}/git/${name}.git`; + const authUrl = `http://${gitCredential}@${SERVER_HOST}:${HOST_PORT}/git/${name}.git`; + + // Retry as the server might take a few ms to make the repo available push + await pRetry(() => initBareRepo(authUrl), {retries: 3, minTimeout: 500, factor: 2}); + await gitShallowClone(authUrl); + return {repositoryUrl, authUrl}; +} + +export default {start, stop, gitCredential, createRepo}; diff --git a/test/integration.test.js b/test/integration.test.js new file mode 100644 index 00000000..c1553154 --- /dev/null +++ b/test/integration.test.js @@ -0,0 +1,288 @@ +import {outputFile, readFile} from 'fs-extra'; +import test from 'ava'; +import {stub} from 'sinon'; +import clearModule from 'clear-module'; +import {push, tag, add} from '../lib/git'; +import { + gitCommit, + gitShallowClone, + gitDetachedHead, + gitCommitedFiles, + gitGetCommit, + gitCheckout, +} from './helpers/git-utils'; +import gitbox from './helpers/gitbox'; + +test.before(async () => { + // Start the local NPM registry + await gitbox.start(); +}); + +test.beforeEach(t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env paramaters that could have been set on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GIT_CREDENTIALS; + delete process.env.GIT_EMAIL; + delete process.env.GIT_USERNAME; + // Save the current working diretory + t.context.cwd = process.cwd(); + // Clear npm cache to refresh the module state + clearModule('..'); + t.context.m = require('..'); + // Stub the logger + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test.after.always(async () => { + await gitbox.stop(); +}); + +test.serial('Throws error if "gitCredentials" is invalid', async t => { + process.env.GIT_CREDENTIALS = 'user:wrong_pass'; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl} = await gitbox.createRepo('unauthorized', branch); + + const error = await t.throws( + t.context.m.verifyConditions( + {gitCredentials: 'user:wrong_pass'}, + {logger: t.context.logger, options: {repositoryUrl, branch}} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EGITNOPERMISSION'); + t.is(error.message, `The git credentials doesn't allow to push on the branch ${branch} of ${repositoryUrl}.`); +}); + +test.serial('Verify git "gitCredentials", set with "GIT_CREDENTIALS"', async t => { + process.env.GIT_CREDENTIALS = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl} = await gitbox.createRepo('authorized', branch); + + await t.notThrows(t.context.m.verifyConditions({}, {logger: t.context.logger, options: {repositoryUrl, branch}})); +}); + +test.serial('Verify git "gitCredentials", set with "GITHUB_TOKEN"', async t => { + process.env.GITHUB_TOKEN = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl} = await gitbox.createRepo('authorized-github', branch); + + await t.notThrows(t.context.m.verifyConditions({}, {logger: t.context.logger, options: {repositoryUrl, branch}})); +}); + +test.serial('Return "undefined" if no version tag exists', async t => { + process.env.GITHUB_TOKEN = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl, authUrl} = await gitbox.createRepo('no-release', branch); + + await gitCommit('First'); + await tag('not-a-version'); + push(authUrl, branch); + + t.falsy(await t.context.m.getLastRelease({}, {logger: t.context.logger, options: {repositoryUrl, branch}})); +}); + +test.serial('Return last version published, even if tags are not in the shallow clone', async t => { + process.env.GH_TOKEN = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl, authUrl} = await gitbox.createRepo('with-release', branch); + + await gitCommit('First'); + await tag('v1.0.0'); + await gitCommit('Second'); + await tag('v2.0.0'); + await gitCommit('Third'); + await tag('not-a-version'); + await push(authUrl, branch); + + await gitShallowClone(authUrl); + + const lastRelease = await t.context.m.getLastRelease( + {}, + {logger: t.context.logger, options: {repositoryUrl, branch}} + ); + t.deepEqual(lastRelease, {gitHead: 'v2.0.0', version: '2.0.0'}); +}); + +test.serial('Return last version published from a detached head repository', async t => { + process.env.GH_TOKEN = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl, authUrl} = await gitbox.createRepo('with-release-detached-head', branch); + + await gitCommit('First'); + await tag('v1.0.0'); + await gitCommit('Second'); + await tag('v2.0.0'); + const {hash: remoteHead} = await gitCommit('Third'); + await tag('not-a-version'); + await push(authUrl, branch); + await gitCheckout('other-branch'); + await gitCommit('Second'); + await tag('v4.0.0'); + await push(authUrl, 'other-branch'); + await gitCheckout(branch, false); + + await gitDetachedHead(authUrl, remoteHead); + + const lastRelease = await t.context.m.getLastRelease( + {}, + {logger: t.context.logger, options: {repositoryUrl, branch}} + ); + t.deepEqual(lastRelease, {gitHead: 'v2.0.0', version: '2.0.0'}); +}); + +test.serial('Publish from a shallow clone', async t => { + process.env.GH_TOKEN = gitbox.gitCredential; + process.env.GIT_EMAIL = 'user@email.com'; + process.env.GIT_USERNAME = 'user'; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl, authUrl} = await gitbox.createRepo('publish', branch); + await outputFile('CHANGELOG.md', 'Version 1.0.0 changelog'); + await outputFile('package.json', "{name: 'test-package', version: '1.0.0'}"); + await outputFile('dist/file.js', 'Initial content'); + await outputFile('dist/file.css', 'Initial content'); + await add('.'); + await gitCommit('First'); + await tag('v1.0.0'); + await push(authUrl, branch); + await gitShallowClone(authUrl); + await outputFile('package.json', "{name: 'test-package', version: '2.0.0'}"); + await outputFile('dist/file.js', 'Updated content'); + await outputFile('dist/file.css', 'Updated content'); + + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Version 2.0.0 changelog'}; + const pluginConfig = { + message: `Release version \${nextRelease.version} from branch \${branch}\n\n\${nextRelease.notes}`, + assets: '**/*.{js,json}', + }; + await t.context.m.publish(pluginConfig, {logger: t.context.logger, options: {repositoryUrl, branch}, nextRelease}); + + t.is((await readFile('CHANGELOG.md')).toString(), `${nextRelease.notes}\n\nVersion 1.0.0 changelog\n`); + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md', 'dist/file.js', 'package.json']); + const commit = (await gitGetCommit())[0]; + t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch}`); + t.is(commit.body, `${nextRelease.notes}\n`); + t.is(commit.gitTags, `(HEAD -> ${branch}, tag: ${nextRelease.gitTag})`); + t.is(commit.author.name, process.env.GIT_USERNAME); + t.is(commit.author.email, process.env.GIT_EMAIL); +}); + +test.serial('Publish from a detached head repository', async t => { + process.env.GH_TOKEN = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl, authUrl} = await gitbox.createRepo('publish-detached-head', branch); + await outputFile('CHANGELOG.md', 'Version 1.0.0 changelog'); + await outputFile('package.json', "{name: 'test-package', version: '1.0.0'}"); + await outputFile('dist/file.js', 'Initial content'); + await outputFile('dist/file.css', 'Initial content'); + await add('.'); + const {hash: remoteHead} = await gitCommit('First'); + await tag('v1.0.0'); + await push(authUrl, branch); + await gitDetachedHead(authUrl, remoteHead); + await outputFile('package.json', "{name: 'test-package', version: '2.0.0'}"); + await outputFile('dist/file.js', 'Updated content'); + await outputFile('dist/file.css', 'Updated content'); + + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Version 2.0.0 changelog'}; + const pluginConfig = { + message: `Release version \${nextRelease.version} from branch \${branch}\n\n\${nextRelease.notes}`, + assets: '**/*.{js,json}', + }; + await t.context.m.publish(pluginConfig, {logger: t.context.logger, options: {repositoryUrl, branch}, nextRelease}); + + t.is((await readFile('CHANGELOG.md')).toString(), `${nextRelease.notes}\n\nVersion 1.0.0 changelog\n`); + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md', 'dist/file.js', 'package.json']); + const commit = (await gitGetCommit())[0]; + t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch}`); + t.is(commit.body, `${nextRelease.notes}\n`); + t.is(commit.gitTags, `(HEAD, tag: ${nextRelease.gitTag})`); +}); + +test.serial('Verify authentication only on the fist call', async t => { + process.env.GIT_CREDENTIALS = gitbox.gitCredential; + const branch = 'master'; + // Create a remote repo, initialize it, create a local shallow clone and set the cwd to the clone + const {repositoryUrl} = await gitbox.createRepo('complete-release', branch); + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Version 2.0.0 changelog'}; + + await t.notThrows(t.context.m.verifyConditions({}, {logger: t.context.logger, options: {repositoryUrl, branch}})); + t.falsy(await t.context.m.getLastRelease({}, {logger: t.context.logger, options: {repositoryUrl, branch}})); + await t.context.m.publish({}, {logger: t.context.logger, options: {repositoryUrl, branch}, nextRelease}); +}); + +test('Throw SemanticReleaseError if publish "assets" option is not a string or false or an array of objects', async t => { + const assets = true; + const error = await t.throws( + t.context.m.verifyConditions( + {}, + {options: {publish: ['@semantic-release/npm', {path: '@semantic-release/git', assets}]}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test('Throw SemanticReleaseError if publish "assets" option is not an Array with invalid elements', async t => { + const assets = ['file.js', 42]; + const error = await t.throws( + t.context.m.verifyConditions( + {}, + {options: {publish: ['@semantic-release/npm', {path: '@semantic-release/git', assets}]}, logger: t.context.logger} + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test('Throw SemanticReleaseError if publish "message" option is not a String', async t => { + const message = 42; + const error = await t.throws( + t.context.m.verifyConditions( + {}, + { + options: {publish: ['@semantic-release/npm', {path: '@semantic-release/git', message}]}, + logger: t.context.logger, + } + ) + ); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDMESSAGE'); +}); + +test('Throw SemanticReleaseError if publish "message" option is a whitespace String', async t => { + const message = ' \n \r '; + const error = await t.throws( + t.context.m.verifyConditions( + {}, + { + options: {publish: ['@semantic-release/npm', {path: '@semantic-release/git', message}]}, + logger: t.context.logger, + } + ) + ); + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDMESSAGE'); +}); diff --git a/test/publish.test.js b/test/publish.test.js new file mode 100644 index 00000000..573a1c5c --- /dev/null +++ b/test/publish.test.js @@ -0,0 +1,269 @@ +import test from 'ava'; +import {outputFile, readFile} from 'fs-extra'; +import {stub} from 'sinon'; +import publish from '../lib/publish'; +import {gitRepo, gitGetCommit, gitRemoteTagHead, gitCommitedFiles} from './helpers/git-utils'; + +test.beforeEach(async t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env variables in case they are on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.GIT_CREDENTIALS; + delete process.env.GIT_EMAIL; + delete process.env.GIT_USERNAME; + // Stub the logger functions + t.context.log = stub(); + t.context.logger = {log: t.context.log}; + // Create a git repository with a remote, set the current working directory at the root of the repo + t.context.repositoryUrl = await gitRepo(true); + t.context.branch = 'master'; + t.context.options = {repositoryUrl: t.context.repositoryUrl, branch: t.context.branch}; +}); + +test.afterEach.always(t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); +}); + +test.serial('With default values', async t => { + const pluginConfig = {}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the remote repo has a the version referencing the same commit sha at the local head + const commit = (await gitGetCommit())[0]; + t.is(await gitRemoteTagHead(t.context.repositoryUrl, nextRelease.gitTag), commit.hash); + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md']); + // Verify the content of the CHANGELOG.md + t.is((await readFile('CHANGELOG.md')).toString(), `${nextRelease.notes}\n`); + + t.is(commit.subject, `chore(release): ${nextRelease.version} [skip ci]`); + t.is(commit.body, `${nextRelease.notes}\n`); + t.is(commit.gitTags, `(HEAD -> ${t.context.branch}, tag: ${nextRelease.gitTag})`); + + t.is(commit.author.name, 'semantic-release-bot'); + t.is(commit.author.email, 'semantic-release-bot@martynus.net'); + + t.true(t.context.log.calledWith('Create %s', 'CHANGELOG.md')); + t.true(t.context.log.calledWith('Add %s to the release commit', 'CHANGELOG.md')); + t.true(t.context.log.calledWith('Found %d file(s) to commit', 1)); + t.true(t.context.log.calledWith('Creating tag %s', nextRelease.gitTag)); + t.true(t.context.log.calledWith('Published Github release: %s', nextRelease.gitTag)); +}); + +test.serial('Commit package.json and npm-shrinkwrap.json if they exists and have been changed', async t => { + const pluginConfig = {}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + await outputFile('package.json', "{name: 'test-package'}"); + await outputFile('npm-shrinkwrap.json', "{name: 'test-package'}"); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md', 'npm-shrinkwrap.json', 'package.json']); + t.true(t.context.log.calledWith('Add %s to the release commit', 'package.json')); + t.true(t.context.log.calledWith('Add %s to the release commit', 'npm-shrinkwrap.json')); + t.true(t.context.log.calledWith('Found %d file(s) to commit', 3)); +}); + +test.serial('Prepend the CHANGELOG.md if there is an existing one', async t => { + const pluginConfig = {}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + await outputFile('CHANGELOG.md', 'Initial CHANGELOG'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md']); + // Verify the content of the CHANGELOG.md + t.is((await readFile('CHANGELOG.md')).toString(), `${nextRelease.notes}\n\nInitial CHANGELOG\n`); + // Verify the commit message contains on the new release notes + const commit = (await gitGetCommit())[0]; + t.is(commit.subject, `chore(release): ${nextRelease.version} [skip ci]`); + t.is(commit.body, `${nextRelease.notes}\n`); +}); + +test.serial('Disable CHANGELOG.md update', async t => { + const pluginConfig = {changelog: false}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + await outputFile('CHANGELOG.md', 'Initial CHANGELOG'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), []); + // Verify the content of the CHANGELOG.md + t.is((await readFile('CHANGELOG.md')).toString(), 'Initial CHANGELOG'); +}); + +test.serial('Skip CHANGELOG.md update is the release is empty', async t => { + const pluginConfig = {changelog: true}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; + await outputFile('CHANGELOG.md', 'Initial CHANGELOG'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), []); + // Verify the content of the CHANGELOG.md + t.is((await readFile('CHANGELOG.md')).toString(), 'Initial CHANGELOG'); +}); + +test.serial('Exclude package.json and and npm-shrinkwrap.json if "assets" is defined without it', async t => { + const pluginConfig = {assets: []}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + await outputFile('CHANGELOG.md', 'Initial CHANGELOG'); + await outputFile('package.json', "{name: 'test-package'}"); + await outputFile('npm-shrinkwrap.json', "{name: 'test-package'}"); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md']); +}); + +test.serial('Allow to customize the commit message', async t => { + const pluginConfig = { + message: `Release version \${nextRelease.version} from branch \${branch} + +Last release: \${lastRelease.version} +\${nextRelease.notes}`, + }; + const lastRelease = {version: 'v1.0.0'}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + await outputFile('CHANGELOG.md', 'Initial CHANGELOG'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md']); + // Verify the content of the CHANGELOG.md + t.is((await readFile('CHANGELOG.md')).toString(), `${nextRelease.notes}\n\nInitial CHANGELOG\n`); + // Verify the commit message contains on the new release notes + const commit = (await gitGetCommit())[0]; + t.is(commit.subject, `Release version ${nextRelease.version} from branch ${t.context.branch}`); + t.is(commit.body, `Last release: ${lastRelease.version}\n${nextRelease.notes}\n`); +}); + +test.serial('Commit files matching the patterns in "assets"', async t => { + const pluginConfig = { + assets: ['file1.js', '*1.js', ['dir/*.js', '!dir/*.css'], 'file5.js', 'dir2', ['**/*.js', '!**/*.js']], + }; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + + // Create .gitignore to ignore file5.js + await outputFile('.gitignore', 'file5.js'); + + await outputFile('file1.js', 'Test content'); + await outputFile('dir/file2.js', 'Test content'); + await outputFile('dir/file3.css', 'Test content'); + await outputFile('file4.js', 'Test content'); + await outputFile('file5.js', 'Test content'); + await outputFile('dir2/file6.js', 'Test content'); + await outputFile('dir2/file7.css', 'Test content'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify file2 and file1 have been commited + // file4.js is excluded as no glob matching + // file3.css is ignored due to the negative glob '!dir/*.css' + // file5.js is ignore because it's in the .gitignore + // file6.js and file7.css are included because dir2 is expanded + t.deepEqual(await gitCommitedFiles(), [ + 'CHANGELOG.md', + 'dir/file2.js', + 'dir2/file6.js', + 'dir2/file7.css', + 'file1.js', + ]); + + // Found 6 files as file5.js is referenced in `asset` but ignored due to .gitignore + t.true(t.context.log.calledWith('Found %d file(s) to commit', 6)); +}); + +test.serial('Commit files matching the patterns in "assets" as Objects', async t => { + const pluginConfig = { + assets: ['file1.js', {path: ['dir/*.js', '!dir/*.css']}, {path: 'file5.js'}, 'dir2'], + }; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + + // Create .gitignore to ignore file5.js + await outputFile('.gitignore', 'file5.js'); + + await outputFile('file1.js', 'Test content'); + await outputFile('dir/file2.js', 'Test content'); + await outputFile('dir/file3.css', 'Test content'); + await outputFile('file4.js', 'Test content'); + await outputFile('file5.js', 'Test content'); + await outputFile('dir2/file6.js', 'Test content'); + await outputFile('dir2/file7.css', 'Test content'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + // Verify file2 and file1 have been commited + // file4.js is excluded as no glob matching + // file3.css is ignored due to the negative glob '!dir/*.css' + // file5.js is ignore because it's in the .gitignore + // file6.js and file7.css are included because dir2 is expanded + t.deepEqual(await gitCommitedFiles(), [ + 'CHANGELOG.md', + 'dir/file2.js', + 'dir2/file6.js', + 'dir2/file7.css', + 'file1.js', + ]); + // Found 6 files as file5.js is referenced in `asset` but ignored due to .gitignore + t.true(t.context.log.calledWith('Found %d file(s) to commit', 6)); +}); + +test.serial('Commit files matching the patterns in "assets" as single glob', async t => { + const pluginConfig = {assets: 'dist/**/*.js'}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + + await outputFile('dist/file1.js', 'Test content'); + await outputFile('dist/file2.css', 'Test content'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md', 'dist/file1.js']); + t.true(t.context.log.calledWith('Found %d file(s) to commit', 2)); +}); + +test.serial('Commit files matching the patterns in "assets", including dot files', async t => { + const pluginConfig = {assets: 'dist'}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + + await outputFile('dist/.dotfile', 'Test content'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md', 'dist/.dotfile']); + t.true(t.context.log.calledWith('Found %d file(s) to commit', 2)); +}); + +test.serial('Skip negated pattern if its alone in its group', async t => { + const pluginConfig = {assets: '!**/*'}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; + + await outputFile('file.js', 'Test content'); + + await publish(pluginConfig, t.context.options, lastRelease, nextRelease, t.context.logger); + + t.deepEqual(await gitCommitedFiles(), ['CHANGELOG.md']); + t.true(t.context.log.calledWith('Found %d file(s) to commit', 1)); +}); diff --git a/test/verify.test.js b/test/verify.test.js new file mode 100644 index 00000000..2fffe0cc --- /dev/null +++ b/test/verify.test.js @@ -0,0 +1,146 @@ +import test from 'ava'; +import {stub} from 'sinon'; +import verify from '../lib/verify'; +import {gitRepo, gitCommit} from './helpers/git-utils'; + +test.beforeEach(t => { + // Save the current process.env + t.context.env = Object.assign({}, process.env); + // Delete env paramaters that could have been set on the machine running the tests + delete process.env.GH_TOKEN; + delete process.env.git_TOKEN; + delete process.env.GIT_CREDENTIALS; + delete process.env.GIT_EMAIL; + delete process.env.GIT_USERNAME; + // Save the current working diretory + t.context.cwd = process.cwd(); + // Stub the logger functions + t.context.log = stub(); + t.context.logger = {log: t.context.log}; +}); + +test.afterEach.always(t => { + // Restore process.env + process.env = Object.assign({}, t.context.env); + // Restore the current working directory + process.chdir(t.context.cwd); +}); + +test('Throw SemanticReleaseError if "assets" option is not a String or false or an Array of Objects', async t => { + const assets = true; + const error = await t.throws(verify({assets}, {}, t.context.logger)); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test('Throw SemanticReleaseError if "assets" option is not an Array with invalid elements', async t => { + const assets = ['file.js', 42]; + const error = await t.throws(verify({assets}, {}, t.context.logger)); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDASSETS'); +}); + +test.serial('Verify "assets" is a String', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = 'file2.js'; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test.serial('Verify "assets" is an Array of String', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = ['file1.js', 'file2.js']; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test.serial('Verify "assets" is an Object with a path property', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = {path: 'file2.js'}; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test.serial('Verify "assets" is an Array of Object with a path property', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = [{path: 'file1.js'}, {path: 'file2.js'}]; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test.serial('Verify disabled "assets" (set to false)', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = false; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test.serial('Verify "assets" is an Array of glob Arrays', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = [['dist/**', '!**/*.js'], 'file2.js']; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test.serial('Verify "assets" is an Array of Object with a glob Arrays in path property', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + const assets = [{path: ['dist/**', '!**/*.js']}, {path: 'file2.js'}]; + + await t.notThrows(verify({assets}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +}); + +test('Throw SemanticReleaseError if "message" option is not a String', async t => { + const message = 42; + const error = await t.throws(verify({message}, {}, t.context.logger)); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDMESSAGE'); +}); + +test('Throw SemanticReleaseError if "message" option is an empty String', async t => { + const message = ''; + const error = await t.throws(verify({message}, {}, t.context.logger)); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDMESSAGE'); +}); + +test('Throw SemanticReleaseError if "message" option is a whitespace String', async t => { + const message = ' \n \r '; + const error = await t.throws(verify({message}, {}, t.context.logger)); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDMESSAGE'); +}); + +test('Throw SemanticReleaseError if "changelog" option is not a Boolean', async t => { + const changelog = 42; + const error = await t.throws(verify({changelog}, {}, t.context.logger)); + + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDCHANGELOG'); +}); + +test.serial('Verify undefined "message" and "assets"', async t => { + // Create a git repository with a remote, set the current working directory at the root of the repo + const repo = await gitRepo(true); + await gitCommit('Test commit'); + + await t.notThrows(verify({}, {repositoryUrl: repo, branch: 'master'}, t.context.logger)); +});