├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib ├── definitions │ └── errors.js ├── get-error.js ├── git.js ├── prepare.js ├── resolve-config.js └── verify.js ├── package-lock.json ├── package.json └── test ├── git.test.js ├── helpers └── git-utils.js ├── integration.test.js ├── prepare.test.js └── verify.test.js /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | - next 7 | - beta 8 | - "*.x" 9 | permissions: 10 | contents: read # for checkout 11 | jobs: 12 | release: 13 | permissions: 14 | contents: write # to be able to publish a GitHub release 15 | issues: write # to be able to comment on released issues 16 | pull-requests: write # to be able to comment on released pull requests 17 | id-token: write # to enable use of OIDC for npm provenance 18 | name: release 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3 22 | - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 23 | with: 24 | cache: npm 25 | node-version: lts/* 26 | - run: npm ci 27 | - run: npx semantic-release 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_BOT_NPM_TOKEN }} 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | "on": 3 | push: 4 | branches: 5 | - master 6 | - renovate/** 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | jobs: 12 | test_matrix: 13 | strategy: 14 | matrix: 15 | node-version: 16 | - '14.17' 17 | - 16 18 | os: 19 | - ubuntu-latest 20 | - macos-latest 21 | - windows-latest 22 | runs-on: "${{ matrix.os }}" 23 | steps: 24 | - uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3 25 | - run: git config --global user.name github-actions 26 | - run: git config --global user.email github-actions@github.com 27 | - name: "Use Node.js ${{ matrix.node-version }}" 28 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 29 | with: 30 | node-version: "${{ matrix.node-version }}" 31 | cache: npm 32 | - run: npm ci 33 | - name: Ensure dependencies are compatible with the version of node 34 | run: npx ls-engines 35 | - run: "npm run test:ci" 36 | test: 37 | runs-on: ubuntu-latest 38 | needs: test_matrix 39 | steps: 40 | - uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3 41 | - name: "Use Node.js ${{ matrix.node-version }}" 42 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2 43 | with: 44 | node-version: "${{ matrix.node-version }}" 45 | cache: npm 46 | - run: npm ci 47 | - run: npm run lint 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos,windows,linux,node 3 | 4 | ### Linux ### 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | 19 | ### macOS ### 20 | *.DS_Store 21 | .AppleDouble 22 | .LSOverride 23 | 24 | # Icon must end with two \r 25 | Icon 26 | 27 | # Thumbnails 28 | ._* 29 | 30 | # Files that might appear in the root of a volume 31 | .DocumentRevisions-V100 32 | .fseventsd 33 | .Spotlight-V100 34 | .TemporaryItems 35 | .Trashes 36 | .VolumeIcon.icns 37 | .com.apple.timemachine.donotpresent 38 | 39 | # Directories potentially created on remote AFP share 40 | .AppleDB 41 | .AppleDesktop 42 | Network Trash Folder 43 | Temporary Items 44 | .apdisk 45 | 46 | ### Node ### 47 | # Logs 48 | logs 49 | *.log 50 | npm-debug.log* 51 | yarn-debug.log* 52 | yarn-error.log* 53 | 54 | # Runtime data 55 | pids 56 | *.pid 57 | *.seed 58 | *.pid.lock 59 | 60 | # Directory for instrumented libs generated by jscoverage/JSCover 61 | lib-cov 62 | 63 | # Coverage directory used by tools like istanbul 64 | coverage 65 | 66 | # nyc test coverage 67 | .nyc_output 68 | 69 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 70 | .grunt 71 | 72 | # Bower dependency directory (https://bower.io/) 73 | bower_components 74 | 75 | # node-waf configuration 76 | .lock-wscript 77 | 78 | # Compiled binary addons (http://nodejs.org/api/addons.html) 79 | build/Release 80 | 81 | # Dependency directories 82 | node_modules/ 83 | jspm_packages/ 84 | 85 | # Typescript v1 declaration files 86 | typings/ 87 | 88 | # Optional npm cache directory 89 | .npm 90 | 91 | # Optional eslint cache 92 | .eslintcache 93 | 94 | # Optional REPL history 95 | .node_repl_history 96 | 97 | # Output of 'npm pack' 98 | *.tgz 99 | 100 | # Yarn Integrity file 101 | .yarn-integrity 102 | 103 | # dotenv environment variables file 104 | .env 105 | 106 | 107 | ### Windows ### 108 | # Windows thumbnail cache files 109 | Thumbs.db 110 | ehthumbs.db 111 | ehthumbs_vista.db 112 | 113 | # Folder config file 114 | Desktop.ini 115 | 116 | # Recycle Bin used on file shares 117 | $RECYCLE.BIN/ 118 | 119 | # Windows Installer files 120 | *.cab 121 | *.msi 122 | *.msm 123 | *.msp 124 | 125 | # Windows shortcuts 126 | *.lnk 127 | 128 | # End of https://www.gitignore.io/api/macos,windows,linux,node 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Pierre-Denis Vanduynslager 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @semantic-release/git 2 | 3 | [**semantic-release**](https://github.com/semantic-release/semantic-release) plugin to commit release assets to the project's [git](https://git-scm.com/) repository. 4 | 5 | > [!WARNING] 6 | > You likely _do not_ need this plugin to accomplish your goals with semantic-release. 7 | > Please consider our [recommendation against making commits during your release](https://semantic-release.gitbook.io/semantic-release/support/faq#making-commits-during-the-release-process-adds-significant-complexity) to avoid unnecessary headaches. 8 | 9 | [![Build Status](https://github.com/semantic-release/git/workflows/Test/badge.svg)](https://github.com/semantic-release/git/actions?query=workflow%3ATest+branch%3Amaster) [![npm latest version](https://img.shields.io/npm/v/@semantic-release/git/latest.svg)](https://www.npmjs.com/package/@semantic-release/git) 10 | [![npm next version](https://img.shields.io/npm/v/@semantic-release/git/next.svg)](https://www.npmjs.com/package/@semantic-release/git) 11 | [![npm beta version](https://img.shields.io/npm/v/@semantic-release/git/beta.svg)](https://www.npmjs.com/package/@semantic-release/git) 12 | 13 | | Step | Description | 14 | |--------------------|------------------------------------------------------------------------------------------------------------------------------------| 15 | | `verifyConditions` | Verify the access to the remote Git repository, the commit [`message`](#message) and the [`assets`](#assets) option configuration. | 16 | | `prepare` | Create a release commit, including configurable file assets. | 17 | 18 | ## Install 19 | 20 | ```bash 21 | $ npm install @semantic-release/git -D 22 | ``` 23 | 24 | ## Usage 25 | 26 | The plugin can be configured in the [**semantic-release** configuration file](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/configuration.md#configuration): 27 | 28 | ```json 29 | { 30 | "plugins": [ 31 | "@semantic-release/commit-analyzer", 32 | "@semantic-release/release-notes-generator", 33 | ["@semantic-release/git", { 34 | "assets": ["dist/**/*.{js,css}", "docs", "package.json"], 35 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 36 | }] 37 | ] 38 | } 39 | ``` 40 | 41 | With this example, for each release a release commit will be pushed to the remote Git repository with: 42 | - a message formatted like `chore(release): [skip ci]\n\n` 43 | - the `.js` and `.css` files in the `dist` directory, the files in the `docs` directory and the `package.json` 44 | 45 | ### Merging between semantic-release branches 46 | 47 | This plugin will, by default, create commit messages with the keyword `[skip ci]`, so they won't trigger a new unnecessary CI build. If you are using **semantic-release** with [multiple branches](https://github.com/semantic-release/semantic-release/blob/beta/docs/usage/workflow-configuration.md), when merging a branch with a head being a release commit, a CI job will be triggered on the target branch. Depending on the CI service that might create an unexpected behavior as the head of the target branch might be ignored by the build due to the `[skip ci]` keyword. 48 | 49 | To avoid any unexpected behavior we recommend to use the [`--no-ff` option](https://git-scm.com/docs/git-merge#Documentation/git-merge.txt---no-ff) when merging branches used by **semantic-release**. 50 | 51 | **Note**: This concerns only merges done between two branches configured in the [`branches` option](https://github.com/semantic-release/semantic-release/blob/beta/docs/usage/configuration.md#branches). 52 | 53 | ## Configuration 54 | 55 | ### Git authentication 56 | 57 | The Git user associated with the [Git credentials](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/ci-configuration.md#authentication) has to be able to push commit to the [release branch](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/configuration.md#branch). 58 | 59 | When configuring branches permission on a Git hosting service (e.g. [GitHub protected branches](https://help.github.com/articles/about-protected-branches), [GitLab protected branches](https://docs.gitlab.com/ee/user/project/protected_branches.html) or [Bitbucket branch permissions](https://confluence.atlassian.com/bitbucket/branch-permissions-385912271.html)) it might be necessary to create a specific configuration in order to allow the **semantic-release** user to bypass global restrictions. For example on GitHub you can uncheck "Include administrators" and configure **semantic-release** to use an administrator user, so the plugin can push the release commit without requiring [status checks](https://help.github.com/articles/about-required-status-checks) and [pull request reviews](https://help.github.com/articles/about-required-reviews-for-pull-requests). 60 | 61 | ### Environment variables 62 | 63 | | Variable | Description | Default | 64 | |-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| 65 | | `GIT_AUTHOR_NAME` | The author name associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot. | 66 | | `GIT_AUTHOR_EMAIL` | The author email associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot email address. | 67 | | `GIT_COMMITTER_NAME` | The committer name associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot. | 68 | | `GIT_COMMITTER_EMAIL` | The committer email associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot email address. | 69 | 70 | ### Options 71 | 72 | | Options | Description | Default | 73 | |-----------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| 74 | | `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` | 75 | | `assets` | Files to include in the release commit. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` | 76 | 77 | #### `message` 78 | 79 | The message for the release commit is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: 80 | 81 | | Parameter | Description | 82 | |---------------------|-----------------------------------------------------------------------------------------------------------------------------------------| 83 | | `branch` | The branch from which the release is done. | 84 | | `branch.name` | The branch name. | 85 | | `branch.type` | The [type of branch](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/workflow-configuration.md#branch-types). | 86 | | `branch.channel` | The distribution channel on which to publish releases from this branch. | 87 | | `branch.range` | The range of [semantic versions](https://semver.org) to support on this branch. | 88 | | `branch.prerelease` | The pre-release detonation to append to [semantic versions](https://semver.org) released from this branch. | 89 | | `lastRelease` | `Object` with `version`, `gitTag` and `gitHead` of the last release. | 90 | | `nextRelease` | `Object` with `version`, `gitTag`, `gitHead` and `notes` of the release being done. | 91 | 92 | **Note**: It is recommended to include `[skip ci]` in the commit message to not trigger a new build. Some CI service support the `[skip ci]` keyword only in the subject of the message. 93 | 94 | ##### `message` examples 95 | 96 | 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: 97 | 98 | > Release v1.0.0 - Oct. 21, 2015 1:24 AM \[skip ci\]

