diff --git a/.releaserc.js b/.releaserc.js index cfa51dc8..e63bd50f 100644 --- a/.releaserc.js +++ b/.releaserc.js @@ -16,7 +16,12 @@ module.exports = { plugins: [ ['@semantic-release/commit-analyzer', { preset: 'conventionalcommits', - releaseRules: rules.map(({ type, release }) => ({ type, release })), + releaseRules: [ + { breaking: true, release: 'major' }, + { revert: true, release: 'patch' }, + ].concat( + rules.map(({ type, release, breaking }) => ({ type, release, breaking })) + ), }], ['@semantic-release/release-notes-generator', { preset: 'conventionalcommits', diff --git a/README.md b/README.md index b5d78ee4..fbc6ebc1 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ ## What's inside? -With this Expo action, you have full access to the [Expo CLI][link-expo-cli] itself. +With this Expo action, you have full access to [Expo CLI][link-expo-cli] and [EAS CLI][link-eas-cli] itself. It allows you to fully automate the `expo publish` or `expo build` process, leaving you with more time available for your project. There are some additional features included to make the usage of this action as simple as possible, like caching and authentication. @@ -37,21 +37,22 @@ There are some additional features included to make the usage of this action as This action is customizable through variables; they are defined in the [`action.yml`](action.yml). Here is a summary of all the variables that you can use and their purpose. -variable | default | description ---- | --- | --- -`expo-username` | - | The username of your Expo account _(e.g. `bycedric`)_ -`expo-token` | - | The token of your Expo account _(e.g. [`${{ secrets.EXPO_TOKEN }}`][link-actions-secrets])_ -`expo-password` | - | The password of your Expo account _(e.g. [`${{ secrets.EXPO_CLI_PASSWORD }}`][link-actions-secrets])_ -`expo-version` | `latest` | The Expo CLI version to use, can be any [SemVer][link-semver-playground]. _(e.g. `3.x`)_ -`expo-packager` | `yarn` | The package manager to install the CLI with. _(e.g. `npm`)_ -`expo-cache` | `false` | If it should use the [GitHub actions (remote) cache](#using-the-built-in-cache). -`expo-cache-key` | - | An optional custom (remote) cache key. _(**use with caution**)_ -`expo-patch-watchers` | `true` | If it should [patch the `fs.inotify.` limits](#enospc-errors-on-linux). +| variable | default | description | +| ---------------- | ------- | ------------------------------------------------------------------------------------------------------------ | +| `expo-version` | - | [Expo CLI](https://github.com/expo/expo-cli) version to install, skips when omitted. | +| `expo-cache` | `false` | If it should use the [GitHub actions (remote) cache](#using-the-built-in-cache). | +| `expo-cache-key` | - | An optional custom (remote) cache key. _(**use with caution**)_ | +| `eas-version` | - | [EAS CLI](https://github.com/expo/eas-cli) version to install, skips when omitted. (`latest` is recommended) | +| `eas-cache` | `false` | If it should use the [GitHub actions (remote) cache](#using-the-built-in-cache). | +| `eas-cache-key` | - | An optional custom (remote) cache key. _(**use with caution**)_ | +| `packager` | `yarn` | The package manager to use. _(e.g. `npm`)_ | +| `token` | - | The token of your Expo account _(e.g. [`${{ secrets.EXPO_TOKEN }}`][link-actions-secrets])_ | +| `username` | - | The username of your Expo account _(e.g. `bycedric`)_ | +| `password` | - | The password of your Expo account _(e.g. [`${{ secrets.EXPO_CLI_PASSWORD }}`][link-actions-secrets])_ | +| `patch-watchers` | `true` | If it should [patch the `fs.inotify.` limits](#enospc-errors-on-linux). | > Never hardcode `expo-token` or `expo-password` in your workflow, use [secrets][link-actions-secrets] to store them. -> It's also recommended to set the `expo-version` to avoid breaking changes when a new major version is released. - ## Example workflows Before you dive into the workflow examples, you should know the basics of GitHub Actions. @@ -59,16 +60,17 @@ You can read more about this in the [GitHub Actions documentation][link-actions] 1. [Publish on any push to master](#publish-on-any-push-to-master) 2. [Cache Expo CLI for other jobs](#cache-expo-cli-for-other-jobs) -3. [Test PRs and publish a review version](#test-prs-and-publish-a-review-version) -4. [Test PRs on multiple nodes and systems](#test-prs-on-multiple-nodes-and-systems) -5. [Test and build web every day at 08:00](#test-and-build-web-every-day-at-0800) -6. [Authenticate using an Expo token](#authenticate-using-an-expo-token) +3. [Creating a new EAS build](#publish-on-any-push-to-master) +4. [Test PRs and publish a review version](#test-prs-and-publish-a-review-version) +5. [Test PRs on multiple nodes and systems](#test-prs-on-multiple-nodes-and-systems) +6. [Test and build web every day at 08:00](#test-and-build-web-every-day-at-0800) +7. [Authenticate using credentials](#authenticate-using-credentials) ### Publish on any push to master Below you can see the example configuration to publish whenever the master branch is updated. The workflow listens to the `push` event and sets up Node 12 using the [Setup Node Action][link-actions-node]. -It also authenticates the Expo project by defining both `expo-username` and `expo-password`. +It also auto-authenticates when the `token` is provided. ```yml name: Expo Publish @@ -88,8 +90,7 @@ jobs: - uses: expo/expo-github-action@v5 with: expo-version: 4.x - expo-username: ${{ secrets.EXPO_CLI_USERNAME }} - expo-password: ${{ secrets.EXPO_CLI_PASSWORD }} + token: ${{ secrets.EXPO_TOKEN }} - run: yarn install - run: expo publish ``` @@ -120,13 +121,43 @@ jobs: - uses: expo/expo-github-action@v5 with: expo-version: 4.x - expo-username: ${{ secrets.EXPO_CLI_USERNAME }} - expo-password: ${{ secrets.EXPO_CLI_PASSWORD }} expo-cache: true + token: ${{ secrets.EXPO_TOKEN }} - run: yarn install - run: expo publish ``` +### Creating a new EAS build + +You can also install [EAS](https://docs.expo.io/eas/) CLI with this Github Action. +Below we've swapped `expo-version` with `eas-version`, but you can also use them together. +Both the `token` and `username`/`password` is shared between both Expo and EAS CLI. + +> We recommend using `latest` for `eas-version` to always have the most up-to-date version. + +```yml +name: EAS build +on: + push: + branches: + - master +jobs: + build: + name: Create new build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 14.x + - uses: expo/expo-github-action@v5 + with: + eas-version: latest + token: ${{ secrets.EXPO_TOKEN }} + - run: yarn install + - run: eas build +``` + ### Test PRs and publish a review version Reviewing pull requests can take some time if you have to read every line of code. @@ -148,8 +179,7 @@ jobs: - uses: expo/expo-github-action@v5 with: expo-version: 4.x - expo-username: ${{ secrets.EXPO_CLI_USERNAME }} - expo-password: ${{ secrets.EXPO_CLI_PASSWORD }} + token: ${{ secrets.EXPO_TOKEN }} - run: yarn install - run: expo publish --release-channel=pr-${{ github.event.number }} - uses: unsplash/comment-on-pr@master @@ -164,7 +194,7 @@ jobs: With GitHub Actions, it's reasonably easy to set up a matrix build and test the app on multiple environments. These matrixes can help to make sure your app runs smoothly on a broad set of different development machines. -> If you don't need automatic authentication, you can omit the `expo-username` and `expo-password` variables. +> If you don't need automatic authentication, you can omit the `token` variables. ```yml name: Expo CI @@ -217,10 +247,10 @@ jobs: - run: expo build:web ``` -### Authenticate using an Expo token +### Authenticate using credentials -Instead of username and password, you can also authenticate using a token. -This might help increasing security and avoids adding username and password to your repository secrets. +Instead of using an access token, you can also authenticate using credentials. +This is only possible when Expo CLI is installed. ```yml name: Expo Publish @@ -240,7 +270,8 @@ jobs: - uses: expo/expo-github-action@v5 with: expo-version: 4.x - expo-token: ${{ secrets.EXPO_TOKEN }} + username: ${{ secrets.EXPO_CLI_USERNAME }} + password: ${{ secrets.EXPO_CLI_PASSWORD }} - run: yarn install - run: expo publish ``` @@ -263,7 +294,7 @@ Under the hood, it uses the [`@action/cache`][link-actions-cache-package] packag This action generates a unique cache key for the OS, used packager, and exact version of the Expo CLI. If you need more control over this cache, you can define a custom cache key with `expo-cache-key`. -> Note, this cache will count towards your [repo cache limit][link-actions-cache-limit]. +> Note, this cache will count towards your [repo cache limit][link-actions-cache-limit]. The Expo and EAS CLI are stored in different caches. ### ENOSPC errors on Linux @@ -272,7 +303,7 @@ Creating these bundles require quite some resources. As of writing, GitHub actions has some small default values for the `fs.inotify` settings. Inside this action, we included a patch that increases these limits for the current workflow. It increases the `max_user_instances`, `max_user_watches` and `max_queued_events` to `524288`. -You can disable this patch by setting the `expo-patch-watchers` to `false`. +You can disable this patch by setting the `patch-watchers` to `false`.

@@ -288,4 +319,5 @@ You can disable this patch by setting the `expo-patch-watchers` to `false`. [link-expo-cli]: https://docs.expo.io/workflow/expo-cli/ [link-expo-cli-password]: https://github.com/expo/expo-cli/blob/master/packages/expo-cli/src/accounts.ts#L88-L90 [link-expo-release-channels]: https://docs.expo.io/distribution/release-channels/ +[link-eas-cli]: https://github.com/expo/eas-cli#readme [link-semver-playground]: https://semver.npmjs.com/ diff --git a/action.yml b/action.yml index 58229e35..c12c442f 100644 --- a/action.yml +++ b/action.yml @@ -10,22 +10,28 @@ runs: main: build/index.js inputs: expo-version: - description: The Expo CLI version to install. (use any semver/dist-tag available) - default: latest - expo-username: + description: The Expo CLI version to install (use any semver/dist-tag available). + expo-cache: + description: If Expo CLI should be stored in the GitHub Actions cache. + default: false + expo-cache-key: + description: A custom remote cache key to use for Expo CLI. + eas-version: + description: The EAS CLI version to install (use any semver/dist-tag available). + eas-cache: + description: If EAS CLI should be stored in the GitHub Actions cache. + default: false + eas-cache-key: + description: A custom remote cache key to use for EAS CLI. + username: description: Your Expo username, for authentication. - expo-token: + token: description: Your Expo token, for authentication. (use with secrets) - expo-password: + password: description: Your Expo password, for authentication. (use with secrets) - expo-packager: + packager: description: The package manager used to install the Expo CLI. (can be yarn or npm) default: yarn - expo-patch-watchers: + patch-watchers: description: If Expo should fix the default watchers limit, helps with ENOSPC errors. (can be true or false) default: true - expo-cache: - description: If Expo should be stored in the GitHub Actions cache (can be true or false) - default: false - expo-cache-key: - description: A custom remote cache key to use (best to let GitHub Actions handle it) diff --git a/build/index.js b/build/index.js index d85639b0..9b71dbad 100644 --- a/build/index.js +++ b/build/index.js @@ -49560,18 +49560,18 @@ const cache_1 = __webpack_require__(5722); * It returns the path where Expo is installed. */ async function install(config) { - let root = await cache_1.fromLocalCache(config.version); + let root = await cache_1.fromLocalCache(config); if (!root && config.cache) { - root = await cache_1.fromRemoteCache(config.version, config.packager, config.cacheKey); + root = await cache_1.fromRemoteCache(config); } else { core.info('Skipping remote cache, not enabled...'); } if (!root) { - root = await fromPackager(config.version, config.packager); - root = await cache_1.toLocalCache(root, config.version); + root = await fromPackager(config); + root = await cache_1.toLocalCache(root, config); if (config.cache) { - await cache_1.toRemoteCache(root, config.version, config.packager, config.cacheKey); + await cache_1.toRemoteCache(root, config); } } return path.join(root, 'node_modules', '.bin'); @@ -49581,11 +49581,11 @@ exports.install = install; * Install `expo-cli`, by version, using npm or yarn. * It creates a temporary directory to store all required files. */ -async function fromPackager(version, packager) { +async function fromPackager(config) { const root = process.env['RUNNER_TEMP'] || ''; - const tool = await io.which(packager); + const tool = await io.which(config.packager); await io.mkdirP(root); - await cli.exec(tool, ['add', `expo-cli@${version}`], { cwd: root }); + await cli.exec(tool, ['add', `${config.package}@${config.version}`], { cwd: root }); return root; } exports.fromPackager = fromPackager; @@ -63093,8 +63093,7 @@ if (typeof Object.create === 'function') { Object.defineProperty(exports, "__esModule", { value: true }); const run_1 = __webpack_require__(4180); -const tools_1 = __webpack_require__(3534); -run_1.run().catch(tools_1.handleError); +run_1.run(); /***/ }), @@ -74612,19 +74611,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.handleError = exports.maybeWarnForUpdate = exports.maybePatchWatchers = exports.maybeAuthenticate = exports.resolveVersion = void 0; +exports.handleError = exports.maybeWarnForUpdate = exports.maybePatchWatchers = exports.maybeAuthenticate = exports.resolveVersion = exports.getBinaryName = exports.getBoolean = void 0; const core = __importStar(__webpack_require__(6470)); const cli = __importStar(__webpack_require__(4986)); const semver_1 = __importDefault(__webpack_require__(7876)); // eslint-disable-next-line @typescript-eslint/no-var-requires const registry = __webpack_require__(5019); +/** + * Get a boolean value from string, useful for Github Actions boolean inputs. + */ +function getBoolean(value, defaultValue = false) { + return (value.toLowerCase() || (defaultValue ? 'true' : 'false')) === 'true'; +} +exports.getBoolean = getBoolean; +/** + * Convert `expo-cli` or `eas-cli` to just their binary name. + * For windows we have to use `.cmd`, toolkit will handle the Windows binary with that. + */ +function getBinaryName(name, forWindows = false) { + const bin = name.toLowerCase().replace('-cli', ''); + return forWindows ? `${bin}.cmd` : bin; +} +exports.getBinaryName = getBinaryName; /** * Resolve the provided semver to exact version of `expo-cli`. * This uses the npm registry and accepts latest, dist-tags or version ranges. * It's used to determine the cached version of `expo-cli`. */ -async function resolveVersion(version) { - return (await registry.manifest(`expo-cli@${version}`)).version; +async function resolveVersion(name, version) { + return (await registry.manifest(`${name}@${version}`)).version; } exports.resolveVersion = resolveVersion; /** @@ -74632,24 +74647,31 @@ exports.resolveVersion = resolveVersion; * If both of them are set, token has priority. */ async function maybeAuthenticate(options = {}) { - // github actions toolkit will handle commands with `.cmd` on windows, we need that - const bin = process.platform === 'win32' ? 'expo.cmd' : 'expo'; if (options.token) { - await cli.exec(bin, ['whoami'], { - env: { ...process.env, EXPO_TOKEN: options.token }, - }); + if (options.cli) { + const bin = getBinaryName(options.cli, process.platform === 'win32'); + await cli.exec(bin, ['whoami'], { + env: { ...process.env, EXPO_TOKEN: options.token }, + }); + } + else { + core.info('Skipping token validation: no CLI installed, can\'t run `whoami`.'); + } return core.exportVariable('EXPO_TOKEN', options.token); } if (options.username || options.password) { + if (options.cli !== 'expo-cli') { + return core.warning('Skipping authentication: only Expo CLI supports programmatic credentials, use `token` instead.'); + } if (!options.username || !options.password) { - return core.info('Skipping authentication: `expo-username` and/or `expo-password` not set...'); + return core.info('Skipping authentication: `username` and/or `password` not set...'); } + const bin = getBinaryName(options.cli, process.platform === 'win32'); await cli.exec(bin, ['login', `--username=${options.username}`], { env: { ...process.env, EXPO_CLI_PASSWORD: options.password }, }); - return; } - core.info('Skipping authentication: `expo-token`, `expo-username`, and/or `expo-password` not set...'); + core.info('Skipping authentication: `token`, `username`, and/or `password` not set...'); } exports.maybeAuthenticate = maybeAuthenticate; /** @@ -74682,12 +74704,13 @@ exports.maybePatchWatchers = maybePatchWatchers; * If there is, create a warning for people to upgrade their workflow. * Because this introduces additional requests, it should only be executed when necessary. */ -async function maybeWarnForUpdate() { - const latest = await resolveVersion('latest'); - const current = await resolveVersion(core.getInput('expo-version') || 'latest'); +async function maybeWarnForUpdate(name) { + const binaryName = getBinaryName(name); + const latest = await resolveVersion(name, 'latest'); + const current = await resolveVersion(name, core.getInput(`${getBinaryName(name)}-version`) || 'latest'); if (semver_1.default.diff(latest, current) === 'major') { core.warning(`There is a new major version available of the Expo CLI (${latest})`); - core.warning(`If you run into issues, try upgrading your workflow to "expo-version: ${semver_1.default.major(latest)}.x"`); + core.warning(`If you run into issues, try upgrading your workflow to "${binaryName}-version: ${semver_1.default.major(latest)}.x"`); } } exports.maybeWarnForUpdate = maybeWarnForUpdate; @@ -74695,9 +74718,9 @@ exports.maybeWarnForUpdate = maybeWarnForUpdate; * Handle errors when this action fails, providing useful next-steps for developers. * This mostly checks if the installed version is the latest version. */ -async function handleError(error) { +async function handleError(name, error) { try { - await maybeWarnForUpdate(); + await maybeWarnForUpdate(name); } catch { // If this fails, ignore it @@ -80923,35 +80946,68 @@ module.exports = minVersion "use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } }); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; Object.defineProperty(exports, "__esModule", { value: true }); exports.run = void 0; const core_1 = __webpack_require__(6470); const install_1 = __webpack_require__(1655); -const tools_1 = __webpack_require__(3534); +const tools = __importStar(__webpack_require__(3534)); async function run() { - const config = { - version: core_1.getInput('expo-version') || 'latest', - packager: core_1.getInput('expo-packager') || 'yarn', - cache: (core_1.getInput('expo-cache') || 'false') === 'true', - cacheKey: core_1.getInput('expo-cache-key') || undefined, - }; - // Resolve the exact requested Expo CLI version - config.version = await tools_1.resolveVersion(config.version); - const path = await core_1.group(config.cache - ? `Installing Expo CLI (${config.version}) from cache or with ${config.packager}` - : `Installing Expo CLI (${config.version}) with ${config.packager}`, () => install_1.install(config)); - core_1.addPath(path); - await core_1.group('Checking current authenticated account', () => tools_1.maybeAuthenticate({ - token: core_1.getInput('expo-token') || undefined, - username: core_1.getInput('expo-username') || undefined, - password: core_1.getInput('expo-password') || undefined, + const expoVersion = await installCli('expo-cli'); + const easVersion = await installCli('eas-cli'); + await core_1.group('Checking current authenticated account', () => tools.maybeAuthenticate({ + cli: expoVersion ? 'expo-cli' : easVersion ? 'eas-cli' : undefined, + token: core_1.getInput('token') || undefined, + username: core_1.getInput('username') || undefined, + password: core_1.getInput('password') || undefined, })); - const shouldPatchWatchers = core_1.getInput('expo-patch-watchers') || 'true'; - if (shouldPatchWatchers !== 'false') { - await core_1.group('Patching system watchers for the `ENOSPC` error', () => tools_1.maybePatchWatchers()); + if (tools.getBoolean(core_1.getInput('patch-watchers'), true)) { + await core_1.group('Patching system watchers for the `ENOSPC` error', () => tools.maybePatchWatchers()); } } exports.run = run; +async function installCli(name) { + const shortName = tools.getBinaryName(name); + const inputVersion = core_1.getInput(`${shortName}-version`); + const packager = core_1.getInput('packager') || 'yarn'; + if (!inputVersion) { + return core_1.info(`Skipping installation of ${name}, \`${shortName}-version\` not provided.`); + } + const version = await tools.resolveVersion(name, inputVersion); + const cache = tools.getBoolean(core_1.getInput(`${shortName}-cache`), false); + try { + const path = await core_1.group(cache + ? `Installing ${name} (${version}) from cache or with ${packager}` + : `Installing ${name} (${version}) with ${packager}`, () => install_1.install({ + packager, version, cache, + package: name, + cacheKey: core_1.getInput(`${shortName}-cache-key`) || undefined, + })); + core_1.addPath(path); + } + catch (error) { + tools.handleError(name, error); + } + return version; +} /***/ }), @@ -101170,8 +101226,8 @@ const os_1 = __importDefault(__webpack_require__(2087)); * * @see https://github.com/actions/toolkit/issues/47 */ -async function fromLocalCache(version) { - return toolCache.find('expo-cli', version); +async function fromLocalCache(config) { + return toolCache.find(config.package, config.version); } exports.fromLocalCache = fromLocalCache; /** @@ -101180,18 +101236,18 @@ exports.fromLocalCache = fromLocalCache; * * @see https://github.com/actions/toolkit/issues/47 */ -async function toLocalCache(root, version) { - return toolCache.cacheDir(root, 'expo-cli', version); +async function toLocalCache(root, config) { + return toolCache.cacheDir(root, config.package, config.version); } exports.toLocalCache = toLocalCache; /** * Download the remotely stored `expo-cli` from cache, if any. * Note, this cache is shared between jobs. */ -async function fromRemoteCache(version, packager, customCacheKey) { +async function fromRemoteCache(config) { // see: https://github.com/actions/toolkit/blob/8a4134761f09d0d97fb15f297705fd8644fef920/packages/tool-cache/src/tool-cache.ts#L401 - const target = path_1.default.join(process.env['RUNNER_TOOL_CACHE'] || '', 'expo-cli', version, os_1.default.arch()); - const cacheKey = customCacheKey || getRemoteKey(version, packager); + const target = path_1.default.join(process.env['RUNNER_TOOL_CACHE'] || '', config.package, config.version, os_1.default.arch()); + const cacheKey = config.cacheKey || getRemoteKey(config); try { // When running with nektos/act, or other custom environments, the cache might not be set up. const hit = await cache_1.restoreCache([target], cacheKey); @@ -101210,8 +101266,8 @@ exports.fromRemoteCache = fromRemoteCache; * Store the root of `expo-cli` in the remote cache, for future reuse. * Note, this cache is shared between jobs. */ -async function toRemoteCache(source, version, packager, customCacheKey) { - const cacheKey = customCacheKey || getRemoteKey(version, packager); +async function toRemoteCache(source, config) { + const cacheKey = config.cacheKey || getRemoteKey(config); try { await cache_1.saveCache([source], cacheKey); } @@ -101225,8 +101281,8 @@ exports.toRemoteCache = toRemoteCache; /** * Get the cache key to use when (re)storing the Expo CLI from remote cache. */ -function getRemoteKey(version, packager) { - return `expo-cli-${process.platform}-${os_1.default.arch()}-${packager}-${version}`; +function getRemoteKey(config) { + return `${config.package}-${process.platform}-${os_1.default.arch()}-${config.packager}-${config.version}`; } /** * Handle any incoming errors from cache methods. diff --git a/src/cache.ts b/src/cache.ts index 25c67984..0b12d2ff 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -4,14 +4,18 @@ import * as toolCache from '@actions/tool-cache'; import path from 'path'; import os from 'os'; +import type { InstallConfig } from './install'; + +export type CacheConfig = Omit; + /** * Get the path to the `expo-cli` from cache, if any. * Note, this cache is **NOT** shared between jobs. * * @see https://github.com/actions/toolkit/issues/47 */ -export async function fromLocalCache(version: string): Promise { - return toolCache.find('expo-cli', version); +export async function fromLocalCache(config: CacheConfig): Promise { + return toolCache.find(config.package, config.version); } /** @@ -20,18 +24,18 @@ export async function fromLocalCache(version: string): Promise { - return toolCache.cacheDir(root, 'expo-cli', version); +export async function toLocalCache(root: string, config: CacheConfig): Promise { + return toolCache.cacheDir(root, config.package, config.version); } /** * Download the remotely stored `expo-cli` from cache, if any. * Note, this cache is shared between jobs. */ -export async function fromRemoteCache(version: string, packager: string, customCacheKey?: string): Promise { +export async function fromRemoteCache(config: CacheConfig): Promise { // see: https://github.com/actions/toolkit/blob/8a4134761f09d0d97fb15f297705fd8644fef920/packages/tool-cache/src/tool-cache.ts#L401 - const target = path.join(process.env['RUNNER_TOOL_CACHE'] || '', 'expo-cli', version, os.arch()); - const cacheKey = customCacheKey || getRemoteKey(version, packager); + const target = path.join(process.env['RUNNER_TOOL_CACHE'] || '', config.package, config.version, os.arch()); + const cacheKey = config.cacheKey || getRemoteKey(config); try { // When running with nektos/act, or other custom environments, the cache might not be set up. @@ -50,8 +54,8 @@ export async function fromRemoteCache(version: string, packager: string, customC * Store the root of `expo-cli` in the remote cache, for future reuse. * Note, this cache is shared between jobs. */ -export async function toRemoteCache(source: string, version: string, packager: string, customCacheKey?: string): Promise { - const cacheKey = customCacheKey || getRemoteKey(version, packager); +export async function toRemoteCache(source: string, config: CacheConfig): Promise { + const cacheKey = config.cacheKey || getRemoteKey(config); try { await saveCache([source], cacheKey); @@ -65,8 +69,8 @@ export async function toRemoteCache(source: string, version: string, packager: s /** * Get the cache key to use when (re)storing the Expo CLI from remote cache. */ -function getRemoteKey(version: string, packager: string): string { - return `expo-cli-${process.platform}-${os.arch()}-${packager}-${version}`; +function getRemoteKey(config: Omit): string { + return `${config.package}-${process.platform}-${os.arch()}-${config.packager}-${config.version}`; } /** diff --git a/src/index.ts b/src/index.ts index c999498e..33c2e475 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ import { run } from './run'; -import { handleError } from './tools'; -run().catch(handleError); +run(); diff --git a/src/install.ts b/src/install.ts index dda44b5a..f137efdf 100644 --- a/src/install.ts +++ b/src/install.ts @@ -4,11 +4,18 @@ import * as io from '@actions/io'; import * as path from 'path'; import { fromLocalCache, fromRemoteCache, toLocalCache, toRemoteCache } from './cache'; +import { PackageName } from './tools'; export type InstallConfig = { + /** The exact version to install */ version: string; + /** The name of the package to install */ + package: PackageName; + /** The packager to install with, likely to be `yarn` or `npm` */ packager: string; + /** If remote caching is enabled or not */ cache?: boolean; + /** The custom remote cache key */ cacheKey?: string; }; @@ -18,20 +25,20 @@ export type InstallConfig = { * It returns the path where Expo is installed. */ export async function install(config: InstallConfig): Promise { - let root: string | undefined = await fromLocalCache(config.version); + let root: string | undefined = await fromLocalCache(config); if (!root && config.cache) { - root = await fromRemoteCache(config.version, config.packager, config.cacheKey); + root = await fromRemoteCache(config); } else { core.info('Skipping remote cache, not enabled...'); } if (!root) { - root = await fromPackager(config.version, config.packager); - root = await toLocalCache(root, config.version); + root = await fromPackager(config); + root = await toLocalCache(root, config); if (config.cache) { - await toRemoteCache(root, config.version, config.packager, config.cacheKey); + await toRemoteCache(root, config); } } @@ -42,12 +49,12 @@ export async function install(config: InstallConfig): Promise { * Install `expo-cli`, by version, using npm or yarn. * It creates a temporary directory to store all required files. */ -export async function fromPackager(version: string, packager: string): Promise { +export async function fromPackager(config: InstallConfig): Promise { const root = process.env['RUNNER_TEMP'] || ''; - const tool = await io.which(packager); + const tool = await io.which(config.packager); await io.mkdirP(root); - await cli.exec(tool, ['add', `expo-cli@${version}`], { cwd: root }); + await cli.exec(tool, ['add', `${config.package}@${config.version}`], { cwd: root }); return root; } diff --git a/src/run.ts b/src/run.ts index 7c864b4f..59a08fcb 100644 --- a/src/run.ts +++ b/src/run.ts @@ -1,43 +1,58 @@ -import { addPath, getInput, group } from '@actions/core'; +import { addPath, getInput, group, info } from '@actions/core'; -import { install, InstallConfig } from './install'; -import { maybeAuthenticate, maybePatchWatchers, resolveVersion } from './tools'; +import { install } from './install'; +import * as tools from './tools'; export async function run(): Promise { - const config: InstallConfig = { - version: getInput('expo-version') || 'latest', - packager: getInput('expo-packager') || 'yarn', - cache: (getInput('expo-cache') || 'false') === 'true', - cacheKey: getInput('expo-cache-key') || undefined, - }; - - // Resolve the exact requested Expo CLI version - config.version = await resolveVersion(config.version); - - const path = await group( - config.cache - ? `Installing Expo CLI (${config.version}) from cache or with ${config.packager}` - : `Installing Expo CLI (${config.version}) with ${config.packager}`, - () => install(config), - ); - - addPath(path); + const expoVersion = await installCli('expo-cli'); + const easVersion = await installCli('eas-cli'); await group( 'Checking current authenticated account', - () => maybeAuthenticate({ - token: getInput('expo-token') || undefined, - username: getInput('expo-username') || undefined, - password: getInput('expo-password') || undefined, + () => tools.maybeAuthenticate({ + cli: expoVersion ? 'expo-cli' : easVersion ? 'eas-cli' : undefined, + token: getInput('token') || undefined, + username: getInput('username') || undefined, + password: getInput('password') || undefined, }), ); - const shouldPatchWatchers = getInput('expo-patch-watchers') || 'true'; - - if (shouldPatchWatchers !== 'false') { + if (tools.getBoolean(getInput('patch-watchers'), true)) { await group( 'Patching system watchers for the `ENOSPC` error', - () => maybePatchWatchers(), + () => tools.maybePatchWatchers(), + ); + } +} + +async function installCli(name: tools.PackageName): Promise { + const shortName = tools.getBinaryName(name); + const inputVersion = getInput(`${shortName}-version`); + const packager = getInput('packager') || 'yarn'; + + if (!inputVersion) { + return info(`Skipping installation of ${name}, \`${shortName}-version\` not provided.`); + } + + const version = await tools.resolveVersion(name, inputVersion); + const cache = tools.getBoolean(getInput(`${shortName}-cache`), false); + + try { + const path = await group( + cache + ? `Installing ${name} (${version}) from cache or with ${packager}` + : `Installing ${name} (${version}) with ${packager}`, + () => install({ + packager, version, cache, + package: name, + cacheKey: getInput(`${shortName}-cache-key`) || undefined, + }), ); + + addPath(path); + } catch (error) { + tools.handleError(name, error); } + + return version; } diff --git a/src/tools.ts b/src/tools.ts index 2083468f..462b93a5 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -5,19 +5,38 @@ import semver from 'semver'; // eslint-disable-next-line @typescript-eslint/no-var-requires const registry = require('libnpm'); -type AuthenticateOptions = { +export type PackageName = 'expo-cli' | 'eas-cli'; + +export type AuthenticateOptions = { + cli?: PackageName; token?: string; username?: string; password?: string; }; +/** + * Get a boolean value from string, useful for Github Actions boolean inputs. + */ +export function getBoolean(value: string, defaultValue = false): boolean { + return (value.toLowerCase() || (defaultValue ? 'true' : 'false')) === 'true'; +} + +/** + * Convert `expo-cli` or `eas-cli` to just their binary name. + * For windows we have to use `.cmd`, toolkit will handle the Windows binary with that. + */ +export function getBinaryName(name: PackageName, forWindows = false): string { + const bin = name.toLowerCase().replace('-cli', ''); + return forWindows ? `${bin}.cmd` : bin; +} + /** * Resolve the provided semver to exact version of `expo-cli`. * This uses the npm registry and accepts latest, dist-tags or version ranges. * It's used to determine the cached version of `expo-cli`. */ -export async function resolveVersion(version: string): Promise { - return (await registry.manifest(`expo-cli@${version}`)).version; +export async function resolveVersion(name: PackageName, version: string): Promise { + return (await registry.manifest(`${name}@${version}`)).version; } /** @@ -25,27 +44,35 @@ export async function resolveVersion(version: string): Promise { * If both of them are set, token has priority. */ export async function maybeAuthenticate(options: AuthenticateOptions = {}): Promise { - // github actions toolkit will handle commands with `.cmd` on windows, we need that - const bin = process.platform === 'win32' ? 'expo.cmd' : 'expo'; - if (options.token) { - await cli.exec(bin, ['whoami'], { - env: { ...process.env, EXPO_TOKEN: options.token }, - }); + if (options.cli) { + const bin = getBinaryName(options.cli, process.platform === 'win32'); + await cli.exec(bin, ['whoami'], { + env: { ...process.env, EXPO_TOKEN: options.token }, + }); + } else { + core.info('Skipping token validation: no CLI installed, can\'t run `whoami`.'); + } + return core.exportVariable('EXPO_TOKEN', options.token); } if (options.username || options.password) { + if (options.cli !== 'expo-cli') { + return core.warning('Skipping authentication: only Expo CLI supports programmatic credentials, use `token` instead.'); + } + if (!options.username || !options.password) { - return core.info('Skipping authentication: `expo-username` and/or `expo-password` not set...'); + return core.info('Skipping authentication: `username` and/or `password` not set...'); } + + const bin = getBinaryName(options.cli, process.platform === 'win32'); await cli.exec(bin, ['login', `--username=${options.username}`], { env: { ...process.env, EXPO_CLI_PASSWORD: options.password }, }); - return; } - core.info('Skipping authentication: `expo-token`, `expo-username`, and/or `expo-password` not set...'); + core.info('Skipping authentication: `token`, `username`, and/or `password` not set...'); } /** @@ -79,13 +106,14 @@ export async function maybePatchWatchers(): Promise { * If there is, create a warning for people to upgrade their workflow. * Because this introduces additional requests, it should only be executed when necessary. */ -export async function maybeWarnForUpdate(): Promise { - const latest = await resolveVersion('latest'); - const current = await resolveVersion(core.getInput('expo-version') || 'latest'); +export async function maybeWarnForUpdate(name: PackageName): Promise { + const binaryName = getBinaryName(name); + const latest = await resolveVersion(name, 'latest'); + const current = await resolveVersion(name, core.getInput(`${getBinaryName(name)}-version`) || 'latest'); if (semver.diff(latest, current) === 'major') { core.warning(`There is a new major version available of the Expo CLI (${latest})`); - core.warning(`If you run into issues, try upgrading your workflow to "expo-version: ${semver.major(latest)}.x"`); + core.warning(`If you run into issues, try upgrading your workflow to "${binaryName}-version: ${semver.major(latest)}.x"`); } } @@ -93,9 +121,9 @@ export async function maybeWarnForUpdate(): Promise { * Handle errors when this action fails, providing useful next-steps for developers. * This mostly checks if the installed version is the latest version. */ -export async function handleError(error: Error) { +export async function handleError(name: PackageName, error: Error) { try { - await maybeWarnForUpdate(); + await maybeWarnForUpdate(name); } catch { // If this fails, ignore it } diff --git a/tests/cache.test.ts b/tests/cache.test.ts index e2abb20b..887b8fbe 100644 --- a/tests/cache.test.ts +++ b/tests/cache.test.ts @@ -7,29 +7,29 @@ import * as toolCache from '@actions/tool-cache'; import * as cache from '../src/cache'; import * as utils from './utils'; -describe('fromLocalCache', () => { +describe(cache.fromLocalCache, () => { it('fetches the version specific cache', async () => { const path = join('path', 'to', 'local', 'cache'); // todo: check why jest wants `never` instead of `string` const find = jest.spyOn(toolCache, 'find').mockResolvedValue(path as never); - const result = await cache.fromLocalCache('3.20.1'); + const result = await cache.fromLocalCache({ package: 'eas-cli', version: '4.2.0', packager: 'npm' }); expect(result).toBe(path); - expect(find).toBeCalledWith('expo-cli', '3.20.1'); + expect(find).toBeCalledWith('eas-cli', '4.2.0'); }); }); -describe('toLocalCache', () => { +describe(cache.toLocalCache, () => { it('stores the version specific cache', async () => { const path = join('path', 'to', 'local', 'cache'); const root = join('path', 'from', 'source'); const cacheDir = jest.spyOn(toolCache, 'cacheDir').mockResolvedValue(path); - const result = await cache.toLocalCache(root, '3.20.1'); + const result = await cache.toLocalCache(root, { package: 'expo-cli', version: '4.2.0', packager: 'yarn' }); expect(result).toBe(path); - expect(cacheDir).toBeCalledWith(root, 'expo-cli', '3.20.1'); + expect(cacheDir).toBeCalledWith(root, 'expo-cli', '4.2.0'); }); }); -describe('fromRemoteCache', () => { +describe(cache.fromRemoteCache, () => { let spy: { [key: string]: jest.SpyInstance } = {}; beforeEach(() => { @@ -50,7 +50,7 @@ describe('fromRemoteCache', () => { }); it('restores remote cache with default key', async () => { - expect(await cache.fromRemoteCache('3.20.1', 'yarn')).toBeUndefined(); + expect(await cache.fromRemoteCache({ package: 'expo-cli', version: '3.20.1', packager: 'yarn' })).toBeUndefined(); expect(remoteCache.restoreCache).toBeCalledWith( [join('cache', 'path', 'expo-cli', '3.20.1', os.arch())], `expo-cli-${process.platform}-${os.arch()}-yarn-3.20.1`, @@ -58,36 +58,45 @@ describe('fromRemoteCache', () => { }); it('restores remote cache with custom key', async () => { - expect(await cache.fromRemoteCache('3.20.0', 'yarn', 'custom-cache-key')).toBeUndefined(); + expect( + await cache.fromRemoteCache({ + package: 'eas-cli', + version: '4.2.0', + packager: 'yarn', + cacheKey: 'custom-cache-key', + }) + ).toBeUndefined(); expect(remoteCache.restoreCache).toBeCalledWith( - [join('cache', 'path', 'expo-cli', '3.20.0', os.arch())], + [join('cache', 'path', 'eas-cli', '4.2.0', os.arch())], 'custom-cache-key', ); }); it('returns path when remote cache exists', async () => { spy.restore.mockResolvedValueOnce(true); - await expect(cache.fromRemoteCache('3.20.1', 'npm')).resolves.toBe( - join('cache', 'path', 'expo-cli', '3.20.1', os.arch()) - ); + expect( + await cache.fromRemoteCache({ package: 'expo-cli', version: '3.20.1', packager: 'npm' }) + ).toBe(join('cache', 'path', 'expo-cli', '3.20.1', os.arch())); }); it('fails when remote cache throws', async () => { const error = new Error('Remote cache restore failed'); spy.restore.mockRejectedValueOnce(error); - await expect(cache.fromRemoteCache('3.20.1', 'yarn')).rejects.toBe(error); + await expect(cache.fromRemoteCache({ package: 'eas-cli', version: '3.20.1', packager: 'yarn' })).rejects.toBe(error); }); it('skips remote cache when unavailable', async () => { // see: https://github.com/actions/toolkit/blob/9167ce1f3a32ad495fc1dbcb574c03c0e013ae53/packages/cache/src/internal/cacheHttpClient.ts#L41 const error = new Error('Cache Service Url not found, unable to restore cache.'); spy.restore.mockRejectedValueOnce(error); - await expect(cache.fromRemoteCache('3.20.1', 'yarn')).resolves.toBeUndefined(); + expect( + await cache.fromRemoteCache({ package: 'expo-cli', version: '3.20.1', packager: 'yarn' }) + ).toBeUndefined(); expect(spy.warning).toHaveBeenCalledWith(expect.stringContaining('Skipping remote cache')); }); }); -describe('toRemoteCache', () => { +describe(cache.toRemoteCache, () => { let spy: { [key: string]: jest.SpyInstance } = {}; beforeEach(() => { @@ -103,29 +112,38 @@ describe('toRemoteCache', () => { }); it('saves remote cache with default key', async () => { - expect(await cache.toRemoteCache(join('local', 'path'), '3.20.1', 'npm')).toBeUndefined(); + expect(await cache.toRemoteCache(join('local', 'path'), { package: 'eas-cli', version: '3.20.1', packager: 'npm' })).toBeUndefined(); expect(remoteCache.saveCache).toBeCalledWith( [join('local', 'path')], - `expo-cli-${process.platform}-${os.arch()}-npm-3.20.1` + `eas-cli-${process.platform}-${os.arch()}-npm-3.20.1` ); }); it('saves remote cache with custom key', async () => { - expect(await cache.toRemoteCache(join('local', 'path'), '3.20.1', 'yarn', 'custom-cache-key')).toBeUndefined(); + expect(await cache.toRemoteCache( + join('local', 'path'), + { package: 'eas-cli', version: '3.20.1', packager: 'yarn', cacheKey: 'custom-cache-key' } + )).toBeUndefined(); expect(remoteCache.saveCache).toBeCalledWith([join('local', 'path')], 'custom-cache-key'); }); it('fails when remote cache throws', async () => { const error = new Error('Remote cache save failed'); spy.save.mockRejectedValueOnce(error); - await expect(cache.toRemoteCache(join('local', 'path'), '3.20.1', 'yarn')).rejects.toBe(error); + await expect(cache.toRemoteCache( + join('local', 'path'), + { package: 'expo-cli', version: '3.20.1', packager: 'yarn' }, + )).rejects.toBe(error); }); it('skips remote cache when unavailable', async () => { // see: https://github.com/actions/toolkit/blob/9167ce1f3a32ad495fc1dbcb574c03c0e013ae53/packages/cache/src/internal/cacheHttpClient.ts#L41 const error = new Error('Cache Service Url not found, unable to restore cache.'); spy.save.mockRejectedValueOnce(error); - await expect(cache.toRemoteCache(join('local', 'path'), '3.20.1', 'yarn')).resolves.toBeUndefined(); + await expect(cache.toRemoteCache( + join('local', 'path'), + { package: 'expo-cli', version: '3.20.1', packager: 'yarn' }, + )).resolves.toBeUndefined(); expect(spy.warning).toHaveBeenCalledWith(expect.stringContaining('Skipping remote cache')); }); }); diff --git a/tests/install.test.ts b/tests/install.test.ts index 2d5b65c7..a717dcec 100644 --- a/tests/install.test.ts +++ b/tests/install.test.ts @@ -19,7 +19,7 @@ import * as utils from './utils'; describe('install', () => { it('installs path from local cache', async () => { cache.fromLocalCache.mockResolvedValue(join('cache', 'path')); - const expoPath = await install.install({ version: '3.0.10', packager: 'npm' }); + const expoPath = await install.install({ package: 'expo-cli', version: '3.0.10', packager: 'npm' }); expect(expoPath).toBe(join('cache', 'path', 'node_modules', '.bin')); }); @@ -27,18 +27,18 @@ describe('install', () => { utils.setEnv('RUNNER_TEMP', join('temp', 'path')); cache.fromLocalCache.mockResolvedValue(undefined); cache.toLocalCache.mockResolvedValue(join('cache', 'path')); - const expoPath = await install.install({ version: '3.0.10', packager: 'npm' }); + const expoPath = await install.install({ package: 'expo-cli', version: '3.0.10', packager: 'npm' }); expect(expoPath).toBe(join('cache', 'path', 'node_modules', '.bin')); - expect(cache.toLocalCache).toBeCalledWith(join('temp', 'path'), '3.0.10'); + expect(cache.toLocalCache).toBeCalledWith(join('temp', 'path'), { package: 'expo-cli', version: '3.0.10', packager: 'npm' }); utils.restoreEnv(); }); it('installs path from remote cache', async () => { cache.fromLocalCache.mockResolvedValue(undefined); cache.fromRemoteCache.mockResolvedValue(join('cache', 'path')); - const expoPath = await install.install({ version: '3.20.1', packager: 'npm', cache: true }); + const expoPath = await install.install({ package: 'eas-cli', version: '4.2.0', packager: 'yarn', cache: true }); expect(expoPath).toBe(join('cache', 'path', 'node_modules', '.bin')); - expect(cache.fromRemoteCache).toBeCalledWith('3.20.1', 'npm', undefined); + expect(cache.fromRemoteCache).toBeCalledWith({ package: 'eas-cli', version: '4.2.0', packager: 'yarn', cache: true }); }); it('installs path from packager and cache it remotely', async () => { @@ -46,22 +46,22 @@ describe('install', () => { cache.fromLocalCache.mockResolvedValue(undefined); cache.fromRemoteCache.mockResolvedValue(undefined); cache.toLocalCache.mockResolvedValue(join('cache', 'path')); - const expoPath = await install.install({ version: '3.20.1', packager: 'npm', cache: true }); + const expoPath = await install.install({ package: 'expo-cli', version: '3.20.1', packager: 'npm', cache: true }); expect(expoPath).toBe(join('cache', 'path', 'node_modules', '.bin')); - expect(cache.toRemoteCache).toBeCalledWith(join('cache', 'path'), '3.20.1', 'npm', undefined); + expect(cache.toRemoteCache).toBeCalledWith(join('cache', 'path'), { package: 'expo-cli', version: '3.20.1', packager: 'npm', cache: true }); utils.restoreEnv(); }); }); describe('fromPackager', () => { it('resolves tool path', async () => { - await install.fromPackager('3.0.10', 'npm'); + await install.fromPackager({ package: 'expo-cli', version: '3.0.10', packager: 'npm' }); expect(io.which).toBeCalledWith('npm'); }); it('creates temporary folder', async () => { utils.setEnv('RUNNER_TEMP', join('temp', 'path')); - await install.fromPackager('latest', 'yarn'); + await install.fromPackager({ package: 'eas-cli', version: 'latest', packager: 'yarn' }); expect(io.mkdirP).toBeCalledWith(join('temp', 'path')); utils.restoreEnv(); }); @@ -69,11 +69,11 @@ describe('fromPackager', () => { it('installs expo with tool', async () => { utils.setEnv('RUNNER_TEMP', join('temp', 'path')); io.which.mockResolvedValue('npm'); - const expoPath = await install.fromPackager('beta', 'npm'); + const expoPath = await install.fromPackager({ package: 'eas-cli', version: 'beta', packager: 'npm' }); expect(expoPath).toBe(join('temp', 'path')); expect(cli.exec).toBeCalled(); expect(cli.exec.mock.calls[0][0]).toBe('npm'); - expect(cli.exec.mock.calls[0][1]).toStrictEqual(['add', 'expo-cli@beta']); + expect(cli.exec.mock.calls[0][1]).toStrictEqual(['add', 'eas-cli@beta']); expect(cli.exec.mock.calls[0][2]).toMatchObject({ cwd: join('temp', 'path') }); utils.restoreEnv(); }); diff --git a/tests/run.test.ts b/tests/run.test.ts index c666bdb0..414706ef 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -1,12 +1,15 @@ const core = { addPath: jest.fn(), getInput: jest.fn(), - group: (message: string, action: () => Promise) => action() + group: (message: string, action: () => Promise) => action(), + info: jest.fn(), }; const exec = { exec: jest.fn() }; const install = { install: jest.fn() }; const tools = { - resolveVersion: jest.fn(v => v), + getBoolean: jest.fn((v, d) => v ? v === 'true' : d), + getBinaryName: jest.fn(v => v.replace('-cli', '')), + resolveVersion: jest.fn((n, v) => v), maybeAuthenticate: jest.fn(), maybePatchWatchers: jest.fn(), }; @@ -18,64 +21,7 @@ jest.mock('../src/install', () => install); import { run } from '../src/run'; -interface MockInputProps { - version?: string; - packager?: string; - token?: string; - username?: string; - password?: string; - patchWatchers?: string; - cache?: string; - cacheKey?: string; -} - -const mockInput = (props: MockInputProps = {}) => { - // fix: kind of dirty workaround for missing "mock 'value' based on arguments" - const input = (name: string) => { - switch (name) { - case 'expo-version': - return props.version || ''; - case 'expo-packager': - return props.packager || ''; - case 'expo-token': - return props.token || ''; - case 'expo-username': - return props.username || ''; - case 'expo-password': - return props.password || ''; - case 'expo-patch-watchers': - return props.patchWatchers || ''; - case 'expo-cache': - return props.cache || ''; - case 'expo-cache-key': - return props.cacheKey || ''; - default: - return ''; - } - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - core.getInput = input as any; -}; - describe('run', () => { - it('installs latest expo-cli with yarn by default', async () => { - await run(); - expect(install.install).toBeCalledWith({ version: 'latest', packager: 'yarn', cache: false }); - }); - - it('installs provided version expo-cli with npm', async () => { - mockInput({ version: '3.0.10', packager: 'npm' }); - await run(); - expect(install.install).toBeCalledWith({ version: '3.0.10', packager: 'npm', cache: false }); - }); - - it('installs path to global path', async () => { - install.install.mockResolvedValue('/expo/install/path'); - await run(); - expect(core.addPath).toBeCalledWith('/expo/install/path'); - }); - it('patches the system when set to true', async () => { mockInput({ patchWatchers: 'true' }); await run(); @@ -105,4 +51,126 @@ describe('run', () => { await run(); expect(tools.maybeAuthenticate).toBeCalledWith({ token: 'ABC123' }); }); + + ['expo', 'eas'].forEach(cliName => { + const packageName = `${cliName}-cli`; + + describe(packageName, () => { + it(`skips installation without \`${cliName}-version\``, async () => { + mockInput(); + await run(); + expect(install.install).not.toBeCalledWith({ package: packageName }); + }); + + it('installs with yarn by default', async () => { + mockInput({ [`${cliName}Version`]: 'latest' }); + await run(); + expect(install.install).toBeCalledWith({ + package: + packageName, + version: 'latest', + packager: 'yarn', + cache: false, + }); + }); + + it('installs provided version with npm', async () => { + mockInput({ [`${cliName}Version`]: '3.0.10', packager: 'npm' }); + await run(); + expect(install.install).toBeCalledWith({ + package: packageName, + version: '3.0.10', + packager: 'npm', + cache: false, + }); + }); + + it('installs with yarn and cache enabled', async () => { + mockInput({ + packager: 'yarn', + [`${cliName}Version`]: '4.2.0', + [`${cliName}Cache`]: 'true', + }); + await run(); + expect(install.install).toBeCalledWith({ + package: packageName, + version: '4.2.0', + packager: 'yarn', + cache: true, + }); + }); + + it('installs with yarn and custom cache key', async () => { + mockInput({ + packager: 'yarn', + [`${cliName}Version`]: '4.2.0', + [`${cliName}Cache`]: 'true', + [`${cliName}CacheKey`]: 'custom-key' + }); + await run(); + expect(install.install).toBeCalledWith({ + package: packageName, + version: '4.2.0', + packager: 'yarn', + cache: true, + cacheKey: 'custom-key', + }); + }); + + it('installs path to global path', async () => { + install.install.mockResolvedValue(`/${cliName}/install/path`); + await run(); + expect(core.addPath).toBeCalledWith(`/${cliName}/install/path`); + }); + }); + }); }); + +interface MockInputProps { + expoVersion?: string; + expoCache?: string; + expoCacheKey?: string; + easVersion?: string; + easCache?: string; + easCacheKey?: string; + packager?: string; + token?: string; + username?: string; + password?: string; + patchWatchers?: string; +} + +function mockInput(props: MockInputProps = {}) { + // fix: kind of dirty workaround for missing "mock 'value' based on arguments" + const input = (name: string) => { + switch (name) { + case 'expo-version': + return props.expoVersion || ''; + case 'expo-cache': + return props.expoCache || ''; + case 'expo-cache-key': + return props.expoCacheKey || ''; + case 'eas-version': + return props.easVersion || ''; + case 'eas-cache': + return props.easCache || ''; + case 'eas-cache-key': + return props.easCacheKey || ''; + case 'packager': + return props.packager || ''; + case 'token': + return props.token || ''; + case 'username': + return props.username || ''; + case 'password': + return props.password || ''; + case 'patch-watchers': + return props.patchWatchers || ''; + default: + return ''; + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + core.getInput = input as any; +} diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 9b932ac7..94ccbb10 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -7,15 +7,57 @@ import * as cli from '@actions/exec'; import * as tools from '../src/tools'; import * as utils from './utils'; -describe('resolveVersion', () => { +describe(tools.getBoolean, () => { + it('rreturns false for empty strings by default', () => { + expect(tools.getBoolean('')).toBeFalsy(); + }); + + it('returns true for empty strings with default set to true', () => { + expect(tools.getBoolean('', true)).toBeTruthy(); + }); + + it('returns true for `true` strings by default', () => { + expect(tools.getBoolean('true')).toBeTruthy(); + }); + + it('returns false for `false` strings with default set to false', () => { + expect(tools.getBoolean('false', false)).toBeFalsy(); + }); + + it('returns false for `false` strings with default set to true', () => { + expect(tools.getBoolean('false', true)).toBeFalsy(); + }); +}); + +describe(tools.getBinaryName, () => { + it('returns expo for `expo-cli`', () => { + expect(tools.getBinaryName('expo-cli')).toBe('expo'); + }); + + it('returns eas for `eas-cli`', () => { + expect(tools.getBinaryName('eas-cli')).toBe('eas'); + }); + + it('returns eas.cmd for `eas-cli` for windows', () => { + expect(tools.getBinaryName('eas-cli', true)).toBe('eas.cmd'); + }); +}); + +describe(tools.resolveVersion, () => { it('fetches exact version of expo-cli', async () => { registry.manifest.mockResolvedValue({ version: '3.0.10' }); - expect(await tools.resolveVersion('latest')).toBe('3.0.10'); + expect(await tools.resolveVersion('expo-cli', 'latest')).toBe('3.0.10'); expect(registry.manifest).toBeCalledWith('expo-cli@latest'); }); + + it('fetches exact version of eas-cli', async () => { + registry.manifest.mockResolvedValue({ version: '4.2.0' }); + expect(await tools.resolveVersion('eas-cli', 'latest')).toBe('4.2.0'); + expect(registry.manifest).toBeCalledWith('eas-cli@latest'); + }); }); -describe('maybeAuthenticate', () => { +describe(tools.maybeAuthenticate, () => { const token = 'ABC123'; const username = 'bycedric'; const password = 'mypassword'; @@ -45,7 +87,7 @@ describe('maybeAuthenticate', () => { it('executes whoami command with token through environment', async () => { utils.setEnv('TEST_INCLUDED', 'hellyeah'); - await tools.maybeAuthenticate({ token }); + await tools.maybeAuthenticate({ token, cli: 'expo-cli' }); expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][1]).toStrictEqual(['whoami']); expect(spy.exec.mock.calls[0][2]).toMatchObject({ @@ -60,28 +102,34 @@ describe('maybeAuthenticate', () => { it('fails when token is incorrect', async () => { const error = new Error('Not logged in'); spy.exec.mockRejectedValue(error); - await expect(tools.maybeAuthenticate({ token })).rejects.toBe(error); + await expect(tools.maybeAuthenticate({ token, cli: 'expo-cli' })).rejects.toBe(error); + }); + + it('skips validation without cli', async () => { + await tools.maybeAuthenticate({ token }); + expect(spy.exec).not.toBeCalled(); + expect(spy.info).toBeCalledWith(expect.stringContaining('Skipping token validation')); }); it('executes whoami command with `expo` on macos', async () => { utils.setPlatform('darwin'); - await tools.maybeAuthenticate({ token }); + await tools.maybeAuthenticate({ token, cli: 'expo-cli' }); expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][0]).toBe('expo'); utils.restorePlatform(); }); - it('executes whoami command with `expo` on ubuntu', async () => { + it('executes whoami command with `eas` on ubuntu', async () => { utils.setPlatform('linux'); - await tools.maybeAuthenticate({ token }); + await tools.maybeAuthenticate({ token, cli: 'eas-cli' }); expect(spy.exec).toBeCalled(); - expect(spy.exec.mock.calls[0][0]).toBe('expo'); + expect(spy.exec.mock.calls[0][0]).toBe('eas'); utils.restorePlatform(); }); it('executes whoami command with `expo.cmd` on windows', async () => { utils.setPlatform('win32'); - await tools.maybeAuthenticate({ token }); + await tools.maybeAuthenticate({ token, cli: 'expo-cli' }); expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][0]).toBe('expo.cmd'); utils.restorePlatform(); @@ -95,29 +143,43 @@ describe('maybeAuthenticate', () => { spy = { exec: jest.spyOn(cli, 'exec').mockImplementation(), info: jest.spyOn(core, 'info').mockImplementation(), + warning: jest.spyOn(core, 'warning').mockImplementation(), }; }); afterAll(() => { spy.exec.mockRestore(); spy.info.mockRestore(); + spy.warning.mockRestore(); + }); + + it('skips authentication without cli', async () => { + await tools.maybeAuthenticate({}); + expect(spy.exec).not.toBeCalled(); + expect(spy.info).toBeCalledWith(expect.stringContaining('Skipping authentication')); }); it('skips authentication without credentials', async () => { - await tools.maybeAuthenticate(); + await tools.maybeAuthenticate({ cli: 'expo-cli' }); expect(spy.exec).not.toBeCalled(); expect(spy.info).toBeCalledWith(expect.stringContaining('Skipping authentication')); }); it('skips authentication without password', async () => { - await tools.maybeAuthenticate({ username }); + await tools.maybeAuthenticate({ username, cli: 'expo-cli' }); expect(spy.exec).not.toBeCalled(); expect(spy.info).toBeCalledWith(expect.stringContaining('Skipping authentication')); }); + it('skips authentication with credentials for eas-cli', async () => { + await tools.maybeAuthenticate({ username, password, cli: 'eas-cli' }); + expect(spy.exec).not.toBeCalled(); + expect(spy.warning).toBeCalledWith(expect.stringContaining('Skipping authentication')); + }); + it('executes login command with password through environment', async () => { utils.setEnv('TEST_INCLUDED', 'hellyeah'); - await tools.maybeAuthenticate({ username, password }) + await tools.maybeAuthenticate({ username, password, cli: 'expo-cli' }) expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][1]).toStrictEqual(['login', `--username=${username}`]); expect(spy.exec.mock.calls[0][2]).toMatchObject({ @@ -132,14 +194,14 @@ describe('maybeAuthenticate', () => { it('fails when credentials are incorrect', async () => { const error = new Error('Invalid username/password. Please try again.'); spy.exec.mockRejectedValue(error); - await expect(tools.maybeAuthenticate({ username, password })) + await expect(tools.maybeAuthenticate({ username, password, cli: 'expo-cli' })) .rejects .toBe(error); }); it('executes login command with `expo` on macos', async () => { utils.setPlatform('darwin'); - await tools.maybeAuthenticate({ username, password }); + await tools.maybeAuthenticate({ username, password, cli: 'expo-cli' }); expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][0]).toBe('expo'); utils.restorePlatform(); @@ -147,7 +209,7 @@ describe('maybeAuthenticate', () => { it('executes login command with `expo` on ubuntu', async () => { utils.setPlatform('linux'); - await tools.maybeAuthenticate({ username, password }); + await tools.maybeAuthenticate({ username, password, cli: 'expo-cli' }); expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][0]).toBe('expo'); utils.restorePlatform(); @@ -155,7 +217,7 @@ describe('maybeAuthenticate', () => { it('executes login command with `expo.cmd` on windows', async () => { utils.setPlatform('win32'); - await tools.maybeAuthenticate({ username, password }); + await tools.maybeAuthenticate({ username, password, cli: 'expo-cli' }); expect(spy.exec).toBeCalled(); expect(spy.exec.mock.calls[0][0]).toBe('expo.cmd'); utils.restorePlatform(); @@ -163,7 +225,7 @@ describe('maybeAuthenticate', () => { }); }); -describe('maybePatchWatchers', () => { +describe(tools.maybePatchWatchers, () => { let spy: { [key: string]: jest.SpyInstance } = {}; beforeEach(() => { @@ -226,7 +288,7 @@ describe('maybePatchWatchers', () => { }); }); -describe('maybeWarnForUpdate', () => { +describe(tools.maybeWarnForUpdate, () => { let spy: { [key: string]: jest.SpyInstance } = {}; beforeEach(() => { @@ -241,7 +303,7 @@ describe('maybeWarnForUpdate', () => { registry.manifest .mockResolvedValueOnce({ version: '4.1.0' }) .mockResolvedValueOnce({ version: '4.0.1'}); - await tools.maybeWarnForUpdate(); + await tools.maybeWarnForUpdate('eas-cli'); expect(spy.warning).not.toBeCalled(); }) @@ -249,13 +311,13 @@ describe('maybeWarnForUpdate', () => { registry.manifest .mockResolvedValueOnce({ version: '4.1.0' }) .mockResolvedValueOnce({ version: '3.0.1'}); - await tools.maybeWarnForUpdate(); + await tools.maybeWarnForUpdate('expo-cli'); expect(spy.warning).toBeCalledWith('There is a new major version available of the Expo CLI (4.1.0)'); expect(spy.warning).toBeCalledWith('If you run into issues, try upgrading your workflow to "expo-version: 4.x"'); }) }); -describe('handleError', () => { +describe(tools.handleError, () => { let spy: { [key: string]: jest.SpyInstance } = {}; beforeEach(() => { @@ -266,17 +328,17 @@ describe('handleError', () => { spy.setFailed.mockRestore(); }); - it('marks the job as failed', async () => { + it('marks the job as failed with expo-cli', async () => { const error = new Error('test'); registry.manifest.mockResolvedValue('4.0.0'); - await tools.handleError(error); + await tools.handleError('expo-cli', error); expect(core.setFailed).toBeCalledWith(error); }); it('fails with original error when update warning failed', async () => { const error = new Error('test'); registry.manifest.mockRejectedValue(new Error('npm issue')); - await tools.handleError(error); + await tools.handleError('eas-cli', error); expect(core.setFailed).toBeCalledWith(error); }); });