diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index ab3e3ca9..3f549a78 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -167,13 +167,13 @@ See [Plugins configuration](plugins.md#configuration) for more details. ### generateNotes -Type: `String`, `Object` +Type: `Array`, `String`, `Object` Default: `['@semantic-release/release-notes-generator']` CLI argument: `--generate-notes` -Define the [generate notes plugin](plugins.md#generatenotes-plugin). +Define the [generate notes plugins](plugins.md#generatenotes-plugin). See [Plugins configuration](plugins.md#configuration) for more details. diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md index 441f61b6..2b5d0c37 100644 --- a/docs/usage/plugins.md +++ b/docs/usage/plugins.md @@ -26,7 +26,7 @@ Default implementation: none. ### generateNotes plugin -Responsible for generating release notes. +Responsible for generating release notes. If multiple `generateNotes` plugins are defined, the release notes will be the result of the concatenation of plugin output. Default implementation: [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator). diff --git a/index.js b/index.js index 7ded1b05..ca961e9a 100644 --- a/index.js +++ b/index.js @@ -15,7 +15,7 @@ const getGitAuthUrl = require('./lib/get-git-auth-url'); const logger = require('./lib/logger'); const {fetch, verifyAuth, isBranchUpToDate, gitHead: getGitHead, tag, push} = require('./lib/git'); const getError = require('./lib/get-error'); -const {COMMIT_NAME, COMMIT_EMAIL} = require('./lib/definitions/constants'); +const {COMMIT_NAME, COMMIT_EMAIL, RELEASE_NOTES_SEPARATOR} = require('./lib/definitions/constants'); marked.setOptions({renderer: new TerminalRenderer()}); @@ -101,14 +101,34 @@ async function run(options, plugins) { if (options.dryRun) { logger.log('Call plugin %s', 'generate-notes'); - const [notes] = await plugins.generateNotes(generateNotesParam); + const notes = (await plugins.generateNotes(generateNotesParam, { + getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ + ...generateNotesParam, + nextRelease: { + ...nextRelease, + notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, + }, + }), + })) + .filter(Boolean) + .join(RELEASE_NOTES_SEPARATOR); logger.log('Release note for version %s:\n', nextRelease.version); if (notes) { process.stdout.write(`${marked(notes)}\n`); } } else { logger.log('Call plugin %s', 'generateNotes'); - [nextRelease.notes] = await plugins.generateNotes(generateNotesParam); + nextRelease.notes = (await plugins.generateNotes(generateNotesParam, { + getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ + ...generateNotesParam, + nextRelease: { + ...nextRelease, + notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, + }, + }), + })) + .filter(Boolean) + .join(RELEASE_NOTES_SEPARATOR); logger.log('Call plugin %s', 'prepare'); await plugins.prepare( @@ -121,7 +141,20 @@ async function run(options, plugins) { nextRelease.gitHead = newGitHead; // Regenerate the release notes logger.log('Call plugin %s', 'generateNotes'); - [nextRelease.notes] = await plugins.generateNotes({nextRelease, ...prepareParam}); + nextRelease.notes = (await plugins.generateNotes( + {nextRelease, ...prepareParam}, + { + getNextInput: ({nextRelease, ...generateNotesParam}, notes) => ({ + ...generateNotesParam, + nextRelease: { + ...nextRelease, + notes: `${nextRelease.notes ? `${nextRelease.notes}${RELEASE_NOTES_SEPARATOR}` : ''}${notes}`, + }, + }), + } + )) + .filter(Boolean) + .join(RELEASE_NOTES_SEPARATOR); } // Call the next publish plugin with the updated `nextRelease` return {...prepareParam, nextRelease}; diff --git a/lib/definitions/constants.js b/lib/definitions/constants.js index e09b8b54..bbce03d9 100644 --- a/lib/definitions/constants.js +++ b/lib/definitions/constants.js @@ -6,4 +6,6 @@ const COMMIT_NAME = 'semantic-release-bot'; const COMMIT_EMAIL = 'semantic-release-bot@martynus.net'; -module.exports = {RELEASE_TYPE, FIRST_RELEASE, COMMIT_NAME, COMMIT_EMAIL}; +const RELEASE_NOTES_SEPARATOR = '\n\n'; + +module.exports = {RELEASE_TYPE, FIRST_RELEASE, COMMIT_NAME, COMMIT_EMAIL, RELEASE_NOTES_SEPARATOR}; diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js index 62c33150..7d3df084 100644 --- a/lib/definitions/plugins.js +++ b/lib/definitions/plugins.js @@ -18,8 +18,8 @@ module.exports = { configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), }, generateNotes: { - default: '@semantic-release/release-notes-generator', - configValidator: conf => !conf || validatePluginConfig(conf), + default: ['@semantic-release/release-notes-generator'], + configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), outputValidator: output => !output || isString(output), }, prepare: { diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js index 5703be5b..9d37ae88 100644 --- a/test/definitions/plugins.test.js +++ b/test/definitions/plugins.test.js @@ -34,15 +34,15 @@ test('The "verifyRelease" plugin, if defined, must be a single or an array of pl t.true(plugins.verifyRelease.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); }); -test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => { +test('The "generateNotes" plugin, if defined, must be a single or an array of plugins definition', t => { t.false(plugins.generateNotes.configValidator({})); t.false(plugins.generateNotes.configValidator({path: null})); - t.false(plugins.generateNotes.configValidator([])); - t.true(plugins.generateNotes.configValidator()); t.true(plugins.generateNotes.configValidator({path: 'plugin-path.js'})); + t.true(plugins.generateNotes.configValidator()); t.true(plugins.generateNotes.configValidator('plugin-path.js')); t.true(plugins.generateNotes.configValidator(() => {})); + t.true(plugins.generateNotes.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); }); test('The "prepare" plugin, if defined, must be a single or an array of plugins definition', t => { diff --git a/test/index.test.js b/test/index.test.js index ccab7cb4..d7a185ad 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -61,12 +61,16 @@ test.serial('Plugins are called with expected values', async t => { const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'}; const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; - const notes = 'Release notes'; + const notes1 = 'Release notes 1'; + const notes2 = 'Release notes 2'; + const notes3 = 'Release notes 3'; const verifyConditions1 = stub().resolves(); const verifyConditions2 = stub().resolves(); const analyzeCommits = stub().resolves(nextRelease.type); const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(notes); + const generateNotes1 = stub().resolves(notes1); + const generateNotes2 = stub().resolves(notes2); + const generateNotes3 = stub().resolves(notes3); const release1 = {name: 'Release 1', url: 'https://release1.com'}; const prepare = stub().resolves(); const publish1 = stub().resolves(release1); @@ -78,7 +82,7 @@ test.serial('Plugins are called with expected values', async t => { verifyConditions: [verifyConditions1, verifyConditions2], analyzeCommits, verifyRelease, - generateNotes, + generateNotes: [generateNotes1, generateNotes2, generateNotes3], prepare, publish: [publish1, pluginNoop], success, @@ -113,14 +117,32 @@ test.serial('Plugins are called with expected values', async t => { t.deepEqual(verifyRelease.args[0][1].commits[0].message, commits[0].message); t.deepEqual(verifyRelease.args[0][1].nextRelease, nextRelease); - t.is(generateNotes.callCount, 1); - t.deepEqual(generateNotes.args[0][0], config); - t.deepEqual(generateNotes.args[0][1].options, options); - t.deepEqual(generateNotes.args[0][1].logger, t.context.logger); - t.deepEqual(generateNotes.args[0][1].lastRelease, lastRelease); - t.deepEqual(generateNotes.args[0][1].commits[0].hash, commits[0].hash); - t.deepEqual(generateNotes.args[0][1].commits[0].message, commits[0].message); - t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease); + t.is(generateNotes1.callCount, 1); + t.deepEqual(generateNotes1.args[0][0], config); + t.deepEqual(generateNotes1.args[0][1].options, options); + t.deepEqual(generateNotes1.args[0][1].logger, t.context.logger); + t.deepEqual(generateNotes1.args[0][1].lastRelease, lastRelease); + t.deepEqual(generateNotes1.args[0][1].commits[0].hash, commits[0].hash); + t.deepEqual(generateNotes1.args[0][1].commits[0].message, commits[0].message); + t.deepEqual(generateNotes1.args[0][1].nextRelease, nextRelease); + + t.is(generateNotes2.callCount, 1); + t.deepEqual(generateNotes2.args[0][0], config); + t.deepEqual(generateNotes2.args[0][1].options, options); + t.deepEqual(generateNotes2.args[0][1].logger, t.context.logger); + t.deepEqual(generateNotes2.args[0][1].lastRelease, lastRelease); + t.deepEqual(generateNotes2.args[0][1].commits[0].hash, commits[0].hash); + t.deepEqual(generateNotes2.args[0][1].commits[0].message, commits[0].message); + t.deepEqual(generateNotes2.args[0][1].nextRelease, {...nextRelease, notes: notes1}); + + t.is(generateNotes3.callCount, 1); + t.deepEqual(generateNotes3.args[0][0], config); + t.deepEqual(generateNotes3.args[0][1].options, options); + t.deepEqual(generateNotes3.args[0][1].logger, t.context.logger); + t.deepEqual(generateNotes3.args[0][1].lastRelease, lastRelease); + t.deepEqual(generateNotes3.args[0][1].commits[0].hash, commits[0].hash); + t.deepEqual(generateNotes3.args[0][1].commits[0].message, commits[0].message); + t.deepEqual(generateNotes3.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}`}); t.is(prepare.callCount, 1); t.deepEqual(prepare.args[0][0], config); @@ -129,7 +151,7 @@ test.serial('Plugins are called with expected values', async t => { t.deepEqual(prepare.args[0][1].lastRelease, lastRelease); t.deepEqual(prepare.args[0][1].commits[0].hash, commits[0].hash); t.deepEqual(prepare.args[0][1].commits[0].message, commits[0].message); - t.deepEqual(prepare.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.deepEqual(prepare.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`}); t.is(publish1.callCount, 1); t.deepEqual(publish1.args[0][0], config); @@ -138,7 +160,7 @@ test.serial('Plugins are called with expected values', async t => { t.deepEqual(publish1.args[0][1].lastRelease, lastRelease); t.deepEqual(publish1.args[0][1].commits[0].hash, commits[0].hash); t.deepEqual(publish1.args[0][1].commits[0].message, commits[0].message); - t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`}); t.is(success.callCount, 1); t.deepEqual(success.args[0][0], config); @@ -147,10 +169,10 @@ test.serial('Plugins are called with expected values', async t => { t.deepEqual(success.args[0][1].lastRelease, lastRelease); t.deepEqual(success.args[0][1].commits[0].hash, commits[0].hash); t.deepEqual(success.args[0][1].commits[0].message, commits[0].message); - t.deepEqual(success.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.deepEqual(success.args[0][1].nextRelease, {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`}); t.deepEqual(success.args[0][1].releases, [ - {...release1, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}}, - {...nextRelease, ...{notes}, ...{pluginName: pluginNoop}}, + {...release1, ...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`, pluginName: '[Function: proxy]'}, + {...nextRelease, notes: `${notes1}\n\n${notes2}\n\n${notes3}`, pluginName: pluginNoop}, ]); // Verify the tag has been created on the local and remote repo and reference the gitHead @@ -625,7 +647,9 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; const analyzeCommits = stub().resolves(nextRelease.type); const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(); + const generateNotes1 = stub().resolves(); + const notes2 = 'Release notes 2'; + const generateNotes2 = stub().resolves(notes2); const publish = stub().resolves(); const options = { @@ -634,7 +658,7 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', verifyConditions: stub().resolves(), analyzeCommits, verifyRelease, - generateNotes, + generateNotes: [generateNotes1, generateNotes2], prepare: stub().resolves(), publish, success: stub().resolves(), @@ -653,12 +677,15 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', t.is(verifyRelease.callCount, 1); t.deepEqual(verifyRelease.args[0][1].lastRelease, lastRelease); - t.is(generateNotes.callCount, 1); - t.deepEqual(generateNotes.args[0][1].lastRelease, lastRelease); + t.is(generateNotes1.callCount, 1); + t.deepEqual(generateNotes1.args[0][1].lastRelease, lastRelease); + + t.is(generateNotes2.callCount, 1); + t.deepEqual(generateNotes2.args[0][1].lastRelease, lastRelease); t.is(publish.callCount, 1); t.deepEqual(publish.args[0][1].lastRelease, lastRelease); - t.falsy(publish.args[0][1].nextRelease.notes); + t.is(publish.args[0][1].nextRelease.notes, notes2); }); test.serial('Returns falsy value if triggered by a PR', async t => {