Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add skipCi option #264

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
24 changes: 17 additions & 7 deletions README.md
Expand Up @@ -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"
}]
]
}
Expand All @@ -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**.

Expand All @@ -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`

Expand Down Expand Up @@ -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:
Expand All @@ -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"
],
]
}
```

Expand Down
12 changes: 9 additions & 3 deletions lib/git.js
@@ -1,3 +1,4 @@
const {flatMap} = require('lodash');
const execa = require('execa');
const debug = require('debug')('semantic-release:git');

Expand Down Expand Up @@ -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
);
}

/**
Expand Down
7 changes: 4 additions & 3 deletions lib/prepare.js
Expand Up @@ -12,6 +12,7 @@ const {getModifiedFiles, add, commit, push} = require('./git');
* @param {Object} pluginConfig The plugin configuration.
* @param {String|Array<String>} [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.
Expand All @@ -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});

Expand Down Expand Up @@ -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);
}
};
5 changes: 4 additions & 1 deletion 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,
});
1 change: 1 addition & 0 deletions lib/verify.js
Expand Up @@ -13,6 +13,7 @@ const VALIDATORS = {
isArrayOf(asset => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path)))
),
message: isNonEmptyString,
skipCi: canBeDisabled(isNonEmptyString),
};

/**
Expand Down
12 changes: 11 additions & 1 deletion test/git.test.js
Expand Up @@ -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);
});
1 change: 1 addition & 0 deletions test/helpers/git-utils.js
Expand Up @@ -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};
}
Expand Down
4 changes: 2 additions & 2 deletions test/integration.test.js
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
40 changes: 40 additions & 0 deletions test/prepare.test.js
Expand Up @@ -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})
);
});