diff --git a/README.md b/README.md index 2d078ed9..cefb8034 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ The plugin can be configured in the [**semantic-release** configuration file](ht "@semantic-release/release-notes-generator", ["@semantic-release/git", { "assets": ["dist/**/*.{js,css}", "docs", "package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", + "skipCi": "message" }] ] } @@ -40,7 +41,7 @@ With this example, for each release a release commit will be pushed to the remot ### Merging between semantic-release branches -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. +This plugin will, by default, create commit messages with the keyword `[skip ci]`, so they won't trigger a new unnecessary CI build. You can however overwrite this behavior with [`skipCi`](#skipCi) option. 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**. @@ -65,10 +66,11 @@ When configuring branches permission on a Git hosting service (e.g. [GitHub prot ### Options -| Options | Description | Default | -|-----------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| -| `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. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` | +| Options | Description | Default | +|-----------|-------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| `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. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` | +| `skipCi` | Customize the way the "skip ci" information is passed to git. Can be `false`, `"message"` or `"pushOption"`. See [skipCi](#skipCi). | `"message"` | #### `message` @@ -117,6 +119,14 @@ If a directory is configured, all the files under this directory and its childre `[['dist/**/*.{js,css}', '!**/*.min.*']]`: include all `js` and `css` files in the `dist` directory and its sub-directories excluding the minified version. +#### `skipCi` + +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 this plugin pass "skip ci" information to git, this can be done : +- [default behavior] with the `[skip ci]` keyword in commit message. The advantage of this approach is that it's compliant with multiple CI systems, and it presents no risk. However, re-merge the targeted branch to another will skip the CI job too, which is sometimes not the desired behavior. +- with the `ci.skip` [push-option](https://git-scm.com/docs/git-push/fr#git-push---push-optionltoptiongt), that skip ci silently just for the specified git push operation, not according to the commit. Be sure that this option is correctly recognized by your CI system (it's the case for [gitlab](https://docs.gitlab.com/ee/user/project/push_options.html#push-options-for-gitlab-cicd) for example). **Beware that this option depends on your git version which can be >= 2.10** + +The value can also be passed to `false` to explicitly remove the `[skip ci]` keyword of the default commit message (without overwrite it) and skipping "push-option" behavior. + ### Examples When used with the [@semantic-release/changelog](https://github.com/semantic-release/changelog) or [@semantic-release/npm](https://github.com/semantic-release/npm) plugins: @@ -131,7 +141,7 @@ When used with the [@semantic-release/changelog](https://github.com/semantic-rel "@semantic-release/changelog", "@semantic-release/npm", "@semantic-release/git" - ], + ] } ``` diff --git a/lib/git.js b/lib/git.js index 267ef1d7..1cf4fc61 100644 --- a/lib/git.js +++ b/lib/git.js @@ -1,3 +1,4 @@ +const {flatMap} = require('lodash'); const execa = require('execa'); const debug = require('debug')('semantic-release:git'); @@ -43,12 +44,17 @@ async function commit(message, execaOptions) { * * @param {String} origin The remote repository URL. * @param {String} branch The branch to push. - * @param {Object} [execaOpts] Options to pass to `execa`. + * @param {Array} [pushOptions] Push options to pass in commit + * @param {Object} [execaOptions] Options to pass to `execa`. * * @throws {Error} if the push failed. */ -async function push(origin, branch, execaOptions) { - await execa('git', ['push', '--tags', origin, `HEAD:${branch}`], execaOptions); +async function push(origin, branch, pushOptions, execaOptions) { + await execa( + 'git', + ['push', ...flatMap(pushOptions, pushOption => ['-o', pushOption]), '--tags', origin, `HEAD:${branch}`], + execaOptions + ); } /** diff --git a/lib/prepare.js b/lib/prepare.js index 0e85b005..d2ea3b33 100644 --- a/lib/prepare.js +++ b/lib/prepare.js @@ -12,6 +12,7 @@ const {getModifiedFiles, add, commit, push} = require('./git'); * @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|false} [pluginConfig.skipCi] The way "skip ci" is passed to git or ignored. * @param {Object} context semantic-release context. * @param {Object} context.options `semantic-release` configuration. * @param {Object} context.lastRelease The last release. @@ -28,7 +29,7 @@ module.exports = async (pluginConfig, context) => { nextRelease, logger, } = context; - const {message, assets} = resolveConfig(pluginConfig, logger); + const {message, assets, skipCi} = resolveConfig(pluginConfig, logger); const modifiedFiles = await getModifiedFiles({env, cwd}); @@ -63,10 +64,10 @@ module.exports = async (pluginConfig, context) => { await commit( message ? template(message)({branch: branch.name, lastRelease, nextRelease}) - : `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}`, + : `chore(release): ${nextRelease.version}${skipCi === 'message' ? ' [skip ci]' : ''}\n\n${nextRelease.notes}`, {env, cwd} ); - await push(repositoryUrl, branch.name, {env, cwd}); + await push(repositoryUrl, branch.name, skipCi === 'pushOption' ? ['ci.skip'] : undefined, {env, cwd}); logger.log('Prepared Git release: %s', nextRelease.gitTag); } }; diff --git a/lib/resolve-config.js b/lib/resolve-config.js index dd62abfe..8ba1bb70 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -1,10 +1,13 @@ const {isNil, castArray} = require('lodash'); +const skipCiOptions = ['message', 'pushOption', false]; +const skipCiDefaultOption = 'message'; -module.exports = ({assets, message}) => ({ +module.exports = ({assets, message, skipCi}) => ({ assets: isNil(assets) ? ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'] : assets ? castArray(assets) : assets, message, + skipCi: skipCiOptions.includes(skipCi) ? skipCi : skipCiDefaultOption, }); diff --git a/lib/verify.js b/lib/verify.js index 5ab2990a..280e0455 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -13,6 +13,7 @@ const VALIDATORS = { isArrayOf(asset => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path))) ), message: isNonEmptyString, + skipCi: canBeDisabled(isNonEmptyString), }; /** diff --git a/test/git.test.js b/test/git.test.js index ada4e6d8..13993a27 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -82,7 +82,17 @@ test('Push commit to remote repository', async t => { const {cwd, repositoryUrl} = await gitRepo(true); const [{hash}] = await gitCommits(['Test commit'], {cwd}); - await push(repositoryUrl, 'master', {cwd}); + await push(repositoryUrl, 'master', undefined, {cwd}); + + t.is(await gitRemoteHead(repositoryUrl, {cwd}), hash); +}); + +test('Push commit with push-option to remote repository', async t => { + // Create a git repository without remote, set the current working directory at the root of the repo + const {cwd, repositoryUrl} = await gitRepo(false); + const [{hash}] = await gitCommits(['Test commit with push-option'], {cwd}); + + await push(repositoryUrl, 'master', ['ci.skip'], {cwd}); t.is(await gitRemoteHead(repositoryUrl, {cwd}), hash); }); diff --git a/test/helpers/git-utils.js b/test/helpers/git-utils.js index fbfa098c..7af72281 100644 --- a/test/helpers/git-utils.js +++ b/test/helpers/git-utils.js @@ -28,6 +28,7 @@ async function gitRepo(withRemote, branch = 'master') { } await execa('git', ['config', 'commit.gpgsign', false], {cwd}); + await execa('git', ['config', 'receive.advertisePushOptions', true], {cwd}); return {cwd, repositoryUrl}; } diff --git a/test/integration.test.js b/test/integration.test.js index 09a33336..fe2f114b 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -32,7 +32,7 @@ test('Prepare from a shallow clone', async t => { await add('.', {cwd}); await gitCommits(['First'], {cwd}); await gitTagVersion('v1.0.0', undefined, {cwd}); - await push(repositoryUrl, branch.name, {cwd}); + await push(repositoryUrl, branch.name, undefined, {cwd}); cwd = await gitShallowClone(repositoryUrl); await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '2.0.0'}"); await outputFile(path.resolve(cwd, 'dist/file.js'), 'Updated content'); @@ -67,7 +67,7 @@ test('Prepare from a detached head repository', async t => { await add('.', {cwd}); const [{hash}] = await gitCommits(['First'], {cwd}); await gitTagVersion('v1.0.0', undefined, {cwd}); - await push(repositoryUrl, branch.name, {cwd}); + await push(repositoryUrl, branch.name, [], {cwd}); cwd = await gitDetachedHead(repositoryUrl, hash); await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '2.0.0'}"); await outputFile(path.resolve(cwd, 'dist/file.js'), 'Updated content'); diff --git a/test/prepare.test.js b/test/prepare.test.js index f72397f5..a4966676 100644 --- a/test/prepare.test.js +++ b/test/prepare.test.js @@ -270,3 +270,43 @@ test('Skip commit if there is no files to commit', async t => { // Verify the files that have been commited t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), []); }); + +test('Allow to disable the label "[skip ci]" in commit message', async t => { + const {cwd, repositoryUrl} = await gitRepo(true); + const pluginConfig = { + skipCi: false, + }; + const branch = {name: 'master'}; + const options = {repositoryUrl}; + const env = {}; + const lastRelease = {version: 'v2.0.0'}; + const nextRelease = {version: '3.0.0', gitTag: 'v3.0.0', notes: 'Test release note'}; + await outputFile(path.resolve(cwd, 'CHANGELOG.md'), 'Initial CHANGELOG'); + await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package'}"); + + await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); + + // Verify the files that have been commited + t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['CHANGELOG.md', 'package.json']); + // Verify the commit message contains on the new release notes + const [commit] = await gitGetCommits(undefined, {cwd, env}); + t.is(commit.subject, `chore(release): ${nextRelease.version}`); + t.is(commit.body, `${nextRelease.notes}\n`); +}); + +test('Push commit with "ci.skip" push-option without error', async t => { + const {cwd, repositoryUrl} = await gitRepo(false); + const pluginConfig = { + skipCi: 'pushOption', + }; + const branch = {name: 'master'}; + const options = {repositoryUrl}; + const env = {}; + const lastRelease = {version: 'v9.9.9'}; + const nextRelease = {version: '10.0.0', gitTag: '10.0.0', notes: 'Release note test'}; + await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-great-lib'}"); + + t.notThrows(async () => + prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}) + ); +});