UNPKG

9.7 kBJavaScriptView Raw
1const {pick} = require('lodash');
2const marked = require('marked');
3const TerminalRenderer = require('marked-terminal');
4const envCi = require('env-ci');
5const hookStd = require('hook-std');
6const semver = require('semver');
7const AggregateError = require('aggregate-error');
8const pkg = require('./package.json');
9const hideSensitive = require('./lib/hide-sensitive');
10const getConfig = require('./lib/get-config');
11const verify = require('./lib/verify');
12const getNextVersion = require('./lib/get-next-version');
13const getCommits = require('./lib/get-commits');
14const getLastRelease = require('./lib/get-last-release');
15const getReleaseToAdd = require('./lib/get-release-to-add');
16const {extractErrors, makeTag} = require('./lib/utils');
17const getGitAuthUrl = require('./lib/get-git-auth-url');
18const getBranches = require('./lib/branches');
19const getLogger = require('./lib/get-logger');
20const {verifyAuth, isBranchUpToDate, getGitHead, tag, push, pushNotes, getTagHead, addNote} = require('./lib/git');
21const getError = require('./lib/get-error');
22const {COMMIT_NAME, COMMIT_EMAIL} = require('./lib/definitions/constants');
23
24marked.setOptions({renderer: new TerminalRenderer()});
25
26/* eslint complexity: off */
27async function run(context, plugins) {
28 const {cwd, env, options, logger} = context;
29 const {isCi, branch, prBranch, isPr} = context.envCi;
30 const ciBranch = isPr ? prBranch : branch;
31
32 if (!isCi && !options.dryRun && !options.noCi) {
33 logger.warn('This run was not triggered in a known CI environment, running in dry-run mode.');
34 options.dryRun = true;
35 } else {
36 // When running on CI, set the commits author and commiter info and prevent the `git` CLI to prompt for username/password. See #703.
37 Object.assign(env, {
38 GIT_AUTHOR_NAME: COMMIT_NAME,
39 GIT_AUTHOR_EMAIL: COMMIT_EMAIL,
40 GIT_COMMITTER_NAME: COMMIT_NAME,
41 GIT_COMMITTER_EMAIL: COMMIT_EMAIL,
42 ...env,
43 GIT_ASKPASS: 'echo',
44 GIT_TERMINAL_PROMPT: 0,
45 });
46 }
47
48 if (isCi && isPr && !options.noCi) {
49 logger.log("This run was triggered by a pull request and therefore a new version won't be published.");
50 return false;
51 }
52
53 // Verify config
54 await verify(context);
55
56 options.repositoryUrl = await getGitAuthUrl({...context, branch: {name: ciBranch}});
57 context.branches = await getBranches(options.repositoryUrl, ciBranch, context);
58 context.branch = context.branches.find(({name}) => name === ciBranch);
59
60 if (!context.branch) {
61 logger.log(
62 `This test run was triggered on the branch ${ciBranch}, while semantic-release is configured to only publish from ${context.branches
63 .map(({name}) => name)
64 .join(', ')}, therefore a new version won’t be published.`
65 );
66 return false;
67 }
68
69 logger[options.dryRun ? 'warn' : 'success'](
70 `Run automated release from branch ${ciBranch} on repository ${options.repositoryUrl}${
71 options.dryRun ? ' in dry-run mode' : ''
72 }`
73 );
74
75 try {
76 try {
77 await verifyAuth(options.repositoryUrl, context.branch.name, {cwd, env});
78 } catch (error) {
79 if (!(await isBranchUpToDate(options.repositoryUrl, context.branch.name, {cwd, env}))) {
80 logger.log(
81 `The local branch ${context.branch.name} is behind the remote one, therefore a new version won't be published.`
82 );
83 return false;
84 }
85
86 throw error;
87 }
88 } catch (error) {
89 logger.error(`The command "${error.command}" failed with the error message ${error.stderr}.`);
90 throw getError('EGITNOPERMISSION', context);
91 }
92
93 logger.success(`Allowed to push to the Git repository`);
94
95 await plugins.verifyConditions(context);
96
97 const errors = [];
98 context.releases = [];
99 const releaseToAdd = getReleaseToAdd(context);
100
101 if (releaseToAdd) {
102 const {lastRelease, currentRelease, nextRelease} = releaseToAdd;
103
104 nextRelease.gitHead = await getTagHead(nextRelease.gitHead, {cwd, env});
105 currentRelease.gitHead = await getTagHead(currentRelease.gitHead, {cwd, env});
106 if (context.branch.mergeRange && !semver.satisfies(nextRelease.version, context.branch.mergeRange)) {
107 errors.push(getError('EINVALIDMAINTENANCEMERGE', {...context, nextRelease}));
108 } else {
109 const commits = await getCommits({...context, lastRelease, nextRelease});
110 nextRelease.notes = await plugins.generateNotes({...context, commits, lastRelease, nextRelease});
111
112 if (options.dryRun) {
113 logger.warn(`Skip ${nextRelease.gitTag} tag creation in dry-run mode`);
114 } else {
115 await addNote({channels: [...currentRelease.channels, nextRelease.channel]}, nextRelease.gitHead, {cwd, env});
116 await push(options.repositoryUrl, {cwd, env});
117 await pushNotes(options.repositoryUrl, {cwd, env});
118 logger.success(
119 `Add ${nextRelease.channel ? `channel ${nextRelease.channel}` : 'default channel'} to tag ${
120 nextRelease.gitTag
121 }`
122 );
123 }
124
125 context.branch.tags.push({
126 version: nextRelease.version,
127 channel: nextRelease.channel,
128 gitTag: nextRelease.gitTag,
129 gitHead: nextRelease.gitHead,
130 });
131
132 const releases = await plugins.addChannel({...context, commits, lastRelease, currentRelease, nextRelease});
133 context.releases.push(...releases);
134 await plugins.success({...context, lastRelease, commits, nextRelease, releases});
135 }
136 }
137
138 if (errors.length > 0) {
139 throw new AggregateError(errors);
140 }
141
142 context.lastRelease = getLastRelease(context);
143 if (context.lastRelease.gitHead) {
144 context.lastRelease.gitHead = await getTagHead(context.lastRelease.gitHead, {cwd, env});
145 }
146
147 if (context.lastRelease.gitTag) {
148 logger.log(
149 `Found git tag ${context.lastRelease.gitTag} associated with version ${context.lastRelease.version} on branch ${context.branch.name}`
150 );
151 } else {
152 logger.log(`No git tag version found on branch ${context.branch.name}`);
153 }
154
155 context.commits = await getCommits(context);
156
157 const nextRelease = {
158 type: await plugins.analyzeCommits(context),
159 channel: context.branch.channel || null,
160 gitHead: await getGitHead({cwd, env}),
161 };
162 if (!nextRelease.type) {
163 logger.log('There are no relevant changes, so no new version is released.');
164 return context.releases.length > 0 ? {releases: context.releases} : false;
165 }
166
167 context.nextRelease = nextRelease;
168 nextRelease.version = getNextVersion(context);
169 nextRelease.gitTag = makeTag(options.tagFormat, nextRelease.version);
170 nextRelease.name = nextRelease.gitTag;
171
172 if (context.branch.type !== 'prerelease' && !semver.satisfies(nextRelease.version, context.branch.range)) {
173 throw getError('EINVALIDNEXTVERSION', {
174 ...context,
175 validBranches: context.branches.filter(
176 ({type, accept}) => type !== 'prerelease' && accept.includes(nextRelease.type)
177 ),
178 });
179 }
180
181 await plugins.verifyRelease(context);
182
183 nextRelease.notes = await plugins.generateNotes(context);
184
185 await plugins.prepare(context);
186
187 if (options.dryRun) {
188 logger.warn(`Skip ${nextRelease.gitTag} tag creation in dry-run mode`);
189 } else {
190 // Create the tag before calling the publish plugins as some require the tag to exists
191 await tag(nextRelease.gitTag, nextRelease.gitHead, {cwd, env});
192 await addNote({channels: [nextRelease.channel]}, nextRelease.gitHead, {cwd, env});
193 await push(options.repositoryUrl, {cwd, env});
194 await pushNotes(options.repositoryUrl, {cwd, env});
195 logger.success(`Created tag ${nextRelease.gitTag}`);
196 }
197
198 const releases = await plugins.publish(context);
199 context.releases.push(...releases);
200
201 await plugins.success({...context, releases});
202
203 logger.success(
204 `Published release ${nextRelease.version} on ${nextRelease.channel ? nextRelease.channel : 'default'} channel`
205 );
206
207 if (options.dryRun) {
208 logger.log(`Release note for version ${nextRelease.version}:`);
209 if (nextRelease.notes) {
210 context.stdout.write(marked(nextRelease.notes));
211 }
212 }
213
214 return pick(context, ['lastRelease', 'commits', 'nextRelease', 'releases']);
215}
216
217function logErrors({logger, stderr}, err) {
218 const errors = extractErrors(err).sort((error) => (error.semanticRelease ? -1 : 0));
219 for (const error of errors) {
220 if (error.semanticRelease) {
221 logger.error(`${error.code} ${error.message}`);
222 if (error.details) {
223 stderr.write(marked(error.details));
224 }
225 } else {
226 logger.error('An error occurred while running semantic-release: %O', error);
227 }
228 }
229}
230
231async function callFail(context, plugins, err) {
232 const errors = extractErrors(err).filter((err) => err.semanticRelease);
233 if (errors.length > 0) {
234 try {
235 await plugins.fail({...context, errors});
236 } catch (error) {
237 logErrors(context, error);
238 }
239 }
240}
241
242module.exports = async (cliOptions = {}, {cwd = process.cwd(), env = process.env, stdout, stderr} = {}) => {
243 const {unhook} = hookStd(
244 {silent: false, streams: [process.stdout, process.stderr, stdout, stderr].filter(Boolean)},
245 hideSensitive(env)
246 );
247 const context = {
248 cwd,
249 env,
250 stdout: stdout || process.stdout,
251 stderr: stderr || process.stderr,
252 envCi: envCi({env, cwd}),
253 };
254 context.logger = getLogger(context);
255 context.logger.log(`Running ${pkg.name} version ${pkg.version}`);
256 try {
257 const {plugins, options} = await getConfig(context, cliOptions);
258 context.options = options;
259 try {
260 const result = await run(context, plugins);
261 unhook();
262 return result;
263 } catch (error) {
264 await callFail(context, plugins, error);
265 throw error;
266 }
267 } catch (error) {
268 logErrors(context, error);
269 unhook();
270 throw error;
271 }
272};