diff --git a/README.md b/README.md index 3f87d33f..8b3ea683 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ When using the _GITHUB_TOKEN_, the **minimum required permissions** are: | `assignees` | The [assignees](https://help.github.com/articles/assigning-issues-and-pull-requests-to-other-github-users) to add to the issue created when a release fails. | - | | `releasedLabels` | The [labels](https://help.github.com/articles/about-labels) to add to each issue and pull request resolved by the release. Set to `false` to not add any label. See [releasedLabels](#releasedlabels). | `['released<%= nextRelease.channel ? \` on @\${nextRelease.channel}\` : "" %>']- | | `addReleases` | Will add release links to the GitHub Release. Can be `false`, `"bottom"` or `"top"`. See [addReleases](#addReleases). | `false` | +| `draftRelease` | A boolean indicating if a GitHub Draft Release should be created instead of publishing an actual GitHub Release. | `false` | #### proxy @@ -218,4 +219,4 @@ Valid values for this option are `false`, `"top"` or `"bottom"`. ##### addReleases example -See [The introducing PR](https://github.com/semantic-release/github/pull/282) for an example on how it will look. \ No newline at end of file +See [The introducing PR](https://github.com/semantic-release/github/pull/282) for an example on how it will look. diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index 952ff5cf..757922f5 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -63,6 +63,12 @@ Your configuration for the \`releasedLabels\` option is \`${stringify(releasedLa details: `The [addReleases option](${linkify('README.md#options')}) if defined, must be one of \`false|top|bottom\`. Your configuration for the \`addReleases\` option is \`${stringify(addReleases)}\`.`, + }), + EINVALIDDRAFTRELEASE: ({draftRelease}) => ({ + message: 'Invalid `draftRelease` option.', + details: `The [draftRelease option](${linkify('README.md#options')}) if defined, must be a \`Boolean\`. + +Your configuration for the \`draftRelease\` option is \`${stringify(draftRelease)}\`.`, }), EINVALIDGITHUBURL: () => ({ message: 'The git repository URL is not a valid GitHub URL.', diff --git a/lib/publish.js b/lib/publish.js index fa46ec4c..a7813bb7 100644 --- a/lib/publish.js +++ b/lib/publish.js @@ -18,7 +18,10 @@ module.exports = async (pluginConfig, context) => { nextRelease: {name, gitTag, notes}, logger, } = context; - const {githubToken, githubUrl, githubApiPathPrefix, proxy, assets} = resolveConfig(pluginConfig, context); + const {githubToken, githubUrl, githubApiPathPrefix, proxy, assets, draftRelease} = resolveConfig( + pluginConfig, + context + ); const {owner, repo} = parseGithubUrl(repositoryUrl); const octokit = getClient({githubToken, githubUrl, githubApiPathPrefix, proxy}); const release = { @@ -33,8 +36,20 @@ module.exports = async (pluginConfig, context) => { debug('release object: %O', release); - // When there are no assets, we publish a release directly + const draftReleaseOptions = {...release, draft: true}; + + // When there are no assets, we publish a release directly. if (!assets || assets.length === 0) { + // If draftRelease is true we create a draft release instead. + if (draftRelease) { + const { + data: {html_url: url, id: releaseId}, + } = await octokit.request('POST /repos/{owner}/{repo}/releases', draftReleaseOptions); + + logger.log('Created GitHub draft release: %s', url); + return {url, name: RELEASE_NAME, id: releaseId}; + } + const { data: {html_url: url, id: releaseId}, } = await octokit.request('POST /repos/{owner}/{repo}/releases', release); @@ -45,11 +60,9 @@ module.exports = async (pluginConfig, context) => { // We'll create a draft release, append the assets to it, and then publish it. // This is so that the assets are available when we get a Github release event. - const draftRelease = {...release, draft: true}; - const { - data: {upload_url: uploadUrl, id: releaseId}, - } = await octokit.request('POST /repos/{owner}/{repo}/releases', draftRelease); + data: {upload_url: uploadUrl, html_url: draftUrl, id: releaseId}, + } = await octokit.request('POST /repos/{owner}/{repo}/releases', draftReleaseOptions); // Append assets to the release const globbedAssets = await globAssets(context, assets); @@ -98,6 +111,12 @@ module.exports = async (pluginConfig, context) => { }) ); + // If we want to create a draft we don't need to update the release again + if (draftRelease) { + logger.log('Created GitHub draft release: %s', draftUrl); + return {url: draftUrl, name: RELEASE_NAME, id: releaseId}; + } + const { data: {html_url: url}, } = await octokit.request('PATCH /repos/{owner}/{repo}/releases/{release_id}', { diff --git a/lib/resolve-config.js b/lib/resolve-config.js index 91b85a84..85190a6e 100644 --- a/lib/resolve-config.js +++ b/lib/resolve-config.js @@ -13,6 +13,7 @@ module.exports = ( assignees, releasedLabels, addReleases, + draftRelease, }, {env} ) => ({ @@ -32,4 +33,5 @@ module.exports = ( ? false : castArray(releasedLabels), addReleases: isNil(addReleases) ? false : addReleases, + draftRelease: isNil(draftRelease) ? false : draftRelease, }); diff --git a/lib/verify.js b/lib/verify.js index 4c83edab..c091d501 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -1,4 +1,4 @@ -const {isString, isPlainObject, isNil, isArray, isNumber} = require('lodash'); +const {isString, isPlainObject, isNil, isArray, isNumber, isBoolean} = require('lodash'); const urlJoin = require('url-join'); const AggregateError = require('aggregate-error'); const parseGithubUrl = require('./parse-github-url'); @@ -27,6 +27,7 @@ const VALIDATORS = { assignees: isArrayOf(isNonEmptyString), releasedLabels: canBeDisabled(isArrayOf(isNonEmptyString)), addReleases: canBeDisabled(oneOf(['bottom', 'top'])), + draftRelease: isBoolean, }; module.exports = async (pluginConfig, context) => { diff --git a/test/publish.test.js b/test/publish.test.js index 9c5a8232..8ee2f69f 100644 --- a/test/publish.test.js +++ b/test/publish.test.js @@ -325,6 +325,95 @@ test.serial('Publish a release with an array of missing assets', async (t) => { t.true(github.isDone()); }); +test.serial('Publish a draft release', async (t) => { + const owner = 'test_user'; + const repo = 'test_repo'; + const env = {GITHUB_TOKEN: 'github_token'}; + const pluginConfig = {draftRelease: true}; + const nextRelease = {gitTag: 'v1.0.0', name: 'v1.0.0', notes: 'Test release note body'}; + const options = {repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const releaseId = 1; + const uploadUri = `/api/uploads/repos/${owner}/${repo}/releases/${releaseId}/assets`; + const uploadUrl = `https://github.com${uploadUri}{?name,label}`; + const branch = 'test_branch'; + + const github = authenticate(env) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: branch, + name: nextRelease.name, + body: nextRelease.notes, + draft: true, + prerelease: false, + }) + .reply(200, {upload_url: uploadUrl, html_url: releaseUrl}); + + const result = await publish(pluginConfig, { + cwd, + env, + options, + branch: {name: branch, type: 'release', main: true}, + nextRelease, + logger: t.context.logger, + }); + + t.is(result.url, releaseUrl); + t.deepEqual(t.context.log.args[0], ['Created GitHub draft release: %s', releaseUrl]); + t.true(github.isDone()); +}); + +test.serial('Publish a draft release with one asset', async (t) => { + const owner = 'test_user'; + const repo = 'test_repo'; + const env = {GITHUB_TOKEN: 'github_token'}; + const pluginConfig = { + assets: [['**', '!**/*.txt'], {path: '.dotfile', label: 'A dotfile with no ext'}], + draftRelease: true, + }; + const nextRelease = {gitTag: 'v1.0.0', name: 'v1.0.0', notes: 'Test release note body'}; + const options = {repositoryUrl: `https://github.com/${owner}/${repo}.git`}; + const releaseUrl = `https://github.com/${owner}/${repo}/releases/${nextRelease.version}`; + const assetUrl = `https://github.com/${owner}/${repo}/releases/download/${nextRelease.version}/.dotfile`; + const releaseId = 1; + const uploadUri = `/api/uploads/repos/${owner}/${repo}/releases/${releaseId}/assets`; + const uploadUrl = `https://github.com${uploadUri}{?name,label}`; + const branch = 'test_branch'; + + const github = authenticate(env) + .post(`/repos/${owner}/${repo}/releases`, { + tag_name: nextRelease.gitTag, + target_commitish: branch, + name: nextRelease.name, + body: nextRelease.notes, + draft: true, + prerelease: false, + }) + .reply(200, {upload_url: uploadUrl, html_url: releaseUrl, id: releaseId}); + + const githubUpload = upload(env, { + uploadUrl: 'https://github.com', + contentLength: (await stat(path.resolve(cwd, '.dotfile'))).size, + }) + .post(`${uploadUri}?name=${escape('.dotfile')}&label=${escape('A dotfile with no ext')}`) + .reply(200, {browser_download_url: assetUrl}); + + const result = await publish(pluginConfig, { + cwd, + env, + options, + branch: {name: branch, type: 'release', main: true}, + nextRelease, + logger: t.context.logger, + }); + + t.is(result.url, releaseUrl); + t.true(t.context.log.calledWith('Created GitHub draft release: %s', releaseUrl)); + t.true(t.context.log.calledWith('Published file %s', assetUrl)); + t.true(github.isDone()); + t.true(githubUpload.isDone()); +}); + test.serial( 'Publish a release when env.GITHUB_URL is set to https://github.com (Default in GitHub Actions, #268)', async (t) => { diff --git a/test/verify.test.js b/test/verify.test.js index fa2f5c66..fc4bd8fa 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -421,6 +421,44 @@ test.serial('Verify "addReleases" is valid (false)', async (t) => { t.true(github.isDone()); }); +test.serial('Verify "draftRelease" is valid (true)', async (t) => { + const owner = 'test_user'; + const repo = 'test_repo'; + const env = {GH_TOKEN: 'github_token'}; + const draftRelease = true; + const github = authenticate(env) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrowsAsync( + verify( + {draftRelease}, + {env, options: {repositoryUrl: `git@othertesturl.com:${owner}/${repo}.git`}, logger: t.context.logger} + ) + ); + + t.true(github.isDone()); +}); + +test.serial('Verify "draftRelease" is valid (false)', async (t) => { + const owner = 'test_user'; + const repo = 'test_repo'; + const env = {GH_TOKEN: 'github_token'}; + const draftRelease = false; + const github = authenticate(env) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + await t.notThrowsAsync( + verify( + {draftRelease}, + {env, options: {repositoryUrl: `git@othertesturl.com:${owner}/${repo}.git`}, logger: t.context.logger} + ) + ); + + t.true(github.isDone()); +}); + // https://github.com/semantic-release/github/issues/182 test.serial('Verify if run in GitHub Action', async (t) => { const owner = 'test_user'; @@ -1148,3 +1186,25 @@ test.serial('Throw SemanticReleaseError if "addReleases" option is not a valid s t.is(error.code, 'EINVALIDADDRELEASES'); t.true(github.isDone()); }); + +test.serial('Throw SemanticReleaseError if "draftRelease" option is not a valid boolean (string)', async (t) => { + const owner = 'test_user'; + const repo = 'test_repo'; + const env = {GH_TOKEN: 'github_token'}; + const draftRelease = 'test'; + const github = authenticate(env) + .get(`/repos/${owner}/${repo}`) + .reply(200, {permissions: {push: true}}); + + const [error, ...errors] = await t.throwsAsync( + verify( + {draftRelease}, + {env, options: {repositoryUrl: `https://github.com/${owner}/${repo}.git`}, logger: t.context.logger} + ) + ); + + t.is(errors.length, 0); + t.is(error.name, 'SemanticReleaseError'); + t.is(error.code, 'EINVALIDDRAFTRELEASE'); + t.true(github.isDone()); +});