xref: /expo/tools/src/publish-packages/helpers.ts (revision 5dab530b)
1import chalk from 'chalk';
2import inquirer from 'inquirer';
3import pick from 'lodash/pick';
4import npmPacklist from 'npm-packlist';
5import semver from 'semver';
6
7import * as Changelogs from '../Changelogs';
8import * as Formatter from '../Formatter';
9import { GitDirectory, GitFileLog, GitFileStatus } from '../Git';
10import logger from '../Logger';
11import { Package } from '../Packages';
12import {
13  BACKUPABLE_OPTIONS_FIELDS,
14  NATIVE_DIRECTORIES,
15  RELEASE_TYPES_ASC_ORDER,
16} from './constants';
17import { BackupableOptions, CommandOptions, PackageGitLogs, Parcel, ReleaseType } from './types';
18
19const { green, cyan, magenta, gray, red } = chalk;
20
21/**
22 * Returns options that are capable of being backed up.
23 * We will need just a few options to determine whether the backup is valid
24 * and we can't pass them all because `options` is in fact commander's `Command` instance.
25 */
26export function pickBackupableOptions(options: CommandOptions): BackupableOptions {
27  return pick(options, BACKUPABLE_OPTIONS_FIELDS);
28}
29
30/**
31 * Whether tasks backup can be used to retry previous command invocation.
32 */
33export async function shouldUseBackupAsync(options: CommandOptions): Promise<boolean> {
34  if (process.env.CI) {
35    return false;
36  }
37  if (options.retry) {
38    return true;
39  }
40  const { restore } = await inquirer.prompt([
41    {
42      type: 'confirm',
43      name: 'restore',
44      prefix: '❔',
45      message: cyan('Found valid backup file. Would you like to use it?'),
46    },
47  ]);
48  logger.log();
49  return restore;
50}
51
52/**
53 * Prints gathered crucial informations about the package.
54 */
55export function printPackageParcel(parcel: Parcel): void {
56  const { pkg, pkgView, state, logs, changelogChanges, dependencies, dependents } = parcel;
57  const { releaseType, releaseVersion } = state;
58  const gitHead = pkgView?.gitHead;
59
60  logger.log(
61    '\n��',
62    `${green.bold(pkg.packageName)},`,
63    `current version ${cyan.bold(pkg.packageVersion)},`,
64    pkgView ? `published from ${Formatter.formatCommitHash(gitHead)}` : 'not published yet'
65  );
66
67  if (dependents.size) {
68    logger.log(
69      '  ',
70      magenta('Dependency of:'),
71      [...dependents].map((dependent) => green(dependent.pkg.packageName)).join(', ')
72    );
73  }
74
75  if (!pkgView) {
76    logger.log(
77      '  ',
78      magenta(`Version ${cyan.bold(pkg.packageVersion)} hasn't been published yet.`)
79    );
80  } else if (!logs) {
81    logger.warn("   We couldn't determine new commits for this package.");
82
83    if (gitHead) {
84      // There are no logs and `gitHead` is there, so probably it's unreachable.
85      logger.warn('   Git head of its current version is not reachable from this branch.');
86    } else {
87      logger.warn("   It doesn't seem to be published by this script yet.");
88    }
89  }
90
91  if (dependencies.size) {
92    logger.log('  ', magenta('Package depends on:'));
93
94    dependencies.forEach((dependency) => {
95      const fromVersion = dependency.pkg.packageVersion;
96      const toVersion = dependency.state.releaseVersion;
97
98      logger.log(
99        '    ',
100        green(dependency.pkg.packageName),
101        gray(`(upgrades from ${cyan(fromVersion)}${toVersion ? ` to ${cyan(toVersion)}` : ''})`)
102      );
103    });
104  }
105  if (logs && logs.commits.length > 0) {
106    logger.log('  ', magenta('New commits:'));
107
108    [...logs.commits].reverse().forEach((commitLog) => {
109      logger.log('    ', Formatter.formatCommitLog(commitLog));
110    });
111  }
112  if (logs && logs.files.length > 0) {
113    logger.log('  ', magenta('File changes:'), gray('(build folder not displayed)'));
114
115    logs.files.forEach((fileLog) => {
116      if (fileLog.relativePath.startsWith('build/')) {
117        return;
118      }
119      logger.log('    ', Formatter.formatFileLog(fileLog));
120    });
121  }
122
123  const unpublishedChanges = changelogChanges?.versions[Changelogs.UNPUBLISHED_VERSION_NAME] ?? {};
124
125  for (const changeType in unpublishedChanges) {
126    const changes = unpublishedChanges[changeType];
127
128    if (changes.length > 0) {
129      logger.log('  ', magenta(`${Formatter.stripNonAsciiChars(changeType).trim()}:`));
130
131      for (const change of unpublishedChanges[changeType]) {
132        logger.log('    ', Formatter.formatChangelogEntry(change.message));
133      }
134    }
135  }
136
137  if (pkgView && releaseType && releaseVersion) {
138    logger.log(
139      '  ',
140      magenta(`Suggested ${cyan.bold(releaseType)} upgrade to ${cyan.bold(releaseVersion)}`)
141    );
142  }
143}
144
145/**
146 * Gets lists of commits and files changed under given directory and since commit with given checksum.
147 * Returned files list is filtered out from files ignored by npm when it creates package's tarball.
148 * Can return `null` if given commit is not an ancestor of head commit.
149 */
150export async function getPackageGitLogsAsync(
151  gitDir: GitDirectory,
152  fromCommit?: string
153): Promise<PackageGitLogs> {
154  if (!fromCommit || !(await gitDir.isAncestorAsync(fromCommit))) {
155    return null;
156  }
157
158  const commits = await gitDir.logAsync({
159    fromCommit,
160    toCommit: 'HEAD',
161  });
162
163  const gitFiles = await gitDir.logFilesAsync({
164    fromCommit,
165    toCommit: commits[0]?.hash,
166  });
167
168  // Get an array of relative paths to files that will be shipped with the package.
169  const packlist = await npmPacklist({ path: gitDir.path });
170
171  // Filter git files to contain only deleted or "packlisted" files.
172  const files = gitFiles.filter(
173    (file) => file.status === GitFileStatus.D || packlist.includes(file.relativePath)
174  );
175
176  return {
177    commits,
178    files,
179  };
180}
181
182export async function getMinReleaseTypeAsync(
183  pkg: Package,
184  logs: PackageGitLogs,
185  changelogChanges: any
186): Promise<ReleaseType> {
187  const unpublishedChanges = changelogChanges?.versions[Changelogs.UNPUBLISHED_VERSION_NAME];
188  const hasBreakingChanges = unpublishedChanges?.[Changelogs.ChangeType.BREAKING_CHANGES]?.length;
189  const hasNewFeatures = unpublishedChanges?.[Changelogs.ChangeType.NEW_FEATURES]?.length;
190
191  // For breaking changes and new features we follow semver.
192  if (hasBreakingChanges) {
193    return ReleaseType.MAJOR;
194  }
195  if (hasNewFeatures) {
196    return ReleaseType.MINOR;
197  }
198
199  // If the package is a native module, then we have to check whether there are any native changes.
200  if (await pkg.isNativeModuleAsync()) {
201    const hasNativeChanges = logs && fileLogsContainNativeChanges(logs.files);
202    return hasNativeChanges ? ReleaseType.MINOR : ReleaseType.PATCH;
203  }
204  return ReleaseType.PATCH;
205}
206
207/**
208 * Returns suggested version based on given current version, already published versions and suggested release type.
209 */
210export function resolveSuggestedVersion(
211  versionToBump: string,
212  otherVersions: string[],
213  releaseType: ReleaseType,
214  prereleaseIdentifier?: string | null
215): string {
216  const targetPrereleaseIdentifier = prereleaseIdentifier ?? getPrereleaseIdentifier(versionToBump);
217
218  // Higher version might have already been published from another place,
219  // so get the highest published version that satisfies release type.
220  const highestSatisfyingVersion = otherVersions
221    .filter((version) => {
222      return (
223        semver.gt(version, versionToBump) &&
224        semver.diff(version, versionToBump) === releaseType &&
225        getPrereleaseIdentifier(version) === targetPrereleaseIdentifier
226      );
227    })
228    .sort(semver.rcompare)[0];
229
230  return semver.inc(
231    highestSatisfyingVersion ?? versionToBump,
232    releaseType,
233    targetPrereleaseIdentifier ?? undefined
234  ) as string;
235}
236
237export function resolveReleaseTypeAndVersion(parcel: Parcel, options: CommandOptions) {
238  const prerelease = options.prerelease === true ? 'rc' : options.prerelease || undefined;
239  const { pkg, pkgView, state } = parcel;
240
241  // Find the highest release type among parcel's dependencies.
242  const accumulatedTypes = recursivelyAccumulateReleaseTypes(parcel);
243  const highestReleaseType = [...accumulatedTypes].reduce(
244    highestReleaseTypeReducer,
245    ReleaseType.PATCH
246  );
247  const allVersions = pkgView?.versions ?? [];
248
249  if (prerelease) {
250    // Make it a prerelease version if `--prerelease` was passed and assign to the state.
251    state.releaseType = ('pre' + highestReleaseType) as ReleaseType;
252  } else if (getPrereleaseIdentifier(pkg.packageVersion)) {
253    // If the current version is a prerelease, just increment its number.
254    state.releaseType = ReleaseType.PRERELEASE;
255  } else {
256    // Set the release type depending on changes made in the package.
257    state.releaseType = highestReleaseType;
258  }
259
260  // If the version to bump is not published yet, then we do want to use it instead,
261  // no matter which release type is suggested.
262  if (allVersions.includes(pkg.packageVersion)) {
263    // Calculate version that we should bump to.
264    state.releaseVersion = resolveSuggestedVersion(
265      pkg.packageVersion,
266      allVersions,
267      state.releaseType,
268      prerelease
269    );
270  } else {
271    state.releaseVersion = pkg.packageVersion;
272  }
273  return state.releaseVersion;
274}
275
276/**
277 * Accumulates all `minReleaseType` in given parcel and all its dependencies.
278 */
279export function recursivelyAccumulateReleaseTypes(
280  parcel: Parcel,
281  set: Set<ReleaseType> = new Set()
282) {
283  if (parcel.minReleaseType) {
284    set.add(parcel.minReleaseType);
285  }
286  for (const dependency of parcel.dependencies) {
287    recursivelyAccumulateReleaseTypes(dependency, set);
288  }
289  return set;
290}
291
292/**
293 * Determines whether git file logs contain any changes in directories with native code.
294 */
295function fileLogsContainNativeChanges(fileLogs: GitFileLog[]): boolean {
296  return fileLogs.some((fileLog) => {
297    return NATIVE_DIRECTORIES.some((dir) => fileLog.relativePath.startsWith(`${dir}/`));
298  });
299}
300
301export function isParcelUnpublished(parcel: Parcel): boolean {
302  const { logs, changelogChanges, dependencies } = parcel;
303  const hasChangedFiles = !logs || logs.files.length > 0;
304  const hasChangelogChanges = changelogChanges ? changelogChanges.totalCount > 0 : false;
305
306  return hasChangedFiles || hasChangelogChanges || dependencies.size > 0;
307}
308
309/**
310 * Used as a reducer to find the highest release type.
311 */
312export function highestReleaseTypeReducer(a: ReleaseType, b: ReleaseType): ReleaseType {
313  const ai = RELEASE_TYPES_ASC_ORDER.indexOf(a);
314  const bi = RELEASE_TYPES_ASC_ORDER.indexOf(b);
315  return bi > ai ? b : a;
316}
317
318/**
319 * Returns prerelease identifier of given version or `null` if given version is not a prerelease version.
320 * `semver.prerelease` returns an array of prerelease parts (`1.0.0-beta.0` results in `['beta', 0]`),
321 * however we just need the identifier.
322 */
323export function getPrereleaseIdentifier(version: string): string | null {
324  const prerelease = semver.prerelease(version);
325  return Array.isArray(prerelease) && typeof prerelease[0] === 'string' ? prerelease[0] : null;
326}
327
328/**
329 * Returns a list of suggested versions to publish.
330 */
331export function getSuggestedVersions(
332  version: string,
333  otherVersions: string[],
334  prerelease?: string | null
335): string[] {
336  const [currentPrereleaseId] = semver.prerelease(version) ?? [];
337
338  // The current version is a prerelease version
339  if (typeof currentPrereleaseId === 'string') {
340    const prereleaseIds = ['alpha', 'beta', 'rc'];
341
342    if (!prereleaseIds.includes(currentPrereleaseId)) {
343      prereleaseIds.unshift(currentPrereleaseId);
344    }
345    return prereleaseIds
346      .slice(prereleaseIds.indexOf(currentPrereleaseId))
347      .map((identifier) => {
348        return resolveSuggestedVersion(version, otherVersions, ReleaseType.PRERELEASE, identifier);
349      })
350      .concat(version.replace(/\-.*$/, ''));
351  }
352  return [ReleaseType.MAJOR, ReleaseType.MINOR, ReleaseType.PATCH].map((type) => {
353    return resolveSuggestedVersion(version, otherVersions, type, prerelease);
354  });
355}
356
357/**
358 * Returns a function that validates the version for given parcel.
359 */
360export function validateVersion(parcel: Parcel) {
361  return (input: string) => {
362    if (input) {
363      if (!semver.valid(input)) {
364        return red(`${cyan.bold(input)} is not a valid semver version.`);
365      }
366      if (parcel.pkgView && parcel.pkgView.versions.includes(input)) {
367        return red(`${cyan.bold(input)} has already been published.`);
368      }
369    }
370    return true;
371  };
372}
373