Installing dependencies.
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
||||
const debug = require('debug')('semantic-release:github');
|
||||
const {RELEASE_NAME} = require('./definitions/constants');
|
||||
const parseGithubUrl = require('./parse-github-url');
|
||||
const resolveConfig = require('./resolve-config');
|
||||
const getClient = require('./get-client');
|
||||
const isPrerelease = require('./is-prerelease');
|
||||
|
||||
module.exports = async (pluginConfig, context) => {
|
||||
const {
|
||||
options: {repositoryUrl},
|
||||
branch,
|
||||
nextRelease: {name, gitTag, notes},
|
||||
logger,
|
||||
} = context;
|
||||
const {githubToken, githubUrl, githubApiPathPrefix, proxy} = resolveConfig(pluginConfig, context);
|
||||
const {owner, repo} = parseGithubUrl(repositoryUrl);
|
||||
const github = getClient({githubToken, githubUrl, githubApiPathPrefix, proxy});
|
||||
let releaseId;
|
||||
|
||||
const release = {owner, repo, name, prerelease: isPrerelease(branch), tag_name: gitTag};
|
||||
|
||||
debug('release object: %O', release);
|
||||
|
||||
try {
|
||||
({
|
||||
data: {id: releaseId},
|
||||
} = await github.repos.getReleaseByTag({owner, repo, tag: gitTag}));
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
logger.log('There is no release for tag %s, creating a new one', gitTag);
|
||||
|
||||
const {
|
||||
data: {html_url: url},
|
||||
} = await github.repos.createRelease({...release, body: notes});
|
||||
|
||||
logger.log('Published GitHub release: %s', url);
|
||||
return {url, name: RELEASE_NAME};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
debug('release release_id: %o', releaseId);
|
||||
|
||||
const {
|
||||
data: {html_url: url},
|
||||
} = await github.repos.updateRelease({...release, release_id: releaseId});
|
||||
|
||||
logger.log('Updated GitHub release: %s', url);
|
||||
|
||||
return {url, name: RELEASE_NAME};
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
const ISSUE_ID = '<!-- semantic-release:github -->';
|
||||
|
||||
const RELEASE_NAME = 'GitHub release';
|
||||
|
||||
module.exports = {ISSUE_ID, RELEASE_NAME};
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
const {inspect} = require('util');
|
||||
const {isString} = require('lodash');
|
||||
const pkg = require('../../package.json');
|
||||
|
||||
const [homepage] = pkg.homepage.split('#');
|
||||
const stringify = (object) =>
|
||||
isString(object) ? object : inspect(object, {breakLength: Infinity, depth: 2, maxArrayLength: 5});
|
||||
const linkify = (file) => `${homepage}/blob/master/${file}`;
|
||||
|
||||
module.exports = {
|
||||
EINVALIDASSETS: ({assets}) => ({
|
||||
message: 'Invalid `assets` option.',
|
||||
details: `The [assets option](${linkify(
|
||||
'README.md#assets'
|
||||
)}) must be an \`Array\` of \`Strings\` or \`Objects\` with a \`path\` property.
|
||||
|
||||
Your configuration for the \`assets\` option is \`${stringify(assets)}\`.`,
|
||||
}),
|
||||
EINVALIDSUCCESSCOMMENT: ({successComment}) => ({
|
||||
message: 'Invalid `successComment` option.',
|
||||
details: `The [successComment option](${linkify(
|
||||
'README.md#successcomment'
|
||||
)}) if defined, must be a non empty \`String\`.
|
||||
|
||||
Your configuration for the \`successComment\` option is \`${stringify(successComment)}\`.`,
|
||||
}),
|
||||
EINVALIDFAILTITLE: ({failTitle}) => ({
|
||||
message: 'Invalid `failTitle` option.',
|
||||
details: `The [failTitle option](${linkify('README.md#failtitle')}) if defined, must be a non empty \`String\`.
|
||||
|
||||
Your configuration for the \`failTitle\` option is \`${stringify(failTitle)}\`.`,
|
||||
}),
|
||||
EINVALIDFAILCOMMENT: ({failComment}) => ({
|
||||
message: 'Invalid `failComment` option.',
|
||||
details: `The [failComment option](${linkify('README.md#failcomment')}) if defined, must be a non empty \`String\`.
|
||||
|
||||
Your configuration for the \`failComment\` option is \`${stringify(failComment)}\`.`,
|
||||
}),
|
||||
EINVALIDLABELS: ({labels}) => ({
|
||||
message: 'Invalid `labels` option.',
|
||||
details: `The [labels option](${linkify(
|
||||
'README.md#options'
|
||||
)}) if defined, must be an \`Array\` of non empty \`String\`.
|
||||
|
||||
Your configuration for the \`labels\` option is \`${stringify(labels)}\`.`,
|
||||
}),
|
||||
EINVALIDASSIGNEES: ({assignees}) => ({
|
||||
message: 'Invalid `assignees` option.',
|
||||
details: `The [assignees option](${linkify('README.md#options')}) must be an \`Array\` of non empty \`Strings\`.
|
||||
|
||||
Your configuration for the \`assignees\` option is \`${stringify(assignees)}\`.`,
|
||||
}),
|
||||
EINVALIDRELEASEDLABELS: ({releasedLabels}) => ({
|
||||
message: 'Invalid `releasedLabels` option.',
|
||||
details: `The [releasedLabels option](${linkify(
|
||||
'README.md#options'
|
||||
)}) if defined, must be an \`Array\` of non empty \`String\`.
|
||||
|
||||
Your configuration for the \`releasedLabels\` option is \`${stringify(releasedLabels)}\`.`,
|
||||
}),
|
||||
EINVALIDADDRELEASES: ({addReleases}) => ({
|
||||
message: 'Invalid `addReleases` option.',
|
||||
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)}\`.`,
|
||||
}),
|
||||
EINVALIDGITHUBURL: () => ({
|
||||
message: 'The git repository URL is not a valid GitHub URL.',
|
||||
details: `The **semantic-release** \`repositoryUrl\` option must a valid GitHub URL with the format \`<GitHub_or_GHE_URL>/<owner>/<repo>.git\`.
|
||||
|
||||
By default the \`repositoryUrl\` option is retrieved from the \`repository\` property of your \`package.json\` or the [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) of the repository cloned by your CI environment.`,
|
||||
}),
|
||||
EINVALIDPROXY: ({proxy}) => ({
|
||||
message: 'Invalid `proxy` option.',
|
||||
details: `The [proxy option](${linkify(
|
||||
'README.md#proxy'
|
||||
)}) must be a \`String\` or an \`Objects\` with a \`host\` and a \`port\` property.
|
||||
|
||||
Your configuration for the \`proxy\` option is \`${stringify(proxy)}\`.`,
|
||||
}),
|
||||
EMISSINGREPO: ({owner, repo}) => ({
|
||||
message: `The repository ${owner}/${repo} doesn't exist.`,
|
||||
details: `The **semantic-release** \`repositoryUrl\` option must refer to your GitHub repository. The repository must be accessible with the [GitHub API](https://developer.github.com/v3).
|
||||
|
||||
By default the \`repositoryUrl\` option is retrieved from the \`repository\` property of your \`package.json\` or the [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) of the repository cloned by your CI environment.
|
||||
|
||||
If you are using [GitHub Enterprise](https://enterprise.github.com) please make sure to configure the \`githubUrl\` and \`githubApiPathPrefix\` [options](${linkify(
|
||||
'README.md#options'
|
||||
)}).`,
|
||||
}),
|
||||
EGHNOPERMISSION: ({owner, repo}) => ({
|
||||
message: `The GitHub token doesn't allow to push on the repository ${owner}/${repo}.`,
|
||||
details: `The user associated with the [GitHub token](${linkify(
|
||||
'README.md#github-authentication'
|
||||
)}) configured in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable must allows to push to the repository ${owner}/${repo}.
|
||||
|
||||
Please make sure the GitHub user associated with the token is an [owner](https://help.github.com/articles/permission-levels-for-a-user-account-repository/#owner-access-on-a-repository-owned-by-a-user-account) or a [collaborator](https://help.github.com/articles/permission-levels-for-a-user-account-repository/#collaborator-access-on-a-repository-owned-by-a-user-account) if the reposotory belong to a user account or has [write permissions](https://help.github.com/articles/managing-team-access-to-an-organization-repository) if the repository [belongs to an organization](https://help.github.com/articles/repository-permission-levels-for-an-organization).`,
|
||||
}),
|
||||
EINVALIDGHTOKEN: ({owner, repo}) => ({
|
||||
message: 'Invalid GitHub token.',
|
||||
details: `The [GitHub token](${linkify(
|
||||
'README.md#github-authentication'
|
||||
)}) configured in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable must be a valid [personal token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line) allowing to push to the repository ${owner}/${repo}.
|
||||
|
||||
Please make sure to set the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable in your CI with the exact value of the GitHub personal token.`,
|
||||
}),
|
||||
ENOGHTOKEN: ({owner, repo}) => ({
|
||||
message: 'No GitHub token specified.',
|
||||
details: `A [GitHub personal token](${linkify(
|
||||
'README.md#github-authentication'
|
||||
)}) must be created and set in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable on your CI environment.
|
||||
|
||||
Please make sure to create a [GitHub personal token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line) and to set it in the \`GH_TOKEN\` or \`GITHUB_TOKEN\` environment variable on your CI environment. The token must allow to push to the repository ${owner}/${repo}.`,
|
||||
}),
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Default exponential backoff configuration for retries.
|
||||
*/
|
||||
const RETRY_CONF = {retries: 3, factor: 2, minTimeout: 1000};
|
||||
|
||||
/**
|
||||
* Rate limit per API endpoints.
|
||||
*
|
||||
* See {@link https://developer.github.com/v3/search/#rate-limit|Search API rate limit}.
|
||||
* See {@link https://developer.github.com/v3/#rate-limiting|Rate limiting}.
|
||||
*/
|
||||
const RATE_LIMITS = {
|
||||
search: ((60 * 1000) / 30) * 1.1, // 30 calls per minutes => 1 call every 2s + 10% safety margin
|
||||
core: {
|
||||
read: ((60 * 60 * 1000) / 5000) * 1.1, // 5000 calls per hour => 1 call per 720ms + 10% safety margin
|
||||
write: 3000, // 1 call every 3 seconds
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Global rate limit to prevent abuse.
|
||||
*
|
||||
* See {@link https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits|Dealing with abuse rate limits}
|
||||
*/
|
||||
const GLOBAL_RATE_LIMIT = 1000;
|
||||
|
||||
module.exports = {RETRY_CONF, RATE_LIMITS, GLOBAL_RATE_LIMIT};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
const {template} = require('lodash');
|
||||
const debug = require('debug')('semantic-release:github');
|
||||
const parseGithubUrl = require('./parse-github-url');
|
||||
const {ISSUE_ID} = require('./definitions/constants');
|
||||
const resolveConfig = require('./resolve-config');
|
||||
const getClient = require('./get-client');
|
||||
const findSRIssues = require('./find-sr-issues');
|
||||
const getFailComment = require('./get-fail-comment');
|
||||
|
||||
module.exports = async (pluginConfig, context) => {
|
||||
const {
|
||||
options: {repositoryUrl},
|
||||
branch,
|
||||
errors,
|
||||
logger,
|
||||
} = context;
|
||||
const {githubToken, githubUrl, githubApiPathPrefix, proxy, failComment, failTitle, labels, assignees} = resolveConfig(
|
||||
pluginConfig,
|
||||
context
|
||||
);
|
||||
|
||||
if (failComment === false || failTitle === false) {
|
||||
logger.log('Skip issue creation.');
|
||||
} else {
|
||||
const github = getClient({githubToken, githubUrl, githubApiPathPrefix, proxy});
|
||||
// In case the repo changed name, get the new `repo`/`owner` as the search API will not follow redirects
|
||||
const [owner, repo] = (await github.repos.get(parseGithubUrl(repositoryUrl))).data.full_name.split('/');
|
||||
const body = failComment ? template(failComment)({branch, errors}) : getFailComment(branch, errors);
|
||||
const [srIssue] = await findSRIssues(github, failTitle, owner, repo);
|
||||
|
||||
if (srIssue) {
|
||||
logger.log('Found existing semantic-release issue #%d.', srIssue.number);
|
||||
const comment = {owner, repo, issue_number: srIssue.number, body};
|
||||
debug('create comment: %O', comment);
|
||||
const {
|
||||
data: {html_url: url},
|
||||
} = await github.issues.createComment(comment);
|
||||
logger.log('Added comment to issue #%d: %s.', srIssue.number, url);
|
||||
} else {
|
||||
const newIssue = {owner, repo, title: failTitle, body: `${body}\n\n${ISSUE_ID}`, labels: labels || [], assignees};
|
||||
debug('create issue: %O', newIssue);
|
||||
const {
|
||||
data: {html_url: url, number},
|
||||
} = await github.issues.create(newIssue);
|
||||
logger.log('Created issue #%d: %s.', number, url);
|
||||
}
|
||||
}
|
||||
};
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
const {ISSUE_ID} = require('./definitions/constants');
|
||||
|
||||
module.exports = async (github, title, owner, repo) => {
|
||||
const {
|
||||
data: {items: issues},
|
||||
} = await github.search.issuesAndPullRequests({
|
||||
q: `in:title+repo:${owner}/${repo}+type:issue+state:open+${title}`,
|
||||
});
|
||||
|
||||
return issues.filter((issue) => issue.body && issue.body.includes(ISSUE_ID));
|
||||
};
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
const {memoize, get} = require('lodash');
|
||||
const {Octokit} = require('@octokit/rest');
|
||||
const pRetry = require('p-retry');
|
||||
const Bottleneck = require('bottleneck');
|
||||
const urljoin = require('url-join');
|
||||
const HttpProxyAgent = require('http-proxy-agent');
|
||||
const HttpsProxyAgent = require('https-proxy-agent');
|
||||
|
||||
const {RETRY_CONF, RATE_LIMITS, GLOBAL_RATE_LIMIT} = require('./definitions/rate-limit');
|
||||
|
||||
/**
|
||||
* Http error status for which to not retry.
|
||||
*/
|
||||
const SKIP_RETRY_CODES = new Set([400, 401, 403]);
|
||||
|
||||
/**
|
||||
* Create or retrieve the throttler function for a given rate limit group.
|
||||
*
|
||||
* @param {Array} rate The rate limit group.
|
||||
* @param {String} limit The rate limits per API endpoints.
|
||||
* @param {Bottleneck} globalThrottler The global throttler.
|
||||
*
|
||||
* @return {Bottleneck} The throller function for the given rate limit group.
|
||||
*/
|
||||
const getThrottler = memoize((rate, globalThrottler) =>
|
||||
new Bottleneck({minTime: get(RATE_LIMITS, rate)}).chain(globalThrottler)
|
||||
);
|
||||
|
||||
module.exports = ({githubToken, githubUrl, githubApiPathPrefix, proxy}) => {
|
||||
const baseUrl = githubUrl && urljoin(githubUrl, githubApiPathPrefix);
|
||||
const globalThrottler = new Bottleneck({minTime: GLOBAL_RATE_LIMIT});
|
||||
const github = new Octokit({
|
||||
auth: `token ${githubToken}`,
|
||||
baseUrl,
|
||||
request: {
|
||||
agent: proxy
|
||||
? baseUrl && new URL(baseUrl).protocol.replace(':', '') === 'http'
|
||||
? new HttpProxyAgent(proxy)
|
||||
: new HttpsProxyAgent(proxy)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
github.hook.wrap('request', (request, options) => {
|
||||
const access = options.method === 'GET' ? 'read' : 'write';
|
||||
const rateCategory = options.url.startsWith('/search') ? 'search' : 'core';
|
||||
const limitKey = [rateCategory, RATE_LIMITS[rateCategory][access] && access].filter(Boolean).join('.');
|
||||
|
||||
return pRetry(async () => {
|
||||
try {
|
||||
return await getThrottler(limitKey, globalThrottler).wrap(request)(options);
|
||||
} catch (error) {
|
||||
if (SKIP_RETRY_CODES.has(error.status)) {
|
||||
throw new pRetry.AbortError(error);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}, RETRY_CONF);
|
||||
});
|
||||
|
||||
return github;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
const SemanticReleaseError = require('@semantic-release/error');
|
||||
const ERROR_DEFINITIONS = require('./definitions/errors');
|
||||
|
||||
module.exports = (code, ctx = {}) => {
|
||||
const {message, details} = ERROR_DEFINITIONS[code](ctx);
|
||||
return new SemanticReleaseError(message, code, details);
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
const HOME_URL = 'https://github.com/semantic-release/semantic-release';
|
||||
const FAQ_URL = `${HOME_URL}/blob/caribou/docs/support/FAQ.md`;
|
||||
const GET_HELP_URL = `${HOME_URL}#get-help`;
|
||||
const USAGE_DOC_URL = `${HOME_URL}/blob/caribou/docs/usage/README.md`;
|
||||
const NEW_ISSUE_URL = `${HOME_URL}/issues/new`;
|
||||
|
||||
const formatError = (error) => `### ${error.message}
|
||||
|
||||
${
|
||||
error.details ||
|
||||
`Unfortunately this error doesn't have any additional information.${
|
||||
error.pluginName
|
||||
? ` Feel free to kindly ask the author of the \`${error.pluginName}\` plugin to add more helpful information.`
|
||||
: ''
|
||||
}`
|
||||
}`;
|
||||
|
||||
module.exports = (branch, errors) => `## :rotating_light: The automated release from the \`${
|
||||
branch.name
|
||||
}\` branch failed. :rotating_light:
|
||||
|
||||
I recommend you give this issue a high priority, so other packages depending on you can benefit from your bug fixes and new features again.
|
||||
|
||||
You can find below the list of errors reported by **semantic-release**. Each one of them has to be resolved in order to automatically publish your package. I’m sure you can fix this 💪.
|
||||
|
||||
Errors are usually caused by a misconfiguration or an authentication problem. With each error reported below you will find explanation and guidance to help you to resolve it.
|
||||
|
||||
Once all the errors are resolved, **semantic-release** will release your package the next time you push a commit to the \`${
|
||||
branch.name
|
||||
}\` branch. You can also manually restart the failed CI job that runs **semantic-release**.
|
||||
|
||||
If you are not sure how to resolve this, here are some links that can help you:
|
||||
- [Usage documentation](${USAGE_DOC_URL})
|
||||
- [Frequently Asked Questions](${FAQ_URL})
|
||||
- [Support channels](${GET_HELP_URL})
|
||||
|
||||
If those don’t help, or if this issue is reporting something you think isn’t right, you can always ask the humans behind **[semantic-release](${NEW_ISSUE_URL})**.
|
||||
|
||||
---
|
||||
|
||||
${errors.map((error) => formatError(error)).join('\n\n---\n\n')}
|
||||
|
||||
---
|
||||
|
||||
Good luck with your project ✨
|
||||
|
||||
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
const {RELEASE_NAME} = require('./definitions/constants');
|
||||
|
||||
const linkify = (releaseInfo) =>
|
||||
`${
|
||||
releaseInfo.url
|
||||
? releaseInfo.url.startsWith('http')
|
||||
? `[${releaseInfo.name}](${releaseInfo.url})`
|
||||
: `${releaseInfo.name}: \`${releaseInfo.url}\``
|
||||
: `\`${releaseInfo.name}\``
|
||||
}`;
|
||||
|
||||
const filterReleases = (releaseInfos) =>
|
||||
releaseInfos.filter((releaseInfo) => releaseInfo.name && releaseInfo.name !== RELEASE_NAME);
|
||||
|
||||
module.exports = (releaseInfos) =>
|
||||
`${
|
||||
filterReleases(releaseInfos).length > 0
|
||||
? `This release is also available on:\n${filterReleases(releaseInfos)
|
||||
.map((releaseInfo) => `- ${linkify(releaseInfo)}`)
|
||||
.join('\n')}`
|
||||
: ''
|
||||
}`;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
module.exports = (base, commits, separator = '+') => {
|
||||
return commits.reduce((searches, commit) => {
|
||||
const lastSearch = searches[searches.length - 1];
|
||||
|
||||
if (lastSearch && lastSearch.length + commit.length <= 256 - separator.length) {
|
||||
searches[searches.length - 1] = `${lastSearch}${separator}${commit}`;
|
||||
} else {
|
||||
searches.push(`${base}${separator}${commit}`);
|
||||
}
|
||||
|
||||
return searches;
|
||||
}, []);
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
const HOME_URL = 'https://github.com/semantic-release/semantic-release';
|
||||
const linkify = (releaseInfo) =>
|
||||
`${releaseInfo.url ? `[${releaseInfo.name}](${releaseInfo.url})` : `\`${releaseInfo.name}\``}`;
|
||||
|
||||
module.exports = (issue, releaseInfos, nextRelease) =>
|
||||
`:tada: This ${issue.pull_request ? 'PR is included' : 'issue has been resolved'} in version ${
|
||||
nextRelease.version
|
||||
} :tada:${
|
||||
releaseInfos.length > 0
|
||||
? `\n\nThe release is available on${
|
||||
releaseInfos.length === 1
|
||||
? ` ${linkify(releaseInfos[0])}`
|
||||
: `:\n${releaseInfos.map((releaseInfo) => `- ${linkify(releaseInfo)}`).join('\n')}`
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
|
||||
Your **[semantic-release](${HOME_URL})** bot :package::rocket:`;
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
const path = require('path');
|
||||
const {isPlainObject, castArray, uniqWith, uniq} = require('lodash');
|
||||
const dirGlob = require('dir-glob');
|
||||
const globby = require('globby');
|
||||
const debug = require('debug')('semantic-release:github');
|
||||
|
||||
module.exports = async ({cwd}, assets) =>
|
||||
uniqWith(
|
||||
[]
|
||||
.concat(
|
||||
...(await Promise.all(
|
||||
assets.map(async (asset) => {
|
||||
// Wrap single glob definition in Array
|
||||
let glob = castArray(isPlainObject(asset) ? asset.path : asset);
|
||||
// TODO Temporary workaround for https://github.com/mrmlnc/fast-glob/issues/47
|
||||
glob = uniq([...(await dirGlob(glob, {cwd})), ...glob]);
|
||||
|
||||
// Skip solo negated pattern (avoid to include every non js file with `!**/*.js`)
|
||||
if (glob.length <= 1 && glob[0].startsWith('!')) {
|
||||
debug(
|
||||
'skipping the negated glob %o as its alone in its group and would retrieve a large amount of files',
|
||||
glob[0]
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const globbed = await globby(glob, {
|
||||
cwd,
|
||||
expandDirectories: false, // TODO Temporary workaround for https://github.com/mrmlnc/fast-glob/issues/47
|
||||
gitignore: false,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
});
|
||||
|
||||
if (isPlainObject(asset)) {
|
||||
if (globbed.length > 1) {
|
||||
// If asset is an Object with a glob the `path` property that resolve to multiple files,
|
||||
// Output an Object definition for each file matched and set each one with:
|
||||
// - `path` of the matched file
|
||||
// - `name` based on the actual file name (to avoid assets with duplicate `name`)
|
||||
// - other properties of the original asset definition
|
||||
return globbed.map((file) => ({...asset, path: file, name: path.basename(file)}));
|
||||
}
|
||||
|
||||
// If asset is an Object, output an Object definition with:
|
||||
// - `path` of the matched file if there is one, or the original `path` definition (will be considered as a missing file)
|
||||
// - other properties of the original asset definition
|
||||
return {...asset, path: globbed[0] || asset.path};
|
||||
}
|
||||
|
||||
if (globbed.length > 0) {
|
||||
// If asset is a String definition, output each files matched
|
||||
return globbed;
|
||||
}
|
||||
|
||||
// If asset is a String definition but no match is found, output the elements of the original glob (each one will be considered as a missing file)
|
||||
return glob;
|
||||
})
|
||||
// Sort with Object first, to prioritize Object definition over Strings in dedup
|
||||
))
|
||||
)
|
||||
.sort((asset) => (isPlainObject(asset) ? -1 : 1)),
|
||||
// Compare `path` property if Object definition, value itself if String
|
||||
(a, b) => path.resolve(cwd, isPlainObject(a) ? a.path : a) === path.resolve(cwd, isPlainObject(b) ? b.path : b)
|
||||
);
|
||||
+1
@@ -0,0 +1 @@
|
||||
module.exports = ({type, main}) => type === 'prerelease' || (type === 'release' && !main);
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
module.exports = (repositoryUrl) => {
|
||||
const [match, auth, host, path] = /^(?!.+:\/\/)(?:(?<auth>.*)@)?(?<host>.*?):(?<path>.*)$/.exec(repositoryUrl) || [];
|
||||
try {
|
||||
const [, owner, repo] = /^\/(?<owner>[^/]+)?\/?(?<repo>.+?)(?:\.git)?$/.exec(
|
||||
new URL(match ? `ssh://${auth ? `${auth}@` : ''}${host}/${path}` : repositoryUrl).pathname
|
||||
);
|
||||
return {owner, repo};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
const path = require('path');
|
||||
const {stat, readFile} = require('fs-extra');
|
||||
const {isPlainObject, template} = require('lodash');
|
||||
const mime = require('mime');
|
||||
const debug = require('debug')('semantic-release:github');
|
||||
const {RELEASE_NAME} = require('./definitions/constants');
|
||||
const parseGithubUrl = require('./parse-github-url');
|
||||
const globAssets = require('./glob-assets');
|
||||
const resolveConfig = require('./resolve-config');
|
||||
const getClient = require('./get-client');
|
||||
const isPrerelease = require('./is-prerelease');
|
||||
|
||||
module.exports = async (pluginConfig, context) => {
|
||||
const {
|
||||
cwd,
|
||||
options: {repositoryUrl},
|
||||
branch,
|
||||
nextRelease: {name, gitTag, notes},
|
||||
logger,
|
||||
} = context;
|
||||
const {githubToken, githubUrl, githubApiPathPrefix, proxy, assets} = resolveConfig(pluginConfig, context);
|
||||
const {owner, repo} = parseGithubUrl(repositoryUrl);
|
||||
const github = getClient({githubToken, githubUrl, githubApiPathPrefix, proxy});
|
||||
const release = {
|
||||
owner,
|
||||
repo,
|
||||
tag_name: gitTag,
|
||||
target_commitish: branch.name,
|
||||
name,
|
||||
body: notes,
|
||||
prerelease: isPrerelease(branch),
|
||||
};
|
||||
|
||||
debug('release object: %O', release);
|
||||
|
||||
// When there are no assets, we publish a release directly
|
||||
if (!assets || assets.length === 0) {
|
||||
const {
|
||||
data: {html_url: url, id: releaseId},
|
||||
} = await github.repos.createRelease(release);
|
||||
|
||||
logger.log('Published GitHub release: %s', url);
|
||||
return {url, name: RELEASE_NAME, id: releaseId};
|
||||
}
|
||||
|
||||
// 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 github.repos.createRelease(draftRelease);
|
||||
|
||||
// Append assets to the release
|
||||
const globbedAssets = await globAssets(context, assets);
|
||||
debug('globed assets: %o', globbedAssets);
|
||||
|
||||
await Promise.all(
|
||||
globbedAssets.map(async (asset) => {
|
||||
const filePath = isPlainObject(asset) ? asset.path : asset;
|
||||
let file;
|
||||
|
||||
try {
|
||||
file = await stat(path.resolve(cwd, filePath));
|
||||
} catch {
|
||||
logger.error('The asset %s cannot be read, and will be ignored.', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file || !file.isFile()) {
|
||||
logger.error('The asset %s is not a file, and will be ignored.', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = template(asset.name || path.basename(filePath))(context);
|
||||
const upload = {
|
||||
url: uploadUrl,
|
||||
data: await readFile(path.resolve(cwd, filePath)),
|
||||
name: fileName,
|
||||
headers: {
|
||||
'content-type': mime.getType(path.extname(fileName)) || 'text/plain',
|
||||
'content-length': file.size,
|
||||
},
|
||||
};
|
||||
|
||||
debug('file path: %o', filePath);
|
||||
debug('file name: %o', fileName);
|
||||
|
||||
if (isPlainObject(asset) && asset.label) {
|
||||
upload.label = template(asset.label)(context);
|
||||
}
|
||||
|
||||
const {
|
||||
data: {browser_download_url: downloadUrl},
|
||||
} = await github.repos.uploadReleaseAsset(upload);
|
||||
logger.log('Published file %s', downloadUrl);
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
data: {html_url: url},
|
||||
} = await github.repos.updateRelease({owner, repo, release_id: releaseId, draft: false});
|
||||
|
||||
logger.log('Published GitHub release: %s', url);
|
||||
return {url, name: RELEASE_NAME, id: releaseId};
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
const {isNil, castArray} = require('lodash');
|
||||
|
||||
module.exports = (
|
||||
{
|
||||
githubUrl,
|
||||
githubApiPathPrefix,
|
||||
proxy,
|
||||
assets,
|
||||
successComment,
|
||||
failTitle,
|
||||
failComment,
|
||||
labels,
|
||||
assignees,
|
||||
releasedLabels,
|
||||
addReleases,
|
||||
},
|
||||
{env}
|
||||
) => ({
|
||||
githubToken: env.GH_TOKEN || env.GITHUB_TOKEN,
|
||||
githubUrl: githubUrl || env.GITHUB_API_URL || env.GH_URL || env.GITHUB_URL,
|
||||
githubApiPathPrefix: githubApiPathPrefix || env.GH_PREFIX || env.GITHUB_PREFIX || '',
|
||||
proxy: isNil(proxy) ? env.http_proxy || env.HTTP_PROXY || false : proxy,
|
||||
assets: assets ? castArray(assets) : assets,
|
||||
successComment,
|
||||
failTitle: isNil(failTitle) ? 'The automated release is failing 🚨' : failTitle,
|
||||
failComment,
|
||||
labels: isNil(labels) ? ['semantic-release'] : labels === false ? false : castArray(labels),
|
||||
assignees: assignees ? castArray(assignees) : assignees,
|
||||
releasedLabels: isNil(releasedLabels)
|
||||
? [`released<%= nextRelease.channel ? \` on @\${nextRelease.channel}\` : "" %>`]
|
||||
: releasedLabels === false
|
||||
? false
|
||||
: castArray(releasedLabels),
|
||||
addReleases: isNil(addReleases) ? false : addReleases,
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
const {isNil, uniqBy, template, flatten, isEmpty} = require('lodash');
|
||||
const pFilter = require('p-filter');
|
||||
const AggregateError = require('aggregate-error');
|
||||
const issueParser = require('issue-parser');
|
||||
const debug = require('debug')('semantic-release:github');
|
||||
const parseGithubUrl = require('./parse-github-url');
|
||||
const resolveConfig = require('./resolve-config');
|
||||
const getClient = require('./get-client');
|
||||
const getSearchQueries = require('./get-search-queries');
|
||||
const getSuccessComment = require('./get-success-comment');
|
||||
const findSRIssues = require('./find-sr-issues');
|
||||
const {RELEASE_NAME} = require('./definitions/constants');
|
||||
const getReleaseLinks = require('./get-release-links');
|
||||
|
||||
module.exports = async (pluginConfig, context) => {
|
||||
const {
|
||||
options: {repositoryUrl},
|
||||
commits,
|
||||
nextRelease,
|
||||
releases,
|
||||
logger,
|
||||
} = context;
|
||||
const {
|
||||
githubToken,
|
||||
githubUrl,
|
||||
githubApiPathPrefix,
|
||||
proxy,
|
||||
successComment,
|
||||
failComment,
|
||||
failTitle,
|
||||
releasedLabels,
|
||||
addReleases,
|
||||
} = resolveConfig(pluginConfig, context);
|
||||
|
||||
const github = getClient({githubToken, githubUrl, githubApiPathPrefix, proxy});
|
||||
// In case the repo changed name, get the new `repo`/`owner` as the search API will not follow redirects
|
||||
const [owner, repo] = (await github.repos.get(parseGithubUrl(repositoryUrl))).data.full_name.split('/');
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (successComment === false) {
|
||||
logger.log('Skip commenting on issues and pull requests.');
|
||||
} else {
|
||||
const parser = issueParser('github', githubUrl ? {hosts: [githubUrl]} : {});
|
||||
const releaseInfos = releases.filter((release) => Boolean(release.name));
|
||||
const shas = commits.map(({hash}) => hash);
|
||||
|
||||
const searchQueries = getSearchQueries(`repo:${owner}/${repo}+type:pr+is:merged`, shas).map(
|
||||
async (q) => (await github.search.issuesAndPullRequests({q})).data.items
|
||||
);
|
||||
|
||||
const prs = await pFilter(
|
||||
uniqBy(flatten(await Promise.all(searchQueries)), 'number'),
|
||||
async ({number}) =>
|
||||
(await github.pulls.listCommits({owner, repo, pull_number: number})).data.find(({sha}) => shas.includes(sha)) ||
|
||||
shas.includes((await github.pulls.get({owner, repo, pull_number: number})).data.merge_commit_sha)
|
||||
);
|
||||
|
||||
debug(
|
||||
'found pull requests: %O',
|
||||
prs.map((pr) => pr.number)
|
||||
);
|
||||
|
||||
// Parse the release commits message and PRs body to find resolved issues/PRs via comment keyworkds
|
||||
const issues = [...prs.map((pr) => pr.body), ...commits.map((commit) => commit.message)].reduce(
|
||||
(issues, message) => {
|
||||
return message
|
||||
? issues.concat(
|
||||
parser(message)
|
||||
.actions.close.filter((action) => isNil(action.slug) || action.slug === `${owner}/${repo}`)
|
||||
.map((action) => ({number: Number.parseInt(action.issue, 10)}))
|
||||
)
|
||||
: issues;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
debug('found issues via comments: %O', issues);
|
||||
|
||||
await Promise.all(
|
||||
uniqBy([...prs, ...issues], 'number').map(async (issue) => {
|
||||
const body = successComment
|
||||
? template(successComment)({...context, issue})
|
||||
: getSuccessComment(issue, releaseInfos, nextRelease);
|
||||
try {
|
||||
const comment = {owner, repo, issue_number: issue.number, body};
|
||||
debug('create comment: %O', comment);
|
||||
const {
|
||||
data: {html_url: url},
|
||||
} = await github.issues.createComment(comment);
|
||||
logger.log('Added comment to issue #%d: %s', issue.number, url);
|
||||
|
||||
if (releasedLabels) {
|
||||
const labels = releasedLabels.map((label) => template(label)(context));
|
||||
// Don’t use .issues.addLabels for GHE < 2.16 support
|
||||
// https://github.com/semantic-release/github/issues/138
|
||||
await github.request('POST /repos/:owner/:repo/issues/:number/labels', {
|
||||
owner,
|
||||
repo,
|
||||
number: issue.number,
|
||||
data: labels,
|
||||
});
|
||||
logger.log('Added labels %O to issue #%d', labels, issue.number);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 403) {
|
||||
logger.error('Not allowed to add a comment to the issue #%d.', issue.number);
|
||||
} else if (error.status === 404) {
|
||||
logger.error("Failed to add a comment to the issue #%d as it doesn't exist.", issue.number);
|
||||
} else {
|
||||
errors.push(error);
|
||||
logger.error('Failed to add a comment to the issue #%d.', issue.number);
|
||||
// Don't throw right away and continue to update other issues
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (failComment === false || failTitle === false) {
|
||||
logger.log('Skip closing issue.');
|
||||
} else {
|
||||
const srIssues = await findSRIssues(github, failTitle, owner, repo);
|
||||
|
||||
debug('found semantic-release issues: %O', srIssues);
|
||||
|
||||
await Promise.all(
|
||||
srIssues.map(async (issue) => {
|
||||
debug('close issue: %O', issue);
|
||||
try {
|
||||
const updateIssue = {owner, repo, issue_number: issue.number, state: 'closed'};
|
||||
debug('closing issue: %O', updateIssue);
|
||||
const {
|
||||
data: {html_url: url},
|
||||
} = await github.issues.update(updateIssue);
|
||||
logger.log('Closed issue #%d: %s.', issue.number, url);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
logger.error('Failed to close the issue #%d.', issue.number);
|
||||
// Don't throw right away and continue to close other issues
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (addReleases !== false && errors.length === 0) {
|
||||
const ghRelease = releases.find((release) => release.name && release.name === RELEASE_NAME);
|
||||
if (!isNil(ghRelease)) {
|
||||
const ghRelaseId = ghRelease.id;
|
||||
const additionalReleases = getReleaseLinks(releases);
|
||||
if (!isEmpty(additionalReleases) && !isNil(ghRelaseId)) {
|
||||
const newBody =
|
||||
addReleases === 'top'
|
||||
? additionalReleases.concat('\n---\n', nextRelease.notes)
|
||||
: nextRelease.notes.concat('\n---\n', additionalReleases);
|
||||
await github.repos.updateRelease({owner, repo, release_id: ghRelaseId, body: newBody});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors);
|
||||
}
|
||||
};
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
const {isString, isPlainObject, isNil, isArray, isNumber} = require('lodash');
|
||||
const urlJoin = require('url-join');
|
||||
const AggregateError = require('aggregate-error');
|
||||
const parseGithubUrl = require('./parse-github-url');
|
||||
const resolveConfig = require('./resolve-config');
|
||||
const getClient = require('./get-client');
|
||||
const getError = require('./get-error');
|
||||
|
||||
const isNonEmptyString = (value) => isString(value) && value.trim();
|
||||
const oneOf = (enumArray) => (value) => enumArray.some((element) => element === value);
|
||||
const isStringOrStringArray = (value) =>
|
||||
isNonEmptyString(value) || (isArray(value) && value.every((string) => isNonEmptyString(string)));
|
||||
const isArrayOf = (validator) => (array) => isArray(array) && array.every((value) => validator(value));
|
||||
const canBeDisabled = (validator) => (value) => value === false || validator(value);
|
||||
|
||||
const VALIDATORS = {
|
||||
proxy: canBeDisabled(
|
||||
(proxy) => isNonEmptyString(proxy) || (isPlainObject(proxy) && isNonEmptyString(proxy.host) && isNumber(proxy.port))
|
||||
),
|
||||
assets: isArrayOf(
|
||||
(asset) => isStringOrStringArray(asset) || (isPlainObject(asset) && isStringOrStringArray(asset.path))
|
||||
),
|
||||
successComment: canBeDisabled(isNonEmptyString),
|
||||
failTitle: canBeDisabled(isNonEmptyString),
|
||||
failComment: canBeDisabled(isNonEmptyString),
|
||||
labels: canBeDisabled(isArrayOf(isNonEmptyString)),
|
||||
assignees: isArrayOf(isNonEmptyString),
|
||||
releasedLabels: canBeDisabled(isArrayOf(isNonEmptyString)),
|
||||
addReleases: canBeDisabled(oneOf(['bottom', 'top'])),
|
||||
};
|
||||
|
||||
module.exports = async (pluginConfig, context) => {
|
||||
const {
|
||||
env,
|
||||
options: {repositoryUrl},
|
||||
logger,
|
||||
} = context;
|
||||
const {githubToken, githubUrl, githubApiPathPrefix, proxy, ...options} = resolveConfig(pluginConfig, context);
|
||||
|
||||
const errors = Object.entries({...options, proxy}).reduce(
|
||||
(errors, [option, value]) =>
|
||||
!isNil(value) && !VALIDATORS[option](value)
|
||||
? [...errors, getError(`EINVALID${option.toUpperCase()}`, {[option]: value})]
|
||||
: errors,
|
||||
[]
|
||||
);
|
||||
|
||||
if (githubUrl) {
|
||||
logger.log('Verify GitHub authentication (%s)', urlJoin(githubUrl, githubApiPathPrefix));
|
||||
} else {
|
||||
logger.log('Verify GitHub authentication');
|
||||
}
|
||||
|
||||
const {repo, owner} = parseGithubUrl(repositoryUrl);
|
||||
if (!owner || !repo) {
|
||||
errors.push(getError('EINVALIDGITHUBURL'));
|
||||
} else if (githubToken && !errors.find(({code}) => code === 'EINVALIDPROXY')) {
|
||||
const github = getClient({githubToken, githubUrl, githubApiPathPrefix, proxy});
|
||||
|
||||
// https://github.com/semantic-release/github/issues/182
|
||||
// Do not check for permissions in GitHub actions, as the provided token is an installation access token.
|
||||
// github.repos.get({repo, owner}) does not return the "permissions" key in that case. But GitHub Actions
|
||||
// have all permissions required for @semantic-release/github to work
|
||||
if (env.GITHUB_ACTION) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
data: {
|
||||
permissions: {push},
|
||||
},
|
||||
} = await github.repos.get({repo, owner});
|
||||
if (!push) {
|
||||
// If authenticated as GitHub App installation, `push` will always be false.
|
||||
// We send another request to check if current authentication is an installation.
|
||||
// Note: we cannot check if the installation has all required permissions, it's
|
||||
// up to the user to make sure it has
|
||||
if (await github.request('HEAD /installation/repositories', {per_page: 1}).catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
errors.push(getError('EGHNOPERMISSION', {owner, repo}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 401) {
|
||||
errors.push(getError('EINVALIDGHTOKEN', {owner, repo}));
|
||||
} else if (error.status === 404) {
|
||||
errors.push(getError('EMISSINGREPO', {owner, repo}));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!githubToken) {
|
||||
errors.push(getError('ENOGHTOKEN', {owner, repo}));
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user