Installing dependencies.

This commit is contained in:
2025-11-11 06:53:11 -05:00
parent 2c36c04da6
commit 0d2fea3c88
14371 changed files with 2770923 additions and 25 deletions
+52
View File
@@ -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
View File
@@ -0,0 +1,5 @@
const ISSUE_ID = '<!-- semantic-release:github -->';
const RELEASE_NAME = 'GitHub release';
module.exports = {ISSUE_ID, RELEASE_NAME};
+115
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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. Im 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 dont help, or if this issue is reporting something you think isnt 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
module.exports = ({type, main}) => type === 'prerelease' || (type === 'release' && !main);
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));
// Dont 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
View File
@@ -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);
}
};