UNPKG

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