## 1.0.0

### Features
* Generate 1.21 gigawatts of electricity
... 99 | 100 | #### `assets` 101 | 102 | Can be an `Array` or a single entry. Each entry can be either: 103 | - a [glob](https://github.com/micromatch/micromatch#matching-features) 104 | - or an `Object` with a `path` property containing a [glob](https://github.com/micromatch/micromatch#matching-features). 105 | 106 | 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"]`). 107 | 108 | If a directory is configured, all the files under this directory and its children will be included. 109 | 110 | **Note**: If a file has a match in `assets` it will be included even if it also has a match in `.gitignore`. 111 | 112 | ##### `assets` examples 113 | 114 | `'dist/*.js'`: include all `js` files in the `dist` directory, but not in its sub-directories. 115 | 116 | `'dist/**/*.js'`: include all `js` files in the `dist` directory and its sub-directories. 117 | 118 | `[['dist', '!**/*.css']]`: include all files in the `dist` directory and its sub-directories excluding the `css` files. 119 | 120 | `[['dist', '!**/*.css'], 'package.json']`: include `package.json` and all files in the `dist` directory and its sub-directories excluding the `css` files. 121 | 122 | `[['dist/**/*.{js,css}', '!**/*.min.*']]`: include all `js` and `css` files in the `dist` directory and its sub-directories excluding the minified version. 123 | 124 | ### Examples 125 | 126 | When used with the [@semantic-release/changelog](https://github.com/semantic-release/changelog) or [@semantic-release/npm](https://github.com/semantic-release/npm) plugins: 127 | - The [@semantic-release/changelog](https://github.com/semantic-release/changelog) plugin must be called first in order to update the changelog file so the `@semantic-release/git` and [@semantic-release/npm](https://github.com/semantic-release/npm) plugins can include it in the release. 128 | - The [@semantic-release/npm](https://github.com/semantic-release/npm) plugin must be called second in order to update the `package.json` file so the `@semantic-release/git` plugin can include it in the release commit. 129 | 130 | ```json 131 | { 132 | "plugins": [ 133 | "@semantic-release/commit-analyzer", 134 | "@semantic-release/release-notes-generator", 135 | "@semantic-release/changelog", 136 | "@semantic-release/npm", 137 | "@semantic-release/git" 138 | ], 139 | } 140 | ``` 141 | 142 | ### GPG signature 143 | 144 | 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. 145 | 146 | #### Generate the GPG keys 147 | 148 | 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. 149 | 150 | [Download and install the GPG command line tools](https://www.gnupg.org/download/#binary) for your operating system. 151 | 152 | Create a GPG key 153 | 154 | ```bash 155 | $ gpg --full-generate-key 156 | ``` 157 | 158 | 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. 159 | 160 | #### Get the GPG keys ID and the public key content 161 | 162 | Use the `gpg --list-secret-keys --keyid-format LONG` command to list your GPG keys. From the list, copy the GPG key ID you just created. 163 | 164 | ```bash 165 | $ gpg --list-secret-keys --keyid-format LONG 166 | /Users//.gnupg/pubring.gpg 167 | --------------------------------------- 168 | sec rsa4096/XXXXXXXXXXXXXXXX 2017-12-01 [SC] 169 | uid 170 | ssb rsa4096/YYYYYYYYYYYYYYYY 2017-12-01 [E] 171 | ``` 172 | the GPG key ID is the 16 character string, on the `sec` line, after `rsa4096`. In this example, the GPG key ID is `XXXXXXXXXXXXXXXX`. 173 | 174 | Export the public key (replace XXXXXXXXXXXXXXXX with your key ID): 175 | 176 | ```bash 177 | $ gpg --armor --export XXXXXXXXXXXXXXXX 178 | ``` 179 | 180 | Copy your GPG key, beginning with -----BEGIN PGP PUBLIC KEY BLOCK----- and ending with -----END PGP PUBLIC KEY BLOCK----- 181 | 182 | #### Add the GPG key to your Git hosted account 183 | 184 | ##### Add the GPG key to GitHub 185 | 186 | In GitHub **Settings**, click on **SSH and GPG keys** in the sidebar, then on the **New GPG Key** button. 187 | 188 | Paste the entire GPG key export previously and click the **Add GPG Key** button. 189 | 190 | See [Adding a new GPG key to your GitHub account](https://docs.github.com/en/authentication/managing-commit-signature-verification/adding-a-gpg-key-to-your-github-account) for more details. 191 | 192 | ### Use the GPG key to sign commit and tags locally 193 | 194 | 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) 195 | This step is optional and unrelated to Semantic-release. 196 | 197 | #### Add the GPG keys to your CI environment 198 | 199 | 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. 200 | 201 | ##### Add the GPG keys to Travis CI 202 | 203 | Install the [Travis CLI](https://github.com/travis-ci/travis.rb#installation): 204 | 205 | ```bash 206 | $ gem install travis 207 | ``` 208 | 209 | [Login](https://github.com/travis-ci/travis.rb#login) to Travis with the CLI: 210 | 211 | ```bash 212 | $ travis login 213 | ``` 214 | 215 | Add the following [environment](https://github.com/travis-ci/travis.rb#env) variables to Travis: 216 | - `GPG_PASSPHRASE` to Travis with the value set during the [GPG keys generation](#generate-the-gpg-keys) step 217 | - `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) 218 | - `GIT_EMAIL` with the email address you set during the [GPG keys generation](#generate-the-gpg-keys) step 219 | - `GIT_USERNAME` with the name you set during the [GPG keys generation](#generate-the-gpg-keys) step 220 | 221 | ```bash 222 | $ travis env set GPG_PASSPHRASE 223 | $ travis env set GPG_KEY_ID XXXXXXXXXXXXXXXX 224 | $ travis env set GIT_EMAIL 225 | $ travis env set GIT_USERNAME 226 | ``` 227 | 228 | From your repository root export your public and private GPG keys in the `git_gpg_keys.asc` (replace XXXXXXXXXXXXXXXX with your key ID): 229 | 230 | ```bash 231 | $ gpg --export -a XXXXXXXXXXXXXXXX > git_gpg_keys.asc 232 | $ gpg --export-secret-key -a XXXXXXXXXXXXXXXX >> git_gpg_keys.asc 233 | ``` 234 | 235 | [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: 236 | 237 | ```bash 238 | $ travis encrypt-file git_gpg_keys.asc 239 | ``` 240 | 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`. 241 | 242 | 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`: 243 | 244 | ```yaml 245 | before_install: 246 | # Decrypt the git_gpg_keys.asc.enc key into /tmp/git_gpg_keys.asc 247 | - 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 248 | # Make sure only the current user can read the keys 249 | - chmod 600 /tmp/git_gpg_keys.asc 250 | # Import the gpg key 251 | - gpg --batch --yes --import /tmp/git_gpg_keys.asc 252 | # Create a script to pass the passphrase to the gpg CLI called by git 253 | - echo '/usr/bin/gpg2 --passphrase ${GPG_PASSPHRASE} --batch --no-tty "$@"' > /tmp/gpg-with-passphrase && chmod +x /tmp/gpg-with-passphrase 254 | # Configure git to use the script that passes the passphrase 255 | - git config gpg.program "/tmp/gpg-with-passphrase" 256 | # Configure git to sign the commits and tags 257 | - git config commit.gpgsign true 258 | # Configure git to use your GPG key 259 | - git config --global user.signingkey ${GPG_KEY_ID} 260 | ``` 261 | 262 | See [Encrypting Files](https://docs.travis-ci.com/user/encrypting-files/) for more details. 263 | 264 | Delete the local keys as it won't be used anymore: 265 | 266 | ```bash 267 | $ rm git_gpg_keys.asc 268 | ``` 269 | 270 | Commit the encrypted keys and the `.travis.yml` file to your repository: 271 | 272 | ```bash 273 | $ git add git_gpg_keys.asc.enc .travis.yml 274 | $ git commit -m "ci(travis): Add the encrypted GPG keys" 275 | $ git push 276 | ``` 277 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const {defaultTo, castArray} = require('lodash'); 2 | const verifyGit = require('./lib/verify.js'); 3 | const prepareGit = require('./lib/prepare.js'); 4 | 5 | let verified; 6 | 7 | function verifyConditions(pluginConfig, context) { 8 | const {options} = context; 9 | // If the Git prepare plugin is used and has `assets` or `message` configured, validate them now in order to prevent any release if the configuration is wrong 10 | if (options.prepare) { 11 | const preparePlugin = 12 | castArray(options.prepare).find((config) => config.path && config.path === '@semantic-release/git') || {}; 13 | 14 | pluginConfig.assets = defaultTo(pluginConfig.assets, preparePlugin.assets); 15 | pluginConfig.message = defaultTo(pluginConfig.message, preparePlugin.message); 16 | } 17 | 18 | verifyGit(pluginConfig); 19 | verified = true; 20 | } 21 | 22 | async function prepare(pluginConfig, context) { 23 | if (!verified) { 24 | verifyGit(pluginConfig); 25 | verified = true; 26 | } 27 | 28 | await prepareGit(pluginConfig, context); 29 | } 30 | 31 | module.exports = {verifyConditions, prepare}; 32 | -------------------------------------------------------------------------------- /lib/definitions/errors.js: -------------------------------------------------------------------------------- 1 | const pkg = require('../../package.json'); 2 | 3 | const [homepage] = pkg.homepage.split('#'); 4 | const linkify = (file) => `${homepage}/blob/master/${file}`; 5 | 6 | module.exports = { 7 | EINVALIDASSETS: ({assets}) => ({ 8 | message: 'Invalid `assets` option.', 9 | details: `The [assets option](${linkify( 10 | 'README.md#assets' 11 | )}) option must be an \`Array\` of \`Strings\` or \`Objects\` with a \`path\` property. 12 | 13 | Your configuration for the \`assets\` option is \`${assets}\`.`, 14 | }), 15 | EINVALIDMESSAGE: ({message}) => ({ 16 | message: 'Invalid `message` option.', 17 | details: `The [message option](${linkify('README.md#message')}) option, if defined, must be a non empty \`String\`. 18 | 19 | Your configuration for the \`successComment\` option is \`${message}\`.`, 20 | }), 21 | }; 22 | -------------------------------------------------------------------------------- /lib/get-error.js: -------------------------------------------------------------------------------- 1 | const SemanticReleaseError = require('@semantic-release/error'); 2 | const ERROR_DEFINITIONS = require('./definitions/errors.js'); 3 | 4 | module.exports = (code, ctx) => { 5 | const {message, details} = ERROR_DEFINITIONS[code](ctx); 6 | return new SemanticReleaseError(message, code, details); 7 | }; 8 | -------------------------------------------------------------------------------- /lib/git.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const debug = require('debug')('semantic-release:git'); 3 | 4 | /** 5 | * Retrieve the list of files modified on the local repository. 6 | * 7 | * @param {Object} [execaOpts] Options to pass to `execa`. 8 | * 9 | * @return {Array} Array of modified files path. 10 | */ 11 | async function getModifiedFiles(execaOptions) { 12 | return (await execa('git', ['ls-files', '-m', '-o'], execaOptions)).stdout 13 | .split('\n') 14 | .map((file) => file.trim()) 15 | .filter((file) => Boolean(file)); 16 | } 17 | 18 | /** 19 | * Add a list of file to the Git index. `.gitignore` will be ignored. 20 | * 21 | * @param {Array} files Array of files path to add to the index. 22 | * @param {Object} [execaOpts] Options to pass to `execa`. 23 | */ 24 | async function add(files, execaOptions) { 25 | const shell = await execa('git', ['add', '--force', '--ignore-errors', ...files], {...execaOptions, reject: false}); 26 | debug('add file to git index', shell); 27 | } 28 | 29 | /** 30 | * Commit to the local repository. 31 | * 32 | * @param {String} message Commit message. 33 | * @param {Object} [execaOpts] Options to pass to `execa`. 34 | * 35 | * @throws {Error} if the commit failed. 36 | */ 37 | async function commit(message, execaOptions) { 38 | await execa('git', ['commit', '-m', message], execaOptions); 39 | } 40 | 41 | /** 42 | * Push to the remote repository. 43 | * 44 | * @param {String} origin The remote repository URL. 45 | * @param {String} branch The branch to push. 46 | * @param {Object} [execaOpts] Options to pass to `execa`. 47 | * 48 | * @throws {Error} if the push failed. 49 | */ 50 | async function push(origin, branch, execaOptions) { 51 | await execa('git', ['push', '--tags', origin, `HEAD:${branch}`], execaOptions); 52 | } 53 | 54 | /** 55 | * Get the HEAD sha. 56 | * 57 | * @param {Object} [execaOpts] Options to pass to `execa`. 58 | * 59 | * @return {String} The sha of the head commit on the local repository 60 | */ 61 | async function gitHead(execaOptions) { 62 | return (await execa('git', ['rev-parse', 'HEAD'], execaOptions)).stdout; 63 | } 64 | 65 | module.exports = {getModifiedFiles, add, gitHead, commit, push}; 66 | -------------------------------------------------------------------------------- /lib/prepare.js: -------------------------------------------------------------------------------- 1 | const {isPlainObject, isArray, template, castArray, uniq} = require('lodash'); 2 | const micromatch = require('micromatch'); 3 | const dirGlob = require('dir-glob'); 4 | const pReduce = require('p-reduce'); 5 | const debug = require('debug')('semantic-release:git'); 6 | const resolveConfig = require('./resolve-config.js'); 7 | const {getModifiedFiles, add, commit, push} = require('./git.js'); 8 | 9 | /** 10 | * Prepare a release commit including configurable files. 11 | * 12 | * @param {Object} pluginConfig The plugin configuration. 13 | * @param {String|Array} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs. 14 | * @param {String} [pluginConfig.message] The message for the release commit. 15 | * @param {Object} context semantic-release context. 16 | * @param {Object} context.options `semantic-release` configuration. 17 | * @param {Object} context.lastRelease The last release. 18 | * @param {Object} context.nextRelease The next release. 19 | * @param {Object} logger Global logger. 20 | */ 21 | module.exports = async (pluginConfig, context) => { 22 | const { 23 | env, 24 | cwd, 25 | branch, 26 | options: {repositoryUrl}, 27 | lastRelease, 28 | nextRelease, 29 | logger, 30 | } = context; 31 | const {message, assets} = resolveConfig(pluginConfig, logger); 32 | 33 | const modifiedFiles = await getModifiedFiles({env, cwd}); 34 | 35 | const filesToCommit = uniq( 36 | await pReduce( 37 | assets.map((asset) => (!isArray(asset) && isPlainObject(asset) ? asset.path : asset)), 38 | async (result, asset) => { 39 | const glob = castArray(asset); 40 | let nonegate; 41 | // Skip solo negated pattern (avoid to include every non js file with `!**/*.js`) 42 | if (glob.length <= 1 && glob[0].startsWith('!')) { 43 | nonegate = true; 44 | debug( 45 | 'skipping the negated glob %o as its alone in its group and would retrieve a large amount of files ', 46 | glob[0] 47 | ); 48 | } 49 | 50 | return [ 51 | ...result, 52 | ...micromatch(modifiedFiles, await dirGlob(glob, {cwd}), {dot: true, nonegate, cwd, expand: true}), 53 | ]; 54 | }, 55 | [] 56 | ) 57 | ); 58 | 59 | if (filesToCommit.length > 0) { 60 | logger.log('Found %d file(s) to commit', filesToCommit.length); 61 | await add(filesToCommit, {env, cwd}); 62 | debug('commited files: %o', filesToCommit); 63 | await commit( 64 | message 65 | ? template(message)({branch: branch.name, lastRelease, nextRelease}) 66 | : `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}`, 67 | {env, cwd} 68 | ); 69 | await push(repositoryUrl, branch.name, {env, cwd}); 70 | logger.log('Prepared Git release: %s', nextRelease.gitTag); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /lib/resolve-config.js: -------------------------------------------------------------------------------- 1 | const {isNil, castArray} = require('lodash'); 2 | 3 | module.exports = ({assets, message}) => ({ 4 | assets: isNil(assets) 5 | ? ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'] 6 | : assets 7 | ? castArray(assets) 8 | : assets, 9 | message, 10 | }); 11 | -------------------------------------------------------------------------------- /lib/verify.js: -------------------------------------------------------------------------------- 1 | const {isString, isNil, isArray, isPlainObject} = require('lodash'); 2 | const AggregateError = require('aggregate-error'); 3 | const getError = require('./get-error.js'); 4 | const resolveConfig = require('./resolve-config.js'); 5 | 6 | const isNonEmptyString = (value) => isString(value) && value.trim(); 7 | const isStringOrStringArray = (value) => { 8 | return isNonEmptyString(value) || (isArray(value) && value.every((element) => isNonEmptyString(element))); 9 | }; 10 | 11 | const isArrayOf = (validator) => (array) => isArray(array) && array.every((value) => validator(value)); 12 | const canBeDisabled = (validator) => (value) => value === false || validator(value); 13 | 14 | const VALIDATORS = { 15 | assets: canBeDisabled( 16 | isArrayOf((asset) => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path))) 17 | ), 18 | message: isNonEmptyString, 19 | }; 20 | 21 | /** 22 | * Verify the commit `message` format and the `assets` option configuration: 23 | * - The commit `message`, is defined, must a non empty `String`. 24 | * - The `assets` configuration must be an `Array` of `String` (file path) or `false` (to disable). 25 | * 26 | * @param {Object} pluginConfig The plugin configuration. 27 | * @param {String|Array} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs. 28 | * @param {String} [pluginConfig.message] The commit message for the release. 29 | */ 30 | module.exports = (pluginConfig) => { 31 | const options = resolveConfig(pluginConfig); 32 | 33 | const errors = Object.entries(options).reduce( 34 | (errors, [option, value]) => 35 | !isNil(value) && !VALIDATORS[option](value) 36 | ? [...errors, getError(`EINVALID${option.toUpperCase()}`, {[option]: value})] 37 | : errors, 38 | [] 39 | ); 40 | 41 | if (errors.length > 0) { 42 | throw new AggregateError(errors); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@semantic-release/git", 3 | "description": "semantic-release plugin to commit release assets to the project's git repository", 4 | "version": "0.0.0-development", 5 | "author": "Pierre Vanduynslager (https://github.com/pvdlg)", 6 | "ava": { 7 | "files": [ 8 | "test/**/*.test.js" 9 | ] 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/semantic-release/git/issues" 13 | }, 14 | "contributors": [ 15 | "Stephan Bönnemann (http://boennemann.me)", 16 | "Gregor Martynus (https://twitter.com/gr2m)" 17 | ], 18 | "dependencies": { 19 | "@semantic-release/error": "^3.0.0", 20 | "aggregate-error": "^3.0.0", 21 | "debug": "^4.0.0", 22 | "dir-glob": "^3.0.0", 23 | "execa": "^5.0.0", 24 | "lodash": "^4.17.4", 25 | "micromatch": "^4.0.0", 26 | "p-reduce": "^2.0.0" 27 | }, 28 | "devDependencies": { 29 | "ava": "3.15.0", 30 | "clear-module": "4.1.2", 31 | "file-url": "3.0.0", 32 | "fs-extra": "11.2.0", 33 | "get-stream": "6.0.1", 34 | "git-log-parser": "1.2.0", 35 | "nyc": "15.1.0", 36 | "semantic-release": "21.1.2", 37 | "sinon": "17.0.1", 38 | "tempy": "1.0.1", 39 | "xo": "0.39.1" 40 | }, 41 | "engines": { 42 | "node": ">=14.17" 43 | }, 44 | "files": [ 45 | "lib", 46 | "index.js" 47 | ], 48 | "homepage": "https://github.com/semantic-release/git#readme", 49 | "keywords": [ 50 | "changelog", 51 | "commit", 52 | "conventional-changelog", 53 | "conventional-commits", 54 | "git", 55 | "release", 56 | "semantic-release", 57 | "version" 58 | ], 59 | "license": "MIT", 60 | "main": "index.js", 61 | "nyc": { 62 | "include": [ 63 | "lib/**/*.js", 64 | "index.js" 65 | ], 66 | "reporter": [ 67 | "json", 68 | "text", 69 | "html" 70 | ], 71 | "all": true 72 | }, 73 | "peerDependencies": { 74 | "semantic-release": ">=18.0.0" 75 | }, 76 | "prettier": { 77 | "printWidth": 120, 78 | "trailingComma": "es5" 79 | }, 80 | "publishConfig": { 81 | "access": "public", 82 | "provenance": true 83 | }, 84 | "repository": { 85 | "type": "git", 86 | "url": "https://github.com/semantic-release/git.git" 87 | }, 88 | "scripts": { 89 | "lint": "xo", 90 | "pretest": "npm run lint", 91 | "semantic-release": "semantic-release", 92 | "test": "nyc ava -v", 93 | "test:ci": "nyc ava -v" 94 | }, 95 | "xo": { 96 | "prettier": true, 97 | "space": true, 98 | "rules": { 99 | "unicorn/no-array-reduce": "off", 100 | "unicorn/string-content": "off" 101 | } 102 | }, 103 | "renovate": { 104 | "extends": [ 105 | "github>semantic-release/.github:renovate-config" 106 | ] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /test/git.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const test = require('ava'); 3 | const {outputFile, appendFile} = require('fs-extra'); 4 | const {add, getModifiedFiles, commit, gitHead, push} = require('../lib/git.js'); 5 | const {gitRepo, gitCommits, gitGetCommits, gitStaged, gitRemoteHead} = require('./helpers/git-utils.js'); 6 | 7 | test('Add file to index', async (t) => { 8 | // Create a git repository, set the current working directory at the root of the repo 9 | const {cwd} = await gitRepo(); 10 | // Create files 11 | await outputFile(path.resolve(cwd, 'file1.js'), ''); 12 | // Add files and commit 13 | await add(['.'], {cwd}); 14 | 15 | await t.deepEqual(await gitStaged({cwd}), ['file1.js']); 16 | }); 17 | 18 | test('Get the modified files, including files in .gitignore but including untracked ones', async (t) => { 19 | // Create a git repository, set the current working directory at the root of the repo 20 | const {cwd} = await gitRepo(); 21 | // Create files 22 | await outputFile(path.resolve(cwd, 'file1.js'), ''); 23 | await outputFile(path.resolve(cwd, 'dir/file2.js'), ''); 24 | await outputFile(path.resolve(cwd, 'file3.js'), ''); 25 | // Create .gitignore to ignore file3.js 26 | await outputFile(path.resolve(cwd, '.gitignore'), 'file.3.js'); 27 | // Add files and commit 28 | await add(['.'], {cwd}); 29 | await commit('Test commit', {cwd}); 30 | // Update file1.js, dir/file2.js and file3.js 31 | await appendFile(path.resolve(cwd, 'file1.js'), 'Test content'); 32 | await appendFile(path.resolve(cwd, 'dir/file2.js'), 'Test content'); 33 | await appendFile(path.resolve(cwd, 'file3.js'), 'Test content'); 34 | // Add untracked file 35 | await outputFile(path.resolve(cwd, 'file4.js'), 'Test content'); 36 | 37 | await t.deepEqual( 38 | (await getModifiedFiles({cwd})).sort(), 39 | ['file1.js', 'dir/file2.js', 'file3.js', 'file4.js'].sort() 40 | ); 41 | }); 42 | 43 | test('Returns [] if there is no modified files', async (t) => { 44 | // Create a git repository, set the current working directory at the root of the repo 45 | const {cwd} = await gitRepo(); 46 | 47 | await t.deepEqual(await getModifiedFiles({cwd}), []); 48 | }); 49 | 50 | test('Commit added files', async (t) => { 51 | // Create a git repository, set the current working directory at the root of the repo 52 | const {cwd} = await gitRepo(); 53 | // Create files 54 | await outputFile(path.resolve(cwd, 'file1.js'), ''); 55 | // Add files and commit 56 | await add(['.'], {cwd}); 57 | await commit('Test commit', {cwd}); 58 | 59 | await t.true((await gitGetCommits(undefined, {cwd})).length === 1); 60 | }); 61 | 62 | test('Get the last commit sha', async (t) => { 63 | // Create a git repository, set the current working directory at the root of the repo 64 | const {cwd} = await gitRepo(); 65 | // Add commits to the master branch 66 | const [{hash}] = await gitCommits(['First'], {cwd}); 67 | 68 | const result = await gitHead({cwd}); 69 | 70 | t.is(result, hash); 71 | }); 72 | 73 | test('Throw error if the last commit sha cannot be found', async (t) => { 74 | // Create a git repository, set the current working directory at the root of the repo 75 | const {cwd} = await gitRepo(); 76 | 77 | await t.throwsAsync(gitHead({cwd})); 78 | }); 79 | 80 | test('Push commit to remote repository', async (t) => { 81 | // Create a git repository with a remote, set the current working directory at the root of the repo 82 | const {cwd, repositoryUrl} = await gitRepo(true); 83 | const [{hash}] = await gitCommits(['Test commit'], {cwd}); 84 | 85 | await push(repositoryUrl, 'master', {cwd}); 86 | 87 | t.is(await gitRemoteHead(repositoryUrl, {cwd}), hash); 88 | }); 89 | -------------------------------------------------------------------------------- /test/helpers/git-utils.js: -------------------------------------------------------------------------------- 1 | const tempy = require('tempy'); 2 | const execa = require('execa'); 3 | const fileUrl = require('file-url'); 4 | const pReduce = require('p-reduce'); 5 | const gitLogParser = require('git-log-parser'); 6 | const getStream = require('get-stream'); 7 | 8 | /** 9 | * Create a temporary git repository. 10 | * If `withRemote` is `true`, creates a bare repository, initialize it and create a shallow clone. Change the current working directory to the clone root. 11 | * If `withRemote` is `false`, creates a regular repository and initialize it. Change the current working directory to the repository root. 12 | * 13 | * @param {Boolean} withRemote `true` to create a shallow clone of a bare repository. 14 | * @param {String} [branch='master'] The branch to initialize. 15 | * @return {String} The path of the clone if `withRemote` is `true`, the path of the repository otherwise. 16 | */ 17 | async function gitRepo(withRemote, branch = 'master') { 18 | let cwd = tempy.directory(); 19 | 20 | await execa('git', ['init', ...(withRemote ? ['--bare'] : [])], {cwd}); 21 | 22 | const repositoryUrl = fileUrl(cwd); 23 | if (withRemote) { 24 | await initBareRepo(repositoryUrl, branch); 25 | cwd = await gitShallowClone(repositoryUrl, branch); 26 | } else { 27 | await gitCheckout(branch, true, {cwd}); 28 | } 29 | 30 | await execa('git', ['config', 'commit.gpgsign', false], {cwd}); 31 | 32 | return {cwd, repositoryUrl}; 33 | } 34 | 35 | /** 36 | * Initialize an existing bare repository: 37 | * - Clone the repository 38 | * - Change the current working directory to the clone root 39 | * - Create a default branch 40 | * - Create an initial commits 41 | * - Push to origin 42 | * 43 | * @param {String} repositoryUrl The URL of the bare repository. 44 | * @param {String} [branch='master'] the branch to initialize. 45 | */ 46 | async function initBareRepo(repositoryUrl, branch = 'master') { 47 | const cwd = tempy.directory(); 48 | await execa('git', ['clone', '--no-hardlinks', repositoryUrl, cwd], {cwd}); 49 | await gitCheckout(branch, true, {cwd}); 50 | await gitCommits(['Initial commit'], {cwd}); 51 | await execa('git', ['push', repositoryUrl, branch], {cwd}); 52 | } 53 | 54 | /** 55 | * Create commits on the current git repository. 56 | * 57 | * @param {Array} messages Commit messages. 58 | * @param {Object} [execaOpts] Options to pass to `execa`. 59 | * 60 | * @returns {Array} The created commits, in reverse order (to match `git log` order). 61 | */ 62 | async function gitCommits(messages, execaOptions) { 63 | await pReduce( 64 | messages, 65 | async (_, message) => 66 | ( 67 | await execa('git', ['commit', '-m', message, '--allow-empty', '--no-gpg-sign'], execaOptions) 68 | ).stdout 69 | ); 70 | return (await gitGetCommits(undefined, execaOptions)).slice(0, messages.length); 71 | } 72 | 73 | /** 74 | * Get the list of parsed commits since a git reference. 75 | * 76 | * @param {String} [from] Git reference from which to seach commits. 77 | * @param {Object} [execaOpts] Options to pass to `execa`. 78 | * 79 | * @return {Array} The list of parsed commits. 80 | */ 81 | async function gitGetCommits(from, execaOptions) { 82 | Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}}); 83 | return ( 84 | await getStream.array( 85 | gitLogParser.parse( 86 | {_: `${from ? from + '..' : ''}HEAD`}, 87 | {...execaOptions, env: {...process.env, ...execaOptions.env}} 88 | ) 89 | ) 90 | ).map((commit) => { 91 | commit.message = commit.message.trim(); 92 | commit.gitTags = commit.gitTags.trim(); 93 | return commit; 94 | }); 95 | } 96 | 97 | /** 98 | * Checkout a branch on the current git repository. 99 | * 100 | * @param {String} branch Branch name. 101 | * @param {Boolean} create to create the branch, `false` to checkout an existing branch. 102 | * @param {Object} [execaOpts] Options to pass to `execa`. 103 | */ 104 | async function gitCheckout(branch, create, execaOptions) { 105 | await execa('git', create ? ['checkout', '-b', branch] : ['checkout', branch], execaOptions); 106 | } 107 | 108 | /** 109 | * Create a tag on the head commit in the current git repository. 110 | * 111 | * @param {String} tagName The tag name to create. 112 | * @param {String} [sha] The commit on which to create the tag. If undefined the tag is created on the last commit. 113 | * @param {Object} [execaOpts] Options to pass to `execa`. 114 | */ 115 | async function gitTagVersion(tagName, sha, execaOptions) { 116 | await execa('git', sha ? ['tag', '-f', tagName, sha] : ['tag', tagName], execaOptions); 117 | } 118 | 119 | /** 120 | * Create a shallow clone of a git repository and change the current working directory to the cloned repository root. 121 | * The shallow will contain a limited number of commit and no tags. 122 | * 123 | * @param {String} repositoryUrl The path of the repository to clone. 124 | * @param {String} [branch='master'] the branch to clone. 125 | * @param {Number} [depth=1] The number of commit to clone. 126 | * @return {String} The path of the cloned repository. 127 | */ 128 | async function gitShallowClone(repositoryUrl, branch = 'master', depth = 1) { 129 | const cwd = tempy.directory(); 130 | 131 | await execa('git', ['clone', '--no-hardlinks', '--no-tags', '-b', branch, '--depth', depth, repositoryUrl, cwd], { 132 | cwd, 133 | }); 134 | return cwd; 135 | } 136 | 137 | /** 138 | * Create a git repo with a detached head from another git repository and change the current working directory to the new repository root. 139 | * 140 | * @param {String} repositoryUrl The path of the repository to clone. 141 | * @param {Number} head A commit sha of the remote repo that will become the detached head of the new one. 142 | * @return {String} The path of the new repository. 143 | */ 144 | async function gitDetachedHead(repositoryUrl, head) { 145 | const cwd = tempy.directory(); 146 | 147 | await execa('git', ['init'], {cwd}); 148 | await execa('git', ['remote', 'add', 'origin', repositoryUrl], {cwd}); 149 | await execa('git', ['fetch', repositoryUrl], {cwd}); 150 | await execa('git', ['checkout', head], {cwd}); 151 | return cwd; 152 | } 153 | 154 | /** 155 | * Get the first commit sha referenced by the tag `tagName` in the remote repository. 156 | * 157 | * @param {String} repositoryUrl The repository remote URL. 158 | * @param {Object} [execaOpts] Options to pass to `execa`. 159 | * 160 | * @return {String} The HEAD sha of the remote repository. 161 | */ 162 | async function gitRemoteHead(repositoryUrl, execaOptions) { 163 | return (await execa('git', ['ls-remote', repositoryUrl, 'HEAD'], execaOptions)).stdout 164 | .split('\n') 165 | .filter((head) => Boolean(head)) 166 | .map((head) => head.match(/^(?\S+)/)[1])[0]; 167 | } 168 | 169 | /** 170 | *Get the list of staged files. 171 | * 172 | * @param {Object} [execaOpts] Options to pass to `execa`. 173 | * 174 | * @return {Array} Array of staged files path. 175 | */ 176 | async function gitStaged(execaOptions) { 177 | return (await execa('git', ['status', '--porcelain'], execaOptions)).stdout 178 | .split('\n') 179 | .filter((status) => status.startsWith('A ')) 180 | .map((status) => status.match(/^A\s+(?.+)$/)[1]); 181 | } 182 | 183 | /** 184 | * Get the list of files included in a commit. 185 | * 186 | * @param {String} ref The git reference for which to retrieve the files. 187 | * @param {Object} [execaOpts] Options to pass to `execa`. 188 | * 189 | * @return {Array} The list of files path included in the commit. 190 | */ 191 | async function gitCommitedFiles(ref, execaOptions) { 192 | return (await execa('git', ['diff-tree', '-r', '--name-only', '--no-commit-id', '-r', ref], execaOptions)).stdout 193 | .split('\n') 194 | .filter((file) => Boolean(file)); 195 | } 196 | 197 | /** 198 | * Add a list of file to the Git index. 199 | * 200 | * @param {Array} files Array of files path to add to the index. 201 | * @param {Object} [execaOpts] Options to pass to `execa`. 202 | */ 203 | async function gitAdd(files, execaOptions) { 204 | await execa('git', ['add', '--force', '--ignore-errors', ...files], {...execaOptions}); 205 | } 206 | 207 | /** 208 | * Push to the remote repository. 209 | * 210 | * @param {String} repositoryUrl The remote repository URL. 211 | * @param {String} branch The branch to push. 212 | * @param {Object} [execaOpts] Options to pass to `execa`. 213 | */ 214 | async function gitPush(repositoryUrl, branch, execaOptions) { 215 | await execa('git', ['push', '--tags', repositoryUrl, `HEAD:${branch}`], execaOptions); 216 | } 217 | 218 | module.exports = { 219 | gitRepo, 220 | initBareRepo, 221 | gitCommits, 222 | gitGetCommits, 223 | gitCheckout, 224 | gitTagVersion, 225 | gitShallowClone, 226 | gitDetachedHead, 227 | gitRemoteHead, 228 | gitStaged, 229 | gitCommitedFiles, 230 | gitAdd, 231 | gitPush, 232 | }; 233 | -------------------------------------------------------------------------------- /test/integration.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const test = require('ava'); 3 | const {outputFile} = require('fs-extra'); 4 | const {stub} = require('sinon'); 5 | const clearModule = require('clear-module'); 6 | const {push, add} = require('../lib/git.js'); 7 | const { 8 | gitRepo, 9 | gitCommits, 10 | gitShallowClone, 11 | gitDetachedHead, 12 | gitCommitedFiles, 13 | gitGetCommits, 14 | gitTagVersion, 15 | } = require('./helpers/git-utils.js'); 16 | 17 | test.beforeEach((t) => { 18 | // Clear npm cache to refresh the module state 19 | clearModule('..'); 20 | t.context.m = require('..'); 21 | // Stub the logger 22 | t.context.log = stub(); 23 | t.context.logger = {log: t.context.log}; 24 | }); 25 | 26 | test('Prepare from a shallow clone', async (t) => { 27 | const branch = {name: 'master'}; 28 | let {cwd, repositoryUrl} = await gitRepo(true); 29 | await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}"); 30 | await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content'); 31 | await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content'); 32 | await add('.', {cwd}); 33 | await gitCommits(['First'], {cwd}); 34 | await gitTagVersion('v1.0.0', undefined, {cwd}); 35 | await push(repositoryUrl, branch.name, {cwd}); 36 | cwd = await gitShallowClone(repositoryUrl); 37 | await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '2.0.0'}"); 38 | await outputFile(path.resolve(cwd, 'dist/file.js'), 'Updated content'); 39 | await outputFile(path.resolve(cwd, 'dist/file.css'), 'Updated content'); 40 | 41 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Version 2.0.0 changelog'}; 42 | const pluginConfig = { 43 | message: `Release version \${nextRelease.version} from branch \${branch}\n\n\${nextRelease.notes}`, 44 | assets: '**/*.{js,json}', 45 | }; 46 | await t.context.m.prepare(pluginConfig, { 47 | cwd, 48 | branch, 49 | options: {repositoryUrl}, 50 | nextRelease, 51 | logger: t.context.logger, 52 | }); 53 | 54 | t.deepEqual((await gitCommitedFiles('HEAD', {cwd})).sort(), ['dist/file.js', 'package.json'].sort()); 55 | const [commit] = await gitGetCommits(undefined, {cwd}); 56 | t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch.name}`); 57 | t.is(commit.body, `${nextRelease.notes}\n`); 58 | t.is(commit.gitTags, `(HEAD -> ${branch.name})`); 59 | }); 60 | 61 | test('Prepare from a detached head repository', async (t) => { 62 | const branch = {name: 'master'}; 63 | let {cwd, repositoryUrl} = await gitRepo(true); 64 | await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}"); 65 | await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content'); 66 | await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content'); 67 | await add('.', {cwd}); 68 | const [{hash}] = await gitCommits(['First'], {cwd}); 69 | await gitTagVersion('v1.0.0', undefined, {cwd}); 70 | await push(repositoryUrl, branch.name, {cwd}); 71 | cwd = await gitDetachedHead(repositoryUrl, hash); 72 | await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '2.0.0'}"); 73 | await outputFile(path.resolve(cwd, 'dist/file.js'), 'Updated content'); 74 | await outputFile(path.resolve(cwd, 'dist/file.css'), 'Updated content'); 75 | 76 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Version 2.0.0 changelog'}; 77 | const pluginConfig = { 78 | message: `Release version \${nextRelease.version} from branch \${branch}\n\n\${nextRelease.notes}`, 79 | assets: '**/*.{js,json}', 80 | }; 81 | await t.context.m.prepare(pluginConfig, { 82 | cwd, 83 | branch, 84 | options: {repositoryUrl}, 85 | nextRelease, 86 | logger: t.context.logger, 87 | }); 88 | 89 | t.deepEqual((await gitCommitedFiles('HEAD', {cwd})).sort(), ['dist/file.js', 'package.json'].sort()); 90 | const [commit] = await gitGetCommits(undefined, {cwd}); 91 | t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch.name}`); 92 | t.is(commit.body, `${nextRelease.notes}\n`); 93 | t.is(commit.gitTags, `(HEAD)`); 94 | }); 95 | 96 | test('Verify authentication only on the fist call', async (t) => { 97 | const branch = {name: 'master'}; 98 | const {cwd, repositoryUrl} = await gitRepo(true); 99 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 100 | const options = {repositoryUrl, prepare: ['@semantic-release/npm']}; 101 | 102 | t.notThrows(() => t.context.m.verifyConditions({}, {cwd, options, logger: t.context.logger})); 103 | await t.context.m.prepare({}, {cwd, options: {repositoryUrl}, branch, nextRelease, logger: t.context.logger}); 104 | }); 105 | 106 | test('Throw SemanticReleaseError if prepare config is invalid', (t) => { 107 | const message = 42; 108 | const assets = true; 109 | const options = {prepare: ['@semantic-release/npm', {path: '@semantic-release/git', message, assets}]}; 110 | 111 | const errors = [...t.throws(() => t.context.m.verifyConditions({}, {options, logger: t.context.logger}))]; 112 | 113 | t.is(errors[0].name, 'SemanticReleaseError'); 114 | t.is(errors[0].code, 'EINVALIDASSETS'); 115 | t.is(errors[1].name, 'SemanticReleaseError'); 116 | t.is(errors[1].code, 'EINVALIDMESSAGE'); 117 | }); 118 | 119 | test('Throw SemanticReleaseError if config is invalid', (t) => { 120 | const message = 42; 121 | const assets = true; 122 | 123 | const errors = [ 124 | ...t.throws(() => t.context.m.verifyConditions({message, assets}, {options: {}, logger: t.context.logger})), 125 | ]; 126 | 127 | t.is(errors[0].name, 'SemanticReleaseError'); 128 | t.is(errors[0].code, 'EINVALIDASSETS'); 129 | t.is(errors[1].name, 'SemanticReleaseError'); 130 | t.is(errors[1].code, 'EINVALIDMESSAGE'); 131 | }); 132 | -------------------------------------------------------------------------------- /test/prepare.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const test = require('ava'); 3 | const {outputFile, remove} = require('fs-extra'); 4 | const {stub} = require('sinon'); 5 | const prepare = require('../lib/prepare.js'); 6 | const {gitRepo, gitGetCommits, gitCommitedFiles, gitAdd, gitCommits, gitPush} = require('./helpers/git-utils.js'); 7 | 8 | test.beforeEach((t) => { 9 | // Stub the logger functions 10 | t.context.log = stub(); 11 | t.context.logger = {log: t.context.log}; 12 | }); 13 | 14 | test('Commit CHANGELOG.md, package.json, package-lock.json, and npm-shrinkwrap.json if they exists and have been changed', async (t) => { 15 | const {cwd, repositoryUrl} = await gitRepo(true); 16 | const pluginConfig = {}; 17 | const branch = {name: 'master'}; 18 | const options = {repositoryUrl}; 19 | const env = {}; 20 | const lastRelease = {}; 21 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; 22 | const changelogPath = path.resolve(cwd, 'CHANGELOG.md'); 23 | const pkgPath = path.resolve(cwd, 'package.json'); 24 | const pkgLockPath = path.resolve(cwd, 'package-lock.json'); 25 | const shrinkwrapPath = path.resolve(cwd, 'npm-shrinkwrap.json'); 26 | 27 | await outputFile(changelogPath, 'Initial CHANGELOG'); 28 | await outputFile(pkgPath, "{name: 'test-package'}"); 29 | await outputFile(pkgLockPath, "{name: 'test-package'}"); 30 | await outputFile(shrinkwrapPath, "{name: 'test-package'}"); 31 | 32 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 33 | 34 | // Verify the remote repo has a the version referencing the same commit sha at the local head 35 | const [commit] = await gitGetCommits(undefined, {cwd, env}); 36 | // Verify the files that have been commited 37 | t.deepEqual( 38 | (await gitCommitedFiles('HEAD', {cwd, env})).sort(), 39 | ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'].sort() 40 | ); 41 | t.is(commit.subject, `chore(release): ${nextRelease.version} [skip ci]`); 42 | t.is(commit.body, `${nextRelease.notes}\n`); 43 | t.is(commit.gitTags, `(HEAD -> ${branch.name})`); 44 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 4]); 45 | t.deepEqual(t.context.log.args[1], ['Prepared Git release: %s', nextRelease.gitTag]); 46 | }); 47 | 48 | test('Exclude CHANGELOG.md, package.json, package-lock.json, and npm-shrinkwrap.json if "assets" is defined without it', async (t) => { 49 | const {cwd, repositoryUrl} = await gitRepo(true); 50 | const pluginConfig = {assets: []}; 51 | const branch = {name: 'master'}; 52 | const options = {repositoryUrl}; 53 | const env = {}; 54 | const lastRelease = {}; 55 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 56 | await outputFile(path.resolve(cwd, 'CHANGELOG.md'), 'Initial CHANGELOG'); 57 | await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package'}"); 58 | await outputFile(path.resolve(cwd, 'package-lock.json'), "{name: 'test-package'}"); 59 | await outputFile(path.resolve(cwd, 'npm-shrinkwrap.json'), "{name: 'test-package'}"); 60 | 61 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 62 | 63 | // Verify no files have been commited 64 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), []); 65 | }); 66 | 67 | test('Allow to customize the commit message', async (t) => { 68 | const {cwd, repositoryUrl} = await gitRepo(true); 69 | const pluginConfig = { 70 | message: `Release version \${nextRelease.version} from branch \${branch} 71 | 72 | Last release: \${lastRelease.version} 73 | \${nextRelease.notes}`, 74 | }; 75 | const branch = {name: 'master'}; 76 | const options = {repositoryUrl}; 77 | const env = {}; 78 | const lastRelease = {version: 'v1.0.0'}; 79 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; 80 | await outputFile(path.resolve(cwd, 'CHANGELOG.md'), 'Initial CHANGELOG'); 81 | 82 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 83 | 84 | // Verify the files that have been commited 85 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['CHANGELOG.md']); 86 | // Verify the commit message contains on the new release notes 87 | const [commit] = await gitGetCommits(undefined, {cwd, env}); 88 | t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch.name}`); 89 | t.is(commit.body, `Last release: ${lastRelease.version}\n${nextRelease.notes}\n`); 90 | }); 91 | 92 | test('Commit files matching the patterns in "assets"', async (t) => { 93 | const {cwd, repositoryUrl} = await gitRepo(true); 94 | const pluginConfig = { 95 | assets: ['file1.js', '*1.js', ['dir/*.js', '!dir/*.css'], 'file5.js', 'dir2', ['**/*.js', '!**/*.js']], 96 | }; 97 | const branch = {name: 'master'}; 98 | const options = {repositoryUrl}; 99 | const env = {}; 100 | const lastRelease = {}; 101 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 102 | // Create .gitignore to ignore file5.js 103 | await outputFile(path.resolve(cwd, '.gitignore'), 'file5.js'); 104 | await outputFile(path.resolve(cwd, 'file1.js'), 'Test content'); 105 | await outputFile(path.resolve(cwd, 'dir/file2.js'), 'Test content'); 106 | await outputFile(path.resolve(cwd, 'dir/file3.css'), 'Test content'); 107 | await outputFile(path.resolve(cwd, 'file4.js'), 'Test content'); 108 | await outputFile(path.resolve(cwd, 'file5.js'), 'Test content'); 109 | await outputFile(path.resolve(cwd, 'dir2/file6.js'), 'Test content'); 110 | await outputFile(path.resolve(cwd, 'dir2/file7.css'), 'Test content'); 111 | 112 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 113 | 114 | // Verify file2 and file1 have been commited 115 | // file4.js is excluded as no glob matching 116 | // file3.css is ignored due to the negative glob '!dir/*.css' 117 | // file5.js is not ignored even if it's in the .gitignore 118 | // file6.js and file7.css are included because dir2 is expanded 119 | t.deepEqual( 120 | (await gitCommitedFiles('HEAD', {cwd, env})).sort(), 121 | ['dir/file2.js', 'dir2/file6.js', 'dir2/file7.css', 'file1.js', 'file5.js'].sort() 122 | ); 123 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 5]); 124 | }); 125 | 126 | test('Commit files matching the patterns in "assets" as Objects', async (t) => { 127 | const {cwd, repositoryUrl} = await gitRepo(true); 128 | const pluginConfig = { 129 | assets: ['file1.js', {path: ['dir/*.js', '!dir/*.css']}, {path: 'file5.js'}, 'dir2'], 130 | }; 131 | const branch = {name: 'master'}; 132 | const options = {repositoryUrl}; 133 | const env = {}; 134 | const lastRelease = {}; 135 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 136 | // Create .gitignore to ignore file5.js 137 | await outputFile(path.resolve(cwd, '.gitignore'), 'file5.js'); 138 | await outputFile(path.resolve(cwd, 'file1.js'), 'Test content'); 139 | await outputFile(path.resolve(cwd, 'dir/file2.js'), 'Test content'); 140 | await outputFile(path.resolve(cwd, 'dir/file3.css'), 'Test content'); 141 | await outputFile(path.resolve(cwd, 'file4.js'), 'Test content'); 142 | await outputFile(path.resolve(cwd, 'file5.js'), 'Test content'); 143 | await outputFile(path.resolve(cwd, 'dir2/file6.js'), 'Test content'); 144 | await outputFile(path.resolve(cwd, 'dir2/file7.css'), 'Test content'); 145 | 146 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 147 | 148 | // Verify file2 and file1 have been commited 149 | // file4.js is excluded as no glob matching 150 | // file3.css is ignored due to the negative glob '!dir/*.css' 151 | // file5.js is not ignored even if it's in the .gitignore 152 | // file6.js and file7.css are included because dir2 is expanded 153 | t.deepEqual( 154 | (await gitCommitedFiles('HEAD', {cwd, env})).sort(), 155 | ['dir/file2.js', 'dir2/file6.js', 'dir2/file7.css', 'file1.js', 'file5.js'].sort() 156 | ); 157 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 5]); 158 | }); 159 | 160 | test('Commit files matching the patterns in "assets" as single glob', async (t) => { 161 | const {cwd, repositoryUrl} = await gitRepo(true); 162 | const pluginConfig = {assets: 'dist/**/*.js'}; 163 | const branch = {name: 'master'}; 164 | const options = {repositoryUrl}; 165 | const env = {}; 166 | const lastRelease = {}; 167 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 168 | await outputFile(path.resolve(cwd, 'dist/file1.js'), 'Test content'); 169 | await outputFile(path.resolve(cwd, 'dist/file2.css'), 'Test content'); 170 | 171 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 172 | 173 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['dist/file1.js']); 174 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]); 175 | }); 176 | 177 | test('Commit files matching the patterns in "assets", including dot files', async (t) => { 178 | const {cwd, repositoryUrl} = await gitRepo(true); 179 | const pluginConfig = {assets: 'dist'}; 180 | const branch = {name: 'master'}; 181 | const options = {repositoryUrl}; 182 | const env = {}; 183 | const lastRelease = {}; 184 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 185 | await outputFile(path.resolve(cwd, 'dist/.dotfile'), 'Test content'); 186 | 187 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 188 | 189 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['dist/.dotfile']); 190 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]); 191 | }); 192 | 193 | test('Include deleted files in release commit', async (t) => { 194 | const {cwd, repositoryUrl} = await gitRepo(true); 195 | const pluginConfig = { 196 | assets: ['file1.js'], 197 | }; 198 | const branch = {name: 'master'}; 199 | const options = {repositoryUrl}; 200 | const env = {}; 201 | const lastRelease = {}; 202 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 203 | await outputFile(path.resolve(cwd, 'file1.js'), 'Test content'); 204 | await outputFile(path.resolve(cwd, 'file2.js'), 'Test content'); 205 | 206 | await gitAdd(['file1.js', 'file2.js'], {cwd, env}); 207 | await gitCommits(['Add file1.js and file2.js'], {cwd, env}); 208 | await gitPush(repositoryUrl, 'master', {cwd, env}); 209 | 210 | await remove(path.resolve(cwd, 'file1.js')); 211 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 212 | 213 | t.deepEqual((await gitCommitedFiles('HEAD', {cwd, env})).sort(), ['file1.js'].sort()); 214 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]); 215 | }); 216 | 217 | test('Set the commit author and committer name/email based on environment variables', async (t) => { 218 | const {cwd, repositoryUrl} = await gitRepo(true); 219 | const branch = {name: 'master'}; 220 | const options = {repositoryUrl}; 221 | const env = { 222 | GIT_AUTHOR_NAME: 'author name', 223 | GIT_AUTHOR_EMAIL: 'author email', 224 | GIT_COMMITTER_NAME: 'committer name', 225 | GIT_COMMITTER_EMAIL: 'committer email', 226 | }; 227 | const lastRelease = {version: 'v1.0.0'}; 228 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; 229 | await outputFile(path.resolve(cwd, 'CHANGELOG.md'), 'Initial CHANGELOG'); 230 | 231 | await prepare({}, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 232 | 233 | // Verify the files that have been commited 234 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['CHANGELOG.md']); 235 | // Verify the commit message contains on the new release notes 236 | const [commit] = await gitGetCommits(undefined, {cwd, env}); 237 | t.is(commit.author.name, 'author name'); 238 | t.is(commit.author.email, 'author email'); 239 | t.is(commit.committer.name, 'committer name'); 240 | t.is(commit.committer.email, 'committer email'); 241 | }); 242 | 243 | test('Skip negated pattern if its alone in its group', async (t) => { 244 | const {cwd, repositoryUrl} = await gitRepo(true); 245 | const pluginConfig = {assets: ['!**/*', 'file.js']}; 246 | const branch = {name: 'master'}; 247 | const options = {repositoryUrl}; 248 | const env = {}; 249 | const lastRelease = {}; 250 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; 251 | await outputFile(path.resolve(cwd, 'file.js'), 'Test content'); 252 | 253 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 254 | 255 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['file.js']); 256 | t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]); 257 | }); 258 | 259 | test('Skip commit if there is no files to commit', async (t) => { 260 | const {cwd, repositoryUrl} = await gitRepo(true); 261 | const pluginConfig = {}; 262 | const branch = {name: 'master'}; 263 | const options = {repositoryUrl}; 264 | const env = {}; 265 | const lastRelease = {}; 266 | const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Test release note'}; 267 | 268 | await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); 269 | 270 | // Verify the files that have been commited 271 | t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), []); 272 | }); 273 | -------------------------------------------------------------------------------- /test/verify.test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const verify = require('../lib/verify.js'); 3 | 4 | test('Throw SemanticReleaseError if "assets" option is not a String or false or an Array of Objects', (t) => { 5 | const assets = true; 6 | const [error] = t.throws(() => verify({assets})); 7 | 8 | t.is(error.name, 'SemanticReleaseError'); 9 | t.is(error.code, 'EINVALIDASSETS'); 10 | }); 11 | 12 | test('Throw SemanticReleaseError if "assets" option is not an Array with invalid elements', (t) => { 13 | const assets = ['file.js', 42]; 14 | const [error] = t.throws(() => verify({assets})); 15 | 16 | t.is(error.name, 'SemanticReleaseError'); 17 | t.is(error.code, 'EINVALIDASSETS'); 18 | }); 19 | 20 | test('Verify "assets" is a String', (t) => { 21 | const assets = 'file2.js'; 22 | 23 | t.notThrows(() => verify({assets})); 24 | }); 25 | 26 | test('Verify "assets" is an Array of String', (t) => { 27 | const assets = ['file1.js', 'file2.js']; 28 | 29 | t.notThrows(() => verify({assets})); 30 | }); 31 | 32 | test('Verify "assets" is an Object with a path property', (t) => { 33 | const assets = {path: 'file2.js'}; 34 | 35 | t.notThrows(() => verify({assets})); 36 | }); 37 | 38 | test('Verify "assets" is an Array of Object with a path property', (t) => { 39 | const assets = [{path: 'file1.js'}, {path: 'file2.js'}]; 40 | 41 | t.notThrows(() => verify({assets})); 42 | }); 43 | 44 | test('Verify disabled "assets" (set to false)', (t) => { 45 | const assets = false; 46 | 47 | t.notThrows(() => verify({assets})); 48 | }); 49 | 50 | test('Verify "assets" is an Array of glob Arrays', (t) => { 51 | const assets = [['dist/**', '!**/*.js'], 'file2.js']; 52 | 53 | t.notThrows(() => verify({assets})); 54 | }); 55 | 56 | test('Verify "assets" is an Array of Object with a glob Arrays in path property', (t) => { 57 | const assets = [{path: ['dist/**', '!**/*.js']}, {path: 'file2.js'}]; 58 | 59 | t.notThrows(() => verify({assets})); 60 | }); 61 | 62 | test('Throw SemanticReleaseError if "message" option is not a String', (t) => { 63 | const message = 42; 64 | const [error] = t.throws(() => verify({message})); 65 | 66 | t.is(error.name, 'SemanticReleaseError'); 67 | t.is(error.code, 'EINVALIDMESSAGE'); 68 | }); 69 | 70 | test('Throw SemanticReleaseError if "message" option is an empty String', (t) => { 71 | const message = ''; 72 | const [error] = t.throws(() => verify({message})); 73 | 74 | t.is(error.name, 'SemanticReleaseError'); 75 | t.is(error.code, 'EINVALIDMESSAGE'); 76 | }); 77 | 78 | test('Throw SemanticReleaseError if "message" option is a whitespace String', (t) => { 79 | const message = ' \n \r '; 80 | const [error] = t.throws(() => verify({message})); 81 | 82 | t.is(error.name, 'SemanticReleaseError'); 83 | t.is(error.code, 'EINVALIDMESSAGE'); 84 | }); 85 | 86 | test('Verify undefined "message" and "assets"', (t) => { 87 | t.notThrows(() => verify({})); 88 | }); 89 | --------------------------------------------------------------------------------