UNPKG

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